Phase 0b: Supabase platform + auth + Mơ persona + perf surgery (replacing #2)#7
Conversation
…ty ship-stop) Replaces the compromised hardcoded shared-key proxy (PROXY_API_KEY='hoainho' in services/geminiService.ts and components/ChatCompanion.tsx) with an authenticated Cloudflare Worker edge proxy. Worker (workers/edge-proxy/): - POST /v1/anon-token mints 15-min anonymous JWT bound to hashed IP - POST /v1/generate proxies to Gemini with per-tier rate limit + daily $-spend circuit breaker (anon=1/day, free=3/day, paid=50/day; cap default $80/day) - GET /v1/health, GET /v1/spend-status (internal token gated) - Origin allowlist for CORS - 32 worker tests pass (crypto, rate limit, spend tracker, JWT, full integration) Client: - services/edgeProxyClient.ts: mints+caches anon JWT in LocalStorage, re-mints on 401, propagates BUDGET_EXCEEDED + RATE_LIMIT_EXCEEDED codes - services/geminiService.ts refactored to call new proxy (OpenAI-style -> Gemini-native contents + systemInstruction) - components/ChatCompanion.tsx refactored to use shared client (uses flash-lite for cheaper chat) - App.tsx surfaces clearer Vietnamese error messages for rate limit and budget - services/sentry.ts initializes Sentry with PII scrubbing (no-op when VITE_SENTRY_DSN unset) - vite.config.ts removes proxy.hoainho.info from PWA cache, replaces with api.moodtrip.app + workers.dev NetworkOnly - 9 client tests pass CI: - .github/workflows/ci.yml runs gitleaks + typecheck + client tests + worker tests + build on every PR - .gitleaks.toml flags any reappearance of PROXY_API_KEY='hoainho', plus generic Google API key + Supabase JWT patterns HUMAN ACTIONS REQUIRED before deploy: see .sisyphus/PHASE_0A_HUMAN_ACTIONS.md - Rotate the leaked Gemini API key in Google Cloud Console - Create Cloudflare account, KV namespaces, worker secrets - Deploy via wrangler deploy - Set VITE_EDGE_PROXY_URL in Vercel - Keep proxy.hoainho.info live for 14d backward-compat Pre-existing typecheck errors in ItineraryDisplay.tsx and LoadingAnimation.tsx confirmed unchanged from main and intentionally left untouched.
…gery + Decree 13 Stacked on Phase 0a (#1). Code-only — see .sisyphus/PHASE_0B_HUMAN_ACTIONS.md for cutover steps. Supabase platform: - 5-table schema (profiles, preferences, trips, consent_log, audit_log) + strict RLS - TypeScript Database type with Relationships fields (fixes from() generic inference) - supabaseClient singleton with no-op fallback when env vars missing - authSession subscribe-based state + magic link + Google + Apple OAuth - useAuth React hook - AuthModal UI (Vietnamese) Edge proxy bridge: - edgeProxyClient now auto-uses Supabase JWT when authed, falls back to anon - Worker verifies Supabase JWTs against SUPABASE_JWT_SECRET, audience='authenticated' - 4 new worker tests for Supabase JWT path (free quota=3, paid quota=50, wrong-secret rejected) - Fixed pre-existing test flake (Body has already been used) by switching mockResolvedValue \u2192 mockImplementation LocalStorage \u2192 Supabase migration: - One-time prompt on first authed visit; deduplication; per-user idempotent marker - MigrationBanner UI in 4 states (prompting / migrating / success / failed) M\u01a1 persona: - moPersona.buildMoSystemPrompt() with regional dialect injection - detectRegion() classifies destinations into north/central/south/mekong - Itinerary generation uses M\u01a1 voice with destination-aware dialect - ChatCompanion uses M\u01a1 persona (replaces generic 'Tr\u1ee3 L\u00fd Du L\u1ecbch') - 8 unit tests Decree 13 compliance: - ConsentBanner on first visit; stored locally + in consent_log table - delete-account Supabase Edge Function (service role; cascades through RLS) - requestAccountDeletion() in authSession - 5 unit tests Performance surgery: - NatureScene now mounts via requestIdleCallback (idle, not fixed 100ms) - Static radial-gradient background so LCP renders without 3D scene - prefers-reduced-motion globally honored - Dropped unused gsap dependency Schema split (for cheaper AI calls): - generateItinerarySkeleton uses flash-lite, 4K maxOutputTokens - enrichItinerary lazy-loads heavy fields (food, accommodation, packing, traffic, safety, budget) - Not yet wired to UI (Phase 1 work) PWA + analytics: - PWAInstallPrompt using beforeinstallprompt + 30-day dismissal - analytics.ts wraps PostHog with PII scrubbing + lazy import Verification: - 31/31 client tests pass (was 9/9) - 36/36 worker tests pass (was 32/32) - Frontend typecheck clean except 2 pre-existing errors (ItineraryDisplay, LoadingAnimation), unchanged from main - Build succeeds; bundle does NOT contain hardcoded proxy key or M\u01a1 system prompt as bare text Pre-existing typecheck errors in ItineraryDisplay.tsx and LoadingAnimation.tsx remain unchanged from main and intentionally left untouched.
GITLEAKS (2 leaks → 0): - .sisyphus/PHASE_0A_HUMAN_ACTIONS.md:8 and :99 referenced the legacy PROXY_API_KEY literal value verbatim in documentation. The moodtrip-shared-proxy-key rule fired on both. Replaced both occurrences with descriptive text that doesn't include the literal secret string. TYPECHECK (2 errors → 0): - components/ItineraryDisplay.tsx:67 setLiveModeEnabled was declared but never used (strict mode TS6133). Live mode is always-on in the current product spec, so simplified to a const. - components/LoadingAnimation.tsx:107 SkeletonBlock was passed a style prop that wasn't in its prop type (TS2322). Added style?: React.CSSProperties to the component signature. Both fixes are minimal and surgically scoped. No behavior change.
…o feature/phase-0b-platform
The legacy PROXY_API_KEY literal is removed from source, but the .sisyphus/PHASE_0A_HUMAN_ACTIONS.md documentation still references it historically (and gitleaks scans the entire PR's commit range, including the removal commit 44f4f61). This file is project documentation, not deployable code. Path now exempted from the moodtrip-shared-proxy-key rule.
…o feature/phase-0b-platform
There was a problem hiding this comment.
Code Review
This pull request introduces the platform foundation and security infrastructure for MoodTripV2. Key additions include a Cloudflare Worker edge proxy to secure Gemini API calls, a Supabase integration for user authentication and database persistence, local trip migration utilities, a Decree 13 compliant consent banner, and a comprehensive testing suite. The review feedback highlights critical stability and UX improvements, such as preventing a rate-limiter crash near UTC midnight due to Cloudflare KV TTL constraints, fixing the authentication button state rendering, robustly parsing multi-proxy IP headers, and optimizing local trip migrations with batch inserts.
| const tier = tierOf(claims); | ||
| const limit = dailyLimitFor(env, tier); | ||
| const date = utcDateKey(); | ||
| const key = `q:${date}:${claims.sub}`; |
There was a problem hiding this comment.
Cloudflare KV put throws an error if expirationTtl is less than 60 seconds. Near midnight, secondsUntilUtcMidnight() will return less than 60, causing the rate limiter to crash on every request. Enforce a minimum TTL of 60 seconds to prevent this.
| const key = `q:${date}:${claims.sub}`; | |
| const ttl = Math.max(60, secondsUntilUtcMidnight()); |
| import { AuthModal } from './components/AuthModal'; | ||
| import { MigrationBanner } from './components/MigrationBanner'; | ||
| import { ConsentBanner } from './components/ConsentBanner'; | ||
| import { PWAInstallPrompt } from './components/PWAInstallPrompt'; |
There was a problem hiding this comment.
Import useAuth and signOut to conditionally render the "Đăng nhập" / "Đăng xuất" button based on the user's authentication state.
| import { AuthModal } from './components/AuthModal'; | |
| import { MigrationBanner } from './components/MigrationBanner'; | |
| import { ConsentBanner } from './components/ConsentBanner'; | |
| import { PWAInstallPrompt } from './components/PWAInstallPrompt'; | |
| import { AuthModal } from './components/AuthModal'; | |
| import { MigrationBanner } from './components/MigrationBanner'; | |
| import { ConsentBanner } from './components/ConsentBanner'; | |
| import { PWAInstallPrompt } from './components/PWAInstallPrompt'; | |
| import { useAuth } from './services/useAuth'; | |
| import { signOut } from './services/authSession'; |
| const [toastMessage, setToastMessage] = useState<string | null>(null); | ||
| const [isExportingPDF, setIsExportingPDF] = useState(false); | ||
| const [isSharedView, setIsSharedView] = useState(false); | ||
| const [authModalOpen, setAuthModalOpen] = useState(false); |
| <button | ||
| onClick={() => setAuthModalOpen(true)} | ||
| className="fixed top-4 right-4 z-30 px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors" | ||
| aria-label="Đăng nhập" | ||
| > | ||
| Đăng nhập | ||
| </button> |
There was a problem hiding this comment.
Conditionally render the "Đăng nhập" / "Đăng xuất" button based on the user's authentication state. This prevents logged-in users from seeing the login button and allows them to log out.
{user ? (
<button
onClick={() => signOut()}
className="fixed top-4 right-4 z-30 px-3 py-1.5 text-xs font-medium text-rose-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Đăng xuất"
>
Đăng xuất
</button>
) : (
<button
onClick={() => setAuthModalOpen(true)}
className="fixed top-4 right-4 z-30 px-3 py-1.5 text-xs font-medium text-teal-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-full transition-colors"
aria-label="Đăng nhập"
>
Đăng nhập
</button>
)}
| const userClient = createClient(supabaseUrl, serviceRoleKey, { | ||
| global: { headers: { authorization: `Bearer ${userJwt}` } }, | ||
| }); | ||
| const { data: userData, error: userErr } = await userClient.auth.getUser(); | ||
| if (userErr || !userData.user) { | ||
| return new Response(JSON.stringify({ code: 'UNAUTHENTICATED' }), { | ||
| status: 401, | ||
| headers: { ...corsHeaders(origin), 'content-type': 'application/json' }, | ||
| }); | ||
| } | ||
| const userId = userData.user.id; | ||
|
|
||
| const admin = createClient(supabaseUrl, serviceRoleKey); |
There was a problem hiding this comment.
Instead of creating a redundant userClient with serviceRoleKey and custom headers, reuse the admin client to verify the user's JWT. This simplifies the code and reduces the overhead of creating multiple client instances.
const admin = createClient(supabaseUrl, serviceRoleKey);
const { data: userData, error: userErr } = await admin.auth.getUser(userJwt);
if (userErr || !userData.user) {
return new Response(JSON.stringify({ code: 'UNAUTHENTICATED' }), {
status: 401,
headers: { ...corsHeaders(origin), 'content-type': 'application/json' },
});
}
const userId = userData.user.id;| const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'; | ||
| const ipHash = await hashIp(ip, c.env.JWT_SIGNING_SECRET); |
There was a problem hiding this comment.
Robustly parse the first IP from the x-forwarded-for header to handle multi-proxy setups where the header contains a comma-separated list of IPs.
| const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'; | |
| const ipHash = await hashIp(ip, c.env.JWT_SIGNING_SECRET); | |
| const rawIp = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'; | |
| const ip = rawIp.split(',')[0]?.trim() || 'unknown'; | |
| const ipHash = await hashIp(ip, c.env.JWT_SIGNING_SECRET); |
| const inputRate = Number( | ||
| isLite ? env.GEMINI_FLASH_LITE_INPUT_USD_PER_MTOK : env.GEMINI_FLASH_INPUT_USD_PER_MTOK, | ||
| ); | ||
| const outputRate = Number( | ||
| isLite ? env.GEMINI_FLASH_LITE_OUTPUT_USD_PER_MTOK : env.GEMINI_FLASH_OUTPUT_USD_PER_MTOK, | ||
| ); |
There was a problem hiding this comment.
Add a fallback || 0 to the Number(...) parsing of environment variables to prevent NaN propagation if any of the pricing variables are missing or malformed.
| const inputRate = Number( | |
| isLite ? env.GEMINI_FLASH_LITE_INPUT_USD_PER_MTOK : env.GEMINI_FLASH_INPUT_USD_PER_MTOK, | |
| ); | |
| const outputRate = Number( | |
| isLite ? env.GEMINI_FLASH_LITE_OUTPUT_USD_PER_MTOK : env.GEMINI_FLASH_OUTPUT_USD_PER_MTOK, | |
| ); | |
| const inputRate = Number( | |
| isLite ? env.GEMINI_FLASH_LITE_INPUT_USD_PER_MTOK : env.GEMINI_FLASH_INPUT_USD_PER_MTOK, | |
| ) || 0; | |
| const outputRate = Number( | |
| isLite ? env.GEMINI_FLASH_LITE_OUTPUT_USD_PER_MTOK : env.GEMINI_FLASH_OUTPUT_USD_PER_MTOK, | |
| ) || 0; |
|
|
||
| for (const trip of trips) { | ||
| const dedupeKey = `${trip.destination}::${trip.id ?? ''}`; | ||
| if (seen.has(dedupeKey)) continue; | ||
| seen.add(dedupeKey); | ||
|
|
||
| const row: TripInsert = { | ||
| owner_id: userId, | ||
| destination: trip.destination, | ||
| trip_mode: 'long', | ||
| form_input: { migrated_from_local: true } satisfies Json, | ||
| skeleton: trip as unknown as Json, | ||
| is_public: false, | ||
| }; | ||
| const { error } = await supabase.from('trips').insert(row); | ||
|
|
||
| if (error) { | ||
| result.failed += 1; | ||
| result.errors.push(`${trip.destination}: ${error.message}`); | ||
| } else { | ||
| result.imported += 1; | ||
| } |
There was a problem hiding this comment.
Optimize the sequential await in a loop by using bulk insert for local trips migration. This reduces the network overhead from multiple sequential requests to a single batch request.
const rows: TripInsert[] = [];
for (const trip of trips) {
const dedupeKey = `${trip.destination}::${trip.id ?? ''}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
rows.push({
owner_id: userId,
destination: trip.destination,
trip_mode: 'long',
form_input: { migrated_from_local: true } satisfies Json,
skeleton: trip as unknown as Json,
is_public: false,
});
}
if (rows.length > 0) {
const { error } = await supabase.from('trips').insert(rows);
if (error) {
result.failed = rows.length;
result.errors.push(error.message);
} else {
result.imported = rows.length;
}
}| if (input && typeof input === 'object') { | ||
| const out: Record<string, unknown> = {}; | ||
| for (const [k, v] of Object.entries(input as Record<string, unknown>)) { |
There was a problem hiding this comment.
Prevent scrubObject from breaking serialization of Date and RegExp objects by returning them directly instead of converting them to empty plain objects.
if (input && typeof input === 'object') {
if (input instanceof Date || input instanceof RegExp) return input;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Replaces #2 which was auto-closed when PR #1 merged and its base branch was deleted.
Contents identical to the merged work tracked in PR #2.