From 153f506b0296030ff6b952d732f3f07ed25495ea Mon Sep 17 00:00:00 2001
From: sr2echa <65058816+sr2echa@users.noreply.github.com>
Date: Fri, 1 May 2026 23:32:50 +0530
Subject: [PATCH 01/23] chore: setup stable dev environment + update routes to
logical names
---
README.md | 115 +++++++++++++++++++++++++-----
app/components/chat/ChatBox.tsx | 2 +-
app/components/ui/ProfileMenu.tsx | 2 +-
app/hooks/useMediaBin.ts | 10 +--
app/hooks/useRenderer.ts | 4 +-
app/routes/home.tsx | 2 +-
app/routes/login.tsx | 2 +-
app/routes/profile.tsx | 6 +-
app/routes/projects.tsx | 12 ++--
app/utils/auth.server.ts | 2 +-
backend/api/routes.py | 64 +++++++++++++----
backend/api/schema.py | 4 ++
backend/main.py | 2 +-
docker-compose.dev.yml | 8 ---
nginx.conf | 12 ++--
nginx.dev.conf | 42 -----------
vite.config.ts | 14 ++++
17 files changed, 197 insertions(+), 106 deletions(-)
delete mode 100644 nginx.dev.conf
diff --git a/README.md b/README.md
index 5a3a0719..a95846c3 100644
--- a/README.md
+++ b/README.md
@@ -56,35 +56,118 @@
and much more...
-## πDeployment
+## π» Development
+ π οΈ Local Development
+
+Only postgres runs in Docker. All three services run directly on your machine β Vite handles proxying so no nginx is needed.
+
+```bash
+# Install dependencies
+pnpm i
+cd backend && uv sync && cd ..
+
+# 1. Start postgres
+docker compose -f docker-compose.dev.yml up -d
+
+# 2. Start FastAPI (terminal 1)
+cd backend && uv run uvicorn main:app --reload --port 3000
+
+# 3. Start renderer (terminal 2)
+pnpm dlx tsx app/videorender/videorender.ts
+
+# 4. Start frontend (terminal 3)
+pnpm dev
```
-git clone https://github.com/robinroy03/videoeditor.git
-cd videoeditor
-docker compose up
+
+Open **`http://localhost:5173`**. The Vite dev server proxies requests transparently:
+
+
+
+- `/backend/*` β FastAPI at `:3000`
+- `/renderer/*` β Renderer at `:8000`
+- `/*` β React Router SSR (Vite)
+
+
+
+`Requirements`
+
+
+
+- Node.js 20+
+- Python 3.12+
+- pnpm
+- Docker (for postgres only)
+
+
+
+## π Production
+
+Everything runs in Docker behind nginx. One command:
+
+```bash
+docker compose up -d
```
-## π§βπ»Development
+**With Custom Domain:**
+```bash
+PROD_DOMAIN=yourdomain.com docker compose up -d
```
-docker compose -f docker-compose.dev.yml up
-migrate the db (docker exec -i videoeditor-postgres-dev psql -U videoeditor -d videoeditor -f /dev/stdin < migrations/000_init.sql)
-pnpm run dev (frontend)
-pnpm dlx tsx app/videorender/videorender.ts (backend)
-cd backend
-uv run main.py
-
-localhost:8080 for the server
+
+nginx routes:
+
+
+
+- `/backend/*` β FastAPI
+- `/renderer/*` β Renderer (video rendering)
+- `/*` β Frontend (React Router SSR)
+
+
+
+**Ports:**
+
+- HTTP: `80` (redirects to HTTPS)
+- HTTPS: `443`
+
+## βοΈ Environment Configuration
+
+Create a `.env` file for custom settings:
+
+```env
+# Domain Configuration
+PROD_DOMAIN=yourdomain.com
+
+# Database
+DATABASE_URL=postgresql://user:pass@localhost:5432/videoeditor
+
+# Authentication (Google OAuth)
+GOOGLE_CLIENT_ID=your_google_client_id
+GOOGLE_CLIENT_SECRET=your_google_client_secret
+
+# AI Features (Optional -> /backend)
+GEMINI_API_KEY=your_gemini_api_key
+
```
+**Environment Variables Explained:**
+
+- `PROD_DOMAIN`: Your production domain (host only, e.g., `yourdomain.com`)
+- `DATABASE_URL`: PostgreSQL connection string
+- `GOOGLE_CLIENT_ID/SECRET`: Google OAuth credentials for authentication
+- `GEMINI_API_KEY`: Required for AI-powered video editing features
+
+
+
## πTODO
-We have a lot of work! For starters, we plan to integrate all Remotion APIs. I'll add a proper roadmap soon. Join the [Discord Server](https://discord.com/invite/GSknuxubZK) for updates and support.
+ We have a lot of work! For starters, we plan to integrate all Remotion APIs. I'll add a proper roadmap soon. Join the [Discord Server](https://discord.com/invite/GSknuxubZK) for updates and support.
## β€οΈContribution
-We would love your contributions! β€οΈ Check the [contribution guide](CONTRIBUTING.md).
+ We would love your contributions! β€οΈ Check the [contribution guide](CONTRIBUTING.md).
## πLicense
-This project is licensed under a dual-license. Refer to [LICENSE](LICENSE.md) for details. The [Remotion license](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md) also applies to the relevant parts of the project.
+ This project is licensed under a dual-license. Refer to [LICENSE](LICENSE.md) for details. The [Remotion license](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md) also applies to the relevant parts of the project.
+
diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx
index 85476600..8c0f9954 100644
--- a/app/components/chat/ChatBox.tsx
+++ b/app/components/chat/ChatBox.tsx
@@ -214,7 +214,7 @@ export function ChatBox({
}));
// Make API call to the backend
- const response = await axios.post("/ai", {
+ const response = await axios.post("/backend/ai", {
message: messageContent,
mentioned_scrubber_ids: mentionedScrubberIds,
timeline_state: timelineState,
diff --git a/app/components/ui/ProfileMenu.tsx b/app/components/ui/ProfileMenu.tsx
index 94b846bf..f0aa98e4 100644
--- a/app/components/ui/ProfileMenu.tsx
+++ b/app/components/ui/ProfileMenu.tsx
@@ -34,7 +34,7 @@ export function ProfileMenu({
let cancelled = false;
(async () => {
try {
- const res = await fetch("/api/storage", { credentials: "include" });
+ const res = await fetch("/backend/storage", { credentials: "include" });
if (!res.ok) return;
const j = await res.json();
if (!cancelled) {
diff --git a/app/hooks/useMediaBin.ts b/app/hooks/useMediaBin.ts
index efe170c3..b8493812 100644
--- a/app/hooks/useMediaBin.ts
+++ b/app/hooks/useMediaBin.ts
@@ -8,7 +8,7 @@ export const deleteMediaFile = async (
filename: string,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
- const response = await fetch(`/media/${encodeURIComponent(filename)}`, {
+ const response = await fetch(`/renderer/media/${encodeURIComponent(filename)}`, {
method: "DELETE",
});
@@ -42,7 +42,7 @@ export const cloneMediaFile = async (
error?: string;
}> => {
try {
- const response = await fetch("/clone-media", {
+ const response = await fetch("/renderer/clone-media", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -274,7 +274,7 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri
formData.append("media", file);
console.log("Uploading file to server...");
- const uploadResponse = await axios.post("/api/upload", formData, {
+ const uploadResponse = await axios.post("/renderer/upload", formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
@@ -392,7 +392,7 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri
}
// Call authenticated delete by asset id
const assetId = item.id;
- const res = await fetch(`/api/assets/${assetId}`, {
+ const res = await fetch(`/renderer/assets/${assetId}`, {
method: "DELETE",
credentials: "include",
});
@@ -426,7 +426,7 @@ export const useMediaBin = (handleDeleteScrubbersByMediaBinId: (mediaBinId: stri
}
// Clone via authenticated API (server will copy within out/ and record)
- const res = await fetch(`/api/assets/${videoItem.id}/clone`, {
+ const res = await fetch(`/renderer/assets/${videoItem.id}/clone`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
diff --git a/app/hooks/useRenderer.ts b/app/hooks/useRenderer.ts
index b1295117..4600d431 100644
--- a/app/hooks/useRenderer.ts
+++ b/app/hooks/useRenderer.ts
@@ -22,7 +22,7 @@ export const useRenderer = () => {
// Test server connection first
setRenderStatus("Connecting to render server...");
try {
- await axios.get("/api/health", { timeout: 5000 });
+ await axios.get("/renderer/health", { timeout: 5000 });
} catch (healthError) {
throw new Error("Cannot connect to render server. Make sure the server is running on http://localhost:8000");
}
@@ -66,7 +66,7 @@ export const useRenderer = () => {
setRenderStatus("Rendering video...");
const response = await axios.post(
- "/api/render",
+ "/renderer/render",
{
timelineData: timelineData,
compositionWidth: compositionWidth,
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 831860c1..5cd088d0 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -338,7 +338,7 @@ export default function TimelineEditor() {
}
const timelineState = getTimelineState();
- await axios.put(`/ai/api/api/projects/${encodeURIComponent(id)}`, timelineState, {
+ await axios.put(`/backend/projects/${encodeURIComponent(id)}`, timelineState, {
withCredentials: true,
});
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
index 31cc9c79..14341e65 100644
--- a/app/routes/login.tsx
+++ b/app/routes/login.tsx
@@ -18,7 +18,7 @@ export default function LoginPage() {
const handleGoogleLogin = async (credentialResponse: CredentialResponse) => {
console.log("credentialResponse", credentialResponse);
- const response = await axios.post("/ai/api/auth/google", {
+ const response = await axios.post("/backend/auth/google", {
credential: credentialResponse.credential,
});
if (response.status === 200) {
diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx
index 3da3c892..f9f683a4 100644
--- a/app/routes/profile.tsx
+++ b/app/routes/profile.tsx
@@ -17,7 +17,7 @@ export default function Profile() {
let cancelled = false;
(async () => {
try {
- const res = await fetch("/api/storage", { credentials: "include" });
+ const res = await fetch("/backend/storage", { credentials: "include" });
if (!res.ok) return;
const j = await res.json();
if (!cancelled) {
@@ -32,7 +32,7 @@ export default function Profile() {
})();
(async () => {
try {
- const res = await fetch("/api/auth/session", { credentials: "include" });
+ const res = await fetch("/backend/auth/session", { credentials: "include" });
if (!res.ok) return;
const j = await res.json();
const created = j?.user?.createdAt || j?.user?.created_at || j?.user?.created_at_ms || null;
@@ -43,7 +43,7 @@ export default function Profile() {
})();
(async () => {
try {
- const res = await fetch("/api/projects", { credentials: "include" });
+ const res = await fetch("/backend/projects", { credentials: "include" });
if (!res.ok) return;
const j = await res.json();
if (!cancelled) setProjectCount(Array.isArray(j?.projects) ? j.projects.length : 0);
diff --git a/app/routes/projects.tsx b/app/routes/projects.tsx
index 3098b8bc..228a1bf2 100644
--- a/app/routes/projects.tsx
+++ b/app/routes/projects.tsx
@@ -58,7 +58,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (res.status !== 200) throw redirect("/login");
const { origin } = new URL(request.url);
- const projectsRes = await axios.get<{ projects: Project[] }>(`${origin}/ai/api/api/projects`, {
+ const projectsRes = await axios.get<{ projects: Project[] }>(`${origin}/backend/projects`, {
headers: { Cookie: request.headers.get("Cookie") },
});
@@ -252,7 +252,7 @@ export default function Projects() {
const name = (projectName || newProjectName || "Untitled Project").trim();
setCreating(true);
try {
- const { data } = await axios.post("/ai/api/api/create-project", { name }, { withCredentials: true });
+ const { data } = await axios.post("/backend/projects", { name }, { withCredentials: true });
navigate(`/project/${data.project.id}`);
} finally {
setCreating(false);
@@ -291,7 +291,7 @@ export default function Projects() {
user={{ name: user.name, email: user.email, image: user.avatar_url }}
starCount={starCount}
onSignOut={async () => {
- await axios.post("/ai/api/auth/logout", {}, { withCredentials: true });
+ await axios.post("/backend/auth/logout", {}, { withCredentials: true });
}}
/>
@@ -387,7 +387,7 @@ export default function Projects() {
setDrawerOpen(true);
}}
onDelete={async (projectId) => {
- const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
+ const res = await fetch(`/backend/projects/${encodeURIComponent(projectId)}`, {
method: "DELETE",
credentials: "include",
});
@@ -448,7 +448,7 @@ export default function Projects() {
const id = renameProjectId!;
const newName = renameValue.trim();
if (!newName) return;
- const res = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
+ const res = await fetch(`/backend/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -484,7 +484,7 @@ export default function Projects() {
onClick={async () => {
const id = renameProjectId!;
if (!id) return;
- const res = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
+ const res = await fetch(`/backend/projects/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts
index 606daaa2..a552611c 100644
--- a/app/utils/auth.server.ts
+++ b/app/utils/auth.server.ts
@@ -23,7 +23,7 @@ export async function requireUser(request: Request): Promise(`${origin}/ai/api/auth/me`, {
+ const res = await axios.get(`${origin}/backend/auth/me`, {
headers: { Cookie: cookie }, // include the cookie in the request headers because we are in server code and not in the browser
validateStatus: null,
});
diff --git a/backend/api/routes.py b/backend/api/routes.py
index f8b770f8..d2534ac9 100644
--- a/backend/api/routes.py
+++ b/backend/api/routes.py
@@ -2,19 +2,16 @@
from fastapi import APIRouter, Depends, HTTPException, status
-from api.schema import CreateProjectRequest
+from api.schema import CreateProjectRequest, RenameProjectRequest
from auth.routes import get_current_user
from auth.schema import KimuJWT
from db import get_db_pool
-router = APIRouter(prefix="/api", tags=["api"])
+router = APIRouter(tags=["api"])
@router.get("/projects")
async def list_projects(user: KimuJWT = Depends(get_current_user)) -> dict:
- """
- Return all projects for the authenticated user.
- """
pool = await get_db_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
@@ -39,14 +36,11 @@ async def list_projects(user: KimuJWT = Depends(get_current_user)) -> dict:
return {"projects": projects}
-@router.post("/create-project", status_code=status.HTTP_201_CREATED)
+@router.post("/projects", status_code=status.HTTP_201_CREATED)
async def create_project(
body: CreateProjectRequest,
user: KimuJWT = Depends(get_current_user),
) -> dict:
- """
- Create a new project for the authenticated user.
- """
pool = await get_db_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
@@ -78,9 +72,6 @@ async def create_project(
async def save_project(
project_id: str, timeline: dict, user: KimuJWT = Depends(get_current_user)
) -> dict:
- """
- Save the project timeline to the database.
- """
pool = await get_db_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
@@ -102,3 +93,52 @@ async def save_project(
)
return {"ok": True, "project_id": str(row["id"])}
+
+
+@router.patch("/projects/{project_id}")
+async def rename_project(
+ project_id: str, body: RenameProjectRequest, user: KimuJWT = Depends(get_current_user)
+) -> dict:
+ pool = await get_db_pool()
+ async with pool.acquire() as conn:
+ row = await conn.fetchrow(
+ """
+ UPDATE projects
+ SET name = $1
+ WHERE id = $2 AND user_id = $3
+ RETURNING id
+ """,
+ body.name,
+ project_id,
+ user.user_id,
+ )
+
+ if row is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Project not found",
+ )
+
+ return {"ok": True, "project_id": str(row["id"])}
+
+
+@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_project(
+ project_id: str, user: KimuJWT = Depends(get_current_user)
+) -> None:
+ pool = await get_db_pool()
+ async with pool.acquire() as conn:
+ result = await conn.execute(
+ """
+ DELETE FROM projects
+ WHERE id = $1 AND user_id = $2
+ """,
+ project_id,
+ user.user_id,
+ )
+
+ if result == "DELETE 0":
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Project not found",
+ )
diff --git a/backend/api/schema.py b/backend/api/schema.py
index e5242605..52209766 100644
--- a/backend/api/schema.py
+++ b/backend/api/schema.py
@@ -3,3 +3,7 @@
class CreateProjectRequest(BaseModel):
name: str = Field(description="The name of the project")
+
+
+class RenameProjectRequest(BaseModel):
+ name: str = Field(description="The new name for the project")
diff --git a/backend/main.py b/backend/main.py
index 692eb33a..8bdb5d21 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -15,7 +15,7 @@
_ALLOWED_ORIGINS = [
"https://trykimu.com",
- "http://localhost:8080", # this is a lil finnicky but it works for now. we will move to an env based permanent solution later.
+ "http://localhost:5173", # Vite dev server
]
app.add_middleware(
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 705fee5a..ec147f46 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -9,11 +9,3 @@ services:
POSTGRES_PASSWORD: videoeditor
ports:
- "5432:5432"
-
- nginx:
- image: nginx:latest
- container_name: videoeditor-nginx-dev
- ports:
- - "8080:80"
- volumes:
- - ./nginx.dev.conf:/etc/nginx/nginx.conf:ro
diff --git a/nginx.conf b/nginx.conf
index 55489099..a03fd1eb 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -35,9 +35,9 @@ http {
# Security Headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
- # trykimu.com/ai/api/ai β http://fastapi/ai
- location /ai/api/ {
- rewrite ^/ai/api/(.*)$ /$1 break;
+ # trykimu.com/backend/* β http://fastapi/*
+ location /backend/ {
+ rewrite ^/backend/(.*)$ /$1 break;
proxy_pass http://fastapi;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -48,9 +48,9 @@ http {
proxy_request_buffering off;
}
- # trykimu.com/render/render β http://backend/render
- location /render/ {
- rewrite ^/render/(.*)$ /$1 break;
+ # trykimu.com/renderer/* β http://backend/*
+ location /renderer/ {
+ rewrite ^/renderer/(.*)$ /$1 break;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
diff --git a/nginx.dev.conf b/nginx.dev.conf
deleted file mode 100644
index d50858a4..00000000
--- a/nginx.dev.conf
+++ /dev/null
@@ -1,42 +0,0 @@
-events {}
-
-http {
- server {
- client_max_body_size 500M;
- listen 80;
-
- location / {
- proxy_pass http://host.docker.internal:5173;
- proxy_http_version 1.1;
- # http_host includes the port also with the host (localhost:8080) so we need to use it instead of $host
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- }
-
- location /api/ {
- proxy_pass http://host.docker.internal:8000/;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- location /ai/api/ {
- rewrite ^/ai/api/(.*)$ /$1 break;
- proxy_pass http://host.docker.internal:3000;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_read_timeout 900s;
- proxy_send_timeout 900s;
- proxy_request_buffering off;
- }
- }
-}
diff --git a/vite.config.ts b/vite.config.ts
index 4a88d587..010f5c03 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,4 +5,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
+ server: {
+ proxy: {
+ "/backend": {
+ target: "http://localhost:3000",
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/backend/, ""),
+ },
+ "/renderer": {
+ target: "http://localhost:8000",
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/renderer/, ""),
+ },
+ },
+ },
});
From 691f8cf681392ff40050a308aae33be091ff1c77 Mon Sep 17 00:00:00 2001
From: sr2echa <65058816+sr2echa@users.noreply.github.com>
Date: Sat, 2 May 2026 12:06:49 +0530
Subject: [PATCH 02/23] refactor: integrate BetterAuth
---
.env.example | 10 +-
README.md | 30 ++--
app/lib/auth-client.ts | 3 +
app/lib/auth.server.ts | 23 +++
app/routes.ts | 1 +
app/routes/api.auth.$.ts | 10 ++
app/routes/learn.tsx | 18 +-
app/routes/login.tsx | 41 ++---
app/routes/profile.tsx | 7 +-
app/routes/projects.tsx | 12 +-
app/utils/auth.server.ts | 37 ++--
backend/api/routes.py | 12 +-
backend/auth/routes.py | 179 +++----------------
backend/auth/schema.py | 27 +--
backend/auth/service.py | 79 ---------
backend/db.py | 4 +-
backend/pyproject.toml | 1 -
backend/uv.lock | 11 --
docker-compose.yml | 11 +-
migrations/000_init.sql | 119 ++++++++++---
package.json | 3 +-
pnpm-lock.yaml | 368 +++++++++++++++++++++++++++++++++++++--
22 files changed, 582 insertions(+), 424 deletions(-)
create mode 100644 app/lib/auth-client.ts
create mode 100644 app/lib/auth.server.ts
create mode 100644 app/routes/api.auth.$.ts
delete mode 100644 backend/auth/service.py
diff --git a/.env.example b/.env.example
index e0258e67..0953762c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,9 @@
+# Local/Docker Postgres: postgresql://videoeditor:videoeditor@localhost:5432/videoeditor
+# Supabase Session Pooler: postgresql://postgres.REF:password@aws-0-REGION.pooler.supabase.com:5432/postgres
DATABASE_URL=
-VITE_GOOGLE_CLIENT_ID=
-GOOGLE_CLIENT_SECRET=
-JWT_SECRET= # for the jwt secret, you can use `openssl rand -hex 32` to generate a random string
+DATABASE_SSL= # Set to "true" when using Supabase or any remote DB that requires SSL. Omit for local/Docker Postgres.
+BETTER_AUTH_SECRET= # openssl rand -hex 32
+BETTER_AUTH_URL= # https://trykimu.com (production) or http://localhost:5173 (dev)
+GOOGLE_CLIENT_ID= # Google OAuth client ID
+GOOGLE_CLIENT_SECRET= # Google OAuth client secret
GEMINI_API_KEY=
diff --git a/README.md b/README.md
index a95846c3..a18589c4 100644
--- a/README.md
+++ b/README.md
@@ -132,30 +132,36 @@ nginx routes:
## βοΈ Environment Configuration
-Create a `.env` file for custom settings:
+Copy `.env.example` to `.env` and fill in your values:
```env
-# Domain Configuration
-PROD_DOMAIN=yourdomain.com
+# Local/Docker Postgres (dev or self-hosted prod):
+DATABASE_URL=postgresql://videoeditor:videoeditor@localhost:5432/videoeditor
-# Database
-DATABASE_URL=postgresql://user:pass@localhost:5432/videoeditor
+# β OR β Supabase Session Pooler (cloud):
+DATABASE_URL=postgresql://postgres.REF:password@aws-0-REGION.pooler.supabase.com:5432/postgres
+DATABASE_SSL=true # required for Supabase; omit for local/Docker Postgres
-# Authentication (Google OAuth)
+# BetterAuth
+BETTER_AUTH_SECRET= # generate with: openssl rand -hex 32
+BETTER_AUTH_URL=https://yourdomain.com # http://localhost:5173 for dev
+
+# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
-# AI Features (Optional -> /backend)
+# AI Features (optional)
GEMINI_API_KEY=your_gemini_api_key
-
```
**Environment Variables Explained:**
-- `PROD_DOMAIN`: Your production domain (host only, e.g., `yourdomain.com`)
-- `DATABASE_URL`: PostgreSQL connection string
-- `GOOGLE_CLIENT_ID/SECRET`: Google OAuth credentials for authentication
-- `GEMINI_API_KEY`: Required for AI-powered video editing features
+- `DATABASE_URL`: PostgreSQL connection string. Use the local Docker URL for dev/self-hosted prod, or the Supabase Session Pooler URL for cloud.
+- `DATABASE_SSL`: Set to `"true"` when connecting to Supabase or any remote DB that requires SSL. Leave unset for local/Docker Postgres.
+- `BETTER_AUTH_SECRET`: Random secret used to sign sessions β generate with `openssl rand -hex 32`.
+- `BETTER_AUTH_URL`: The public URL of the app. Used by BetterAuth for OAuth callbacks.
+- `GOOGLE_CLIENT_ID/SECRET`: Google OAuth credentials β register at [console.cloud.google.com](https://console.cloud.google.com).
+- `GEMINI_API_KEY`: Required for AI-powered video editing features.
diff --git a/app/lib/auth-client.ts b/app/lib/auth-client.ts
new file mode 100644
index 00000000..f1012dd4
--- /dev/null
+++ b/app/lib/auth-client.ts
@@ -0,0 +1,3 @@
+import { createAuthClient } from "better-auth/react";
+
+export const authClient = createAuthClient();
diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts
new file mode 100644
index 00000000..a12b0e71
--- /dev/null
+++ b/app/lib/auth.server.ts
@@ -0,0 +1,23 @@
+import { betterAuth } from "better-auth";
+import { Pool } from "pg";
+
+export const auth = betterAuth({
+ database: new Pool({
+ connectionString: process.env.DATABASE_URL,
+ ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false,
+ connectionTimeoutMillis: 5000,
+ }),
+ baseURL: process.env.BETTER_AUTH_URL,
+ secret: process.env.BETTER_AUTH_SECRET,
+ socialProviders: {
+ google: {
+ clientId: process.env.GOOGLE_CLIENT_ID as string,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
+ },
+ },
+ advanced: {
+ database: {
+ generateId: "uuid",
+ },
+ },
+});
diff --git a/app/routes.ts b/app/routes.ts
index 41cd76fb..ca004193 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -1,6 +1,7 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
+ route("/api/auth/*", "routes/api.auth.$.ts"),
route("/", "routes/landing.tsx"),
route("/marketplace", "routes/marketplace.tsx"),
route("/login", "routes/login.tsx"),
diff --git a/app/routes/api.auth.$.ts b/app/routes/api.auth.$.ts
new file mode 100644
index 00000000..d9e84a3d
--- /dev/null
+++ b/app/routes/api.auth.$.ts
@@ -0,0 +1,10 @@
+import { auth } from "~/lib/auth.server";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ return auth.handler(request);
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ return auth.handler(request);
+}
diff --git a/app/routes/learn.tsx b/app/routes/learn.tsx
index 3ebee557..a2e9f867 100644
--- a/app/routes/learn.tsx
+++ b/app/routes/learn.tsx
@@ -3,7 +3,6 @@
// import { Player } from "@remotion/player";
// import { createTikTokStyleCaptions } from "@remotion/captions";
-import { GoogleLogin, GoogleOAuthProvider, type CredentialResponse } from "@react-oauth/google";
// type Caption = {
// text: string;
@@ -552,24 +551,9 @@ import { GoogleLogin, GoogleOAuthProvider, type CredentialResponse } from "@reac
// export default CaptionsPlayer;
export default function learn() {
- const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
-
- const handleGoogleSuccess = (credentialResponse: CredentialResponse) => {
- console.log("Google sign-in success", credentialResponse);
- };
-
return (
-
-
-
-
- console.log("Google sign-in error")} />
-
-
-
+
Learn page
);
}
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
index 14341e65..fde89dbe 100644
--- a/app/routes/login.tsx
+++ b/app/routes/login.tsx
@@ -1,29 +1,19 @@
-import { GoogleLogin, GoogleOAuthProvider, type CredentialResponse } from "@react-oauth/google";
import { Clapperboard, Wand2, Scissors } from "lucide-react";
import { KimuLogo } from "~/components/ui/KimuLogo";
import { FaGoogle } from "react-icons/fa";
-import axios from "axios";
import { redirect, type LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/utils/auth.server";
+import { authClient } from "~/lib/auth-client";
export async function loader({ request }: LoaderFunctionArgs) {
- const res = await requireUser(request);
- // the user has a cookie and it is valid, so we redirect to the projects page
- if (res.status === 200) throw redirect("/projects");
+ const user = await requireUser(request);
+ if (user) throw redirect("/projects");
return null;
}
export default function LoginPage() {
- const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
-
- const handleGoogleLogin = async (credentialResponse: CredentialResponse) => {
- console.log("credentialResponse", credentialResponse);
- const response = await axios.post("/backend/auth/google", {
- credential: credentialResponse.credential,
- });
- if (response.status === 200) {
- window.location.href = "/projects";
- }
+ const handleGoogleLogin = async () => {
+ await authClient.signIn.social({ provider: "google", callbackURL: "/projects" });
};
return (
@@ -101,20 +91,13 @@ export default function LoginPage() {
Welcome to Kimu
Cinematic editing, reimagined.
-
-
-
-
- handleGoogleLogin(credentialResponse)}
- onError={() => console.log("Google sign-in error")}
- />
-
-
{" "}
-
+
We never post on your behalf.
diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx
index f9f683a4..b97422b1 100644
--- a/app/routes/profile.tsx
+++ b/app/routes/profile.tsx
@@ -32,10 +32,9 @@ export default function Profile() {
})();
(async () => {
try {
- const res = await fetch("/backend/auth/session", { credentials: "include" });
- if (!res.ok) return;
- const j = await res.json();
- const created = j?.user?.createdAt || j?.user?.created_at || j?.user?.created_at_ms || null;
+ const { authClient } = await import("~/lib/auth-client");
+ const session = await authClient.getSession();
+ const created = session?.data?.user?.createdAt ?? null;
if (!cancelled && created) setMemberSince(String(created));
} catch (error) {
console.error("Failed to fetch user session:", error);
diff --git a/app/routes/projects.tsx b/app/routes/projects.tsx
index 228a1bf2..45e8d2be 100644
--- a/app/routes/projects.tsx
+++ b/app/routes/projects.tsx
@@ -53,9 +53,8 @@ type Project = {
};
export async function loader({ request }: LoaderFunctionArgs) {
- const res = await requireUser(request);
- // the user does not have a cookie or the cookie is invalid, so we redirect to the login page
- if (res.status !== 200) throw redirect("/login");
+ const user = await requireUser(request);
+ if (!user) throw redirect("/login");
const { origin } = new URL(request.url);
const projectsRes = await axios.get<{ projects: Project[] }>(`${origin}/backend/projects`, {
@@ -63,7 +62,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
return {
- user: res.data,
+ user,
projects: projectsRes.data.projects,
};
}
@@ -288,10 +287,11 @@ export default function Projects() {
{
- await axios.post("/backend/auth/logout", {}, { withCredentials: true });
+ const { authClient } = await import("~/lib/auth-client");
+ await authClient.signOut({ fetchOptions: { onSuccess: () => { window.location.href = "/login"; } } });
}}
/>
diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts
index a552611c..42429c41 100644
--- a/app/utils/auth.server.ts
+++ b/app/utils/auth.server.ts
@@ -1,32 +1,19 @@
-import axios, { type AxiosResponse } from "axios";
+import { auth } from "~/lib/auth.server";
-// the data in the cookie
export type SessionUser = {
- user_id: string;
+ id: string;
email: string;
name: string;
- avatar_url: string;
+ image: string | null;
};
-export async function requireUser(request: Request): Promise> {
- const cookie = request.headers.get("Cookie");
-
- // there is no cookie, so we return a 401
- if (!cookie)
- return {
- data: {} as SessionUser,
- status: 401,
- statusText: "Unauthorized",
- headers: {},
- config: {} as never,
- };
-
- const { origin } = new URL(request.url);
-
- const res = await axios.get(`${origin}/backend/auth/me`, {
- headers: { Cookie: cookie }, // include the cookie in the request headers because we are in server code and not in the browser
- validateStatus: null,
- });
-
- return res;
+export async function requireUser(request: Request): Promise {
+ const session = await auth.api.getSession({ headers: request.headers });
+ if (!session) return null;
+ return {
+ id: session.user.id,
+ email: session.user.email,
+ name: session.user.name,
+ image: session.user.image ?? null,
+ };
}
diff --git a/backend/api/routes.py b/backend/api/routes.py
index d2534ac9..152a8367 100644
--- a/backend/api/routes.py
+++ b/backend/api/routes.py
@@ -4,14 +4,14 @@
from api.schema import CreateProjectRequest, RenameProjectRequest
from auth.routes import get_current_user
-from auth.schema import KimuJWT
+from auth.schema import SessionUser
from db import get_db_pool
router = APIRouter(tags=["api"])
@router.get("/projects")
-async def list_projects(user: KimuJWT = Depends(get_current_user)) -> dict:
+async def list_projects(user: SessionUser = Depends(get_current_user)) -> dict:
pool = await get_db_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
@@ -39,7 +39,7 @@ async def list_projects(user: KimuJWT = Depends(get_current_user)) -> dict:
@router.post("/projects", status_code=status.HTTP_201_CREATED)
async def create_project(
body: CreateProjectRequest,
- user: KimuJWT = Depends(get_current_user),
+ user: SessionUser = Depends(get_current_user),
) -> dict:
pool = await get_db_pool()
async with pool.acquire() as conn:
@@ -70,7 +70,7 @@ async def create_project(
@router.put("/projects/{project_id}")
async def save_project(
- project_id: str, timeline: dict, user: KimuJWT = Depends(get_current_user)
+ project_id: str, timeline: dict, user: SessionUser = Depends(get_current_user)
) -> dict:
pool = await get_db_pool()
async with pool.acquire() as conn:
@@ -97,7 +97,7 @@ async def save_project(
@router.patch("/projects/{project_id}")
async def rename_project(
- project_id: str, body: RenameProjectRequest, user: KimuJWT = Depends(get_current_user)
+ project_id: str, body: RenameProjectRequest, user: SessionUser = Depends(get_current_user)
) -> dict:
pool = await get_db_pool()
async with pool.acquire() as conn:
@@ -124,7 +124,7 @@ async def rename_project(
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
- project_id: str, user: KimuJWT = Depends(get_current_user)
+ project_id: str, user: SessionUser = Depends(get_current_user)
) -> None:
pool = await get_db_pool()
async with pool.acquire() as conn:
diff --git a/backend/auth/routes.py b/backend/auth/routes.py
index 0fea98ed..f569785c 100644
--- a/backend/auth/routes.py
+++ b/backend/auth/routes.py
@@ -1,175 +1,46 @@
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
-from fastapi.responses import JSONResponse
-from auth.schema import KimuJWT, KimuPayload, SignUpGoogleRequest
-from auth.service import (
- COOKIE_MAX_AGE,
- COOKIE_NAME,
- generate_kimu_jwt,
- verify_google_id_token,
- verify_kimu_jwt,
-)
+from auth.schema import SessionUser
from db import get_db_pool
-from utils import require_env
-router = APIRouter(prefix="/auth", tags=["auth"])
+_BETTER_AUTH_COOKIE = "better-auth.session_token"
-GOOGLE_CLIENT_ID: str = require_env("VITE_GOOGLE_CLIENT_ID")
-JWT_SECRET: str = require_env("JWT_SECRET")
+router = APIRouter(prefix="/auth", tags=["auth"])
async def get_current_user(
- kimu_session: str = Cookie(alias=COOKIE_NAME),
-) -> KimuJWT:
- """
- FastAPI dependency. Reads the session JWT from the HttpOnly cookie.
- """
- try:
- return verify_kimu_jwt(kimu_session, JWT_SECRET)
- except Exception as exc:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=str(exc),
- ) from exc
-
-
-@router.post("/google")
-async def google_sign_in(body: SignUpGoogleRequest) -> JSONResponse:
+ session_token: str | None = Cookie(default=None, alias=_BETTER_AUTH_COOKIE),
+) -> SessionUser:
"""
- Verify the Google ID token, upsert the user, return user info and
- set an HttpOnly session cookie with the Kimu JWT.
+ FastAPI dependency. Reads the BetterAuth session token from the HttpOnly
+ cookie and validates it against the session/user tables in Postgres.
"""
- # 1. Verify the Google credential
- try:
- google_user = verify_google_id_token(body.credential, GOOGLE_CLIENT_ID)
- except ValueError as exc:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=f"Google token verification failed: {exc}",
- ) from exc
+ if not session_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
- # 2. Upsert user + identity in Postgres
pool = await get_db_pool()
- async with pool.acquire() as conn, conn.transaction():
+ async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
- SELECT u.id, u.email, u.name
- FROM user_identities ui
- JOIN users u ON u.id = ui.user_id
- WHERE ui.provider = $1 AND ui.provider_sub = $2
+ SELECT u.id, u.name, u.email, u.image
+ FROM session s
+ JOIN "user" u ON u.id = s."userId"
+ WHERE s.token = $1 AND s."expiresAt" > now()
""",
- "google",
- google_user.sub,
+ session_token,
)
- if row is None:
- # Create or reuse the user row by email, then link Google identity.
- user_row = await conn.fetchrow(
- """
- INSERT INTO users (email, name)
- VALUES ($1, $2)
- ON CONFLICT (email)
- DO NOTHING
- RETURNING id, email, name
- """,
- google_user.email,
- google_user.name,
- )
-
- if user_row is None:
- user_row = await conn.fetchrow(
- """
- SELECT id, email, name
- FROM users
- WHERE email = $1
- """,
- google_user.email,
- )
- if user_row is None:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Failed to create or fetch user",
- )
-
- await conn.execute(
- """
- INSERT INTO user_identities (user_id, provider, provider_sub)
- VALUES ($1, $2, $3)
- ON CONFLICT (provider, provider_sub) DO NOTHING
- """,
- user_row["id"],
- "google",
- google_user.sub,
- )
-
- row = await conn.fetchrow(
- """
- SELECT u.id, u.email, u.name
- FROM user_identities ui
- JOIN users u ON u.id = ui.user_id
- WHERE ui.provider = $1 AND ui.provider_sub = $2
- """,
- "google",
- google_user.sub,
- )
-
- if row is None:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Failed to create or fetch user identity",
- )
-
- user_id = str(row["id"])
- user_email = str(row["email"])
- user_name = str(row["name"])
+ if row is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
- # 3. Generate Kimu JWT
- payload = KimuPayload(
- user_id=user_id,
- email=user_email,
- name=user_name,
- avatar_url=google_user.picture,
- )
- token = generate_kimu_jwt(payload, JWT_SECRET)
-
- # 4. Build response with HttpOnly cookie
- body_data = KimuPayload(
- user_id=user_id,
- email=user_email,
- name=user_name,
- avatar_url=google_user.picture,
- )
- response = JSONResponse(content=body_data.model_dump())
- response.set_cookie(
- key=COOKIE_NAME,
- value=token,
- max_age=COOKIE_MAX_AGE,
- httponly=True,
- secure=True,
- samesite="lax",
- path="/",
- )
- return response
-
-
-@router.get("/me", response_model=KimuPayload)
-async def get_me(user: KimuJWT = Depends(get_current_user)) -> KimuPayload:
- """
- Return the current user's profile from the JWT.
- """
- return KimuPayload(
- user_id=user.user_id,
- email=user.email,
- name=user.name,
- avatar_url=user.avatar_url,
+ return SessionUser(
+ user_id=str(row["id"]),
+ email=str(row["email"]),
+ name=str(row["name"]),
+ image=str(row["image"]) if row["image"] else None,
)
-@router.post("/logout")
-async def logout() -> JSONResponse:
- """
- Log out the current user by clearing the HttpOnly session cookie.
- """
- response = JSONResponse(content={})
- response.delete_cookie(key=COOKIE_NAME)
- return response
+@router.get("/me", response_model=SessionUser)
+async def get_me(user: SessionUser = Depends(get_current_user)) -> SessionUser:
+ return user
diff --git a/backend/auth/schema.py b/backend/auth/schema.py
index 6218b9db..5d719038 100644
--- a/backend/auth/schema.py
+++ b/backend/auth/schema.py
@@ -1,29 +1,10 @@
from pydantic import BaseModel
-class SignUpGoogleRequest(BaseModel):
- credential: str # Google ID token from the client
+class SessionUser(BaseModel):
+ """User resolved from a valid BetterAuth session."""
-
-class GoogleJWT(BaseModel):
- """Subset of claims we need from a verified Google ID token."""
-
- sub: str # stable unique Google user ID
- email: str
- name: str
- picture: str # URL of the user's profile picture, not stored in the database
-
-
-class KimuPayload(BaseModel):
- """Claims embedded in Kimu's own application JWT. This is the payload that is signed by the server and sent to the client."""
-
- user_id: str # UUID as string
+ user_id: str
email: str
name: str
- avatar_url: str
-
-
-class KimuJWT(KimuPayload):
- """Decoded Kimu JWT (includes the expiration claim)."""
-
- exp: int
+ image: str | None = None
diff --git a/backend/auth/service.py b/backend/auth/service.py
deleted file mode 100644
index df9794ce..00000000
--- a/backend/auth/service.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import datetime
-
-import jwt
-from google.auth.transport import requests as google_requests
-from google.oauth2 import id_token
-
-from auth.schema import GoogleJWT, KimuJWT, KimuPayload
-
-COOKIE_NAME = "kimu_session"
-COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days in seconds
-
-
-def verify_google_id_token(token: str, client_id: str) -> GoogleJWT:
- """Verify a Google ID token and return the decoded claims.
-
- Uses google-auth which handles JWKS fetching, key rotation, signature
- verification, and iss/aud/exp validation internally.
-
- Raises ValueError on any verification failure.
- """
- request = google_requests.Request()
-
- id_info: dict[str, object] = id_token.verify_oauth2_token(
- token, request, audience=client_id
- )
-
- issuer = id_info.get("iss")
- if issuer not in ("accounts.google.com", "https://accounts.google.com"):
- raise ValueError(f"Invalid issuer: {issuer}")
-
- # i still dont know how email can NOT be verified. but it is a field and guess we'll follow the spec.
- if not id_info.get("email_verified"):
- raise ValueError("Email address is not verified by Google")
-
- return GoogleJWT(
- sub=str(id_info["sub"]),
- email=str(id_info["email"]),
- name=str(id_info["name"]),
- picture=str(id_info["picture"]),
- )
-
-
-def generate_kimu_jwt(payload: KimuPayload, secret_key: str) -> str:
- """Generate a signed HS256 JWT for Kimu sessions."""
- expiration = (
- datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30)
- ) # TODO: this is a very basic mvp ship. we need to migrate to a better solution with token rotation.
-
- token: str = jwt.encode(
- {
- "user_id": payload.user_id,
- "email": payload.email,
- "name": payload.name,
- "avatar_url": payload.avatar_url,
- "exp": expiration,
- },
- secret_key,
- algorithm="HS256",
- )
- return token
-
-
-def verify_kimu_jwt(token: str, secret_key: str) -> KimuJWT:
- """Decode and verify a Kimu application JWT.
-
- Raises jwt.ExpiredSignatureError if the token is expired.
- Raises jwt.InvalidTokenError for any other verification failure.
- """
- decoded: dict[str, object] = jwt.decode(token, secret_key, algorithms=["HS256"])
- raw_exp = decoded["exp"]
- if not isinstance(raw_exp, int):
- raise jwt.InvalidTokenError("Missing or invalid exp claim")
- return KimuJWT(
- user_id=str(decoded["user_id"]),
- email=str(decoded["email"]),
- name=str(decoded["name"]),
- avatar_url=str(decoded["avatar_url"]),
- exp=raw_exp,
- )
diff --git a/backend/db.py b/backend/db.py
index 9d179645..e6f53d04 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -1,3 +1,4 @@
+import os
from typing import Optional
import asyncpg # type: ignore[import-untyped]
@@ -15,5 +16,6 @@ async def get_db_pool() -> asyncpg.Pool:
"""
global _pool
if _pool is None:
- _pool = await asyncpg.create_pool(DATABASE_URL)
+ ssl = "require" if os.getenv("DATABASE_SSL") == "true" else None
+ _pool = await asyncpg.create_pool(DATABASE_URL, ssl=ssl)
return _pool
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 822ec458..8cbcbed1 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -12,7 +12,6 @@ dependencies = [
"asyncpg>=0.31.0",
"fastapi[standard]>=0.115.13",
"google-genai>=1.22.0",
- "pyjwt>=2.11.0",
"python-multipart>=0.0.22",
]
diff --git a/backend/uv.lock b/backend/uv.lock
index 7e23526a..f293d0f2 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -86,7 +86,6 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi", extra = ["standard"] },
{ name = "google-genai" },
- { name = "pyjwt" },
{ name = "python-multipart" },
]
@@ -102,7 +101,6 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.13" },
{ name = "google-genai", specifier = ">=1.22.0" },
- { name = "pyjwt", specifier = ">=2.11.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
]
@@ -1025,15 +1023,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
-[[package]]
-name = "pyjwt"
-version = "2.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
-]
-
[[package]]
name = "python-discovery"
version = "1.1.0"
diff --git a/docker-compose.yml b/docker-compose.yml
index 84cf6840..1469cd6e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,17 +16,11 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
- args:
- VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
container_name: videoeditor-frontend
env_file:
- .env
environment:
- # Ensure server-side code can construct proper callback URLs
- AUTH_BASE_URL: https://trykimu.com
- AUTH_TRUSTED_ORIGINS: https://trykimu.com,https://www.trykimu.com
- AUTH_COOKIE_DOMAIN: trykimu.com
+ BETTER_AUTH_URL: https://trykimu.com
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3000
@@ -43,9 +37,6 @@ services:
env_file:
- .env
environment:
- AUTH_BASE_URL: https://trykimu.com
- AUTH_TRUSTED_ORIGINS: https://trykimu.com,https://www.trykimu.com
- AUTH_COOKIE_DOMAIN: trykimu.com
NODE_ENV: production
PORT: 8000
# ports:
diff --git a/migrations/000_init.sql b/migrations/000_init.sql
index 4a6b4f5e..b0d594dc 100644
--- a/migrations/000_init.sql
+++ b/migrations/000_init.sql
@@ -1,39 +1,102 @@
-CREATE TABLE users (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- email TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+-- BetterAuth: user table
+CREATE TABLE "user" (
+ id TEXT PRIMARY KEY,
+ name TEXT,
+ email TEXT NOT NULL UNIQUE,
+ "emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
+ image TEXT,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
+-- BetterAuth: session table
+CREATE TABLE session (
+ id TEXT PRIMARY KEY,
+ "expiresAt" TIMESTAMPTZ NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "ipAddress" TEXT,
+ "userAgent" TEXT,
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
+);
+
+-- BetterAuth: account table (OAuth providers)
+CREATE TABLE account (
+ id TEXT PRIMARY KEY,
+ "accountId" TEXT,
+ "providerId" TEXT,
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ "accessToken" TEXT,
+ "refreshToken" TEXT,
+ "idToken" TEXT,
+ "accessTokenExpiresAt" TIMESTAMPTZ,
+ "refreshTokenExpiresAt" TIMESTAMPTZ,
+ scope TEXT,
+ password TEXT,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- BetterAuth: verification table
+CREATE TABLE verification (
+ id TEXT PRIMARY KEY,
+ identifier TEXT NOT NULL,
+ value TEXT NOT NULL,
+ "expiresAt" TIMESTAMPTZ NOT NULL,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- Auto-update "updatedAt" on row changes
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+ NEW."updatedAt" = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_user_updated_at
+ BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER trg_session_updated_at
+ BEFORE UPDATE ON session FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER trg_account_updated_at
+ BEFORE UPDATE ON account FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER trg_verification_updated_at
+ BEFORE UPDATE ON verification FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- Application: projects
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name TEXT NOT NULL,
timeline_state JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-
+-- Application: assets
CREATE TABLE assets (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- storage_key TEXT NOT NULL,
- name TEXT NOT NULL,
- mime_type TEXT NOT NULL,
- size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
- metadata JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
-CREATE TABLE user_identities (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- provider TEXT NOT NULL, -- google, apple, etc. we only support google for now.
- provider_sub TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE (provider, provider_sub) -- ensures each user has only one identity per provider
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
+ original_name TEXT NOT NULL,
+ storage_key TEXT NOT NULL,
+ mime_type TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ width INT,
+ height INT,
+ duration_seconds DOUBLE PRECISION,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
);
-CREATE INDEX idx_projects_user_id ON projects(user_id, created_at DESC);
-CREATE INDEX idx_assets_project_id ON assets(project_id);
-CREATE INDEX idx_user_identities_user_id ON user_identities(user_id);
+-- Indexes
+CREATE INDEX idx_session_token ON session(token);
+CREATE INDEX idx_session_user_id ON session("userId");
+CREATE INDEX idx_account_user_id ON account("userId");
+CREATE INDEX idx_account_provider ON account("providerId", "accountId");
+CREATE INDEX idx_projects_user_id ON projects(user_id, created_at DESC);
+CREATE INDEX idx_assets_user_id ON assets(user_id, created_at DESC);
+CREATE INDEX idx_assets_user_project_id ON assets(user_id, project_id, created_at DESC);
+CREATE UNIQUE INDEX idx_assets_user_storage_key ON assets(user_id, storage_key);
diff --git a/package.json b/package.json
index 3dd2726d..bd386c40 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,6 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
- "@react-oauth/google": "^0.13.4",
"@react-router/node": "^7.9.5",
"@react-router/serve": "^7.7.1",
"@remotion/bundler": "4.0.329",
@@ -39,6 +38,7 @@
"@remotion/transitions": "4.0.329",
"@types/cors": "^2.8.19",
"axios": "^1.13.5",
+ "better-auth": "^1.6.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@@ -49,6 +49,7 @@
"lucide-react": "^0.534.0",
"motion": "^12.23.12",
"next-themes": "^0.4.6",
+ "pg": "^8.20.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db5f1844..60369929 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,9 +69,6 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@react-oauth/google':
- specifier: ^0.13.4
- version: 0.13.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@react-router/node':
specifier: ^7.9.5
version: 7.12.0(react-router@7.12.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.8.3)
@@ -108,6 +105,9 @@ importers:
axios:
specifier: ^1.13.5
version: 1.13.5
+ better-auth:
+ specifier: ^1.6.9
+ version: 1.6.9(pg@8.20.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -138,6 +138,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ pg:
+ specifier: ^8.20.0
+ version: 8.20.0
react:
specifier: ^19.1.1
version: 19.1.1
@@ -376,6 +379,85 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
+ '@better-auth/core@1.6.9':
+ resolution: {integrity: sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==}
+ peerDependencies:
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+ '@cloudflare/workers-types': '>=4'
+ '@opentelemetry/api': ^1.9.0
+ better-call: 1.3.5
+ jose: ^6.1.0
+ kysely: ^0.28.5
+ nanostores: ^1.0.1
+ peerDependenciesMeta:
+ '@cloudflare/workers-types':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+
+ '@better-auth/drizzle-adapter@1.6.9':
+ resolution: {integrity: sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+ drizzle-orm: ^0.45.2
+ peerDependenciesMeta:
+ drizzle-orm:
+ optional: true
+
+ '@better-auth/kysely-adapter@1.6.9':
+ resolution: {integrity: sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+ kysely: ^0.28.14
+ peerDependenciesMeta:
+ kysely:
+ optional: true
+
+ '@better-auth/memory-adapter@1.6.9':
+ resolution: {integrity: sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+
+ '@better-auth/mongo-adapter@1.6.9':
+ resolution: {integrity: sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+ mongodb: ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ mongodb:
+ optional: true
+
+ '@better-auth/prisma-adapter@1.6.9':
+ resolution: {integrity: sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ '@prisma/client':
+ optional: true
+ prisma:
+ optional: true
+
+ '@better-auth/telemetry@1.6.9':
+ resolution: {integrity: sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==}
+ peerDependencies:
+ '@better-auth/core': ^1.6.9
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+
+ '@better-auth/utils@0.4.0':
+ resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==}
+
+ '@better-fetch/fetch@1.1.21':
+ resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
+
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -941,6 +1023,14 @@ packages:
'@mjackson/node-fetch-server@0.2.0':
resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==}
+ '@noble/ciphers@2.2.0':
+ resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.2.0':
+ resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
+ engines: {node: '>= 20.19.0'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -965,6 +1055,10 @@ packages:
resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+ '@opentelemetry/semantic-conventions@1.40.0':
+ resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
+ engines: {node: '>=14'}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1450,12 +1544,6 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@react-oauth/google@0.13.4':
- resolution: {integrity: sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==}
- peerDependencies:
- react: '>=16.8.0'
- react-dom: '>=16.8.0'
-
'@react-router/dev@7.7.1':
resolution: {integrity: sha512-ByfgHmAyfx/JQYN/QwUx1sFJlBA5Z3HQAZ638wHSb+m6khWtHqSaKCvPqQh1P00wdEAeV3tX5L1aUM/ceCF6+w==}
engines: {node: '>=20.0.0'}
@@ -1740,6 +1828,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@tailwindcss/node@4.1.11':
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
@@ -2157,6 +2248,76 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
+ better-auth@1.6.9:
+ resolution: {integrity: sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==}
+ peerDependencies:
+ '@lynx-js/react': '*'
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@sveltejs/kit': ^2.0.0
+ '@tanstack/react-start': ^1.0.0
+ '@tanstack/solid-start': ^1.0.0
+ better-sqlite3: ^12.0.0
+ drizzle-kit: '>=0.31.4'
+ drizzle-orm: ^0.45.2
+ mongodb: ^6.0.0 || ^7.0.0
+ mysql2: ^3.0.0
+ next: ^14.0.0 || ^15.0.0 || ^16.0.0
+ pg: ^8.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ solid-js: ^1.0.0
+ svelte: ^4.0.0 || ^5.0.0
+ vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
+ vue: ^3.0.0
+ peerDependenciesMeta:
+ '@lynx-js/react':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@sveltejs/kit':
+ optional: true
+ '@tanstack/react-start':
+ optional: true
+ '@tanstack/solid-start':
+ optional: true
+ better-sqlite3:
+ optional: true
+ drizzle-kit:
+ optional: true
+ drizzle-orm:
+ optional: true
+ mongodb:
+ optional: true
+ mysql2:
+ optional: true
+ next:
+ optional: true
+ pg:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vitest:
+ optional: true
+ vue:
+ optional: true
+
+ better-call@1.3.5:
+ resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==}
+ peerDependencies:
+ zod: 4.0.9
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -2395,6 +2556,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -3041,6 +3205,9 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
+ jose@6.2.3:
+ resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3088,6 +3255,10 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
+ kysely@0.28.16:
+ resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==}
+ engines: {node: '>=20.0.0'}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -3335,6 +3506,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanostores@1.3.0:
+ resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -3495,17 +3670,43 @@ packages:
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+ pg-cloudflare@1.3.0:
+ resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
+
+ pg-connection-string@2.12.0:
+ resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
+
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
+ pg-pool@3.13.0:
+ resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
+ peerDependencies:
+ pg: '>=8.0'
+
pg-protocol@1.10.3:
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
+ pg-protocol@1.13.0:
+ resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
+
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
+ pg@8.20.0:
+ resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ pg-native: '>=3.0.1'
+ peerDependenciesMeta:
+ pg-native:
+ optional: true
+
+ pgpass@1.0.5:
+ resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3765,6 +3966,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ rou3@0.7.12:
+ resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
+
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -3843,6 +4047,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+ set-cookie-parser@3.1.0:
+ resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3926,6 +4133,10 @@ packages:
spdx-license-ids@3.0.21:
resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==}
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -4556,6 +4767,57 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
+ '@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)':
+ dependencies:
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+ '@opentelemetry/semantic-conventions': 1.40.0
+ '@standard-schema/spec': 1.1.0
+ better-call: 1.3.5(zod@4.0.9)
+ jose: 6.2.3
+ kysely: 0.28.16
+ nanostores: 1.3.0
+ zod: 4.0.9
+
+ '@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+
+ '@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+ optionalDependencies:
+ kysely: 0.28.16
+
+ '@better-auth/memory-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+
+ '@better-auth/mongo-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+
+ '@better-auth/prisma-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+
+ '@better-auth/telemetry@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)':
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+
+ '@better-auth/utils@0.4.0':
+ dependencies:
+ '@noble/hashes': 2.2.0
+
+ '@better-fetch/fetch@1.1.21': {}
+
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -4895,6 +5157,10 @@ snapshots:
'@mjackson/node-fetch-server@0.2.0': {}
+ '@noble/ciphers@2.2.0': {}
+
+ '@noble/hashes@2.2.0': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4936,6 +5202,8 @@ snapshots:
dependencies:
which: 3.0.1
+ '@opentelemetry/semantic-conventions@1.40.0': {}
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.2': {}
@@ -5421,11 +5689,6 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
- '@react-oauth/google@0.13.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
- dependencies:
- react: 19.1.1
- react-dom: 19.1.1(react@19.1.1)
-
'@react-router/dev@7.7.1(@react-router/serve@7.7.1(react-router@7.12.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.8.3))(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-router@7.12.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(terser@5.43.1)(tsx@4.20.4)(typescript@5.8.3)(vite@7.3.1(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4))':
dependencies:
'@babel/core': 7.28.0
@@ -5765,6 +6028,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.50.1':
optional: true
+ '@standard-schema/spec@1.1.0': {}
+
'@tailwindcss/node@4.1.11':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -6266,6 +6531,42 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
+ better-auth@1.6.9(pg@8.20.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
+ '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
+ '@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)
+ '@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
+ '@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
+ '@better-auth/prisma-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
+ '@better-auth/telemetry': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.0.9))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+ '@noble/ciphers': 2.2.0
+ '@noble/hashes': 2.2.0
+ better-call: 1.3.5(zod@4.0.9)
+ defu: 6.1.7
+ jose: 6.2.3
+ kysely: 0.28.16
+ nanostores: 1.3.0
+ zod: 4.0.9
+ optionalDependencies:
+ pg: 8.20.0
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ transitivePeerDependencies:
+ - '@cloudflare/workers-types'
+ - '@opentelemetry/api'
+
+ better-call@1.3.5(zod@4.0.9):
+ dependencies:
+ '@better-auth/utils': 0.4.0
+ '@better-fetch/fetch': 1.1.21
+ rou3: 0.7.12
+ set-cookie-parser: 3.1.0
+ optionalDependencies:
+ zod: 4.0.9
+
big.js@5.2.2: {}
body-parser@1.20.3:
@@ -6512,6 +6813,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ defu@6.1.7: {}
+
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@@ -7369,6 +7672,8 @@ snapshots:
jiti@2.5.1: {}
+ jose@6.2.3: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -7404,6 +7709,8 @@ snapshots:
kleur@3.0.3: {}
+ kysely@0.28.16: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -7597,6 +7904,8 @@ snapshots:
nanoid@3.3.11: {}
+ nanostores@1.3.0: {}
+
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -7757,10 +8066,21 @@ snapshots:
pend@1.2.0: {}
+ pg-cloudflare@1.3.0:
+ optional: true
+
+ pg-connection-string@2.12.0: {}
+
pg-int8@1.0.1: {}
+ pg-pool@3.13.0(pg@8.20.0):
+ dependencies:
+ pg: 8.20.0
+
pg-protocol@1.10.3: {}
+ pg-protocol@1.13.0: {}
+
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
@@ -7769,6 +8089,20 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
+ pg@8.20.0:
+ dependencies:
+ pg-connection-string: 2.12.0
+ pg-pool: 3.13.0(pg@8.20.0)
+ pg-protocol: 1.13.0
+ pg-types: 2.2.0
+ pgpass: 1.0.5
+ optionalDependencies:
+ pg-cloudflare: 1.3.0
+
+ pgpass@1.0.5:
+ dependencies:
+ split2: 4.2.0
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -8026,6 +8360,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.50.1
fsevents: 2.3.3
+ rou3@0.7.12: {}
+
router@2.2.0:
dependencies:
debug: 4.4.1
@@ -8148,6 +8484,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
+ set-cookie-parser@3.1.0: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -8244,6 +8582,8 @@ snapshots:
spdx-license-ids@3.0.21: {}
+ split2@4.2.0: {}
+
statuses@2.0.1: {}
statuses@2.0.2: {}
From 1985bbe743d3fb237b7bb1f158c0bf18e5066183 Mon Sep 17 00:00:00 2001
From: sr2echa <65058816+sr2echa@users.noreply.github.com>
Date: Sat, 2 May 2026 15:04:58 +0530
Subject: [PATCH 03/23] fix: improve session token extraction
---
app/routes/projects.tsx | 14 ++++++++++---
backend/auth/routes.py | 21 +++++++++++++++++--
migrations/000_init.sql | 41 +++++++++++++++++++++++++++++++-------
public/screenshot-app.png | Bin 737943 -> 0 bytes
4 files changed, 64 insertions(+), 12 deletions(-)
delete mode 100644 public/screenshot-app.png
diff --git a/app/routes/projects.tsx b/app/routes/projects.tsx
index 45e8d2be..1f26dbc2 100644
--- a/app/routes/projects.tsx
+++ b/app/routes/projects.tsx
@@ -57,9 +57,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (!user) throw redirect("/login");
const { origin } = new URL(request.url);
- const projectsRes = await axios.get<{ projects: Project[] }>(`${origin}/backend/projects`, {
- headers: { Cookie: request.headers.get("Cookie") },
- });
+ let projectsRes;
+ try {
+ projectsRes = await axios.get<{ projects: Project[] }>(`${origin}/backend/projects`, {
+ headers: { Cookie: request.headers.get("Cookie") ?? "" },
+ });
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
+ throw redirect("/login");
+ }
+ throw error;
+ }
return {
user,
diff --git a/backend/auth/routes.py b/backend/auth/routes.py
index f569785c..e9722419 100644
--- a/backend/auth/routes.py
+++ b/backend/auth/routes.py
@@ -1,20 +1,37 @@
-from fastapi import APIRouter, Cookie, Depends, HTTPException, status
+from urllib.parse import unquote
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
from auth.schema import SessionUser
from db import get_db_pool
_BETTER_AUTH_COOKIE = "better-auth.session_token"
+
+def _extract_session_token_from_cookies(request: Request) -> str | None:
+ """
+ Better Auth stores a signed cookie value as ".".
+ Extract the raw token used in the session table.
+ """
+ raw_cookie_value = request.cookies.get(_BETTER_AUTH_COOKIE)
+ if not raw_cookie_value:
+ return None
+
+ decoded_cookie = unquote(raw_cookie_value)
+ token = decoded_cookie.split(".", 1)[0]
+ return token or None
+
router = APIRouter(prefix="/auth", tags=["auth"])
async def get_current_user(
- session_token: str | None = Cookie(default=None, alias=_BETTER_AUTH_COOKIE),
+ request: Request,
) -> SessionUser:
"""
FastAPI dependency. Reads the BetterAuth session token from the HttpOnly
cookie and validates it against the session/user tables in Postgres.
"""
+ session_token = _extract_session_token_from_cookies(request)
if not session_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
diff --git a/migrations/000_init.sql b/migrations/000_init.sql
index b0d594dc..37fac771 100644
--- a/migrations/000_init.sql
+++ b/migrations/000_init.sql
@@ -1,7 +1,9 @@
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
-- BetterAuth: user table
CREATE TABLE "user" (
- id TEXT PRIMARY KEY,
- name TEXT,
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
"emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
image TEXT,
@@ -11,7 +13,7 @@ CREATE TABLE "user" (
-- BetterAuth: session table
CREATE TABLE session (
- id TEXT PRIMARY KEY,
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"expiresAt" TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -23,9 +25,9 @@ CREATE TABLE session (
-- BetterAuth: account table (OAuth providers)
CREATE TABLE account (
- id TEXT PRIMARY KEY,
- "accountId" TEXT,
- "providerId" TEXT,
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ "accountId" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
"accessToken" TEXT,
"refreshToken" TEXT,
@@ -40,7 +42,7 @@ CREATE TABLE account (
-- BetterAuth: verification table
CREATE TABLE verification (
- id TEXT PRIMARY KEY,
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ NOT NULL,
@@ -48,6 +50,26 @@ CREATE TABLE verification (
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
+-- Guard against adapter inserts that send null for user.id
+CREATE OR REPLACE FUNCTION set_user_id() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.id IS NULL OR NEW.id = '' THEN
+ NEW.id = gen_random_uuid()::text;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Guard against adapter inserts that send null for verification.id
+CREATE OR REPLACE FUNCTION set_verification_id() RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.id IS NULL OR NEW.id = '' THEN
+ NEW.id = gen_random_uuid()::text;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
-- Auto-update "updatedAt" on row changes
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN
@@ -64,6 +86,10 @@ CREATE TRIGGER trg_account_updated_at
BEFORE UPDATE ON account FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_verification_updated_at
BEFORE UPDATE ON verification FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+CREATE TRIGGER trg_user_set_id
+ BEFORE INSERT ON "user" FOR EACH ROW EXECUTE FUNCTION set_user_id();
+CREATE TRIGGER trg_verification_set_id
+ BEFORE INSERT ON verification FOR EACH ROW EXECUTE FUNCTION set_verification_id();
-- Application: projects
CREATE TABLE projects (
@@ -96,6 +122,7 @@ CREATE INDEX idx_session_token ON session(token);
CREATE INDEX idx_session_user_id ON session("userId");
CREATE INDEX idx_account_user_id ON account("userId");
CREATE INDEX idx_account_provider ON account("providerId", "accountId");
+CREATE INDEX idx_verification_identifier ON verification(identifier);
CREATE INDEX idx_projects_user_id ON projects(user_id, created_at DESC);
CREATE INDEX idx_assets_user_id ON assets(user_id, created_at DESC);
CREATE INDEX idx_assets_user_project_id ON assets(user_id, project_id, created_at DESC);
diff --git a/public/screenshot-app.png b/public/screenshot-app.png
deleted file mode 100644
index a9f5b9736585459165bc36f937070feba8580bd4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 737943
zcmagG2UL?;zc>0MCL?7O5CIh-sH0I4(6NBDfDJ2HC@Ml28%jr7C@G+02Xri`K!Tzo
zH3-s6Vu7d#QRyv-M2M6SLLece-OV}gx$8UMoA=)3aw&q(es=knU)i~F*wtl$)-o*!
zf)?!CyXzJ9##;z8a)r*}ggu7~f}u0Q?k*{#YyJkKYQ`{1t$2?+@n@_8@~CkU=f
zf)HlV|N5)_Ue2T+EwF-lNEPK{t&)uf%4o7r<%*qW2F42&P5(@GKWR%=?Td~0`G$ph
zed|3efcxXj5vkr2+0;098jr_Yc%e`~Mn|@HxasNSSJCjvjC|f;gXl$fQN85}c{#W0
z#}iqrH?1Tzc;=@#*}siTZHoDlpvvyeYoBKJPM2FuWtb8j$h51UO;JtPw)XkobL7u{
zmxrkc`y@sNI@T*Md<@(Tl@Hu#RP*+8bGo2`Lw1=`7TL7TxYGS4F3Mck#BTv{89LRNqKv&57i|;|m
z6TPIpr&T{Cw!UqsbPG>Nv*amlvB<%ZH~i4yNn%Ke%}v$L-TF)y83xDWQSDI8
zQ7Tn~Xi~e$+oG%lCMK0836$u>VD>Hsf3un5+*dQZH%7@RnbaEE=>
zpD?vT(3X^^ZSskf;F
z+sFZ{*sKf26c?kcc61OML*|X^%+CL<*U$sKOmY=(96IYdR=0{7VD0*9YT^FQfI19r
zRdiCZEv#T%*RZuyw2XHwD#1&Tro@%Wt{J
ViI4!Hx``x^U;TYql9)
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