refactor: unified google vein, prefixed module loading, cfg separation

- Unified google vein with OAuth + Sheets API
- Prefixed vein module loading (vein_google) to avoid pip package shadowing
- Preload pip packages before vein loading
- Added common/auth framework
- Rebranded sbwrapper from Pawprint to Soleprint
- Removed cfg/ from history (now separate repo)
- Keep cfg/standalone/ as sample configuration
- gitignore cfg/amar/ and cfg/dlt/ (private configs)
This commit is contained in:
buenosairesam
2026-01-26 21:55:44 -03:00
parent 6e18324a43
commit c4e702eae3
18 changed files with 1135 additions and 116 deletions

6
.gitignore vendored
View File

@@ -12,5 +12,7 @@ venv/
# Generated runnable instance (entirely gitignored - regenerate with build.py)
gen/
# Database dumps (sensitive data)
cfg/*/dumps/*.sql
# Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ as sample, ignore actual rooms
cfg/amar/
cfg/dlt/

View File

@@ -1,14 +0,0 @@
# Soleprint Pipeline
when:
- event: push
- event: manual
steps:
- name: notify
image: alpine
commands:
- echo "=== Soleprint ==="
- "echo Branch: $CI_COMMIT_BRANCH"
- "echo Commit: $CI_COMMIT_SHA"
- "echo Build locally: ./ctrl/deploy-domains.sh standalone --build"

View File

@@ -294,6 +294,10 @@ def build_soleprint(output_dir: Path, room: str):
if source.exists():
copy_path(source, output_dir / system)
# Common modules (auth, etc)
if (soleprint / "common").exists():
copy_path(soleprint / "common", output_dir / "common")
# Room config (includes merging room-specific artery/atlas/station)
copy_cfg(output_dir, room)

View File

@@ -18,7 +18,7 @@
"name": "google",
"slug": "google",
"title": "Google",
"status": "planned",
"status": "building",
"system": "artery"
},
{

View File

@@ -762,12 +762,116 @@
</ul>
</section>
<!-- Placeholder tabs -->
<section id="tab-google" class="tab-content">
<h2>Google</h2>
<p>Google connector. Planned.</p>
<!-- Google API Tab -->
<section id="tab-google_api" class="tab-content">
<h2>Google Sheets</h2>
<!-- Auth Status -->
<div id="google-auth-status" class="api-form">
<div id="google-not-connected">
<p style="color: #a3a3a3; margin: 0 0 1rem 0">
Connect your Google account to access Sheets.
</p>
<div class="api-controls" style="margin-top: 0">
<button id="btn-google-connect">
Connect Google Account
</button>
</div>
</div>
<div id="google-connected" style="display: none">
<p style="color: #4ade80; margin: 0 0 1rem 0">
✓ Connected to Google
</p>
<div class="api-controls" style="margin-top: 0">
<button id="btn-google-disconnect" class="tab-button">
Disconnect
</button>
</div>
</div>
</div>
<!-- Sheets Form (shown when connected) -->
<div id="google-sheets-form" style="display: none">
<div class="api-form">
<label for="spreadsheet-id">Spreadsheet ID</label>
<input
type="text"
id="spreadsheet-id"
placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
/>
<p style="color: #666; font-size: 0.8rem; margin: 0.5rem 0">
Find this in the spreadsheet URL:
docs.google.com/spreadsheets/d/<strong>SPREADSHEET_ID</strong>/edit
</p>
<div class="api-controls" style="margin-top: 1rem">
<button id="btn-list-sheets" class="tab-button">
List Sheets
</button>
<button id="btn-get-metadata" class="tab-button">
Get Metadata
</button>
<label style="margin-left: auto">
<input
type="checkbox"
id="google-text-mode"
checked
/>
Text output
</label>
</div>
</div>
<div class="api-form" style="margin-top: 1.5rem">
<label for="sheet-range">Range (A1 notation)</label>
<input
type="text"
id="sheet-range"
placeholder="Sheet1!A1:D10"
/>
<div class="api-controls">
<button id="btn-get-values">Get Values</button>
</div>
</div>
</div>
<!-- Output -->
<div id="google-output-container" class="output-container">
<div id="google-output" class="output-area scrollable"></div>
</div>
<h2 style="margin-top: 2rem">Endpoints</h2>
<ul class="endpoints">
<li>
<code>/artery/google_api/oauth/start</code>
<span class="desc">Start OAuth flow</span>
</li>
<li>
<code>/artery/google_api/oauth/status</code>
<span class="desc">Check connection status</span>
</li>
<li>
<code>/artery/google_api/spreadsheets/{id}</code>
<span class="desc">Spreadsheet metadata</span>
</li>
<li>
<code>/artery/google_api/spreadsheets/{id}/sheets</code>
<span class="desc">List sheets</span>
</li>
<li>
<code
>/artery/google_api/spreadsheets/{id}/values?range=...</code
>
<span class="desc">Get cell values</span>
</li>
</ul>
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
Add <code>?text=true</code> for LLM-friendly output.
</p>
</section>
<!-- Placeholder tabs -->
<section id="tab-maps" class="tab-content">
<h2>Maps</h2>
<p>Maps connector. Planned.</p>
@@ -1487,6 +1591,223 @@
showError(output, e.message);
}
});
// =====================================================================
// Google Tab
// =====================================================================
const googleNotConnected = document.getElementById(
"google-not-connected",
);
const googleConnected = document.getElementById("google-connected");
const googleSheetsForm =
document.getElementById("google-sheets-form");
const googleOutput = document.getElementById("google-output");
const googleOutputContainer = document.getElementById(
"google-output-container",
);
async function checkGoogleAuth() {
try {
const res = await fetch("/artery/google_api/oauth/status");
const data = await res.json();
if (data.authenticated) {
googleNotConnected.style.display = "none";
googleConnected.style.display = "block";
googleSheetsForm.style.display = "block";
} else {
googleNotConnected.style.display = "block";
googleConnected.style.display = "none";
googleSheetsForm.style.display = "none";
}
} catch (e) {
console.error("Failed to check Google auth status:", e);
}
}
// Check auth on page load if Google tab elements exist
if (googleNotConnected) {
checkGoogleAuth();
}
// Also check when switching to Google API tab
document
.querySelectorAll('.vein[data-tab="google_api"]')
.forEach((vein) => {
vein.addEventListener("click", () => {
checkGoogleAuth();
});
});
// Connect button
document
.getElementById("btn-google-connect")
?.addEventListener("click", () => {
// Redirect to OAuth start, will come back to /artery after auth
window.location.href =
"/artery/google_api/oauth/start?redirect=/artery";
});
// Disconnect button
document
.getElementById("btn-google-disconnect")
?.addEventListener("click", async () => {
await fetch("/artery/google_api/oauth/logout");
checkGoogleAuth();
googleOutputContainer.classList.remove("visible");
});
// Google output helpers
function showGoogleOutput(text, isError = false) {
googleOutput.textContent = text;
googleOutput.classList.toggle("error", isError);
googleOutputContainer.classList.add("visible");
}
// List Sheets
document
.getElementById("btn-list-sheets")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}/sheets?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
// Get Metadata
document
.getElementById("btn-get-metadata")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
// Get Values
document
.getElementById("btn-get-values")
?.addEventListener("click", async () => {
const spreadsheetId = document
.getElementById("spreadsheet-id")
.value.trim();
const range = document
.getElementById("sheet-range")
.value.trim();
if (!spreadsheetId) {
showGoogleOutput(
"Error: Please enter a Spreadsheet ID",
true,
);
return;
}
if (!range) {
showGoogleOutput(
"Error: Please enter a Range (e.g., Sheet1!A1:D10)",
true,
);
return;
}
const textMode =
document.getElementById("google-text-mode").checked;
showGoogleOutput("Loading...");
try {
const params = new URLSearchParams();
params.set("range", range);
if (textMode) params.set("text", "true");
const res = await fetch(
`/artery/google_api/spreadsheets/${spreadsheetId}/values?${params}`,
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || res.statusText);
}
if (textMode) {
showGoogleOutput(await res.text());
} else {
showGoogleOutput(
JSON.stringify(await res.json(), null, 2),
);
}
} catch (e) {
showGoogleOutput("Error: " + e.message, true);
}
});
</script>
</body>
</html>

View File

@@ -2,22 +2,19 @@
API routes for Google vein.
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse, RedirectResponse
from typing import Optional
from core.oauth import GoogleOAuth
from core.sheets import GoogleSheetsClient, GoogleSheetsError
from models.spreadsheet import SpreadsheetMetadata, SheetValues
from models.formatter import format_spreadsheet_metadata, format_sheet_values
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse, RedirectResponse
# Import from parent vein module
import sys
from pathlib import Path
vein_path = Path(__file__).parent.parent.parent
sys.path.insert(0, str(vein_path))
# Import shared OAuth utilities from veins parent
from oauth import TokenStorage
from ..core.oauth import GoogleOAuth
from ..core.sheets import GoogleSheetsClient, GoogleSheetsError
from ..models.formatter import format_sheet_values, format_spreadsheet_metadata
from ..models.spreadsheet import SheetValues, SpreadsheetMetadata
router = APIRouter()
# OAuth client and token storage
@@ -71,18 +68,30 @@ def _maybe_text(data, text: bool, formatter):
@router.get("/health")
async def health():
"""Check if user is authenticated."""
"""Check vein health and configuration status."""
from ..core.config import settings
configured = bool(settings.google_client_id and settings.google_client_secret)
if not configured:
return {
"status": "not_configured",
"configured": False,
"message": "Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env",
}
try:
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
if not tokens:
return {
"status": "not_authenticated",
"message": "Visit /google/oauth/start to login",
"configured": True,
"message": "Visit /artery/google/oauth/start to login",
}
expired = token_storage.is_expired(tokens)
return {
"status": "ok" if not expired else "token_expired",
"configured": True,
"has_refresh_token": "refresh_token" in tokens,
"user": DEFAULT_USER_ID,
}
@@ -91,9 +100,25 @@ async def health():
@router.get("/oauth/start")
async def start_oauth(state: Optional[str] = None):
"""Start OAuth flow - redirect to Google authorization."""
auth_url = oauth_client.get_authorization_url(state=state)
async def start_oauth(
state: Optional[str] = None,
hd: Optional[str] = None,
redirect: Optional[str] = None,
):
"""
Start OAuth flow - redirect to Google authorization.
Args:
state: CSRF token (passed through to callback)
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
redirect: URL to redirect after successful auth
"""
# Encode redirect in state if provided
full_state = state or ""
if redirect:
full_state = f"{full_state}|{redirect}" if full_state else redirect
auth_url = oauth_client.get_authorization_url(state=full_state, hd=hd)
return RedirectResponse(auth_url)
@@ -122,6 +147,23 @@ async def oauth_callback(
raise HTTPException(500, f"Failed to exchange code: {e}")
@router.get("/oauth/userinfo")
async def get_userinfo(
code: str = Query(..., description="Authorization code from callback"),
):
"""
Exchange code and return user info (identity flow).
Used by common/auth for login - returns user identity without storing tokens.
For API access flows, use /oauth/callback instead.
"""
try:
user_info = oauth_client.exchange_code_for_user(code)
return user_info
except Exception as e:
raise HTTPException(400, f"Failed to get user info: {e}")
@router.get("/oauth/logout")
async def logout():
"""Clear stored tokens."""

View File

@@ -3,21 +3,37 @@ Google OAuth2 configuration loaded from .env file.
"""
from pathlib import Path
from pydantic_settings import BaseSettings
ENV_FILE = Path(__file__).parent.parent / ".env"
# OpenID scopes for identity verification
IDENTITY_SCOPES = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
# API scopes for data access (Sheets, Drive)
API_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/drive.readonly",
]
class GoogleConfig(BaseSettings):
google_client_id: str
google_client_secret: str
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:12000/artery/google/oauth/callback"
# Default to identity-only scopes; add API scopes when needed
google_scopes: str = " ".join(IDENTITY_SCOPES)
api_port: int = 8003
model_config = {
"env_file": ENV_FILE,
"env_file_encoding": "utf-8",
"extra": "ignore",
}

View File

@@ -2,12 +2,17 @@
Google OAuth2 flow implementation.
Isolated OAuth2 client that can run without FastAPI.
Supports both identity (OpenID) and API access flows.
"""
from typing import Optional
import requests
from google.auth.transport.requests import Request
from google.oauth2 import id_token
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from .config import settings
@@ -52,21 +57,29 @@ class GoogleOAuth:
)
return flow
def get_authorization_url(self, state: Optional[str] = None) -> str:
def get_authorization_url(
self, state: Optional[str] = None, hd: Optional[str] = None
) -> str:
"""
Generate OAuth2 authorization URL.
Args:
state: Optional state parameter for CSRF protection
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
Returns:
URL to redirect user for Google authorization
"""
flow = self._create_flow()
extra_params = {}
if hd:
extra_params["hd"] = hd
auth_url, _ = flow.authorization_url(
access_type="offline", # Request refresh token
include_granted_scopes="true",
state=state,
**extra_params,
)
return auth_url
@@ -97,6 +110,42 @@ class GoogleOAuth:
"token_type": "Bearer",
}
def exchange_code_for_user(self, code: str) -> dict:
"""
Exchange authorization code and return user identity info.
Used for identity/login flows (OpenID Connect).
Args:
code: Authorization code from callback
Returns:
User info dict containing:
- email
- name
- picture
- hd (hosted domain, if Google Workspace account)
"""
flow = self._create_flow()
flow.fetch_token(code=code)
credentials = flow.credentials
# Fetch user info from Google's userinfo endpoint
userinfo_response = requests.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers={"Authorization": f"Bearer {credentials.token}"},
)
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
return {
"email": userinfo.get("email"),
"name": userinfo.get("name"),
"picture": userinfo.get("picture"),
"hd": userinfo.get("hd"), # Hosted domain (Google Workspace)
}
def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh an expired access token.
@@ -126,7 +175,9 @@ class GoogleOAuth:
"token_type": "Bearer",
}
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
def get_credentials(
self, access_token: str, refresh_token: Optional[str] = None
) -> Credentials:
"""
Create Google Credentials object from tokens.

View File

@@ -3,20 +3,24 @@ Jira credentials loaded from .env file.
"""
from pathlib import Path
from pydantic_settings import BaseSettings
ENV_FILE = Path(__file__).parent.parent / ".env"
class JiraConfig(BaseSettings):
jira_url: str
jira_url: str = "" # Required for use, optional for loading
jira_email: str | None = None # Optional: can be provided per-request via headers
jira_api_token: str | None = None # Optional: can be provided per-request via headers
jira_api_token: str | None = (
None # Optional: can be provided per-request via headers
)
api_port: int = 8001
model_config = {
"env_file": ENV_FILE,
"env_file_encoding": "utf-8",
"extra": "ignore",
}

View File

@@ -11,7 +11,7 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from .base import BaseVein, TClient, TCredentials
from base import BaseVein, TClient, TCredentials
class TokenStorage:

View File

@@ -0,0 +1,3 @@
"""
Common module - shared abstractions reusable across soleprint systems.
"""

View File

@@ -0,0 +1,10 @@
"""
Generic authentication framework for soleprint.
Provider-agnostic - delegates to configured provider vein (e.g., google_login).
"""
from .config import AuthConfig, load_auth_config
from .session import get_current_user, require_auth
__all__ = ["AuthConfig", "load_auth_config", "get_current_user", "require_auth"]

View File

@@ -0,0 +1,43 @@
"""
Authentication configuration.
Generic config that works with any provider vein.
"""
from typing import Optional
from pydantic import BaseModel
class AuthConfig(BaseModel):
"""Authentication configuration for a room."""
enabled: bool = False
provider: str = "google" # Vein name to use for auth
allowed_domains: list[str] = [] # Empty = allow any domain
session_secret: str = "" # Required if enabled, can be "ENV:VAR_NAME"
session_timeout_hours: int = 24
login_redirect: str = "/"
public_routes: list[str] = [
"/health",
"/auth/login",
"/auth/callback",
"/auth/logout",
]
def load_auth_config(config: dict) -> Optional[AuthConfig]:
"""
Load auth config from room config.json.
Returns None if auth is not enabled.
"""
auth_data = config.get("auth")
if not auth_data:
return None
auth_config = AuthConfig(**auth_data)
if not auth_config.enabled:
return None
return auth_config

View File

@@ -0,0 +1,91 @@
"""
Authentication middleware for route protection.
Generic middleware, provider-agnostic.
"""
from datetime import datetime
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, RedirectResponse
from .config import AuthConfig
class AuthMiddleware(BaseHTTPMiddleware):
"""
Middleware that protects routes by requiring authentication.
- Public routes (configurable) are allowed without auth
- Unauthenticated browser requests redirect to /auth/login
- Unauthenticated API requests get 401 JSON response
"""
def __init__(self, app, auth_config: AuthConfig):
super().__init__(app)
self.config = auth_config
self.public_routes = set(auth_config.public_routes)
# Also allow static files and common paths
self.public_prefixes = ["/static", "/favicon", "/artery"]
async def dispatch(self, request, call_next):
path = request.url.path
# Check if route is public
if self._is_public(path):
return await call_next(request)
# Check session
session = request.session
user_email = session.get("user_email")
expires_at = session.get("expires_at")
if not user_email:
return self._unauthorized(request, "Not authenticated")
# Check expiry
if expires_at:
if datetime.fromisoformat(expires_at) < datetime.now():
session.clear()
return self._unauthorized(request, "Session expired")
# Check domain restriction
user_domain = session.get("domain")
if self.config.allowed_domains:
if not user_domain or user_domain not in self.config.allowed_domains:
session.clear()
return self._unauthorized(
request,
f"Access restricted to: {', '.join(self.config.allowed_domains)}",
)
# Attach user to request state for downstream use
request.state.user = {
"email": user_email,
"name": session.get("user_name"),
"domain": user_domain,
}
return await call_next(request)
def _is_public(self, path: str) -> bool:
"""Check if path is public (no auth required)."""
if path in self.public_routes:
return True
for prefix in self.public_prefixes:
if path.startswith(prefix):
return True
return False
def _unauthorized(self, request, message: str):
"""Return appropriate unauthorized response."""
# API requests get JSON 401
accept = request.headers.get("accept", "")
if "application/json" in accept:
return JSONResponse({"error": message}, status_code=401)
# Browser requests redirect to login with return URL
next_url = str(request.url.path)
if request.url.query:
next_url += f"?{request.url.query}"
return RedirectResponse(url=f"/auth/login?next={next_url}")

View File

@@ -0,0 +1,170 @@
"""
Authentication routes.
Generic routes that delegate to configured provider vein.
/auth/login - Start login flow (redirects to provider)
/auth/callback - Handle provider callback, create session
/auth/logout - Clear session
/auth/me - Get current user info
"""
import os
import secrets
from datetime import datetime, timedelta
from typing import Optional
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from .config import AuthConfig
router = APIRouter(prefix="/auth", tags=["auth"])
# Will be initialized by setup_auth() in run.py
auth_config: Optional[AuthConfig] = None
def init_auth(config: AuthConfig):
"""
Initialize auth module with configuration.
Called by run.py when setting up authentication.
"""
global auth_config
auth_config = config
def _get_provider_base_url() -> str:
"""Get base URL for the configured provider vein."""
if not auth_config:
raise HTTPException(500, "Auth not configured")
# Provider is a vein name like "google_login"
return f"/artery/{auth_config.provider}"
@router.get("/login")
async def login(request: Request, next: str = "/"):
"""
Start login flow.
Redirects to the configured provider vein's OAuth start endpoint.
"""
if not auth_config:
raise HTTPException(500, "Auth not configured")
# Generate CSRF state token
state = secrets.token_urlsafe(32)
request.session["oauth_state"] = state
request.session["oauth_next"] = next
# Get domain hint from config (first allowed domain)
hd = auth_config.allowed_domains[0] if auth_config.allowed_domains else None
# Build provider OAuth URL
provider_url = _get_provider_base_url()
params = f"?state={state}"
if hd:
params += f"&hd={hd}"
# Redirect includes callback to our /auth/callback
return RedirectResponse(url=f"{provider_url}/oauth/start{params}")
@router.get("/callback")
async def callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
):
"""
Handle OAuth callback.
Receives code from provider, exchanges for user info, creates session.
"""
if not auth_config:
raise HTTPException(500, "Auth not configured")
if error:
raise HTTPException(400, f"OAuth error: {error}")
# Verify state
expected_state = request.session.get("oauth_state")
if not state or state != expected_state:
raise HTTPException(400, "Invalid state parameter")
# Call provider vein to exchange code for user info
provider_url = _get_provider_base_url()
try:
async with httpx.AsyncClient() as client:
# Get base URL from request
base_url = str(request.base_url).rstrip("/")
response = await client.get(
f"{base_url}{provider_url}/oauth/userinfo",
params={"code": code},
)
if response.status_code != 200:
raise HTTPException(400, f"Provider error: {response.text}")
user_info = response.json()
except httpx.RequestError as e:
raise HTTPException(500, f"Failed to contact provider: {e}")
# Verify domain if restricted
user_domain = user_info.get("hd")
if auth_config.allowed_domains:
if not user_domain or user_domain not in auth_config.allowed_domains:
raise HTTPException(
403,
f"Access restricted to: {', '.join(auth_config.allowed_domains)}. "
f"Your account is from: {user_domain or 'personal Gmail'}",
)
# Create session
expires_at = datetime.now() + timedelta(hours=auth_config.session_timeout_hours)
request.session.update(
{
"user_email": user_info["email"],
"user_name": user_info.get("name"),
"user_picture": user_info.get("picture"),
"domain": user_domain,
"authenticated_at": datetime.now().isoformat(),
"expires_at": expires_at.isoformat(),
}
)
# Clean up oauth state
request.session.pop("oauth_state", None)
next_url = request.session.pop("oauth_next", "/")
return RedirectResponse(url=next_url)
@router.get("/logout")
async def logout(request: Request):
"""Clear session and redirect to login."""
request.session.clear()
return RedirectResponse(url="/auth/login")
@router.get("/me")
async def me(request: Request):
"""
Return current user info.
API endpoint for checking auth status.
"""
user = getattr(request.state, "user", None)
if not user:
# Try to get from session directly (in case middleware didn't run)
user_email = request.session.get("user_email")
if not user_email:
raise HTTPException(401, "Not authenticated")
user = {
"email": user_email,
"name": request.session.get("user_name"),
"picture": request.session.get("user_picture"),
"domain": request.session.get("domain"),
}
return user

View File

@@ -0,0 +1,51 @@
"""
Session helpers for authentication.
Generic session management, provider-agnostic.
"""
from datetime import datetime
from typing import Optional
from fastapi import HTTPException, Request
def get_current_user(request: Request) -> Optional[dict]:
"""
Get current authenticated user from session.
Returns:
User dict with email, name, domain, etc. or None if not authenticated.
"""
session = getattr(request, "session", None)
if not session:
return None
user_email = session.get("user_email")
if not user_email:
return None
# Check expiry
expires_at = session.get("expires_at")
if expires_at:
if datetime.fromisoformat(expires_at) < datetime.now():
return None
return {
"email": user_email,
"name": session.get("user_name"),
"picture": session.get("user_picture"),
"domain": session.get("domain"),
}
def require_auth(request: Request) -> dict:
"""
Get current user or raise 401.
For use as FastAPI dependency.
"""
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user

View File

@@ -11,6 +11,7 @@ Usage:
This is for soleprint development only, not for managed rooms (use docker for those).
"""
import importlib.util
import json
import os
import sys
@@ -34,6 +35,212 @@ DATA_DIR = SPR_ROOT / "data"
CFG_DIR = SPR_ROOT / "cfg"
# ============================================================================
# Vein Loading
# ============================================================================
# Preload pip packages that share names with veins to prevent shadowing
# These must be imported BEFORE any vein that might shadow them
_preloaded_packages = []
for _pkg in ["google.auth", "jira", "slack_sdk"]:
try:
__import__(_pkg)
_preloaded_packages.append(_pkg)
except ImportError:
pass # Package not installed, vein will fail gracefully
def load_vein(vein_name: str):
"""
Load a vein's router dynamically.
Veins use relative imports for their internal modules (..core, ..models)
and absolute imports for shared utilities (oauth, base from veins/).
IMPORTANT: Vein folder names may shadow pip packages (e.g., 'google', 'jira').
We ONLY register under prefixed names (vein_google) to avoid shadowing.
Relative imports work because we set __package__ correctly on each module.
"""
vein_path = SPR_ROOT / "artery" / "veins" / vein_name
if not vein_path.exists():
raise FileNotFoundError(f"Vein not found: {vein_path}")
routes_file = vein_path / "api" / "routes.py"
if not routes_file.exists():
raise FileNotFoundError(f"Vein routes not found: {routes_file}")
# Use prefixed name to avoid shadowing pip packages
vein_prefix = f"vein_{vein_name}"
# Clear any previously loaded vein modules to avoid conflicts
for mod_name in list(sys.modules.keys()):
if mod_name == vein_prefix or mod_name.startswith(f"{vein_prefix}."):
del sys.modules[mod_name]
# Clear shared modules that might have stale references
for mod_name in ["oauth", "base"]:
if mod_name in sys.modules:
del sys.modules[mod_name]
# Add veins directory to path for shared modules (oauth.py, base.py)
veins_path = vein_path.parent
if str(veins_path) not in sys.path:
sys.path.insert(0, str(veins_path))
# Create the vein package module
vein_init = vein_path / "__init__.py"
spec = importlib.util.spec_from_file_location(
vein_prefix,
vein_init if vein_init.exists() else None,
submodule_search_locations=[str(vein_path)],
)
vein_pkg = importlib.util.module_from_spec(spec)
sys.modules[vein_prefix] = vein_pkg
if spec.loader:
spec.loader.exec_module(vein_pkg)
# Load subpackages (core, api, models)
for subpkg in ["core", "models", "api"]:
subpkg_path = vein_path / subpkg
if subpkg_path.exists():
_load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path)
# Load individual modules in core/ and models/ that routes.py needs
for subpkg in ["core", "models"]:
subpkg_path = vein_path / subpkg
if subpkg_path.exists():
for py_file in subpkg_path.glob("*.py"):
if py_file.name.startswith("_"):
continue
module_name = py_file.stem
_load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, py_file)
# Now load routes.py with all dependencies available
routes_mod = _load_vein_module(vein_pkg, vein_prefix, "api", "routes", routes_file)
return routes_mod.router
def _load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path):
"""Load a vein subpackage (core, api, models)."""
subpkg_init = subpkg_path / "__init__.py"
sub_spec = importlib.util.spec_from_file_location(
f"{vein_prefix}.{subpkg}",
subpkg_init if subpkg_init.exists() else None,
submodule_search_locations=[str(subpkg_path)],
)
sub_mod = importlib.util.module_from_spec(sub_spec)
sys.modules[f"{vein_prefix}.{subpkg}"] = sub_mod
setattr(vein_pkg, subpkg, sub_mod)
if sub_spec.loader:
sub_spec.loader.exec_module(sub_mod)
return sub_mod
def _load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, file_path):
"""Load a specific module within a vein subpackage."""
mod_spec = importlib.util.spec_from_file_location(
f"{vein_prefix}.{subpkg}.{module_name}",
file_path,
)
mod = importlib.util.module_from_spec(mod_spec)
# Set __package__ so relative imports resolve via vein_prefix
mod.__package__ = f"{vein_prefix}.{subpkg}"
sys.modules[f"{vein_prefix}.{subpkg}.{module_name}"] = mod
# Also set on parent package
parent_pkg = getattr(vein_pkg, subpkg, None)
if parent_pkg:
setattr(parent_pkg, module_name, mod)
mod_spec.loader.exec_module(mod)
return mod
def mount_veins(app):
"""Auto-discover and mount all veins from artery/veins/."""
veins_dir = SPR_ROOT / "artery" / "veins"
if not veins_dir.exists():
return
for vein_path in sorted(veins_dir.iterdir()):
if not vein_path.is_dir():
continue
if vein_path.name.startswith(("_", ".")):
continue
# Skip non-vein directories (no api/routes.py)
if not (vein_path / "api" / "routes.py").exists():
continue
vein_name = vein_path.name
try:
router = load_vein(vein_name)
app.include_router(router, prefix=f"/artery/{vein_name}", tags=[vein_name])
print(f"Vein mounted: /artery/{vein_name}")
except Exception as e:
print(f"Warning: Could not load vein '{vein_name}': {e}")
# ============================================================================
# Authentication Setup (optional, based on room config)
# ============================================================================
def setup_auth(app, config: dict):
"""
Configure authentication if enabled in room config.
Auth is optional - rooms without auth config run without authentication.
"""
try:
from common.auth.config import load_auth_config
auth_config = load_auth_config(config)
if not auth_config:
return
print(f"Auth: enabled for domains {auth_config.allowed_domains or ['*']}")
# Get session secret
session_secret = auth_config.session_secret
if session_secret.startswith("ENV:"):
session_secret = os.getenv(session_secret[4:], "dev-secret-change-in-prod")
# Add session middleware
from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(
SessionMiddleware,
secret_key=session_secret,
session_cookie="soleprint_session",
max_age=auth_config.session_timeout_hours * 3600,
same_site="lax",
https_only=os.getenv("HTTPS_ONLY", "").lower() == "true",
)
# Initialize auth routes
from common.auth.routes import init_auth
from common.auth.routes import router as auth_router
init_auth(auth_config)
app.include_router(auth_router)
print("Auth: routes mounted at /auth")
# Add auth middleware
from common.auth.middleware import AuthMiddleware
app.add_middleware(AuthMiddleware, auth_config=auth_config)
except ImportError:
# common.auth not available (standalone without auth)
pass
except Exception as e:
print(f"Auth setup error: {e}")
def load_config() -> dict:
"""Load config.json from cfg/ directory."""
config_path = CFG_DIR / "config.json"
@@ -91,6 +298,15 @@ def scan_directory(base_path: Path, pattern: str = "*") -> list[dict]:
return items
# ============================================================================
# Initialize: Load config, setup auth, mount veins
# ============================================================================
_config = load_config()
setup_auth(app, _config)
mount_veins(app)
@app.get("/health")
def health():
return {

View File

@@ -1,6 +1,6 @@
// Pawprint Wrapper - Sidebar Logic
// Soleprint Wrapper - Sidebar Logic
class PawprintSidebar {
class SoleprintSidebar {
constructor() {
this.config = null;
this.currentUser = null;
@@ -28,38 +28,38 @@ class PawprintSidebar {
async loadConfig() {
try {
const response = await fetch('/wrapper/config.json');
const response = await fetch("/wrapper/config.json");
this.config = await response.json();
console.log('[Pawprint] Config loaded:', this.config.nest_name);
console.log("[Soleprint] Config loaded:", this.config.nest_name);
} catch (error) {
console.error('[Pawprint] Failed to load config:', error);
console.error("[Soleprint] Failed to load config:", error);
// Use default config
this.config = {
nest_name: 'default',
nest_name: "default",
wrapper: {
environment: {
backend_url: 'http://localhost:8000',
frontend_url: 'http://localhost:3000'
backend_url: "http://localhost:8000",
frontend_url: "http://localhost:3000",
},
users: [],
},
users: []
}
};
}
}
createSidebar() {
const sidebar = document.createElement('div');
sidebar.id = 'pawprint-sidebar';
const sidebar = document.createElement("div");
sidebar.id = "pawprint-sidebar";
sidebar.innerHTML = this.getSidebarHTML();
document.body.appendChild(sidebar);
this.sidebar = sidebar;
}
createToggleButton() {
const button = document.createElement('button');
button.id = 'sidebar-toggle';
const button = document.createElement("button");
button.id = "sidebar-toggle";
button.innerHTML = '<span class="icon">◀</span>';
button.title = 'Toggle Pawprint Sidebar (Ctrl+Shift+P)';
button.title = "Toggle Soleprint Sidebar (Ctrl+Shift+P)";
document.body.appendChild(button);
this.toggleBtn = button;
}
@@ -69,7 +69,7 @@ class PawprintSidebar {
return `
<div class="sidebar-header">
<h2>🐾 Pawprint</h2>
<h2>🐾 Soleprint</h2>
<div class="nest-name">${this.config.nest_name}</div>
</div>
@@ -83,22 +83,26 @@ class PawprintSidebar {
<div id="current-user-display" style="display: none;">
<div class="current-user">
Logged in as: <strong id="current-username"></strong>
<button class="logout-btn" onclick="pawprintSidebar.logout()">
<button class="logout-btn" onclick="soleprintSidebar.logout()">
Logout
</button>
</div>
</div>
<div class="user-cards">
${users.map(user => `
<div class="user-card" data-user-id="${user.id}" onclick="pawprintSidebar.loginAs('${user.id}')">
${users
.map(
(user) => `
<div class="user-card" data-user-id="${user.id}" onclick="soleprintSidebar.loginAs('${user.id}')">
<div class="icon">${user.icon}</div>
<div class="info">
<span class="label">${user.label}</span>
<span class="role">${user.role}</span>
</div>
</div>
`).join('')}
`,
)
.join("")}
</div>
</div>
@@ -119,18 +123,18 @@ class PawprintSidebar {
</div>
<div class="sidebar-footer">
Pawprint Dev Tools
Soleprint Dev Tools
</div>
`;
}
setupEventListeners() {
// Toggle button
this.toggleBtn.addEventListener('click', () => this.toggle());
this.toggleBtn.addEventListener("click", () => this.toggle());
// Keyboard shortcut: Ctrl+Shift+P
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "P") {
e.preventDefault();
this.toggle();
}
@@ -138,28 +142,29 @@ class PawprintSidebar {
}
toggle() {
this.sidebar.classList.toggle('expanded');
this.sidebar.classList.toggle("expanded");
this.saveSidebarState();
}
saveSidebarState() {
const isExpanded = this.sidebar.classList.contains('expanded');
localStorage.setItem('pawprint_sidebar_expanded', isExpanded);
const isExpanded = this.sidebar.classList.contains("expanded");
localStorage.setItem("pawprint_sidebar_expanded", isExpanded);
}
loadSidebarState() {
const isExpanded = localStorage.getItem('pawprint_sidebar_expanded') === 'true';
const isExpanded =
localStorage.getItem("pawprint_sidebar_expanded") === "true";
if (isExpanded) {
this.sidebar.classList.add('expanded');
this.sidebar.classList.add("expanded");
}
}
showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
const statusDiv = document.createElement('div');
showStatus(message, type = "info") {
const container = document.getElementById("status-container");
const statusDiv = document.createElement("div");
statusDiv.className = `status-message ${type}`;
statusDiv.textContent = message;
container.innerHTML = '';
container.innerHTML = "";
container.appendChild(statusDiv);
// Auto-remove after 5 seconds
@@ -169,22 +174,22 @@ class PawprintSidebar {
}
async loginAs(userId) {
const user = this.config.wrapper.users.find(u => u.id === userId);
const user = this.config.wrapper.users.find((u) => u.id === userId);
if (!user) return;
this.showStatus(`Logging in as ${user.label}... ⏳`, 'info');
this.showStatus(`Logging in as ${user.label}... ⏳`, "info");
try {
const backendUrl = this.config.wrapper.environment.backend_url;
const response = await fetch(`${backendUrl}/api/token/`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
username: user.username,
password: user.password
})
password: user.password,
}),
});
if (!response.ok) {
@@ -194,17 +199,20 @@ class PawprintSidebar {
const data = await response.json();
// Store tokens
localStorage.setItem('access_token', data.access);
localStorage.setItem('refresh_token', data.refresh);
localStorage.setItem("access_token", data.access);
localStorage.setItem("refresh_token", data.refresh);
// Store user info
localStorage.setItem('user_info', JSON.stringify({
localStorage.setItem(
"user_info",
JSON.stringify({
username: user.username,
label: user.label,
role: data.details?.role || user.role
}));
role: data.details?.role || user.role,
}),
);
this.showStatus(`✓ Logged in as ${user.label}`, 'success');
this.showStatus(`✓ Logged in as ${user.label}`, "success");
this.currentUser = user;
this.updateCurrentUserDisplay();
@@ -212,19 +220,18 @@ class PawprintSidebar {
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
console.error('[Pawprint] Login error:', error);
this.showStatus(`✗ Login failed: ${error.message}`, 'error');
console.error("[Soleprint] Login error:", error);
this.showStatus(`✗ Login failed: ${error.message}`, "error");
}
}
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_info');
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user_info");
this.showStatus('✓ Logged out', 'success');
this.showStatus("✓ Logged out", "success");
this.currentUser = null;
this.updateCurrentUserDisplay();
@@ -235,52 +242,54 @@ class PawprintSidebar {
}
checkCurrentUser() {
const userInfo = localStorage.getItem('user_info');
const userInfo = localStorage.getItem("user_info");
if (userInfo) {
try {
this.currentUser = JSON.parse(userInfo);
this.updateCurrentUserDisplay();
} catch (error) {
console.error('[Pawprint] Failed to parse user info:', error);
console.error("[Soleprint] Failed to parse user info:", error);
}
}
}
updateCurrentUserDisplay() {
const display = document.getElementById('current-user-display');
const username = document.getElementById('current-username');
const display = document.getElementById("current-user-display");
const username = document.getElementById("current-username");
if (this.currentUser) {
display.style.display = 'block';
display.style.display = "block";
username.textContent = this.currentUser.username;
// Highlight active user card
document.querySelectorAll('.user-card').forEach(card => {
card.classList.remove('active');
document.querySelectorAll(".user-card").forEach((card) => {
card.classList.remove("active");
});
const activeCard = document.querySelector(`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`);
const activeCard = document.querySelector(
`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`,
);
if (activeCard) {
activeCard.classList.add('active');
activeCard.classList.add("active");
}
} else {
display.style.display = 'none';
display.style.display = "none";
}
}
getUserIdByUsername(username) {
const user = this.config.wrapper.users.find(u => u.username === username);
const user = this.config.wrapper.users.find((u) => u.username === username);
return user ? user.id : null;
}
}
// Initialize sidebar when DOM is ready
const pawprintSidebar = new PawprintSidebar();
const soleprintSidebar = new SoleprintSidebar();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => pawprintSidebar.init());
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => soleprintSidebar.init());
} else {
pawprintSidebar.init();
soleprintSidebar.init();
}
console.log('[Pawprint] Sidebar script loaded');
console.log("[Soleprint] Sidebar script loaded");