diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56ef247d..cf477b1a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,9 @@ importers: '@fireproof/core': specifier: 0.24.0 version: 0.24.0(react@19.2.0)(typescript@5.9.3) + '@fireproof/core-protocols-dashboard': + specifier: 0.24.0 + version: 0.24.0(typescript@5.9.3) '@fireproof/core-runtime': specifier: 0.24.0 version: 0.24.0(typescript@5.9.3) @@ -718,6 +721,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.17) + '@tanstack/react-query': + specifier: ^5.90.10 + version: 5.90.10(react@19.2.0) '@vibes.diy/hosting-base': specifier: workspace:* version: link:../../hosting/base @@ -3678,6 +3684,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.10': + resolution: {integrity: sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==} + + '@tanstack/react-query@5.90.10': + resolution: {integrity: sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -12737,6 +12751,13 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/query-core@5.90.10': {} + + '@tanstack/react-query@5.90.10(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.10 + react: 19.2.0 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 diff --git a/token-auth-and-exchange.md b/token-auth-and-exchange.md new file mode 100644 index 000000000..ee9503a1c --- /dev/null +++ b/token-auth-and-exchange.md @@ -0,0 +1,81 @@ +# Token Auth & Exchange Flow Analysis + +This document describes the current observed authentication flow and the specific failure point encountered when integrating the `vibes.diy` development environment with the Fireproof Connect backend. + +## The Goal + +Authenticate a user on the `vibes.diy` local development environment using a **Clerk Development Instance** (`sincere-cheetah-30...`) and successfully exchange this token for a Fireproof Cloud token (`fp-cloud-jwt`) via the production Fireproof backend (`https://connect.fireproof.direct/api`). + +## The Flow + +1. **Client Authentication (Success)** + - The `vibes.diy` app uses `@clerk/clerk-react` to sign the user in. + - **Result:** A valid JWT is obtained. + - **Issuer (`iss`):** `https://sincere-cheetah-30.clerk.accounts.dev` + - **Key ID (`kid`):** `ins_35qNS5Jwyc7z4aJRBIS7o205yzb` + - **Algorithm:** `RS256` + - **Audience (`aud`):** `undefined` (No audience claim present). + +2. **Token Verification / Exchange Request (Failure)** + - The client calls `DashboardApi.getCloudSessionToken({})`. + - **Payload:** `{ auth: { type: "clerk", token: "..." } }` + - **Endpoint:** `PUT https://connect.fireproof.direct/api` + +3. **Backend Verification (Error)** + _ The backend receives the request and attempts to verify the token. + _ **Backend Configuration:** The backend is configured with `CLERK_PUB_JWT_URL` containing three issuers, including the development one: + _ `https://clerk.fireproof.direct` + _ `https://clerk.vibes.diy` + _ `https://sincere-cheetah-30.clerk.accounts.dev` + _ **Response:** HTTP 500 \* **Error Message:** + `json + { + "type": "error", + "message": "No well-known JWKS URL could verify the token:\n[ + { + "type": "error", + "error": { + "reason": "token-invalid-signature" + }, + "url": "https://clerk.fireproof.direct/.well-known/jwks.json" + }, + { + "type": "error", + "error": { + "reason": "token-invalid-signature" + }, + "url": "https://clerk.vibes.diy/.well-known/jwks.json" + }, + { + "type": "error", + "error": { + "reason": "token-invalid-signature" + }, + "url": "https://sincere-cheetah-30.clerk.accounts.dev/.well-known/jwks.json" + } +]" + } + ` + +## Diagnosis Findings + +- **✅ Issuer Match:** The client sends a token from `sincere-cheetah-30`. The backend _is_ attempting to verify against the corresponding JWKS URL. +- **✅ Key ID Match:** The client token has `kid: 'ins_35qNS5Jwyc7z4aJRBIS7o205yzb'`. The public JWKS at `https://sincere-cheetah-30.clerk.accounts.dev/.well-known/jwks.json` currently contains this exact key. +- **❌ Signature Verification Failure:** Despite the correct issuer and key ID, the backend reports `token-invalid-signature`. + +## Potential Root Causes for Review + +1. **Audience (`aud`) Validation:** + - The client token has **no `aud` claim**. + - Does the backend's `@hono/clerk-auth` middleware (or underlying `verifyToken` function) enforce a default audience check? If so, the lack of an audience would cause verification to fail even if the signature is valid. + +2. **Stale JWKS Cache:** + - Could the backend be caching an older version of the JWKS for `sincere-cheetah-30` that does not yet contain the key `ins_35qNS5Jwyc7z4aJRBIS7o205yzb`? + - _Note: This is less likely if this is a stable dev instance, but possible if keys were recently rotated._ + +3. **Crypto/Environment Mismatch:** + - Is the backend environment (e.g., Cloudflare Workers) correctly handling the RS256 signature verification for this specific key? + +## Request for Engineering Team + +Please verify if the Fireproof backend enforces an **Audience (`aud`) claim** on Clerk tokens. If so, what audience value is expected? We may need to configure our Clerk development instance to include this audience in its issued tokens. diff --git a/vibes.diy/pkg/app/config/env.ts b/vibes.diy/pkg/app/config/env.ts index 57c1a4164..793c816d1 100644 --- a/vibes.diy/pkg/app/config/env.ts +++ b/vibes.diy/pkg/app/config/env.ts @@ -48,11 +48,17 @@ class vibesDiyEnv { this.env().get("VITE_CONNECT_URL") ?? "https://connect.fireproof.direct/token", ); - readonly CONNECT_API_URL = Lazy( - () => - this.env().get("VITE_CONNECT_API_URL") ?? - "https://connect.fireproof.direct/api", - ); + readonly CONNECT_API_URL = Lazy(() => { + const envUrl = this.env().get("VITE_CONNECT_API_URL"); + if (envUrl) { + return envUrl; + } + const isProduction = + runtimeFn().isBrowser && window.location.hostname === "vibes.diy"; + return isProduction + ? "https://connect.fireproof.direct/api" + : "https://dev.connect.fireproof.direct/api"; + }); readonly CLOUD_SESSION_TOKEN_PUBLIC_KEY = Lazy( () => this.env().get("VITE_CLOUD_SESSION_TOKEN_PUBLIC") ?? diff --git a/vibes.diy/pkg/app/root.tsx b/vibes.diy/pkg/app/root.tsx index 9f38be90e..c79b5b66b 100644 --- a/vibes.diy/pkg/app/root.tsx +++ b/vibes.diy/pkg/app/root.tsx @@ -20,6 +20,17 @@ import { ClerkProvider } from "@clerk/clerk-react"; import { CookieConsentProvider } from "./contexts/CookieConsentContext.js"; import { ThemeProvider } from "./contexts/ThemeContext.js"; import { getLibraryImportMap } from "./config/import-map.js"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Create a client instance for React Query +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + }, + }, +}); export const links: Route.LinksFunction = () => { const rawBase = VibesDiyEnv.APP_BASENAME(); @@ -120,24 +131,26 @@ export function Layout({ children }: { children: React.ReactNode }) { {/* TODO: Re-enable GtmNoScript when consent can be checked server-side */} {/* */} - - - - {children} - - - - - - - - + + + + + {children} + + + + + + + + + diff --git a/vibes.diy/pkg/app/routes.ts b/vibes.diy/pkg/app/routes.ts index 32859788d..b5721e7fc 100644 --- a/vibes.diy/pkg/app/routes.ts +++ b/vibes.diy/pkg/app/routes.ts @@ -24,6 +24,7 @@ export default [ route("settings", "./routes/settings.tsx", { id: "settings" }), route("about", "./routes/about.tsx", { id: "about" }), + route("fireproof", "./routes/fireproof.tsx", { id: "fireproof" }), route("sso-callback", "./routes/sso-callback.tsx", { id: "sso-callback" }), route("remix/:vibeSlug?", "./routes/remix.tsx", { id: "remix" }), route("vibe/:titleId/:installId", "./routes/vibe.$titleId.$installId.tsx", { diff --git a/vibes.diy/pkg/app/routes/fireproof.tsx b/vibes.diy/pkg/app/routes/fireproof.tsx new file mode 100644 index 000000000..d4ca64710 --- /dev/null +++ b/vibes.diy/pkg/app/routes/fireproof.tsx @@ -0,0 +1,184 @@ +import React, { useMemo } from "react"; +import { useAuth } from "@clerk/clerk-react"; +import { useQuery } from "@tanstack/react-query"; +import { DashboardApi } from "@fireproof/core-protocols-dashboard"; +import type { ResEnsureUser } from "@fireproof/core-protocols-dashboard"; +import type { Result } from "@adviser/cement"; +import SimpleAppLayout from "../components/SimpleAppLayout.js"; +import { HomeIcon } from "../components/SessionSidebar/HomeIcon.js"; +import { VibesDiyEnv } from "../config/env.js"; + +export function meta() { + return [ + { title: "Fireproof Dashboard - Vibes DIY" }, + { + name: "description", + content: "Manage your Fireproof tenants and ledgers", + }, + ]; +} + +// Helper to convert Result monad to Promise for React Query +function wrapResultToPromise(pro: () => Promise>, label: string) { + return async (): Promise => { + const res = await pro(); + if (res.isOk()) { + return res.Ok(); + } + const error = res.Err(); + console.error(`[Fireproof Dashboard] ❌ Error for ${label}:`, error); + throw error; + }; +} + +export default function FireproofDashboard() { + const { isSignedIn, isLoaded, getToken, userId } = useAuth(); + + // Create DashboardApi instance with Clerk auth + const api = useMemo(() => { + const apiUrl = VibesDiyEnv.CONNECT_API_URL(); + console.log("[Fireproof Dashboard] API URL:", apiUrl); + return new DashboardApi({ + apiUrl, + fetch: window.fetch.bind(window), + getToken: async () => { + // Use with-email template to get email claims in params + const token = await getToken({ template: "with-email" }); + if (token) { + // Decode JWT header to log details + try { + const [headerB64, payloadB64] = token.split("."); + const header = JSON.parse(atob(headerB64)); + const payload = JSON.parse(atob(payloadB64)); + console.log("[Fireproof Dashboard] JWT kid:", header.kid); + console.log("[Fireproof Dashboard] JWT iss:", payload.iss); + console.log("[Fireproof Dashboard] JWT sub:", payload.sub); + console.log("[Fireproof Dashboard] Full token:", token); + } catch (e) { + console.error("[Fireproof Dashboard] Failed to decode JWT:", e); + } + } else { + console.warn("[Fireproof Dashboard] No token available from getToken()"); + } + return { + type: "clerk" as const, + token: token || "", + }; + }, + }); + }, [getToken]); + + // Query to ensure the user exists and is active + const ensureUserQuery = useQuery({ + queryKey: ["ensureUser", userId], + queryFn: wrapResultToPromise(() => api.ensureUser({}), "ensureUser"), + enabled: isLoaded && isSignedIn, + }); + + // Loading state while Clerk initializes + if (!isLoaded) { + return ( + + + + + + } + > +
+

Fireproof Dashboard

+
+

+ Loading... +

+
+
+
+ ); + } + + // Not authenticated view + if (!isSignedIn) { + return ( + + + + + + } + > +
+

Fireproof Dashboard

+
+

+ Please sign in to view your Fireproof dashboard. +

+
+
+
+ ); + } + + return ( + + + + + + } + > +
+

Fireproof Dashboard

+
+ {ensureUserQuery.isLoading && ( +
+

+ Ensuring user presence... +

+
+ )} + {ensureUserQuery.isError && ( +
+

+ Error ensuring user: +

+
+                {ensureUserQuery.error instanceof Error
+                  ? ensureUserQuery.error.message
+                  : JSON.stringify(ensureUserQuery.error, null, 2)}
+              
+

+ Check browser console for JWT details (kid, iss, sub, full token) +

+
+ )} + {ensureUserQuery.isSuccess && ensureUserQuery.data?.user && ( +
+

+ User {ensureUserQuery.data.user.userId} is active. +

+
+ )} +
+
+
+ ); +} diff --git a/vibes.diy/pkg/package.json b/vibes.diy/pkg/package.json index 13b5670a2..b1ccb27fd 100644 --- a/vibes.diy/pkg/package.json +++ b/vibes.diy/pkg/package.json @@ -32,6 +32,7 @@ "@clerk/clerk-react": "^5.57.0", "@cloudflare/workers-types": "^4.20251111.0", "@fireproof/core": "0.24.0", + "@fireproof/core-protocols-dashboard": "0.24.0", "@fireproof/core-runtime": "0.24.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-slot": "^1.2.4", @@ -39,6 +40,7 @@ "@react-router/serve": "^7.9.6", "@shikijs/monaco": "^3.17.1", "@tailwindcss/typography": "^0.5.19", + "@tanstack/react-query": "^5.90.10", "@vibes.diy/hosting-base": "workspace:*", "@vibes.diy/prompts": "workspace:*", "@vibes.diy/use-vibes-base": "workspace:*",