@@ -113,16 +159,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
- {/* Fork explanation */}
- The recipient gets a fork — an editable copy
- of this canvas at revision {page?.headRevision}. They can build on it and open a merge
- request to propose their changes back.
+ The recipient gets a fork of this canvas at the selected revision.
+ They can edit their copy and later open a merge request back into the source branch.
@@ -131,17 +175,16 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
-
Fork created successfully!
-
{recipient?.name} can now access their copy.
+
Fork created successfully.
+
{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.
- {/* Recipient picker */}
Recipient
setMemberDropOpen((p) => !p)}
+ onClick={() => setMemberDropOpen((prev) => !prev)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
style={{
background: 'rgba(255,255,255,0.04)',
@@ -151,10 +194,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
>
- {recipient?.name ?? 'Select team member…'}
+ {selectedRecipientLabel}
-
+
+
{memberDropOpen && (
- {TEAM_MEMBERS.map((m) => (
+ {loadingMembers && (
+ Loading verified accounts...
+ )}
+ {!loadingMembers && membersError && (
+ {membersError}
+ )}
+ {!loadingMembers && !membersError && teamMembers.length === 0 && (
+ No verified users available.
+ )}
+ {!loadingMembers && !membersError && teamMembers.map((member) => (
{ setRecipient(m); setMemberDropOpen(false); }}
+ onClick={() => {
+ setRecipient(member);
+ setMemberDropOpen(false);
+ }}
>
-
-
-
{m.name}
-
{m.role}
+ {member.avatar_url ? (
+
+ ) : (
+
+ {getInitials(member)}
+
+ )}
+
+
{getDisplayName(member)}
+
+ {member.email || member.user_id} · {getRoleLabel(member)}
+
))}
@@ -183,7 +254,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
- {/* Visibility */}
Fork visibility
@@ -210,7 +280,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
- {/* Message */}
@@ -219,7 +288,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
- {/* Actions */}
- {submitting ? 'Creating fork…' : 'Share (Create Fork)'}
+ {submitting ? 'Creating fork...' : 'Share (Create Fork)'}
diff --git a/app/src/oracle/hooks/useOraclePage.ts b/app/src/oracle/hooks/useOraclePage.ts
index 01053c9a..94904bd4 100644
--- a/app/src/oracle/hooks/useOraclePage.ts
+++ b/app/src/oracle/hooks/useOraclePage.ts
@@ -50,11 +50,11 @@ export function useOraclePage(pageId: string | null): OraclePageState {
}
const disconnect = connectPageSocket(pageId, {
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
- onReconnect: () => void load(),
+ onReconnect: () => undefined,
+ onOpen: () => setIsConnected(true),
onClose: () => setIsConnected(false),
});
disconnectRef.current = disconnect;
- setIsConnected(true);
return () => {
disconnect();
disconnectRef.current = null;
diff --git a/app/src/oracle/lib/oracleApiClient.ts b/app/src/oracle/lib/oracleApiClient.ts
index 350d647a..58386210 100644
--- a/app/src/oracle/lib/oracleApiClient.ts
+++ b/app/src/oracle/lib/oracleApiClient.ts
@@ -167,7 +167,8 @@ export function connectPageSocket(
pageId: string,
handlers: {
onMessage: (msg: OracleWSMessage) => void;
- onReconnect: () => void;
+ onReconnect?: () => void;
+ onOpen?: () => void;
onClose: () => void;
},
): () => void {
@@ -184,6 +185,10 @@ export function connectPageSocket(
function connect() {
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
+ ws.onopen = () => {
+ handlers.onOpen?.();
+ };
+
ws.onmessage = (event) => {
try {
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
@@ -196,7 +201,7 @@ export function connectPageSocket(
handlers.onClose();
if (!stopped) {
retryTimeout = setTimeout(() => {
- handlers.onReconnect();
+ handlers.onReconnect?.();
connect();
}, 3000);
}
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index 897eb441..a11a2749 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -14,6 +14,8 @@ export interface NavItem {
export interface User {
id: string;
name: string;
+ fullName?: string;
+ email?: string;
avatar?: string;
role: string;
}
diff --git a/app/vite.config.ts b/app/vite.config.ts
index c5dafa78..fb81cc4a 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
-const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://api.desineuron.in"
+const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://velocity.desineuron.in"
// https://vite.dev/config/
export default defineConfig({
@@ -37,6 +37,12 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
+ "/ws": {
+ target: backendProxyTarget,
+ changeOrigin: true,
+ secure: false,
+ ws: true,
+ },
},
},
});
diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py
index 598dedcf..3ec861af 100644
--- a/backend/auth/dependencies.py
+++ b/backend/auth/dependencies.py
@@ -34,13 +34,19 @@ ROLE_HIERARCHY = {
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+def _truncate_bcrypt_input(value: str) -> str:
+ raw = value.encode("utf-8")
+ if len(raw) <= 72:
+ return value
+ return raw[:72].decode("utf-8", errors="ignore")
+
+
def hash_password(plain: str) -> str:
- return pwd_context.hash(plain)
+ return pwd_context.hash(_truncate_bcrypt_input(plain))
def verify_password(plain: str, hashed: str) -> bool:
- # Truncate to 72 bytes to prevent bcrypt 500 errors
- return pwd_context.verify(plain[:72], hashed)
+ return pwd_context.verify(_truncate_bcrypt_input(plain), hashed)
# ── JWT helpers ───────────────────────────────────────────────────────────────
diff --git a/backend/main.py b/backend/main.py
index 5697ba96..27c497f6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -11,15 +11,50 @@ import os
import json
import asyncio
import logging
+import re
from contextlib import asynccontextmanager
from datetime import UTC, datetime
+from pathlib import Path
from typing import Set
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
+def _load_velocity_env() -> None:
+ repo_root = Path(__file__).resolve().parent.parent
+ backend_root = repo_root / "backend"
+
+ explicit_env = os.getenv("VELOCITY_ENV_FILE", "").strip()
+ candidate_paths = []
+
+ if explicit_env:
+ candidate_paths.append(Path(explicit_env))
+
+ candidate_paths.extend(
+ [
+ backend_root / ".env",
+ repo_root / ".env",
+ ]
+ )
+
+ loaded_any = False
+ seen: set[Path] = set()
+ for candidate in candidate_paths:
+ resolved = candidate.resolve()
+ if resolved in seen or not candidate.exists():
+ continue
+ load_dotenv(candidate, override=not loaded_any)
+ loaded_any = True
+ seen.add(resolved)
+
+ if not loaded_any:
+ load_dotenv()
+
+
+_load_velocity_env()
+
from backend.api.routes_catalyst import router as catalyst_router
from backend.api.routes_crm import crm_router, analytics_router
from backend.api.routes_oracle import router as oracle_helper_router
@@ -39,8 +74,6 @@ from backend.routers.videos import router as videos_router
from backend.routers.vault import router as vault_router
from backend.routers.sentinel import router as sentinel_router, broadcast_sentinel_event
-load_dotenv()
-
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("velocity.main")
@@ -91,6 +124,11 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
if os.path.isdir(ASSET_DIR):
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
+
+def _sanitize_filename(value: str) -> str:
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
+ return cleaned or "upload"
+
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
@@ -160,7 +198,7 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
- SELECT full_name, email
+ SELECT full_name, email, avatar_url
FROM users_and_roles
WHERE id = $1::uuid
""",
@@ -172,9 +210,85 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
"role": user.role,
"full_name": row["full_name"] if row else None,
"email": row["email"] if row else None,
+ "avatar_url": row["avatar_url"] if row else None,
}
+@app.get("/api/auth/users", tags=["Auth"])
+async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
+ pool = app.state.db_pool
+ if pool is None:
+ raise HTTPException(status_code=503, detail="Database unavailable.")
+
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT
+ id::text AS user_id,
+ role,
+ full_name,
+ email,
+ avatar_url
+ FROM users_and_roles
+ WHERE is_active = TRUE
+ ORDER BY
+ COALESCE(NULLIF(full_name, ''), email, id::text) ASC
+ """
+ )
+
+ return [
+ {
+ "user_id": row["user_id"],
+ "role": row["role"],
+ "full_name": row["full_name"],
+ "email": row["email"],
+ "avatar_url": row["avatar_url"],
+ }
+ for row in rows
+ ]
+
+
+@app.post("/api/auth/profile/avatar", tags=["Auth"])
+async def upload_profile_avatar(
+ file: UploadFile = File(...),
+ user: UserPrincipal = Depends(get_current_user),
+):
+ pool = app.state.db_pool
+ if pool is None:
+ raise HTTPException(status_code=503, detail="Database unavailable.")
+
+ allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
+ if file.content_type not in allowed:
+ raise HTTPException(status_code=400, detail="Unsupported avatar format.")
+
+ extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
+ if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
+ extension = ".png"
+
+ avatar_dir = Path(ASSET_DIR) / "profile_avatars"
+ avatar_dir.mkdir(parents=True, exist_ok=True)
+
+ filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
+ destination = avatar_dir / filename
+ contents = await file.read()
+ destination.write_bytes(contents)
+
+ avatar_url = f"/assets/profile_avatars/{filename}"
+
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ UPDATE users_and_roles
+ SET avatar_url = $2
+ WHERE id = $1::uuid
+ """,
+ user.user_id,
+ avatar_url,
+ )
+
+ return {"avatar_url": avatar_url}
+
+
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
class _CatalystManager:
diff --git a/docs/LOCAL_DEV_SNAPSHOT.md b/docs/LOCAL_DEV_SNAPSHOT.md
new file mode 100644
index 00000000..82af4544
--- /dev/null
+++ b/docs/LOCAL_DEV_SNAPSHOT.md
@@ -0,0 +1,143 @@
+# Velocity Local Dev Snapshot
+
+This document defines the correct way to work against a local copy of the current Velocity backend state without polluting Git and without guessing whether a backend-only change will work in production.
+
+## Why This Exists
+
+Project Velocity now depends on live PostgreSQL-backed surfaces and runtime state that cannot be validated reliably from frontend-only local development.
+
+Examples:
+
+- operator identity and profile hydration
+- CRM-backed Sentinel client selection
+- Oracle Canvas data access and sharing
+- verified team-account lists for share flows
+
+The correct solution is a repeatable local snapshot workflow.
+
+## Design Rule
+
+Local runtime copies must never be committed.
+
+Everything produced by this workflow lives under gitignored paths:
+
+- `.local-dev/`
+- `runtime-snapshots/`
+- `local-dev-bundles/`
+
+## Bundle Shape
+
+The Linux-origin export bundle contains:
+
+- `backend.env.export`
+- `velocity.dump`
+- `restore.instructions.txt`
+
+This is intentionally minimal. It gives local developers what they need to stand up a local database-backed verification surface without pushing runtime state into the repository.
+
+## Export From Linux Box
+
+Run this on the Linux-origin host:
+
+```bash
+cd /opt/desineuron-velocity-site/repo
+bash scripts/export_velocity_local_bundle.sh
+```
+
+This creates a tarball in `/tmp` named like:
+
+```text
+/tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
+```
+
+## Import On Windows Dev Machine
+
+1. Copy the tarball to the Windows machine.
+2. Extract it under:
+
+```text
+Project_Velocity/.local-dev/source-bundles/
/
+```
+
+3. Run:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\import_velocity_local_bundle.ps1
+```
+
+This will:
+
+- copy the exported env snapshot into `.local-dev/backend/.env.local`
+- copy the PostgreSQL dump into `.local-dev/db/velocity.dump`
+- generate a local Docker Compose file for PostgreSQL
+- generate a restore script
+
+## Restore Local Database
+
+After import, run:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\.local-dev\db\restore_local_snapshot.ps1
+```
+
+This restores the dump into a local PostgreSQL container on:
+
+- host: `127.0.0.1`
+- port: `54329`
+- db: `velocity_local`
+- user: `velocity_local`
+- password: `velocity_local`
+
+## Local Backend Wiring
+
+The backend expects these env keys:
+
+- `VELOCITY_DB_HOST`
+- `VELOCITY_DB_PORT`
+- `VELOCITY_DB_NAME`
+- `VELOCITY_DB_USER`
+- `VELOCITY_DB_PASSWORD`
+- `DATABASE_URL`
+- `CORS_ORIGINS`
+- `VELOCITY_ASSET_DIR`
+- `JWT_SECRET_KEY`
+
+The exported env snapshot provides the production-shaped variable names. For local verification, point the DB values to the local PostgreSQL container rather than the production database.
+
+## Create A Zip For Sayan And Sourik
+
+Once `.local-dev/` is hydrated locally, run:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\package_velocity_local_bundle.ps1
+```
+
+This creates a gitignored zip under:
+
+```text
+Project_Velocity/local-dev-bundles/
+```
+
+That zip is the correct handoff artifact for teammate local verification.
+
+## What This Solves
+
+This workflow allows local verification of:
+
+- real account-backed login flows
+- profile name/avatar hydration
+- share-recipient lookup
+- Sentinel CRM client selection
+- Oracle-backed DB reads
+
+## What It Does Not Solve By Itself
+
+This does not automatically fix backend bugs.
+
+If Oracle still returns `500` locally after restoring the snapshot, that is now a real reproducible backend issue rather than a blind production-only guess.
+
+## Current Constraint
+
+The current machine cannot SSH into the Linux box because the host is configured for publickey auth and this workstation is not authorized yet.
+
+That means the export step must currently be triggered from a machine or shell that already has valid SSH/key access to the Linux-origin host.
diff --git a/scripts/export_velocity_local_bundle.sh b/scripts/export_velocity_local_bundle.sh
new file mode 100644
index 00000000..f27b6778
--- /dev/null
+++ b/scripts/export_velocity_local_bundle.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Run this on the Linux origin host that currently serves velocity.desineuron.in.
+# It exports a local-development bundle containing:
+# - backend env snapshot (non-shell format)
+# - PostgreSQL custom-format dump
+# - app build metadata
+# - optional asset snapshot manifest
+#
+# Output:
+# /tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
+
+REPO_ROOT="${REPO_ROOT:-/opt/desineuron-velocity-site/repo}"
+BACKEND_ENV_PATH="${BACKEND_ENV_PATH:-$REPO_ROOT/backend/.env}"
+OUTPUT_ROOT="${OUTPUT_ROOT:-/tmp}"
+DB_CONTAINER_NAME="${DB_CONTAINER_NAME:-desineuron-ops-db}"
+STAMP="$(date +%Y%m%d-%H%M%S)"
+WORKDIR="$OUTPUT_ROOT/velocity-local-bundle-$STAMP"
+ARCHIVE="$OUTPUT_ROOT/velocity-local-bundle-$STAMP.tar.gz"
+
+if [[ ! -f "$BACKEND_ENV_PATH" ]]; then
+ echo "Missing backend env file: $BACKEND_ENV_PATH" >&2
+ exit 1
+fi
+
+mkdir -p "$WORKDIR"
+
+cp "$BACKEND_ENV_PATH" "$WORKDIR/backend.env.raw"
+
+python3 - "$WORKDIR/backend.env.raw" "$WORKDIR/backend.env.export" <<'PY'
+import pathlib
+import sys
+
+src = pathlib.Path(sys.argv[1])
+dst = pathlib.Path(sys.argv[2])
+
+allowed = {
+ "VELOCITY_DB_HOST",
+ "VELOCITY_DB_PORT",
+ "VELOCITY_DB_NAME",
+ "VELOCITY_DB_USER",
+ "VELOCITY_DB_PASSWORD",
+ "DATABASE_URL",
+ "CORS_ORIGINS",
+ "VELOCITY_ASSET_DIR",
+ "JWT_SECRET_KEY",
+ "JWT_ALGORITHM",
+ "JWT_EXP_MINUTES",
+}
+
+lines = []
+for raw_line in src.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key = key.strip()
+ if key in allowed:
+ lines.append(f"{key}={value.strip()}")
+
+dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
+PY
+
+set -a
+source "$BACKEND_ENV_PATH"
+set +a
+
+dump_with_local_pg_dump() {
+ PGPASSWORD="${VELOCITY_DB_PASSWORD}" pg_dump \
+ -h "${VELOCITY_DB_HOST}" \
+ -p "${VELOCITY_DB_PORT}" \
+ -U "${VELOCITY_DB_USER}" \
+ -d "${VELOCITY_DB_NAME}" \
+ -Fc \
+ -f "$WORKDIR/velocity.dump"
+}
+
+dump_with_docker_exec() {
+ if ! command -v docker >/dev/null 2>&1; then
+ return 1
+ fi
+
+ sudo -n docker exec \
+ -e PGPASSWORD="${VELOCITY_DB_PASSWORD}" \
+ "$DB_CONTAINER_NAME" \
+ pg_dump \
+ -U "${VELOCITY_DB_USER}" \
+ -d "${VELOCITY_DB_NAME}" \
+ -Fc \
+ > "$WORKDIR/velocity.dump"
+}
+
+if command -v pg_dump >/dev/null 2>&1; then
+ dump_with_local_pg_dump
+elif dump_with_docker_exec; then
+ :
+else
+ echo "Neither host pg_dump nor docker-exec pg_dump is available." >&2
+ exit 1
+fi
+
+cat > "$WORKDIR/README.txt" < "$WORKDIR/restore.instructions.txt" <.
+3. Run scripts/import_velocity_local_bundle.ps1 from the repo root.
+EOF
+
+tar -C "$OUTPUT_ROOT" -czf "$ARCHIVE" "$(basename "$WORKDIR")"
+rm -rf "$WORKDIR"
+
+echo "Created bundle: $ARCHIVE"
diff --git a/scripts/import_velocity_local_bundle.ps1 b/scripts/import_velocity_local_bundle.ps1
new file mode 100644
index 00000000..9878ac61
--- /dev/null
+++ b/scripts/import_velocity_local_bundle.ps1
@@ -0,0 +1,140 @@
+param(
+ [string]$BundleRoot = "",
+ [string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
+)
+
+$ErrorActionPreference = "Stop"
+
+if (-not $BundleRoot) {
+ $candidate = Join-Path $RepoRoot ".local-dev\source-bundles"
+ if (-not (Test-Path $candidate)) {
+ throw "Bundle root not provided and no source bundles found at $candidate"
+ }
+
+ $latest = Get-ChildItem -Path $candidate -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if (-not $latest) {
+ throw "No extracted bundle directories found under $candidate"
+ }
+ $BundleRoot = $latest.FullName
+}
+
+$envExport = Join-Path $BundleRoot "backend.env.export"
+$dumpFile = Join-Path $BundleRoot "velocity.dump"
+
+if (-not (Test-Path $envExport)) {
+ throw "Missing backend.env.export in $BundleRoot"
+}
+
+if (-not (Test-Path $dumpFile)) {
+ throw "Missing velocity.dump in $BundleRoot"
+}
+
+$localRoot = Join-Path $RepoRoot ".local-dev"
+$backendRoot = Join-Path $localRoot "backend"
+$dbRoot = Join-Path $localRoot "db"
+$assetsRoot = Join-Path $localRoot "assets"
+
+New-Item -ItemType Directory -Force -Path $localRoot, $backendRoot, $dbRoot, $assetsRoot | Out-Null
+
+$targetEnv = Join-Path $backendRoot ".env.local"
+$restoreScript = Join-Path $dbRoot "restore_local_snapshot.ps1"
+$composeFile = Join-Path $dbRoot "docker-compose.local-db.yml"
+$copiedDump = Join-Path $dbRoot "velocity.dump"
+
+Copy-Item $dumpFile $copiedDump -Force
+
+$rawEnv = @{}
+Get-Content $envExport | ForEach-Object {
+ if ($_ -match '^\s*#' -or $_ -notmatch '=') {
+ return
+ }
+ $key, $value = $_ -split '=', 2
+ $rawEnv[$key.Trim()] = $value.Trim()
+}
+
+$localEnvLines = [System.Collections.Generic.List[string]]::new()
+$localEnvLines.Add("VELOCITY_DB_HOST=127.0.0.1")
+$localEnvLines.Add("VELOCITY_DB_PORT=54329")
+$localEnvLines.Add("VELOCITY_DB_NAME=velocity_local")
+$localEnvLines.Add("VELOCITY_DB_USER=velocity_local")
+$localEnvLines.Add("VELOCITY_DB_PASSWORD=velocity_local")
+$localEnvLines.Add("DATABASE_URL=postgresql://velocity_local:velocity_local@127.0.0.1:54329/velocity_local")
+$localEnvLines.Add("CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:3000,http://localhost:3000")
+
+if ($rawEnv.ContainsKey("VELOCITY_JWT_SECRET")) {
+ $localEnvLines.Add("VELOCITY_JWT_SECRET=$($rawEnv['VELOCITY_JWT_SECRET'])")
+}
+
+if ($rawEnv.ContainsKey("JWT_SECRET_KEY")) {
+ $localEnvLines.Add("JWT_SECRET_KEY=$($rawEnv['JWT_SECRET_KEY'])")
+}
+
+if ($rawEnv.ContainsKey("JWT_ALGORITHM")) {
+ $localEnvLines.Add("JWT_ALGORITHM=$($rawEnv['JWT_ALGORITHM'])")
+}
+
+if ($rawEnv.ContainsKey("JWT_EXP_MINUTES")) {
+ $localEnvLines.Add("JWT_EXP_MINUTES=$($rawEnv['JWT_EXP_MINUTES'])")
+}
+
+$localAssetsRoot = Join-Path $localRoot "assets"
+$localEnvLines.Add("VELOCITY_ASSET_DIR=$localAssetsRoot")
+
+Set-Content -Path $targetEnv -Value ($localEnvLines -join "`r`n") -Encoding UTF8
+
+$compose = @"
+services:
+ velocity-postgres-local:
+ image: postgres:16
+ container_name: velocity-postgres-local
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: velocity_local
+ POSTGRES_USER: velocity_local
+ POSTGRES_PASSWORD: velocity_local
+ ports:
+ - "54329:5432"
+ volumes:
+ - velocity_postgres_local_data:/var/lib/postgresql/data
+
+volumes:
+ velocity_postgres_local_data:
+"@
+
+Set-Content -Path $composeFile -Value $compose -Encoding UTF8
+
+$restoreTemplate = @'
+param(
+ [string]$RepoRoot = "__REPO_ROOT__"
+)
+
+$ErrorActionPreference = "Stop"
+$DbRoot = Join-Path $RepoRoot ".local-dev\db"
+$Compose = Join-Path $DbRoot "docker-compose.local-db.yml"
+$Dump = Join-Path $DbRoot "velocity.dump"
+
+docker compose -f $Compose up -d
+Start-Sleep -Seconds 4
+docker exec -i velocity-postgres-local dropdb --if-exists -U velocity_local velocity_local
+docker exec -i velocity-postgres-local createdb -U velocity_local velocity_local
+docker cp $Dump velocity-postgres-local:/tmp/velocity.dump
+docker exec -i velocity-postgres-local pg_restore -U velocity_local -d velocity_local --clean --if-exists --no-owner --no-privileges /tmp/velocity.dump
+
+Write-Host "Local PostgreSQL restore complete."
+Write-Host "Use these local DB env values:"
+Write-Host "VELOCITY_DB_HOST=127.0.0.1"
+Write-Host "VELOCITY_DB_PORT=54329"
+Write-Host "VELOCITY_DB_NAME=velocity_local"
+Write-Host "VELOCITY_DB_USER=velocity_local"
+Write-Host "VELOCITY_DB_PASSWORD=velocity_local"
+'@
+
+$restore = $restoreTemplate.Replace("__REPO_ROOT__", $RepoRoot)
+
+Set-Content -Path $restoreScript -Value $restore -Encoding UTF8
+
+Write-Host "Imported local bundle from $BundleRoot"
+Write-Host "Backend env snapshot: $targetEnv"
+Write-Host "DB dump copied to: $copiedDump"
+Write-Host "Run this next:"
+Write-Host "powershell -ExecutionPolicy Bypass -File `"$restoreScript`""
diff --git a/scripts/package_velocity_local_bundle.ps1 b/scripts/package_velocity_local_bundle.ps1
new file mode 100644
index 00000000..302f7de4
--- /dev/null
+++ b/scripts/package_velocity_local_bundle.ps1
@@ -0,0 +1,25 @@
+param(
+ [string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
+ [string]$OutputDir = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\local-dev-bundles"
+)
+
+$ErrorActionPreference = "Stop"
+
+$localRoot = Join-Path $RepoRoot ".local-dev"
+if (-not (Test-Path $localRoot)) {
+ throw "Missing .local-dev folder. Import a local bundle first."
+}
+
+New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
+
+$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$zipPath = Join-Path $OutputDir "project-velocity-local-dev-$stamp.zip"
+
+if (Test-Path $zipPath) {
+ Remove-Item $zipPath -Force
+}
+
+Compress-Archive -Path (Join-Path $localRoot "*") -DestinationPath $zipPath -Force
+
+Write-Host "Created local dev zip: $zipPath"
+Write-Host "This zip is gitignored and can be handed to Sayan and Sourik for local verification."
diff --git a/scripts/start_velocity_backend_local.ps1 b/scripts/start_velocity_backend_local.ps1
new file mode 100644
index 00000000..68b9c1de
--- /dev/null
+++ b/scripts/start_velocity_backend_local.ps1
@@ -0,0 +1,28 @@
+param(
+ [string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
+ [int]$Port = 8001
+)
+
+$ErrorActionPreference = "Stop"
+
+$env:VELOCITY_ENV_FILE = Join-Path $RepoRoot ".local-dev\backend\.env.local"
+if (-not (Test-Path $env:VELOCITY_ENV_FILE)) {
+ throw "Missing local backend env file at $($env:VELOCITY_ENV_FILE). Import a local bundle first."
+}
+
+$pythonExe = Join-Path $RepoRoot ".venv\Scripts\python.exe"
+if (-not (Test-Path $pythonExe)) {
+ $pythonExe = Join-Path $RepoRoot "venv\Scripts\python.exe"
+}
+
+if (-not (Test-Path $pythonExe)) {
+ $pythonExe = "python"
+}
+
+Push-Location $RepoRoot
+try {
+ & $pythonExe -m uvicorn backend.main:app --reload --host 127.0.0.1 --port $Port
+}
+finally {
+ Pop-Location
+}
diff --git a/scripts/start_velocity_frontend_local.ps1 b/scripts/start_velocity_frontend_local.ps1
new file mode 100644
index 00000000..33c00f08
--- /dev/null
+++ b/scripts/start_velocity_frontend_local.ps1
@@ -0,0 +1,15 @@
+param(
+ [string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
+)
+
+$ErrorActionPreference = "Stop"
+
+$env:VITE_BACKEND_PROXY_TARGET = "http://127.0.0.1:8001"
+
+Push-Location (Join-Path $RepoRoot "app")
+try {
+ npm run dev -- --host 127.0.0.1 --port 5173
+}
+finally {
+ Pop-Location
+}