feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

View File

@@ -29,6 +29,10 @@ ROLE_HIERARCHY = {
"ADMIN": 3,
}
def default_tenant_id() -> str:
return os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity").strip() or "tenant_velocity"
# ── Password hashing ──────────────────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -57,12 +61,14 @@ JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 8
def create_access_token(user_id: str, role: str) -> str:
def create_access_token(user_id: str, role: str, tenant_id: Optional[str] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
normalized_role = role.strip().upper()
normalized_tenant = (tenant_id or default_tenant_id()).strip() or default_tenant_id()
payload = {
"sub": user_id,
"role": normalized_role,
"tenant_id": normalized_tenant,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
@@ -75,6 +81,7 @@ def create_access_token(user_id: str, role: str) -> str:
class UserPrincipal:
user_id: str
role: str
tenant_id: str = default_tenant_id()
@property
def role_level(self) -> int:
@@ -112,7 +119,11 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
) from exc
return UserPrincipal(user_id=payload["sub"], role=str(payload["role"]).strip().upper())
return UserPrincipal(
user_id=payload["sub"],
role=str(payload["role"]).strip().upper(),
tenant_id=str(payload.get("tenant_id") or default_tenant_id()).strip() or default_tenant_id(),
)
# ── Dependency factory: role gate ─────────────────────────────────────────────

105
backend/auth/routes.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.auth.service import (
list_tenant_users,
login_with_directory,
read_authenticated_user_profile,
)
from backend.auth.user_directory import ensure_user_directory_schema
router = APIRouter()
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
def _sanitize_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return cleaned or "upload"
class LoginRequest(BaseModel):
email: str
password: str
@router.post("/api/auth/login", tags=["Auth"])
async def login(body: LoginRequest, request: Request):
"""
Authenticate a user and return a JWT.
Credentials are verified against the users_and_roles table.
"""
return await login_with_directory(
app=request.app,
email=body.email,
password=body.password,
)
@router.get("/api/auth/me", tags=["Auth"])
async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
return await read_authenticated_user_profile(app=request.app, user=user)
@router.get("/api/auth/users", tags=["Auth"])
async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)):
return await list_tenant_users(app=request.app, user=user)
@router.post("/api/auth/profile/avatar", tags=["Auth"])
async def upload_profile_avatar(
request: Request,
file: UploadFile = File(...),
user: UserPrincipal = Depends(get_current_user),
):
await ensure_user_directory_schema(request.app)
pool = getattr(request.app.state, "db_pool", None)
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)}_"
f"{int(datetime.now(timezone.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:
result = await conn.execute(
"""
UPDATE users_and_roles
SET avatar_url = $2
WHERE id = $1::uuid
AND tenant_id = $3
""",
user.user_id,
avatar_url,
user.tenant_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Authenticated user profile was not found.")
return {"avatar_url": avatar_url}

123
backend/auth/service.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, status
from backend.auth.dependencies import (
UserPrincipal,
create_access_token,
default_tenant_id,
verify_password,
)
from backend.auth.user_directory import ensure_user_directory_schema
async def _get_pool(app: Any):
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
async def login_with_directory(*, app: Any, email: str, password: str) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_fallback = default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
id::text,
role,
password_hash,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE email = $1 AND is_active = TRUE
""",
email.strip(),
tenant_fallback,
)
if not row or not verify_password(password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password.",
)
token = create_access_token(
user_id=row["id"],
role=row["role"],
tenant_id=row["tenant_id"],
)
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
async def read_authenticated_user_profile(*, app: Any, user: UserPrincipal) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
full_name,
email,
avatar_url,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE id = $1::uuid
AND COALESCE(NULLIF(tenant_id, ''), $2) = $2
""",
user.user_id,
tenant_scope,
)
return {
"user_id": user.user_id,
"role": user.role,
"tenant_id": row["tenant_id"] if row else tenant_scope,
"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,
}
async def list_tenant_users(*, app: Any, user: UserPrincipal) -> list[dict[str, Any]]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
id::text AS user_id,
role,
COALESCE(NULLIF(tenant_id, ''), $1) AS tenant_id,
full_name,
email,
avatar_url
FROM users_and_roles
WHERE is_active = TRUE
AND COALESCE(NULLIF(tenant_id, ''), $1) = $2
ORDER BY
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
""",
default_tenant_id(),
tenant_scope,
)
return [
{
"user_id": row["user_id"],
"role": row["role"],
"tenant_id": row["tenant_id"],
"full_name": row["full_name"],
"email": row["email"],
"avatar_url": row["avatar_url"],
}
for row in rows
]

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from backend.auth.dependencies import default_tenant_id
_AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY = "_auth_user_directory_schema_ready"
def _sql_text_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
async def ensure_user_directory_schema(app: Any) -> None:
if getattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, False):
return
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
tenant_fallback = default_tenant_id()
tenant_default_literal = _sql_text_literal(tenant_fallback)
async with pool.acquire() as conn:
await conn.execute("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute(
"""
UPDATE users_and_roles
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
tenant_fallback,
)
await conn.execute(
f"ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
)
await conn.execute("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"
)
setattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, True)