) => {
+ if (typeof window !== "undefined" && window.umami) {
+ window.umami.track(eventName, properties);
+ console.log(`[VibnTracker] Event tracked: "${eventName}"`, properties || "");
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// ── CUSTOM HOOK ───────────────────────────────────────────────────────────────
+
+export function useVibnTracker() {
+ const context = useContext(VibnTrackerContext);
+ if (!context) {
+ throw new Error("useVibnTracker must be used within a VibnTrackerProvider");
+ }
+ return context;
+}
diff --git a/vibn-attribution-package/umami-bridge/umami_service.py b/vibn-attribution-package/umami-bridge/umami_service.py
new file mode 100644
index 0000000..04bd979
--- /dev/null
+++ b/vibn-attribution-package/umami-bridge/umami_service.py
@@ -0,0 +1,139 @@
+import logging
+
+import requests
+from django.db import connections
+from django.utils import timezone
+
+logger = logging.getLogger(__name__)
+
+
+class VibnUmamiService:
+ """
+ Vibn Umami Bridge Service.
+ Supports querying Umami analytics via either its official REST API or
+ performing direct, high-performance database JOINs against your self-hosted Postgres tables.
+ """
+
+ def __init__(self, api_url=None, token=None, website_id=None):
+ self.api_url = api_url or "https://analytics.vibnai.com/api"
+ self.token = token
+ self.website_id = website_id
+
+ # ── DRIVER 1: REST API INTEGRATION ────────────────────────────────────────
+
+ def _get_headers(self):
+ return {
+ "Authorization": f"Bearer {self.token}",
+ "Content-Type": "application/json",
+ }
+
+ def fetch_website_stats(self, start_at, end_at):
+ """
+ Query website aggregated statistics (pageviews, visitors, bounce_rate)
+ via Umami REST API.
+ """
+ if not self.website_id or not self.token:
+ logger.warning("Umami API connection details missing.")
+ return None
+
+ url = f"{self.api_url}/websites/{self.website_id}/stats"
+ params = {
+ "startAt": int(start_at.timestamp() * 1000),
+ "endAt": int(end_at.timestamp() * 1000),
+ }
+ try:
+ response = requests.get(
+ url, headers=self._get_headers(), params=params, timeout=10
+ )
+ response.raise_for_status()
+ return response.json()
+ except Exception as e:
+ logger.error(f"Failed to fetch Umami API stats: {e}")
+ return None
+
+ # ── DRIVER 2: DIRECT POSTGRESQL READ-ONLY QUERIES ─────────────────────────
+
+ def get_user_session_click_trail(self, user_id, db_connection_name="umami_db"):
+ """
+ Retrieves raw browsing timeline for a identified user directly from
+ the self-hosted Umami DB instance using Django cross-database routing.
+ """
+ if db_connection_name not in connections:
+ logger.warning(
+ f"Database connection '{db_connection_name}' is not configured."
+ )
+ return []
+
+ query = """
+ SELECT
+ we.created_at,
+ we.event_name,
+ we.url_path,
+ we.url_query,
+ s.device,
+ s.browser,
+ s.country
+ FROM website_event we
+ JOIN session s ON we.session_id = s.session_id
+ WHERE s.user_id = %s
+ ORDER BY we.created_at DESC
+ LIMIT 50;
+ """
+ try:
+ with connections[db_connection_name].cursor() as cursor:
+ cursor.execute(query, [str(user_id)])
+ rows = cursor.fetchall()
+
+ trail = []
+ for r in rows:
+ trail.append(
+ {
+ "timestamp": r[0].isoformat()
+ if hasattr(r[0], "isoformat")
+ else r[0],
+ "event": r[1],
+ "path": r[2],
+ "query": r[3],
+ "device": r[4],
+ "browser": r[5],
+ "country": r[6],
+ }
+ )
+ return trail
+ except Exception as e:
+ logger.error(f"Direct Umami DB query failed: {e}")
+ return []
+
+ def get_aggregated_funnel_for_user(self, user_id, db_connection_name="umami_db"):
+ """
+ Retrieves high-level counts for pageviews, unique sessions, and first-seen dates
+ directly from the raw Umami tables.
+ """
+ if db_connection_name not in connections:
+ return None
+
+ query = """
+ SELECT
+ COUNT(*),
+ COUNT(DISTINCT we.session_id),
+ MIN(we.created_at)
+ FROM website_event we
+ JOIN session s ON we.session_id = s.session_id
+ WHERE s.user_id = %s;
+ """
+ try:
+ with connections[db_connection_name].cursor() as cursor:
+ cursor.execute(query, [str(user_id)])
+ row = cursor.fetchone()
+ if row:
+ return {
+ "total_interactions": row[0],
+ "total_sessions": row[1],
+ "first_seen_at": row[2].isoformat()
+ if hasattr(row[2], "isoformat")
+ else row[2],
+ }
+ return None
+ except Exception as e:
+ logger.error(f"Failed to fetch aggregated user funnel: {e}")
+ return None
diff --git a/vibn-code b/vibn-code
new file mode 160000
index 0000000..5543bf9
--- /dev/null
+++ b/vibn-code
@@ -0,0 +1 @@
+Subproject commit 5543bf9264366daa2f26880095fc34e016ffb304
diff --git a/vibn-frontend/app/api/auth/me/route.ts b/vibn-frontend/app/api/auth/me/route.ts
new file mode 100644
index 0000000..0a7a5ba
--- /dev/null
+++ b/vibn-frontend/app/api/auth/me/route.ts
@@ -0,0 +1,46 @@
+/**
+ * GET /api/auth/me
+ *
+ * Exposes a profile endpoint for the VibnCode desktop app.
+ * Resolves the Bearer vibn_sk_... Workspace API key, queries the database,
+ * and returns the corresponding owner's user details.
+ */
+
+import { NextResponse } from "next/server";
+import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
+import { queryOne } from "@/lib/db-postgres";
+
+export async function GET(request: Request) {
+ // 1. Authenticate the Workspace API key or Browser Session
+ const principal = await requireWorkspacePrincipal(request);
+ if (principal instanceof NextResponse) return principal;
+
+ try {
+ // 2. Query user details from fs_users
+ const user = await queryOne<{
+ id: string;
+ data: any;
+ }>(
+ `SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`,
+ [principal.userId]
+ );
+
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ // 3. Return user profile compatible with the desktop client's User expectations
+ return NextResponse.json({
+ user: {
+ id: user.id,
+ name: user.data?.name || user.data?.display_name || "Vibn Owner",
+ email: user.data?.email || "",
+ photoUrl: user.data?.image || user.data?.photo_url || null,
+ workspace: principal.workspace.slug,
+ }
+ });
+ } catch (err) {
+ console.error("[api/auth/me GET]", err);
+ return NextResponse.json({ error: "Failed to resolve profile" }, { status: 500 });
+ }
+}
diff --git a/vibn-frontend/app/api/auth/token/route.ts b/vibn-frontend/app/api/auth/token/route.ts
new file mode 100644
index 0000000..74a2f18
--- /dev/null
+++ b/vibn-frontend/app/api/auth/token/route.ts
@@ -0,0 +1,61 @@
+/**
+ * GET /api/auth/token
+ *
+ * Secure endpoint called by the browser during desktop SSO.
+ * Verifies the user has a valid NextAuth browser session, resolves or mints
+ * a Workspace API key, and returns it.
+ */
+
+import { NextResponse } from "next/server";
+import { authSession } from "@/lib/auth/session-server";
+import { queryOne } from "@/lib/db-postgres";
+import { getWorkspaceByOwner } from "@/lib/workspaces";
+import { mintWorkspaceApiKey, listWorkspaceApiKeys, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";
+
+export async function GET() {
+ // 1. Verify caller has an active NextAuth browser session cookie
+ const session = await authSession();
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ // 2. Fetch the corresponding Postgres user
+ const user = await queryOne<{ id: string }>(
+ `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
+ [session.user.email]
+ );
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 401 });
+ }
+
+ // 3. Get the user's active workspace
+ const workspace = await getWorkspaceByOwner(user.id);
+ if (!workspace) {
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
+ }
+
+ try {
+ // 4. Try to reuse their existing, active workspace API key to avoid key bloating
+ const keys = await listWorkspaceApiKeys(workspace.id);
+ const activeKey = keys.find((k) => !k.revoked_at);
+
+ if (activeKey) {
+ const revealed = await revealWorkspaceApiKey(workspace.id, activeKey.id);
+ if (revealed) {
+ return NextResponse.json({ token: revealed.token });
+ }
+ }
+
+ // 5. Otherwise, mint a fresh key for the desktop client
+ const minted = await mintWorkspaceApiKey({
+ workspaceId: workspace.id,
+ name: "VibnCode Desktop SSO",
+ createdBy: user.id,
+ });
+
+ return NextResponse.json({ token: minted.token });
+ } catch (err) {
+ console.error("[api/auth/token GET]", err);
+ return NextResponse.json({ error: "Failed to resolve workspace token" }, { status: 500 });
+ }
+}
diff --git a/vibn-frontend/app/auth/page.tsx b/vibn-frontend/app/auth/page.tsx
index 419234c..c196d78 100644
--- a/vibn-frontend/app/auth/page.tsx
+++ b/vibn-frontend/app/auth/page.tsx
@@ -21,14 +21,39 @@ function AuthPageInner() {
const router = useRouter();
const searchParams = useSearchParams();
+ const [ssoProcessing, setSsoProcessing] = React.useState(false);
+
useEffect(() => {
if (status === "authenticated" && session?.user?.email) {
+ const isVibnCodeSSO = searchParams?.get("vibncode") === "true";
+
+ if (isVibnCodeSSO) {
+ setSsoProcessing(true);
+ // Call our new secure token endpoint
+ fetch("/api/auth/token")
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.token) {
+ // Deep-link redirect back to the VibnCode desktop app
+ window.location.href = `vibncode://auth/callback?token=${data.token}`;
+ } else {
+ console.error("SSO Token missing from response", data);
+ setSsoProcessing(false);
+ }
+ })
+ .catch((err) => {
+ console.error("Desktop SSO failed:", err);
+ setSsoProcessing(false);
+ });
+ return;
+ }
+
const workspace = deriveWorkspace(session.user.email);
-
+
// Check if user has projects. If 0, go to onboarding, else go to projects.
fetch("/api/projects")
- .then(r => r.json())
- .then(d => {
+ .then((r) => r.json())
+ .then((d) => {
if (d.projects && d.projects.length > 0) {
router.push(`/${workspace}/projects`);
} else {
@@ -39,7 +64,7 @@ function AuthPageInner() {
}
}, [status, session, router, searchParams]);
- if (status === "loading") {
+ if (status === "loading" || ssoProcessing) {
return (
- Checking session
+ {ssoProcessing
+ ? "Authorizing VibnCode Desktop..."
+ : "Checking session"}