Compare commits

...

13 Commits

Author SHA1 Message Date
buenosairesam
dcc5191ba3 fix: rename VNC to VPN vein 2026-01-27 09:24:20 -03:00
buenosairesam
220d3dc5a6 fix: pass rooms and depots to artery template to fix 500 error 2026-01-27 09:24:05 -03:00
buenosairesam
fa7bbe3953 Improve soleprint docs: bilingual EN/ES, system pages, architecture cleanup 2026-01-27 09:24:05 -03:00
buenosairesam
ed1c8f6c96 Add /spr/ route for soleprint index, fix sidebar system labels 2026-01-27 09:24:05 -03:00
buenosairesam
5603979d5c Add session cookie for browser-isolated OAuth sessions 2026-01-27 09:24:05 -03:00
buenosairesam
2babd47835 homogeineze sidebar conf 2026-01-27 09:24:05 -03:00
buenosairesam
027f73794d added sample both local docker and system ngins options 2026-01-27 09:24:05 -03:00
buenosairesam
8c5deb74e8 fixed network issue with multiple managed rooms 2026-01-27 09:24:05 -03:00
buenosairesam
dd47f9c66f env merge fix 2026-01-27 09:24:05 -03:00
buenosairesam
e1f81889fc tuning the sidebar 2026-01-27 09:24:05 -03:00
buenosairesam
fecb978a5f updated sidebar 2026-01-27 09:24:05 -03:00
buenosairesam
cae5a913ca removed cfgs from repo 2026-01-27 09:24:05 -03:00
buenosairesam
c4e702eae3 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)
2026-01-27 09:24:05 -03:00
42 changed files with 4426 additions and 812 deletions

8
.gitignore vendored
View File

@@ -12,5 +12,9 @@ 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/ and cfg/sample/ as templates, ignore actual rooms
cfg/amar/
cfg/dlt/
# Add new rooms here as they are created
# cfg/<room>/

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

@@ -179,10 +179,25 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict):
):
copy_path(item, managed_dir / item.name)
# Scripts from ctrl/ -> managed/ctrl/
# Copy managed app config from cfg/<room>/<managed_name>/ (e.g., .env, dumps/)
room_managed_cfg = room_cfg / managed_name
if room_managed_cfg.exists():
log.info(f" Copying {managed_name} config...")
for item in room_managed_cfg.iterdir():
if item.is_file():
copy_path(item, managed_dir / item.name, quiet=True)
elif item.is_dir():
target = managed_dir / item.name
if target.exists():
# Merge into existing repo directory
merge_into(item, target)
else:
copy_path(item, target)
# Scripts from ctrl/ -> output_dir/ctrl/ (sibling of managed, link, soleprint)
room_ctrl = room_cfg / "ctrl"
if room_ctrl.exists():
ctrl_dir = managed_dir / "ctrl"
ctrl_dir = output_dir / "ctrl"
ensure_dir(ctrl_dir)
for item in room_ctrl.iterdir():
if item.is_file():
@@ -294,6 +309,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)
@@ -303,13 +322,19 @@ def build_soleprint(output_dir: Path, room: str):
log.warning("Model generation failed")
def build(output_dir: Path, cfg_name: str | None = None):
def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
"""Build complete room instance."""
room = cfg_name or "standalone"
config = load_config(cfg_name)
managed = config.get("managed")
log.info(f"\n=== Building {room} ===")
# Clean output directory first
if clean and output_dir.exists():
log.info(f"Cleaning {output_dir}...")
shutil.rmtree(output_dir)
ensure_dir(output_dir)
if managed:

15
cfg/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Environment files with credentials (use .env.example as template)
**/.env
!sample/**/.env
# Database dumps (sensitive data)
**/dumps/*.sql
# Python
__pycache__/
*.pyc
*.pyo
# These are kept in main soleprint repo as templates
standalone/
sample/

125
cfg/README.md Normal file
View File

@@ -0,0 +1,125 @@
# Soleprint Room Configurations
Private repository containing room-specific configurations for Soleprint instances.
## Structure
```
cfg/
├── amar/ # Amar managed room
│ ├── config.json
│ ├── data/
│ ├── soleprint/ # Soleprint customizations
│ ├── link/ # Bridge to managed app
│ └── ctrl/ # Build/run scripts
├── dlt/ # DLT placeholder room
└── README.md
```
## Setup
This repo lives inside the main soleprint repo at `cfg/`. The `standalone/` folder is tracked in the main soleprint repo as a sample.
### Fresh clone (new machine)
```bash
# Clone main soleprint repo
git clone <soleprint-repo-url> spr
cd spr
# Clone this cfg repo into cfg/ (standalone/ already exists from main repo)
git clone <this-cfg-repo-url> cfg-private
mv cfg-private/.git cfg/
mv cfg-private/* cfg/ 2>/dev/null
rm -rf cfg-private
# Now cfg/ has both:
# - standalone/ (from main soleprint repo)
# - amar/, dlt/ (from this cfg repo)
```
### Alternative: Separate directories
```bash
# Keep repos separate, use --cfg-path
git clone <soleprint-repo-url> spr
git clone <this-cfg-repo-url> spr-cfg
# Build with external cfg path
cd spr
python build.py --cfg amar --cfg-path ../spr-cfg
```
## Building
From the main soleprint repo:
```bash
python build.py # Build standalone (sample)
python build.py --cfg amar # Build amar room
python build.py --cfg dlt # Build dlt room
```
## Deploy/Sync Workflow
### Local Development
```bash
# 1. Build the room
python build.py --cfg amar
# 2. Start with Docker
cd gen/amar/soleprint && docker compose up -d
# 3. For managed rooms, also start the app
cd gen/amar/amar && docker compose up -d
```
### Production Deploy
```bash
# On deploy server:
# 1. Pull both repos
cd /opt/spr && git pull
cd /opt/spr/cfg && git pull
# 2. Rebuild
python build.py --cfg amar
# 3. Restart services
cd gen/amar/soleprint && docker compose up -d --build
```
### CI/CD Pipeline (example)
```yaml
# Deploy soleprint changes
deploy-soleprint:
script:
- ssh deploy@server "cd /opt/spr && git pull && python build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
# Deploy cfg changes (private repo)
deploy-cfg:
script:
- ssh deploy@server "cd /opt/spr/cfg && git pull && python ../build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
```
## Credentials
- `.env` files are gitignored - copy from `.env.example`
- Never commit actual credentials
- Database dumps in `*/dumps/` are also gitignored
## Adding a New Room
```bash
# From cfg/ directory
mkdir -p newroom/data newroom/soleprint
cp ../cfg/standalone/config.json newroom/ # Use standalone as template
# Edit config.json for your room
# Add room-specific customizations in newroom/soleprint/
git add newroom && git commit -m "Add newroom configuration"
```

143
cfg/sample/config.json Normal file
View File

@@ -0,0 +1,143 @@
{
"framework": {
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "",
"hub_port": 12030
},
"auth": {
"enabled": true,
"provider": "google",
"allowed_domains": [],
"allowed_emails": [],
"session_secret": "ENV:AUTH_SESSION_SECRET"
},
"veins": ["google"],
"managed": {
"name": "sample"
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": ""
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentacion accionable",
"icon": ""
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": ""
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

28
cfg/sample/ctrl/start.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Start all sample services
# Usage: ./ctrl/start.sh [-d]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DETACH=""
if [[ "$1" == "-d" ]]; then
DETACH="-d"
fi
echo "=== Starting sample services ==="
# Start soleprint
echo "Starting soleprint..."
cd "$ROOT_DIR/soleprint"
docker compose up $DETACH &
# Start sample app
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
echo "Starting sample app..."
cd "$ROOT_DIR/sample"
docker compose up $DETACH &
fi
wait
echo "=== All services started ==="

22
cfg/sample/ctrl/stop.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Stop all sample services
# Usage: ./ctrl/stop.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Stopping sample services ==="
# Stop sample app
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
echo "Stopping sample app..."
cd "$ROOT_DIR/sample"
docker compose down
fi
# Stop soleprint
echo "Stopping soleprint..."
cd "$ROOT_DIR/soleprint"
docker compose down
echo "=== All services stopped ==="

15
cfg/sample/sample/.env Normal file
View File

@@ -0,0 +1,15 @@
# =============================================================================
# Sample Managed App - Environment Configuration
# =============================================================================
# Copy this to cfg/<your-room>/<app-name>/.env and customize
# =============================================================================
# DEPLOYMENT
# =============================================================================
DEPLOYMENT_NAME=sample
NETWORK_NAME=sample_network
# =============================================================================
# PORTS
# =============================================================================
FRONTEND_PORT=3020

View File

@@ -0,0 +1,19 @@
# Sample Mock Frontend
# Simple nginx serving static HTML
#
# For a real app, customize this with your actual services
services:
frontend:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_frontend
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
ports:
- "${FRONTEND_PORT}:80"
networks:
- default
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sample - Public Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
color: #e5e5e5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #3a86ff, #8338ec);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
font-size: 1.2rem;
color: #a3a3a3;
margin-bottom: 2rem;
}
.status {
display: inline-block;
padding: 0.5rem 1rem;
background: rgba(58, 134, 255, 0.2);
border: 1px solid #3a86ff;
border-radius: 4px;
color: #3a86ff;
font-size: 0.9rem;
}
.info {
margin-top: 3rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
max-width: 400px;
}
.info h3 {
color: #8338ec;
margin-bottom: 1rem;
}
.info ul {
list-style: none;
text-align: left;
}
.info li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info li:last-child {
border-bottom: none;
}
a {
color: #3a86ff;
}
</style>
</head>
<body>
<div class="container">
<h1>Sample</h1>
<p>Public demo - open to any Gmail account</p>
<span class="status">Public Demo</span>
<div class="info">
<h3>Soleprint Managed Room</h3>
<ul>
<li>
With sidebar:
<a href="https://sample.spr.mcrn.ar"
>sample.spr.mcrn.ar</a
>
</li>
<li>
Standalone:
<a href="https://sample.mcrn.ar">sample.mcrn.ar</a>
</li>
<li>Login with any Google account</li>
</ul>
</div>
</div>
</body>
</html>

31
cfg/sample/soleprint/.env Normal file
View File

@@ -0,0 +1,31 @@
# =============================================================================
# Sample Soleprint Configuration
# =============================================================================
# =============================================================================
# DEPLOYMENT
# =============================================================================
DEPLOYMENT_NAME=sample_spr
# =============================================================================
# NETWORK
# =============================================================================
NETWORK_NAME=sample_network
# =============================================================================
# PORTS (unique per room)
# =============================================================================
SOLEPRINT_PORT=12030
# =============================================================================
# GOOGLE OAUTH
# =============================================================================
GOOGLE_CLIENT_ID=1076380473867-k6gvdg8etujj2e51bqejve78ft99hnqd.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
# =============================================================================
# AUTH
# =============================================================================
AUTH_BYPASS=true
AUTH_SESSION_SECRET=sample-dev-secret-change-in-production

View File

@@ -0,0 +1,35 @@
# Nginx Reverse Proxy for Docker-based Local Development
#
# Usage:
# # Start all services including nginx:
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# # Or start nginx separately after other services:
# docker compose -f docker-compose.nginx.yml up -d
#
# Routes:
# - sample.spr.local.ar -> frontend with sidebar injection
# - sample.local.ar -> frontend without sidebar
#
# Note: Requires /etc/hosts entries:
# 127.0.0.1 sample.spr.local.ar sample.local.ar
name: ${DEPLOYMENT_NAME}_nginx
services:
nginx:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "80:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- default
depends_on:
- soleprint
restart: unless-stopped
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -0,0 +1,36 @@
# Soleprint Services - Docker Compose
#
# Runs soleprint hub as a single service
# Artery, atlas, station are accessed via path-based routing
#
# Usage:
# cd gen/<room>/soleprint && docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
soleprint:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
ports:
- "${SOLEPRINT_PORT}:8000"
env_file:
- .env
environment:
# For single-port mode, all subsystems are internal routes
- ARTERY_EXTERNAL_URL=/artery
- ATLAS_EXTERNAL_URL=/atlas
- STATION_EXTERNAL_URL=/station
networks:
- default
# Use run.py for single-port bare-metal mode
command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -0,0 +1,72 @@
# Sample Room - Nginx Config for Docker
#
# This config uses docker service names (soleprint, frontend, backend)
# which resolve within the docker network.
# sample.spr.local.ar - frontend with soleprint sidebar
server {
listen 80;
server_name sample.spr.local.ar;
# Soleprint routes - sidebar API and assets
location /spr/ {
proxy_pass http://soleprint:8000/;
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;
}
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# 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;
# }
# Frontend with sidebar injection
location / {
proxy_pass http://frontend:80;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
# Inject sidebar CSS and JS into head
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
sub_filter_types text/html;
}
}
# sample.local.ar - frontend without sidebar (direct access)
server {
listen 80;
server_name sample.local.ar;
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# 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 / {
proxy_pass http://frontend:80;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,4 +1,5 @@
{
"showcase_url": "https://sample.spr.mcrn.ar",
"framework": {
"name": "soleprint",
"slug": "soleprint",

View File

@@ -18,7 +18,7 @@
"name": "google",
"slug": "google",
"title": "Google",
"status": "planned",
"status": "building",
"system": "artery"
},
{
@@ -43,9 +43,9 @@
"system": "artery"
},
{
"name": "vnc",
"slug": "vnc",
"title": "VNC",
"name": "vpn",
"slug": "vpn",
"title": "VPN",
"status": "planned",
"system": "artery"
},

View File

@@ -5,12 +5,15 @@
# Usage:
# cd gen/standalone && docker compose up -d
name: soleprint_standalone
services:
soleprint:
build:
context: .
dockerfile: Dockerfile
container_name: soleprint
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
ports:

View File

@@ -0,0 +1,217 @@
# Sidebar Injection Architecture
## Overview
The soleprint sidebar is injected into managed app pages using a hybrid nginx + JavaScript approach. This allows any frontend framework (React, Next.js, static HTML) to receive the sidebar without code modifications.
## How It Works
```
┌─────────────────────────────────────────────────────────────────┐
│ Browser Request │
│ http://room.spr.local.ar/ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Nginx │
│ │
│ 1. Routes /spr/* → soleprint:PORT (sidebar assets + API) │
│ 2. Routes /* → frontend:PORT (app pages) │
│ 3. Injects CSS+JS into HTML responses via sub_filter │
│ │
│ sub_filter '</head>' │
│ '<link rel="stylesheet" href="/spr/sidebar.css"> │
│ <script src="/spr/sidebar.js" defer></script></head>'; │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Browser Renders │
│ │
│ 1. Page loads with injected CSS (sidebar styles ready) │
│ 2. sidebar.js executes (deferred, after DOM ready) │
│ 3. JS fetches /spr/api/sidebar/config (room name, auth, etc) │
│ 4. JS creates sidebar DOM elements and injects into page │
│ 5. Sidebar appears on left side, pushes content with margin │
└─────────────────────────────────────────────────────────────────┘
```
## Key Design Decisions
### Why nginx sub_filter instead of modifying app code?
- **Framework agnostic**: Works with any frontend (Next.js, React, Vue, static HTML)
- **No app changes needed**: The managed app doesn't need to know about soleprint
- **Easy to disable**: Just access `room.local.ar` instead of `room.spr.local.ar`
### Why inject into `</head>` instead of `</body>`?
Next.js and other streaming SSR frameworks may not include `</body>` in the initial HTML response. The `</head>` tag is always present, so we inject both CSS and JS there using `defer` to ensure JS runs after DOM is ready.
### Why JavaScript injection instead of iframe?
- **No iframe isolation issues**: Sidebar can interact with page if needed
- **Better UX**: No double scrollbars, native feel
- **Simpler CSS**: Just push content with `margin-left`
## Nginx Configuration
There are two options for local development:
### Option 1: Docker Nginx (Recommended for portability)
Each room includes a docker-compose.nginx.yml that runs nginx in a container.
```bash
# Add to /etc/hosts
127.0.0.1 sample.spr.local.ar sample.local.ar
# Start room with nginx
cd gen/<room>/soleprint
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
```
The nginx config in `cfg/<room>/soleprint/nginx/local.conf` uses docker service names:
```nginx
location /spr/ {
proxy_pass http://soleprint:8000/;
}
location / {
proxy_pass http://frontend:80;
# ... sub_filter for sidebar injection
}
```
**Pros**: Portable, no system dependencies, isolated per room
**Cons**: Only one room can use port 80 at a time
### Option 2: System Nginx (For running multiple rooms)
If you need multiple rooms running simultaneously, use your system's nginx.
1. Install nginx: `sudo apt install nginx`
2. Add hosts entries for all rooms:
```
# /etc/hosts
127.0.0.1 amar.spr.local.ar amar.local.ar
127.0.0.1 dlt.spr.local.ar dlt.local.ar
127.0.0.1 sample.spr.local.ar sample.local.ar
```
3. Create config in `/etc/nginx/sites-enabled/spr_local.conf`:
```nginx
# room.spr.local.ar - app with sidebar
server {
listen 80;
server_name room.spr.local.ar;
# Soleprint assets and API
location /spr/ {
proxy_pass http://127.0.0.1:SOLEPRINT_PORT/;
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;
}
# Backend API (if applicable)
location /api/ {
proxy_pass http://127.0.0.1:BACKEND_PORT/api/;
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;
}
# Frontend with sidebar injection
location / {
proxy_pass http://127.0.0.1:FRONTEND_PORT;
proxy_set_header Host $host;
proxy_set_header Accept-Encoding ""; # Required for sub_filter
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Inject sidebar
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
sub_filter_types text/html;
}
}
# room.local.ar - app without sidebar (direct access)
server {
listen 80;
server_name room.local.ar;
# ... same locations but without sub_filter in / block
}
```
4. Reload nginx: `sudo nginx -t && sudo systemctl reload nginx`
**Pros**: Multiple rooms on port 80 via different hostnames
**Cons**: Requires system nginx, manual config updates
## Port Allocation
Each room should use unique ports to allow concurrent operation:
| Room | Soleprint | Frontend | Backend |
|--------|-----------|----------|---------|
| amar | 12000 | 3000 | 8001 |
| dlt | 12010 | 3010 | - |
| sample | 12020 | 3020 | 8020 |
## Sidebar Assets
The sidebar consists of two files served by soleprint:
- `/sidebar.css` - Styles for the sidebar (dark theme, positioning)
- `/sidebar.js` - Self-contained JS that fetches config and renders sidebar
Source location: `soleprint/station/tools/sbwrapper/`
## Sidebar Config API
The sidebar JS fetches configuration from `/spr/api/sidebar/config`:
```json
{
"room": "amar",
"soleprint_base": "/spr",
"auth_enabled": true,
"tools": {
"artery": "/spr/artery",
"atlas": "/spr/atlas",
"station": "/spr/station"
},
"auth": {
"login_url": "/spr/artery/google/oauth/login",
"logout_url": "/spr/artery/google/oauth/logout"
}
}
```
## Troubleshooting
### Sidebar not appearing
1. Check if soleprint is running: `curl http://room.spr.local.ar/spr/sidebar.js`
2. Check browser console for `[Soleprint]` messages
3. Verify nginx has `Accept-Encoding ""` set (required for sub_filter)
4. Hard refresh (Ctrl+Shift+R) to clear cached HTML
### Wrong room config showing
Each room needs its own docker network (`room_network`) to isolate services. Check `NETWORK_NAME` in `.env` files and ensure containers are on correct networks.
### sub_filter not working
- Ensure `proxy_set_header Accept-Encoding ""` is set
- Check that response is `text/html` (sub_filter_types)
- Streaming SSR may not have `</body>`, use `</head>` injection instead

View File

@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidebar Injection - Soleprint</title>
<link rel="stylesheet" href="styles.css" />
<style>
pre {
background: #1a1a1a;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.5;
}
code {
font-family: monospace;
background: #2a2a2a;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
pre code {
background: none;
padding: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
border: 1px solid #3f3f3f;
padding: 0.5rem;
text-align: left;
}
th {
background: #1a1a1a;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Sidebar Injection</h1>
<p class="subtitle">
<span class="lang-en">How managed room sidebar works</span>
<span class="lang-es">Como funciona el sidebar del managed room</span>
</p>
</header>
<main>
<section class="findings-section">
<h2>Overview</h2>
<p class="lang-en">The soleprint sidebar is injected into managed app pages using a hybrid nginx + JavaScript approach. This allows any frontend framework (React, Next.js, static HTML) to receive the sidebar without code modifications.</p>
<p class="lang-es">El sidebar de soleprint se inyecta en las paginas de apps manejadas usando un enfoque hibrido nginx + JavaScript. Esto permite que cualquier framework frontend (React, Next.js, HTML estatico) reciba el sidebar sin modificaciones de codigo.</p>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">How It Works</span>
<span class="lang-es">Como Funciona</span>
</h2>
<pre>
┌─────────────────────────────────────────────────────────────────┐
│ Browser Request │
│ http://room.spr.local.ar/ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Nginx │
│ │
│ 1. Routes /spr/* → soleprint:PORT (sidebar assets + API) │
│ 2. Routes /* → frontend:PORT (app pages) │
│ 3. Injects CSS+JS into HTML responses via sub_filter │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Browser Renders │
│ │
│ 1. Page loads with injected CSS (sidebar styles ready) │
│ 2. sidebar.js executes (deferred, after DOM ready) │
│ 3. JS fetches /spr/api/sidebar/config │
│ 4. JS creates sidebar DOM elements and injects into page │
│ 5. Sidebar appears on left side, pushes content with margin │
└─────────────────────────────────────────────────────────────────┘</pre>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Key Design Decisions</span>
<span class="lang-es">Decisiones de Diseno Clave</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3><span class="lang-en">Why nginx sub_filter?</span><span class="lang-es">Por que nginx sub_filter?</span></h3>
<p class="lang-en"><strong>Framework agnostic</strong>: Works with any frontend. No app changes needed. Easy to disable.</p>
<p class="lang-es"><strong>Agnostico de framework</strong>: Funciona con cualquier frontend. Sin cambios en la app. Facil de deshabilitar.</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">Why inject into &lt;/head&gt;?</span><span class="lang-es">Por que inyectar en &lt;/head&gt;?</span></h3>
<p class="lang-en">Next.js and streaming SSR may not include &lt;/body&gt; in initial response. &lt;/head&gt; is always present.</p>
<p class="lang-es">Next.js y SSR streaming pueden no incluir &lt;/body&gt; en la respuesta inicial. &lt;/head&gt; siempre esta presente.</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">Why JS instead of iframe?</span><span class="lang-es">Por que JS en vez de iframe?</span></h3>
<p class="lang-en">No isolation issues, better UX (no double scrollbars), simpler CSS with margin-left.</p>
<p class="lang-es">Sin problemas de aislamiento, mejor UX (sin doble scrollbar), CSS mas simple con margin-left.</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Nginx Configuration</span>
<span class="lang-es">Configuracion de Nginx</span>
</h2>
<p class="lang-en">The nginx config injects CSS+JS into HTML responses:</p>
<p class="lang-es">La config de nginx inyecta CSS+JS en las respuestas HTML:</p>
<pre><code>location / {
proxy_pass http://frontend:PORT;
proxy_set_header Accept-Encoding ""; # Required for sub_filter
# Inject sidebar
sub_filter '&lt;/head&gt;'
'&lt;link rel="stylesheet" href="/spr/sidebar.css"&gt;
&lt;script src="/spr/sidebar.js" defer&gt;&lt;/script&gt;&lt;/head&gt;';
sub_filter_once off;
sub_filter_types text/html;
}</code></pre>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Port Allocation</span>
<span class="lang-es">Asignacion de Puertos</span>
</h2>
<p class="lang-en">Each room uses unique ports for concurrent operation:</p>
<p class="lang-es">Cada room usa puertos unicos para operacion concurrente:</p>
<table>
<tr><th>Room</th><th>Soleprint</th><th>Frontend</th><th>Backend</th></tr>
<tr><td>amar</td><td>12000</td><td>3000</td><td>8001</td></tr>
<tr><td>dlt</td><td>12010</td><td>3010</td><td>-</td></tr>
<tr><td>sample</td><td>12020</td><td>3020</td><td>8020</td></tr>
</table>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Sidebar Config API</span>
<span class="lang-es">API de Config del Sidebar</span>
</h2>
<p class="lang-en">The sidebar JS fetches configuration from <code>/spr/api/sidebar/config</code>:</p>
<p class="lang-es">El JS del sidebar obtiene configuracion de <code>/spr/api/sidebar/config</code>:</p>
<pre><code>{
"room": "amar",
"soleprint_base": "/spr",
"auth_enabled": true,
"tools": {
"artery": "/spr/artery",
"atlas": "/spr/atlas",
"station": "/spr/station"
}
}</code></pre>
</section>
<section class="findings-section">
<h2>Troubleshooting</h2>
<div class="findings-grid">
<article class="finding-card">
<h3><span class="lang-en">Sidebar not appearing</span><span class="lang-es">Sidebar no aparece</span></h3>
<p class="lang-en">Check if soleprint is running. Verify nginx has <code>Accept-Encoding ""</code>. Hard refresh (Ctrl+Shift+R).</p>
<p class="lang-es">Verificar que soleprint esta corriendo. Verificar que nginx tiene <code>Accept-Encoding ""</code>. Refresco forzado (Ctrl+Shift+R).</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">sub_filter not working</span><span class="lang-es">sub_filter no funciona</span></h3>
<p class="lang-en">Ensure <code>proxy_set_header Accept-Encoding ""</code> is set. Check response is <code>text/html</code>.</p>
<p class="lang-es">Asegurar que <code>proxy_set_header Accept-Encoding ""</code> esta seteado. Verificar que la respuesta es <code>text/html</code>.</p>
</article>
</div>
</section>
</main>
<footer>
<p><a href="../"><span class="lang-en">← Back to index</span><span class="lang-es">← Volver al indice</span></a></p>
</footer>
</body>
</html>

314
docs/artery/index.html Normal file
View File

@@ -0,0 +1,314 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artery - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #b91c1c;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #fca5a5;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #fca5a5;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Artery</h1>
<p class="subtitle">Todo lo vital</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Pulse</h3>
<p class="lang-en">
Composed data flow: a vein configured for a room
with storage
</p>
<p class="lang-es">
Flujo de datos compuesto: vein configurado para un
room con almacenamiento
</p>
<div class="components">
<div class="component">
<h4>Vein</h4>
<p class="lang-en">API connector</p>
<p class="lang-es">Conector API</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data storage</p>
<p class="lang-es">Almacenamiento</p>
</div>
</div>
</div>
<div class="composition">
<h3>Shunt</h3>
<p class="lang-en">
Mock connector for testing - same interface, fake
data
</p>
<p class="lang-es">
Conector mock para testing - misma interfaz, datos
falsos
</p>
<div class="components">
<div class="component">
<h4>Vein Interface</h4>
<p class="lang-en">Same API shape</p>
<p class="lang-es">Misma forma de API</p>
</div>
<div class="component">
<h4>Mock Data</h4>
<p class="lang-en">Fake responses</p>
<p class="lang-es">Respuestas falsas</p>
</div>
</div>
</div>
<div class="composition">
<h3>Plexus</h3>
<p class="lang-en">
Full application when you need more than data flow
</p>
<p class="lang-es">
Aplicacion completa cuando necesitas mas que flujo
de datos
</p>
<div class="components">
<div class="component">
<h4>Backend</h4>
<p>FastAPI server</p>
</div>
<div class="component">
<h4>Frontend</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>Infra</h4>
<p class="lang-en">DB, queues, etc</p>
<p class="lang-es">DB, colas, etc</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/02-artery-hierarchy.svg"
alt="Artery Hierarchy"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Vein</h3>
<p class="lang-en">
Stateless API connector. Connects to external
services like Google Sheets, Jira, Slack. Pure data
flow - no state, no storage.
</p>
<p class="lang-es">
Conector API sin estado. Conecta a servicios
externos como Google Sheets, Jira, Slack. Flujo de
datos puro.
</p>
</article>
<article class="finding-card">
<h3>Shunt</h3>
<p class="lang-en">
Mock connector for testing. Same interface as a vein
but returns fake data.
</p>
<p class="lang-es">
Conector mock para testing. Misma interfaz que un
vein pero devuelve datos falsos.
</p>
</article>
<article class="finding-card">
<h3>Pulse</h3>
<p class="lang-en">
Composed data flow. Formula:
<strong>Vein + Room + Depot</strong>.
</p>
<p class="lang-es">
Flujo de datos compuesto. Formula:
<strong>Vein + Room + Depot</strong>.
</p>
</article>
<article class="finding-card">
<h3>Plexus</h3>
<p class="lang-en">
Full application with backend, frontend, and
infrastructure.
</p>
<p class="lang-es">
Aplicacion completa con backend, frontend e
infraestructura.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Each room is an
isolated instance with its own config and
credentials.
</p>
<p class="lang-es">
Configuracion del entorno. Cada room es una
instancia aislada con su propia config y
credenciales.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage / provisions. JSON files, configs,
cached responses.
</p>
<p class="lang-es">
Almacenamiento de datos. Archivos JSON, configs,
respuestas cacheadas.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Veins</span
><span class="lang-es">Veins Disponibles</span>
</h2>
<div class="findings-grid">
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Google</h3>
<p class="lang-en">
Google Sheets API. OAuth authentication, read/write
spreadsheets.
</p>
<p class="lang-es">
Google Sheets API. Autenticacion OAuth,
leer/escribir hojas de calculo.
</p>
</a>
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Jira</h3>
<p class="lang-en">
Jira Cloud API. Query issues, projects, sprints.
</p>
<p class="lang-es">
Jira Cloud API. Consultar issues, proyectos,
sprints.
</p>
</a>
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Slack</h3>
<p class="lang-en">
Slack API. Channels, messages, users.
</p>
<p class="lang-es">
Slack API. Canales, mensajes, usuarios.
</p>
</a>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

258
docs/atlas/index.html Normal file
View File

@@ -0,0 +1,258 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #15803d;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #86efac;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #86efac;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Atlas</h1>
<p class="subtitle">Todo lo escrito</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Plain Book</h3>
<p class="lang-en">
Static documentation with an index page
</p>
<p class="lang-es">
Documentacion estatica con una pagina indice
</p>
<div class="components">
<div class="component">
<h4>index.html</h4>
<p class="lang-en">Entry point</p>
<p class="lang-es">Punto de entrada</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Static content</p>
<p class="lang-es">Contenido estatico</p>
</div>
</div>
</div>
<div class="composition">
<h3>Templated Book</h3>
<p class="lang-en">
Dynamic docs from template + data elements
</p>
<p class="lang-es">
Docs dinamicos desde template + elementos de datos
</p>
<div class="components">
<div class="component">
<h4>Template</h4>
<p class="lang-en">Jinja2 pattern</p>
<p class="lang-es">Patron Jinja2</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data elements</p>
<p class="lang-es">Elementos de datos</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/01-system-overview.svg"
alt="System Overview"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Book</h3>
<p class="lang-en">
Collection of related documentation. Can be plain
(static HTML) or templated (template + depot
elements).
</p>
<p class="lang-es">
Coleccion de documentacion relacionada. Puede ser
plain (HTML estatico) o templated (template +
elementos de depot).
</p>
</article>
<article class="finding-card">
<h3>Template</h3>
<p class="lang-en">
Jinja2 templates that generate documentation. Define
the structure once, fill with data from depot.
</p>
<p class="lang-es">
Templates Jinja2 que generan documentacion. Definen
la estructura una vez, llenan con datos del depot.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Each room can
have its own atlas with project-specific books.
</p>
<p class="lang-es">
Configuracion del entorno. Cada room puede tener su
propio atlas con books especificos del proyecto.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage. For plain books: static files. For
templated books: elements that fill the template.
</p>
<p class="lang-es">
Almacenamiento de datos. Para plain books: archivos
estaticos. Para templated books: elementos que
llenan el template.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Examples</span
><span class="lang-es">Ejemplos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Feature Flow</h3>
<p class="lang-en">
Plain book - HTML presentation explaining the
standardization pipeline.
</p>
<p class="lang-es">
Plain book - Presentacion HTML explicando el
pipeline de estandarizacion.
</p>
</article>
<article class="finding-card">
<h3>Feature Form Samples</h3>
<p class="lang-en">
Templated book - Form template + depot of actual
feature forms.
</p>
<p class="lang-es">
Templated book - Template de formulario + depot de
feature forms reales.
</p>
</article>
<article class="finding-card">
<h3>Gherkin Samples</h3>
<p class="lang-en">
Templated book - Gherkin viewer + depot of .feature
files.
</p>
<p class="lang-es">
Templated book - Visor Gherkin + depot de archivos
.feature.
</p>
</article>
<article class="finding-card">
<h3>Arch Model</h3>
<p class="lang-en">
Plain book - Static site with architecture diagrams.
</p>
<p class="lang-es">
Plain book - Sitio estatico con diagramas de
arquitectura.
</p>
</article>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

View File

@@ -5,39 +5,151 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soleprint - Documentation</title>
<link rel="stylesheet" href="architecture/styles.css" />
<style>
.one-liner {
font-size: 1.2rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.demo-link {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--bg-primary);
text-decoration: none;
border-radius: 4px;
font-weight: 500;
}
.demo-link:hover {
opacity: 0.9;
}
.systems-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.system-card {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
border-left: 3px solid var(--accent);
}
.system-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.system-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.arch-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.arch-card {
display: block;
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
text-decoration: none;
transition: transform 0.15s;
}
.arch-card:hover {
transform: translateY(-2px);
}
.arch-card h4 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.arch-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
pre {
background: var(--bg-primary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 0.5rem;
}
</style>
<script src="lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Soleprint</h1>
<p class="subtitle">
Cada paso deja huella / Each step leaves a mark
<p class="subtitle">Cada paso deja huella</p>
<p class="one-liner">
<span class="lang-en"
>Pluggable stuff to tackle any challenge</span
>
<span class="lang-es"
>Piezas enchufables para cualquier desafio</span
>
</p>
<a href="https://sample.spr.mcrn.ar" class="demo-link">
<span class="lang-en">Try the Demo</span>
<span class="lang-es">Ver Demo</span>
</a>
</header>
<main>
<section class="findings-section">
<h2>Documentation</h2>
<div class="findings-grid">
<h2>
<span class="lang-en">The Three Systems</span>
<span class="lang-es">Los Tres Sistemas</span>
</h2>
<div class="systems-grid">
<a
href="architecture/index.html"
class="finding-card"
href="artery/"
class="system-card"
style="text-decoration: none"
>
<h3>Architecture</h3>
<p>
System overview, connector hierarchy, build flow,
and room configuration diagrams.
<h3>Artery</h3>
<p class="lang-en">
API connectors and data flow. Veins for real APIs,
shunts for mocks.
</p>
<p class="lang-es">
Conectores y flujo de datos. Veins para APIs reales,
shunts para mocks.
</p>
</a>
<a
href="veins/index.html"
class="finding-card"
href="atlas/"
class="system-card"
style="text-decoration: none"
>
<h3>Veins & Shunts</h3>
<p>
API connectors (Jira, Slack, Google) and mock
connectors for testing.
<h3>Atlas</h3>
<p class="lang-en">
Actionable documentation. Templates that generate
living docs.
</p>
<p class="lang-es">
Documentacion accionable. Templates que generan docs
vivos.
</p>
</a>
<a
href="station/"
class="system-card"
style="text-decoration: none"
>
<h3>Station</h3>
<p class="lang-en">
Tools and monitors. Everything to run, test, and
observe.
</p>
<p class="lang-es">
Herramientas y monitores. Todo para correr, testear,
y observar.
</p>
</a>
</div>
@@ -45,53 +157,106 @@
<section class="findings-section">
<h2>Quick Start</h2>
<div class="finding-card">
<h3>Build & Run</h3>
<pre
style="
background: var(--bg-primary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 0.5rem;
"
>
# Build standalone
python build.py
cd gen/standalone && .venv/bin/python run.py
<pre>
# Clone
git clone https://git.mcrn.ar/soleprint
cd soleprint
# Build with room config
python build.py --cfg amar
cd gen/amar && .venv/bin/python run.py
# Setup (once)
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Visit http://localhost:12000</pre
>
</div>
# Option 1: Build standalone (soleprint only)
python build.py --cfg standalone
cd gen/standalone && ./ctrl/start.sh
# Visit http://localhost:12000
# Option 2: Build managed room (soleprint + your app)
python build.py --cfg myroom
cd gen/myroom && ./ctrl/start.sh
# Visit http://myroom.spr.local.ar
# Option 3: Use the installer (coming soon)</pre
>
<p style="margin-top: 1rem">
<span class="lang-en">Minimal config example:</span>
<span class="lang-es">Ejemplo de config minima:</span>
</p>
<pre>
// cfg/myroom/config.json
{
"room": "myroom",
"artery": {
"veins": ["google", "jira", "slack"],
"shunts": ["mercadopago"]
},
"atlas": {
"books": ["gherkin", "feature-flow"]
},
"station": {
"tools": ["tester", "modelgen"],
"monitors": ["databrowse"]
}
}</pre
>
</section>
<section class="findings-section">
<h2>What Soleprint Solves</h2>
<h2>
<span class="lang-en">Architecture</span>
<span class="lang-es">Arquitectura</span>
</h2>
<p class="lang-en">Deep dive into how soleprint works.</p>
<p class="lang-es">Profundizando en como funciona soleprint.</p>
<div class="findings-grid">
<article class="finding-card">
<h3>Freelance Standardization</h3>
<p>Consistent framework across projects.</p>
</article>
<article class="finding-card">
<h3>Missing Infrastructure</h3>
<p>
Mock systems not ready yet - DBs, APIs, Kubernetes.
<a
href="architecture/"
class="finding-card"
style="text-decoration: none"
>
<h3>
<span class="lang-en">Architecture Diagrams →</span
><span class="lang-es"
>Diagramas de Arquitectura →</span
>
</h3>
<p class="lang-en">
System overview, artery hierarchy, build flow, room
configuration.
</p>
</article>
<article class="finding-card">
<h3>Reliable Testing</h3>
<p>BDD -> Gherkin -> Tests.</p>
</article>
<p class="lang-es">
Vista general, jerarquia de artery, flujo de build,
configuracion de rooms.
</p>
</a>
<a
href="architecture/sidebar-injection.html"
class="finding-card"
style="text-decoration: none"
>
<h3>
<span class="lang-en">Sidebar Injection →</span
><span class="lang-es">Inyeccion de Sidebar →</span>
</h3>
<p class="lang-en">
How the managed room sidebar works with nginx +
JavaScript.
</p>
<p class="lang-es">
Como funciona el sidebar del managed room con nginx
+ JavaScript.
</p>
</a>
</div>
</section>
</main>
<footer>
<p>Soleprint - Development Workflow Platform</p>
<p>
<a href="https://soleprint.mcrn.ar">soleprint.mcrn.ar</a> ·
<a href="https://sample.spr.mcrn.ar">Live Demo</a>
</p>
</footer>
</body>
</html>

54
docs/lang-toggle.js Normal file
View File

@@ -0,0 +1,54 @@
// Language toggle for soleprint docs
// Include this script and add: <div id="lang-toggle"></div> in header
(function () {
function setLang(lang) {
localStorage.setItem("spr-docs-lang", lang);
document.documentElement.lang = lang;
document.querySelectorAll(".lang-toggle button").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.lang === lang);
});
}
document.addEventListener("DOMContentLoaded", () => {
const currentLang = localStorage.getItem("spr-docs-lang") || "en";
// Inject toggle HTML
const container = document.getElementById("lang-toggle");
if (container) {
container.className = "lang-toggle";
const btnEn = document.createElement("button");
btnEn.textContent = "EN";
btnEn.dataset.lang = "en";
btnEn.addEventListener("click", () => setLang("en"));
const btnEs = document.createElement("button");
btnEs.textContent = "ES";
btnEs.dataset.lang = "es";
btnEs.addEventListener("click", () => setLang("es"));
container.appendChild(btnEn);
container.appendChild(btnEs);
}
setLang(currentLang);
});
// Inject styles
const style = document.createElement("style");
style.textContent = `
.lang-toggle { position: absolute; top: 1.5rem; right: 2rem; display: flex; border: 1px solid #888; border-radius: 4px; overflow: hidden; }
.lang-toggle button { background: #1a1a1a; border: none; color: #888; padding: 0.4rem 0.8rem; font-family: inherit; font-size: 0.75rem; cursor: pointer; }
.lang-toggle button:first-child { border-right: 1px solid #888; }
.lang-toggle button:hover { background: #0a0a0a; color: #fff; }
.lang-toggle button.active { background: var(--accent, #b91c1c); color: #fff; }
header { position: relative; }
.lang-en, .lang-es { display: none; }
html[lang="en"] .lang-en { display: block; }
html[lang="es"] .lang-es { display: block; }
html[lang="en"] span.lang-en { display: inline; }
html[lang="es"] span.lang-es { display: inline; }
`;
document.head.appendChild(style);
})();

333
docs/station/index.html Normal file
View File

@@ -0,0 +1,333 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Station - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #7c3aed;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #c4b5fd;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #c4b5fd;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Station</h1>
<p class="subtitle">Todo lo construido</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Desk</h3>
<p class="lang-en">
Control center - collection of monitors
</p>
<p class="lang-es">
Centro de control - coleccion de monitores
</p>
<div class="components">
<div class="component">
<h4>Monitor</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>Monitor</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>...</h4>
<p></p>
</div>
</div>
</div>
<div class="composition">
<h3>Monitor</h3>
<p class="lang-en">
Web interface - always running, always watching
</p>
<p class="lang-es">
Interfaz web - siempre corriendo, siempre observando
</p>
<div class="components">
<div class="component">
<h4>Web UI</h4>
<p>Dashboard</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data source</p>
<p class="lang-es">Fuente de datos</p>
</div>
</div>
</div>
<div class="composition">
<h3>Tool</h3>
<p class="lang-en">
CLI utility - run once, get results
</p>
<p class="lang-es">
Utilidad CLI - ejecutar una vez, obtener resultados
</p>
<div class="components">
<div class="component">
<h4>CLI</h4>
<p class="lang-en">Command interface</p>
<p class="lang-es">Interfaz de comandos</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Output storage</p>
<p class="lang-es">Almacenamiento de salida</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/01-system-overview.svg"
alt="System Overview"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Desk</h3>
<p class="lang-en">
Collection of monitors. Your control center with all
the views you need.
</p>
<p class="lang-es">
Coleccion de monitores. Tu centro de control con
todas las vistas que necesitas.
</p>
</article>
<article class="finding-card">
<h3>Monitor</h3>
<p class="lang-en">
Web interfaces for observation. Data browsers,
dashboards, log viewers. Always running.
</p>
<p class="lang-es">
Interfaces web para observacion. Navegadores de
datos, dashboards, visores de logs. Siempre
corriendo.
</p>
</article>
<article class="finding-card">
<h3>Tool</h3>
<p class="lang-en">
CLI utilities and scripts. Code generators, test
runners, infra provisioners. Run once, get results.
</p>
<p class="lang-es">
Utilidades CLI y scripts. Generadores de codigo,
test runners, provisioners de infra. Ejecutar una
vez, obtener resultados.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Tools and
monitors are configured per-room for isolation.
</p>
<p class="lang-es">
Configuracion del entorno. Tools y monitors se
configuran por room para aislamiento.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage. For tools: output files, results. For
monitors: data to display.
</p>
<p class="lang-es">
Almacenamiento de datos. Para tools: archivos de
salida, resultados. Para monitors: datos a mostrar.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Tools</span
><span class="lang-es">Tools Disponibles</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Tester</h3>
<p class="lang-en">
API and Playwright test runner. Discover tests, run
them, collect artifacts.
</p>
<p class="lang-es">
Test runner de API y Playwright. Descubrir tests,
ejecutarlos, recolectar artefactos.
</p>
</article>
<article class="finding-card">
<h3>ModelGen</h3>
<p class="lang-en">
Generate model diagrams from code. Introspect
soleprint structure, output SVG.
</p>
<p class="lang-es">
Generar diagramas de modelo desde codigo.
Introspeccionar estructura de soleprint, generar
SVG.
</p>
</article>
<article class="finding-card">
<h3>Infra</h3>
<p class="lang-en">
Infrastructure provisioners. AWS, GCP, DigitalOcean
deployment helpers.
</p>
<p class="lang-es">
Provisioners de infraestructura. Helpers de deploy
para AWS, GCP, DigitalOcean.
</p>
</article>
<article class="finding-card">
<h3>DataGen</h3>
<p class="lang-en">
Test data generation. Create realistic fake data for
development.
</p>
<p class="lang-es">
Generacion de datos de test. Crear datos falsos
realistas para desarrollo.
</p>
</article>
<article class="finding-card">
<h3>DataBrowse</h3>
<p class="lang-en">
Navigable data model graphs generated from existing
models.
</p>
<p class="lang-es">
Grafos de modelo de datos navegables generados desde
modelos existentes.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Monitors</span
><span class="lang-es">Monitors Disponibles</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>DataBrowse</h3>
<p class="lang-en">
Navigable data model graphs generated from existing
models.
</p>
<p class="lang-es">
Grafos de modelo de datos navegables generados desde
modelos existentes.
</p>
</article>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

View File

@@ -17,4 +17,4 @@ COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "run:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -419,6 +419,21 @@
<div class="component"><h4>Depot</h4></div>
</div>
</div>
<div class="composition" style="margin-top: 1rem">
<h3>Shunt</h3>
<div class="components">
<div class="component"><h4>Vein Interface</h4></div>
<div class="component"><h4>Mock Data</h4></div>
</div>
</div>
<div class="composition" style="margin-top: 1rem">
<h3>Plexus</h3>
<div class="components">
<div class="component"><h4>Backend</h4></div>
<div class="component"><h4>Frontend</h4></div>
<div class="component"><h4>Infra</h4></div>
</div>
</div>
</section>
<section>
@@ -428,7 +443,14 @@
<div
class="vein{% if vein.status == 'live' or vein.status == 'building' %} active{% else %} disabled{% endif %}{% if loop.first %} selected{% endif %}"
data-tab="{{ vein.slug }}"
{% if vein.status == "planned" %}data-disabled="true"{% endif %}
{%
if
vein.status=""
="planned"
%}data-disabled="true"
{%
endif
%}
>
<h3>{{ vein.title }}</h3>
</div>
@@ -762,12 +784,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 +1613,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,59 @@
API routes for Google vein.
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse, RedirectResponse
import json
import secrets
from pathlib import Path
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, Cookie, HTTPException, Query, Request, Response
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
SESSION_COOKIE = "spr_session"
def _load_auth_config():
"""Load auth config from room config.json."""
config_path = Path("/app/cfg/config.json")
if not config_path.exists():
return {}
try:
with open(config_path) as f:
config = json.load(f)
return config.get("auth", {})
except Exception:
return {}
def _is_user_allowed(email: str, domain: str) -> bool:
"""Check if user is allowed based on auth config."""
auth = _load_auth_config()
allowed_domains = auth.get("allowed_domains", [])
allowed_emails = auth.get("allowed_emails", [])
# No restrictions = allow all
if not allowed_domains and not allowed_emails:
return True
# Check email list
if email in allowed_emails:
return True
# Check domain list
if domain and domain in allowed_domains:
return True
return False
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 +108,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,14 +140,31 @@ 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)
@router.get("/oauth/callback")
async def oauth_callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
@@ -110,23 +176,123 @@ async def oauth_callback(
if not code:
raise HTTPException(400, "Missing authorization code")
# Extract redirect URL from state if present
# Format: "csrf_state|redirect_url" OR just "redirect_url" (path starting with /)
redirect_url = None
if state:
if "|" in state:
parts = state.split("|", 1)
redirect_url = parts[1] if len(parts) > 1 else None
else:
# No separator, state is the redirect URL itself
redirect_url = state
try:
tokens = oauth_client.exchange_code_for_tokens(code)
token_storage.save_tokens(DEFAULT_USER_ID, tokens)
return {
"status": "ok",
"message": "Successfully authenticated with Google",
"user": DEFAULT_USER_ID,
# First get user info to validate before storing tokens
user_info = oauth_client.exchange_code_for_user(code)
user_email = user_info.get("email", "")
user_domain = user_info.get("hd", "") # hosted domain (for Google Workspace)
# Check if user is allowed
if not _is_user_allowed(user_email, user_domain):
raise HTTPException(
403,
f"Access restricted. Your account ({user_email}) is not authorized.",
)
# Generate session ID for this browser
session_id = secrets.token_urlsafe(32)
# Store tokens with session ID
tokens = {
"access_token": user_info.get("access_token", ""),
"refresh_token": user_info.get("refresh_token", ""),
"token_type": "Bearer",
"email": user_email,
}
if tokens["access_token"]:
token_storage.save_tokens(session_id, tokens)
# Create response with session cookie
if redirect_url:
response = RedirectResponse(url=redirect_url)
else:
response = RedirectResponse(url="/")
response.set_cookie(
key=SESSION_COOKIE,
value=session_id,
httponly=True,
secure=True,
samesite="lax",
max_age=60 * 60 * 24 * 30, # 30 days
)
return response
except HTTPException:
raise
except Exception as e:
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/status")
async def oauth_status(spr_session: Optional[str] = Cookie(None)):
"""Check if this browser's session is authenticated."""
try:
if not spr_session:
return {"authenticated": False}
# Load tokens for this session
tokens = token_storage.load_tokens(spr_session)
if not tokens:
return {"authenticated": False}
# Validate token content
access_token = tokens.get("access_token")
email = tokens.get("email")
if not access_token or not email:
return {"authenticated": False}
return {
"authenticated": True,
"user": {"email": email},
}
except Exception:
return {"authenticated": False}
@router.get("/oauth/logout")
async def logout():
"""Clear stored tokens."""
token_storage.delete_tokens(DEFAULT_USER_ID)
return {"status": "ok", "message": "Logged out"}
async def logout(redirect: str = "/", spr_session: Optional[str] = Cookie(None)):
"""Clear this browser's session and redirect."""
response = RedirectResponse(url=redirect)
# Delete token file for this session
if spr_session:
storage_path = Path("/app/artery/veins/google/storage")
token_file = storage_path / f"tokens_{spr_session}.json"
if token_file.exists():
token_file.unlink()
# Clear the session cookie
response.delete_cookie(key=SESSION_COOKIE)
return response
@router.get("/spreadsheets/{spreadsheet_id}")

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,45 @@ 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)
# Include tokens for storage
"access_token": credentials.token,
"refresh_token": credentials.refresh_token,
}
def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh an expired access token.
@@ -126,7 +178,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,44 @@
"""
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
allowed_emails: list[str] = [] # Specific emails to allow
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,110 @@
"""
Authentication middleware for route protection.
Generic middleware, provider-agnostic.
"""
import os
from datetime import datetime
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, RedirectResponse
from .config import AuthConfig
# Local dev bypass - set via environment variable only, can't be triggered remotely
AUTH_BYPASS = os.environ.get("AUTH_BYPASS", "").lower() == "true"
AUTH_BYPASS_USER = os.environ.get("AUTH_BYPASS_USER", "dev@local")
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
# Local dev bypass - auto-authenticate
if AUTH_BYPASS:
request.state.user = {
"email": AUTH_BYPASS_USER,
"name": "Dev User",
"domain": "local",
}
return await call_next(request)
# 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/email restriction
user_domain = session.get("domain")
email_allowed = user_email in self.config.allowed_emails
domain_allowed = user_domain and user_domain in self.config.allowed_domains
no_restrictions = (
not self.config.allowed_domains and not self.config.allowed_emails
)
if not (email_allowed or domain_allowed or no_restrictions):
session.clear()
return self._unauthorized(
request,
f"Access restricted",
)
# 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,173 @@
"""
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/email restriction
user_email = user_info.get("email")
user_domain = user_info.get("hd")
email_allowed = user_email in auth_config.allowed_emails
domain_allowed = user_domain and user_domain in auth_config.allowed_domains
no_restrictions = not auth_config.allowed_domains and not auth_config.allowed_emails
if not (email_allowed or domain_allowed or no_restrictions):
raise HTTPException(
403,
f"Access restricted. Your account ({user_email}) is not authorized.",
)
# 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

@@ -1,174 +1,432 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>soleprint</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E">
<style>
* { box-sizing: border-box; }
html { background: #0a0a0a; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #0a0a0a;
}
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.logo { width: 64px; height: 64px; }
h1 { font-size: 2.5rem; margin: 0; color: white; }
.tagline {
color: #a3a3a3;
margin-bottom: 2rem;
border-bottom: 1px solid #333;
padding-bottom: 2rem;
}
.mission {
background: #1a1a1a;
border-left: 3px solid #d4a574;
padding: 1rem 1.5rem;
margin: 2rem 0;
border-radius: 0 8px 8px 0;
color: #d4a574;
}
.systems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.system {
display: flex;
align-items: flex-start;
gap: 1rem;
text-decoration: none;
padding: 1.5rem;
border-radius: 12px;
transition: transform 0.15s, box-shadow 0.15s;
}
.system:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.system.disabled {
opacity: 0.5;
pointer-events: none;
}
.system svg { width: 48px; height: 48px; flex-shrink: 0; }
.system-info h2 { margin: 0 0 0.25rem 0; font-size: 1.2rem; }
.system-info p { margin: 0; font-size: 0.9rem; color: #a3a3a3; }
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>soleprint</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"
/>
<style>
* {
box-sizing: border-box;
}
html {
background: #0a0a0a;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #0a0a0a;
}
/* Sidebar styles */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 60px;
height: 100vh;
background: #1a1a1a;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
z-index: 99999;
}
.sidebar-item {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
margin-bottom: 0.5rem;
text-decoration: none;
color: #a3a3a3;
transition: all 0.2s;
}
a.sidebar-item:hover {
background: #333;
color: white;
}
.sidebar-item.active {
background: #d4a574;
color: #0a0a0a;
}
.sidebar-item svg {
width: 24px;
height: 24px;
}
.sidebar-divider {
width: 32px;
height: 1px;
background: #333;
margin: 0.5rem 0;
}
.sidebar-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: #555;
margin-bottom: 0.5rem;
}
.sidebar-icon svg {
width: 20px;
height: 20px;
}
.sidebar-item .tooltip {
position: absolute;
left: 70px;
background: #333;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.sidebar-item:hover .tooltip {
opacity: 1;
}
body.has-sidebar {
margin-left: 60px;
}
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.logo {
width: 64px;
height: 64px;
}
h1 {
font-size: 2.5rem;
margin: 0;
color: white;
}
.tagline {
color: #a3a3a3;
margin-bottom: 2rem;
border-bottom: 1px solid #333;
padding-bottom: 2rem;
}
.mission {
background: #1a1a1a;
border-left: 3px solid #d4a574;
padding: 1rem 1.5rem;
margin: 2rem 0;
border-radius: 0 8px 8px 0;
color: #d4a574;
}
.systems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.system {
display: flex;
align-items: flex-start;
gap: 1rem;
text-decoration: none;
padding: 1.5rem;
border-radius: 12px;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.system:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.system.disabled {
opacity: 0.5;
pointer-events: none;
}
.system svg {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.system-info h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
}
.system-info p {
margin: 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.artery { background: #1a1a1a; border: 1px solid #b91c1c; }
.artery h2 { color: #fca5a5; }
.artery svg { color: #b91c1c; }
.artery {
background: #1a1a1a;
border: 1px solid #b91c1c;
}
.artery h2 {
color: #fca5a5;
}
.artery svg {
color: #b91c1c;
}
.atlas { background: #1a1a1a; border: 1px solid #15803d; }
.atlas h2 { color: #86efac; }
.atlas svg { color: #15803d; }
.atlas {
background: #1a1a1a;
border: 1px solid #15803d;
}
.atlas h2 {
color: #86efac;
}
.atlas svg {
color: #15803d;
}
.station { background: #1a1a1a; border: 1px solid #1d4ed8; }
.station h2 { color: #93c5fd; }
.station svg { color: #1d4ed8; }
.station {
background: #1a1a1a;
border: 1px solid #1d4ed8;
}
.station h2 {
color: #93c5fd;
}
.station svg {
color: #1d4ed8;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
font-size: 0.85rem;
color: #666;
}
</style>
</head>
<body>
<header>
<!-- Two shoe prints walking -->
<svg class="logo" viewBox="0 0 64 64" fill="currentColor">
<!-- Left shoe print (back, lower) -->
<g transform="rotate(-15 18 38)">
<!-- Sole -->
<ellipse cx="18" cy="32" rx="7" ry="13"/>
<!-- Heel -->
<ellipse cx="18" cy="48" rx="6" ry="7"/>
</g>
<!-- Right shoe print (front, higher) -->
<g transform="rotate(15 46 28)">
<!-- Sole -->
<ellipse cx="46" cy="22" rx="7" ry="13"/>
<!-- Heel -->
<ellipse cx="46" cy="38" rx="6" ry="7"/>
</g>
</svg>
<h1>soleprint</h1>
</header>
<p class="tagline">Cada paso deja huella</p>
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
font-size: 0.85rem;
color: #666;
}
<p class="mission" style="display:none;"><!-- placeholder for session alerts --></p>
.showcase-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.showcase-link {
display: inline-block;
background: linear-gradient(135deg, #d4a574, #b8956a);
color: #0a0a0a;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: transform 0.15s, box-shadow 0.15s;
}
.showcase-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
}
.showcase-hint {
color: #666;
font-size: 0.8rem;
text-decoration: none;
}
.showcase-hint:hover {
color: #d4a574;
}
</style>
{% if managed %}
<link rel="stylesheet" href="/sidebar.css">
<script src="/sidebar.js"></script>
{% endif %}
</head>
<body{% if managed %} class="has-sidebar"{% endif %}>
<div class="systems">
<a {% if artery %}href="{{ artery }}"{% endif %} class="system artery{% if not artery %} disabled{% endif %}">
<!-- Flux capacitor style -->
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40"/>
<circle cx="24" cy="4" r="3" fill="currentColor"/>
<circle cx="8" cy="40" r="3" fill="currentColor"/>
<circle cx="40" cy="40" r="3" fill="currentColor"/>
<circle cx="24" cy="20" r="5" fill="none"/>
<circle cx="24" cy="20" r="2" fill="currentColor"/>
<header>
<!-- Two shoe prints walking -->
<svg class="logo" viewBox="0 0 64 64" fill="currentColor">
<!-- Left shoe print (back, lower) -->
<g transform="rotate(-15 18 38)">
<!-- Sole -->
<ellipse cx="18" cy="32" rx="7" ry="13" />
<!-- Heel -->
<ellipse cx="18" cy="48" rx="6" ry="7" />
</g>
<!-- Right shoe print (front, higher) -->
<g transform="rotate(15 46 28)">
<!-- Sole -->
<ellipse cx="46" cy="22" rx="7" ry="13" />
<!-- Heel -->
<ellipse cx="46" cy="38" rx="6" ry="7" />
</g>
</svg>
<div class="system-info">
<h2>Artery</h2>
<p>Todo lo vital</p>
</div>
</a>
<h1>soleprint</h1>
</header>
<p class="tagline">Cada paso deja huella</p>
<a {% if atlas %}href="{{ atlas }}"{% endif %} class="system atlas{% if not atlas %} disabled{% endif %}">
<!-- Map/Atlas with compass rose -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Map fold lines -->
<path d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
<path d="M16 4 L16 44 M32 4 L32 44" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
<!-- Compass rose in center -->
<circle cx="24" cy="24" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M24 16 L24 32 M16 24 L32 24" stroke="currentColor" stroke-width="2"/>
<path d="M24 16 L26 20 L24 24 L22 20 Z" fill="currentColor"/><!-- North arrow -->
</svg>
<div class="system-info">
<h2>Atlas</h2>
<p>Documentación accionable</p>
</div>
</a>
{% if showcase_url %}
<div class="showcase-container">
<a href="{{ showcase_url }}" class="showcase-link">Managed Room Demo</a>
<a href="/artery" class="showcase-hint">what's a room?</a>
<a href="https://mariano.mcrn.ar/docs/soleprint/" class="showcase-hint">see docs</a>
</div>
{% endif %}
<a {% if station %}href="{{ station }}"{% endif %} class="system station{% if not station %} disabled{% endif %}">
<!-- Control panel with knobs and meters -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Panel frame -->
<rect x="4" y="8" width="40" height="32" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<!-- Knobs -->
<circle cx="14" cy="18" r="5"/>
<circle cx="14" cy="18" r="2" fill="white"/>
<circle cx="34" cy="18" r="5"/>
<circle cx="34" cy="18" r="2" fill="white"/>
<!-- Meter displays -->
<rect x="10" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/>
<rect x="30" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/>
<!-- Indicator lights -->
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8"/>
</svg>
<div class="system-info">
<h2>Station</h2>
<p>Monitores, Entornos y Herramientas</p>
</div>
</a>
</div>
<p class="mission" style="display: none">
<!-- placeholder for session alerts -->
</p>
<footer>soleprint</footer>
</body>
<div class="systems">
<a
{%
if
artery
%}href="{{ artery }}"
{%
endif
%}
class="system artery{% if not artery %} disabled{% endif %}"
>
<!-- Flux capacitor style -->
<svg
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40" />
<circle cx="24" cy="4" r="3" fill="currentColor" />
<circle cx="8" cy="40" r="3" fill="currentColor" />
<circle cx="40" cy="40" r="3" fill="currentColor" />
<circle cx="24" cy="20" r="5" fill="none" />
<circle cx="24" cy="20" r="2" fill="currentColor" />
</svg>
<div class="system-info">
<h2>Artery</h2>
<p>Todo lo vital</p>
</div>
</a>
<a
{%
if
atlas
%}href="{{ atlas }}"
{%
endif
%}
class="system atlas{% if not atlas %} disabled{% endif %}"
>
<!-- Map/Atlas with compass rose -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Map fold lines -->
<path
d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40"
stroke="currentColor"
stroke-width="1.5"
opacity="0.3"
fill="none"
/>
<path
d="M16 4 L16 44 M32 4 L32 44"
stroke="currentColor"
stroke-width="1.5"
opacity="0.3"
fill="none"
/>
<!-- Compass rose in center -->
<circle
cx="24"
cy="24"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M24 16 L24 32 M16 24 L32 24"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M24 16 L26 20 L24 24 L22 20 Z"
fill="currentColor"
/>
<!-- North arrow -->
</svg>
<div class="system-info">
<h2>Atlas</h2>
<p>Documentación accionable</p>
</div>
</a>
<a
{%
if
station
%}href="{{ station }}"
{%
endif
%}
class="system station{% if not station %} disabled{% endif %}"
>
<!-- Control panel with knobs and meters -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Panel frame -->
<rect
x="4"
y="8"
width="40"
height="32"
rx="2"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Knobs -->
<circle cx="14" cy="18" r="5" />
<circle cx="14" cy="18" r="2" fill="white" />
<circle cx="34" cy="18" r="5" />
<circle cx="34" cy="18" r="2" fill="white" />
<!-- Meter displays -->
<rect
x="10"
y="28"
width="8"
height="6"
rx="1"
fill="white"
opacity="0.6"
/>
<rect
x="30"
y="28"
width="8"
height="6"
rx="1"
fill="white"
opacity="0.6"
/>
<!-- Indicator lights -->
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8" />
</svg>
<div class="system-info">
<h2>Station</h2>
<p>Monitores, Entornos y Herramientas</p>
</div>
</a>
</div>
<footer>soleprint</footer>
</body>
</html>

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 {
@@ -135,6 +351,10 @@ def artery_index(request: Request):
from jinja2 import Template
# Load rooms and depots
rooms = load_data("rooms.json") or []
depots = load_data("depots.json") or []
template = Template(html_path.read_text())
return HTMLResponse(
template.render(
@@ -145,6 +365,8 @@ def artery_index(request: Request):
pulses=pulses,
shunts=shunts,
plexuses=plexuses,
rooms=rooms,
depots=depots,
soleprint_url="/",
)
)
@@ -296,20 +518,107 @@ def station_route(path: str):
return {"system": "station", "path": path}
# === Sidebar Wrapper (served at /spr/* when proxied) ===
@app.get("/sidebar.css")
def sidebar_css():
"""Serve sidebar CSS for injection."""
css_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.css"
if css_path.exists():
from fastapi.responses import Response
return Response(content=css_path.read_text(), media_type="text/css")
return Response(content="/* sidebar.css not found */", media_type="text/css")
@app.get("/sidebar.js")
def sidebar_js():
"""Serve sidebar JS for injection."""
js_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.js"
if js_path.exists():
from fastapi.responses import Response
return Response(
content=js_path.read_text(), media_type="application/javascript"
)
return Response(
content="/* sidebar.js not found */", media_type="application/javascript"
)
@app.get("/api/sidebar/config")
def sidebar_config(request: Request):
"""Return sidebar configuration for the current room."""
config = load_config()
managed = config.get("managed", {})
auth = config.get("auth", {})
# Get soleprint URL (where tools are)
host = request.headers.get("host", "localhost")
host_no_port = host.split(":")[0]
scheme = request.headers.get("x-forwarded-proto", "http")
# Soleprint tools are at root (no prefix needed)
soleprint_base = ""
return {
"room": managed.get("name", "standalone"),
"soleprint_base": soleprint_base,
"auth_enabled": auth.get("enabled", False),
"veins": config.get("veins", []),
"auth": {
"login_url": f"{soleprint_base}/artery/google/oauth/start",
"status_url": f"{soleprint_base}/artery/google/oauth/status",
"logout_url": f"{soleprint_base}/artery/google/oauth/logout",
},
}
# === Main ===
def get_managed_url(request: Request, managed: dict) -> str | None:
"""
Derive managed app URL from current host.
Pattern: <room>.spr.<domain> -> <room>.<domain>
localhost:port -> None (no managed URL for direct port access)
"""
if not managed:
return None
host = request.headers.get("host", "localhost")
host_no_port = host.split(":")[0]
# Check if host matches pattern: <name>.spr.<domain>
if ".spr." in host_no_port:
# Replace .spr. with . to get managed app domain
managed_host = host_no_port.replace(".spr.", ".")
scheme = request.headers.get("x-forwarded-proto", "http")
return f"{scheme}://{managed_host}"
return None
@app.get("/")
def index(request: Request):
"""Landing page with links to subsystems."""
config = load_config()
managed = config.get("managed", {})
managed_url = get_managed_url(request, managed)
showcase_url = config.get("showcase_url")
return templates.TemplateResponse(
"index.html",
{
"request": request,
# In bare-metal mode, all routes are internal
"artery": "/artery",
"atlas": "/atlas",
"station": "/station",
"managed": managed,
"managed_url": managed_url,
"showcase_url": showcase_url,
},
)

View File

@@ -1,296 +1,238 @@
/* Pawprint Wrapper - Sidebar Styles */
:root {
--sidebar-width: 320px;
--sidebar-bg: #1e1e1e;
--sidebar-text: #e0e0e0;
--sidebar-accent: #007acc;
--sidebar-border: #333;
--sidebar-shadow: 0 0 20px rgba(0,0,0,0.5);
--card-bg: #2a2a2a;
--card-hover: #3a3a3a;
--success: #4caf50;
--error: #f44336;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
}
/* Soleprint Sidebar - Injected Styles */
/* Matches the original Jinja2 sidebar look */
/* Sidebar Container */
#pawprint-sidebar {
position: fixed;
right: 0;
top: 0;
width: var(--sidebar-width);
height: 100vh;
background: var(--sidebar-bg);
color: var(--sidebar-text);
box-shadow: var(--sidebar-shadow);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9999;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
#spr-sidebar {
position: fixed;
top: 0;
left: 0;
width: 60px;
height: 100vh;
background: #1a1a1a;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
z-index: 99999;
font-family:
system-ui,
-apple-system,
sans-serif;
transition: width 0.2s ease;
}
#pawprint-sidebar.expanded {
transform: translateX(0);
#spr-sidebar.expanded {
width: 200px;
align-items: flex-start;
padding: 1rem;
}
/* Toggle Button */
#sidebar-toggle {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
background: var(--sidebar-bg);
color: var(--sidebar-text);
border: 1px solid var(--sidebar-border);
border-right: none;
border-radius: 8px 0 0 8px;
padding: 12px 8px;
cursor: pointer;
z-index: 10000;
font-size: 16px;
transition: background 0.2s;
box-shadow: -2px 0 8px rgba(0,0,0,0.3);
/* Push page content */
body.spr-sidebar-active {
margin-left: 60px !important;
transition: margin-left 0.2s ease;
}
#sidebar-toggle:hover {
background: var(--card-hover);
body.spr-sidebar-active.spr-sidebar-expanded {
margin-left: 200px !important;
}
#sidebar-toggle .icon {
display: block;
transition: transform 0.3s;
/* Sidebar Items (clickable) */
.spr-sidebar-item {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
margin-bottom: 0.5rem;
text-decoration: none;
color: #a3a3a3;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
#pawprint-sidebar.expanded ~ #sidebar-toggle .icon {
transform: scaleX(-1);
}
/* Header */
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--sidebar-border);
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
color: var(--sidebar-accent);
}
.sidebar-header .nest-name {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Content */
.sidebar-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* Panel */
.panel {
margin-bottom: 24px;
padding: 16px;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--sidebar-border);
}
.panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--sidebar-accent);
display: flex;
align-items: center;
gap: 8px;
}
/* Current User Display */
.current-user {
padding: 12px;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
.current-user strong {
color: var(--success);
font-weight: 600;
}
.current-user .logout-btn {
margin-top: 8px;
padding: 6px 12px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
color: var(--error);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
width: 100%;
}
.current-user .logout-btn:hover {
background: rgba(244, 67, 54, 0.2);
}
/* User Cards */
.user-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--sidebar-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.user-card:hover {
background: var(--card-hover);
border-color: var(--sidebar-accent);
transform: translateX(-2px);
}
.user-card.active {
background: rgba(0, 122, 204, 0.2);
border-color: var(--sidebar-accent);
}
.user-card .icon {
font-size: 24px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
border-radius: 50%;
}
.user-card .info {
flex: 1;
}
.user-card .label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.user-card .role {
display: block;
font-size: 11px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status Messages */
.status-message {
padding: 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 16px;
border: 1px solid;
}
.status-message.success {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
color: var(--success);
}
.status-message.error {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.3);
color: var(--error);
}
.status-message.info {
background: rgba(0, 122, 204, 0.1);
border-color: rgba(0, 122, 204, 0.3);
color: var(--sidebar-accent);
}
/* Loading Spinner */
.loading {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.1);
border-top-color: var(--sidebar-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Scrollbar */
#pawprint-sidebar::-webkit-scrollbar {
width: 8px;
}
#pawprint-sidebar::-webkit-scrollbar-track {
background: #1a1a1a;
}
#pawprint-sidebar::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
#pawprint-sidebar::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Footer */
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--sidebar-border);
font-size: 11px;
opacity: 0.5;
text-align: center;
}
/* Responsive */
@media (max-width: 768px) {
#pawprint-sidebar {
#spr-sidebar.expanded .spr-sidebar-item {
width: 100%;
}
justify-content: flex-start;
padding: 0 12px;
gap: 12px;
}
.spr-sidebar-item:hover {
background: #333;
color: white;
}
.spr-sidebar-item.active {
background: #d4a574;
color: #0a0a0a;
}
.spr-sidebar-item svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
/* Label (hidden when collapsed) */
.spr-sidebar-item .label {
display: none;
font-size: 0.9rem;
white-space: nowrap;
}
#spr-sidebar.expanded .spr-sidebar-item .label {
display: inline;
}
/* Tooltip (hidden when expanded) */
.spr-sidebar-item .tooltip {
position: absolute;
left: 54px;
background: #333;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.spr-sidebar-item:hover .tooltip {
opacity: 1;
}
#spr-sidebar.expanded .spr-sidebar-item .tooltip {
display: none;
}
/* Toggle button */
.spr-toggle {
border: none;
background: none;
cursor: pointer;
}
/* Divider */
.spr-sidebar-divider {
width: 32px;
height: 1px;
background: #333;
margin: 0.5rem 0;
}
/* Decorative icons (non-clickable) */
.spr-sidebar-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: #555;
margin-bottom: 0.5rem;
}
.spr-sidebar-icon svg {
width: 20px;
height: 20px;
}
/* Spacer to push auth to bottom */
.spr-sidebar-spacer {
flex: 1;
}
/* Vein items under artery icon */
.spr-vein-item {
width: 36px;
height: 28px;
font-size: 0.7rem;
margin-left: 8px;
background: #252525;
}
.spr-vein-item .label {
display: block !important;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#spr-sidebar.expanded .spr-vein-item {
margin-left: 20px;
width: calc(100% - 20px);
}
/* System links (atlas, station when logged in) */
.spr-system-link {
height: 28px;
font-size: 0.75rem;
}
/* Locked state (not logged in) */
.spr-sidebar-locked {
width: 44px;
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 0.5rem;
margin-bottom: 0.5rem;
text-align: center;
}
.spr-sidebar-locked .lock-text {
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
white-space: nowrap;
}
#spr-sidebar.expanded .spr-sidebar-locked {
width: 100%;
justify-content: flex-start;
margin-left: 20px;
}
#spr-sidebar.expanded .spr-sidebar-locked .lock-text {
writing-mode: horizontal-tb;
transform: none;
}
/* User/Auth Section */
.spr-sidebar-user {
margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
}
/* Login Button */
.spr-login-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: none;
border: none;
cursor: pointer;
color: #a3a3a3;
transition: all 0.2s;
}
.spr-login-btn:hover {
background: #333;
color: white;
}
.spr-login-btn svg {
width: 24px;
height: 24px;
}

View File

@@ -1,286 +1,223 @@
// Pawprint Wrapper - Sidebar Logic
// Soleprint Sidebar - Self-Injecting Script
// Matches the original Jinja2 sidebar look
class PawprintSidebar {
constructor() {
this.config = null;
this.currentUser = null;
this.sidebar = null;
this.toggleBtn = null;
}
(function () {
"use strict";
async init() {
// Load configuration
await this.loadConfig();
// Soleprint routes are at root (no prefix)
const SPR_BASE = "";
// Create sidebar elements
this.createSidebar();
this.createToggleButton();
let config = null;
let user = null;
// Setup event listeners
this.setupEventListeners();
let expanded = localStorage.getItem("spr_sidebar_expanded") === "true";
// Check if user is already logged in
this.checkCurrentUser();
// Icons as SVG strings
const icons = {
toggle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 3v18M3 9h6"/>
</svg>`,
home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3m10-11v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1"/>
</svg>`,
soleprint: `<svg viewBox="0 0 24 24" fill="currentColor">
<ellipse cx="8" cy="10" rx="3" ry="5" transform="rotate(-10 8 10)"/>
<ellipse cx="8" cy="17" rx="2.5" ry="3" transform="rotate(-10 8 17)"/>
<ellipse cx="16" cy="8" rx="3" ry="5" transform="rotate(10 16 8)"/>
<ellipse cx="16" cy="15" rx="2.5" ry="3" transform="rotate(10 16 15)"/>
</svg>`,
artery: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v8M12 10L6 18M12 10l6 8"/>
<circle cx="12" cy="10" r="2"/>
</svg>`,
atlas: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="8"/>
<path d="M12 4v16M4 12h16"/>
</svg>`,
station: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="6" width="16" height="12" rx="1"/>
<circle cx="9" cy="11" r="2"/>
<circle cx="15" cy="11" r="2"/>
</svg>`,
google: `<svg viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>`,
user: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
</svg>`,
logout: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>`,
};
// Load saved sidebar state
this.loadSidebarState();
}
async loadConfig() {
async function init() {
try {
const response = await fetch('/wrapper/config.json');
this.config = await response.json();
console.log('[Pawprint] Config loaded:', this.config.nest_name);
// Load config using dynamic base URL
const response = await fetch(`${SPR_BASE}/api/sidebar/config`);
config = await response.json();
// Check auth status
await checkAuth();
// Create sidebar
createSidebar();
console.log("[Soleprint] Sidebar initialized for room:", config.room);
} catch (error) {
console.error('[Pawprint] Failed to load config:', error);
// Use default config
this.config = {
nest_name: 'default',
wrapper: {
environment: {
backend_url: 'http://localhost:8000',
frontend_url: 'http://localhost:3000'
},
users: []
}
};
console.error("[Soleprint] Failed to initialize sidebar:", error);
}
}
createSidebar() {
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';
button.innerHTML = '<span class="icon">◀</span>';
button.title = 'Toggle Pawprint Sidebar (Ctrl+Shift+P)';
document.body.appendChild(button);
this.toggleBtn = button;
}
getSidebarHTML() {
const users = this.config.wrapper.users || [];
return `
<div class="sidebar-header">
<h2>🐾 Pawprint</h2>
<div class="nest-name">${this.config.nest_name}</div>
</div>
<div class="sidebar-content">
<div id="status-container"></div>
<!-- Quick Login Panel -->
<div class="panel">
<h3>👤 Quick Login</h3>
<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()">
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}')">
<div class="icon">${user.icon}</div>
<div class="info">
<span class="label">${user.label}</span>
<span class="role">${user.role}</span>
</div>
</div>
`).join('')}
</div>
</div>
<!-- Environment Info Panel -->
<div class="panel">
<h3>🌍 Environment</h3>
<div style="font-size: 12px; opacity: 0.8;">
<div style="margin-bottom: 8px;">
<strong>Backend:</strong><br>
<code style="font-size: 11px;">${this.config.wrapper.environment.backend_url}</code>
</div>
<div>
<strong>Frontend:</strong><br>
<code style="font-size: 11px;">${this.config.wrapper.environment.frontend_url}</code>
</div>
</div>
</div>
</div>
<div class="sidebar-footer">
Pawprint Dev Tools
</div>
`;
}
setupEventListeners() {
// Toggle button
this.toggleBtn.addEventListener('click', () => this.toggle());
// Keyboard shortcut: Ctrl+Shift+P
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.sidebar.classList.toggle('expanded');
this.saveSidebarState();
}
saveSidebarState() {
const isExpanded = this.sidebar.classList.contains('expanded');
localStorage.setItem('pawprint_sidebar_expanded', isExpanded);
}
loadSidebarState() {
const isExpanded = localStorage.getItem('pawprint_sidebar_expanded') === 'true';
if (isExpanded) {
this.sidebar.classList.add('expanded');
}
}
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.appendChild(statusDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
statusDiv.remove();
}, 5000);
}
async loginAs(userId) {
const user = this.config.wrapper.users.find(u => u.id === userId);
if (!user) return;
this.showStatus(`Logging in as ${user.label}... ⏳`, 'info');
async function checkAuth() {
if (!config.auth_enabled) return;
try {
const backendUrl = this.config.wrapper.environment.backend_url;
const response = await fetch(`${backendUrl}/api/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: user.username,
password: user.password
})
const response = await fetch(`${SPR_BASE}/artery/google/oauth/status`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status}`);
}
const data = await response.json();
// Store tokens
localStorage.setItem('access_token', data.access);
localStorage.setItem('refresh_token', data.refresh);
// Store user info
localStorage.setItem('user_info', JSON.stringify({
username: user.username,
label: user.label,
role: data.details?.role || user.role
}));
this.showStatus(`✓ Logged in as ${user.label}`, 'success');
this.currentUser = user;
this.updateCurrentUserDisplay();
// Reload page after short delay
setTimeout(() => {
window.location.reload();
}, 1000);
if (data.authenticated) {
user = data.user;
}
} catch (error) {
console.error('[Pawprint] Login error:', error);
this.showStatus(`✗ Login failed: ${error.message}`, 'error');
console.log("[Soleprint] Auth check failed:", error);
}
}
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_info');
function renderSystemSection(systemKey, title) {
const veins = config.veins || [];
this.showStatus('✓ Logged out', 'success');
this.currentUser = null;
this.updateCurrentUserDisplay();
// System link with icon and label (same as soleprint)
let html = `
<a href="${SPR_BASE}/${systemKey}" class="spr-sidebar-item" title="${title}">
${icons[systemKey]}
<span class="label">${title}</span>
<span class="tooltip">${title}</span>
</a>
`;
// Reload page after short delay
setTimeout(() => {
window.location.reload();
}, 1000);
}
checkCurrentUser() {
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);
// Nested elements below - auth-gated
if (user) {
// Logged in - show vein links under artery
if (systemKey === "artery") {
for (const vein of veins) {
html += `
<a href="${SPR_BASE}/artery/${vein}" class="spr-sidebar-item spr-vein-item" title="${vein}">
<span class="label">${vein}</span>
<span class="tooltip">${vein}</span>
</a>
`;
}
}
} else if (config.auth_enabled) {
// Not logged in: show "login to access" below each system icon
html += `
<div class="spr-sidebar-locked">
<span class="lock-text">login to access</span>
</div>
`;
}
return html;
}
updateCurrentUserDisplay() {
const display = document.getElementById('current-user-display');
const username = document.getElementById('current-username');
function createSidebar() {
// Add body class
document.body.classList.add("spr-sidebar-active");
if (expanded) {
document.body.classList.add("spr-sidebar-expanded");
}
if (this.currentUser) {
display.style.display = 'block';
username.textContent = this.currentUser.username;
const sidebar = document.createElement("nav");
sidebar.id = "spr-sidebar";
if (expanded) sidebar.classList.add("expanded");
// Highlight active user card
document.querySelectorAll('.user-card').forEach(card => {
card.classList.remove('active');
});
sidebar.innerHTML = `
<button class="spr-sidebar-item spr-toggle" title="Toggle sidebar">
${icons.toggle}
<span class="label">Menu</span>
<span class="tooltip">Toggle</span>
</button>
const activeCard = document.querySelector(`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`);
if (activeCard) {
activeCard.classList.add('active');
}
<a href="/" class="spr-sidebar-item" title="${config.room}">
${icons.home}
<span class="label">${config.room}</span>
<span class="tooltip">${config.room}</span>
</a>
<div class="spr-sidebar-divider"></div>
<a href="/spr/" class="spr-sidebar-item active" title="Soleprint">
${icons.soleprint}
<span class="label">Soleprint</span>
<span class="tooltip">Soleprint</span>
</a>
<div class="spr-sidebar-divider"></div>
${renderSystemSection("artery", "Artery")}
${renderSystemSection("atlas", "Atlas")}
${renderSystemSection("station", "Station")}
<div class="spr-sidebar-spacer"></div>
${config.auth_enabled ? renderAuthSection() : ""}
`;
// Add toggle click handler
sidebar
.querySelector(".spr-toggle")
.addEventListener("click", toggleSidebar);
document.body.appendChild(sidebar);
}
function toggleSidebar() {
const sidebar = document.getElementById("spr-sidebar");
expanded = !expanded;
sidebar.classList.toggle("expanded", expanded);
document.body.classList.toggle("spr-sidebar-expanded", expanded);
localStorage.setItem("spr_sidebar_expanded", expanded);
}
function renderAuthSection() {
if (user) {
return `
<div class="spr-sidebar-user">
<a href="${config.auth.logout_url}" class="spr-sidebar-item" title="Logout (${user.email})">
${icons.logout}
<span class="label">Logout</span>
<span class="tooltip">Logout</span>
</a>
</div>
`;
} else {
display.style.display = 'none';
// Include current path as redirect target after login (relative, not full URL)
const redirectPath = window.location.pathname + window.location.search;
const loginUrl = `${config.auth.login_url}?redirect=${encodeURIComponent(redirectPath)}`;
return `
<div class="spr-sidebar-user">
<a href="${loginUrl}" class="spr-sidebar-item" title="Login with Google">
${icons.google}
<span class="label">Login</span>
<span class="tooltip">Login</span>
</a>
</div>
`;
}
}
getUserIdByUsername(username) {
const user = this.config.wrapper.users.find(u => u.username === username);
return user ? user.id : null;
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
// Initialize sidebar when DOM is ready
const pawprintSidebar = new PawprintSidebar();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => pawprintSidebar.init());
} else {
pawprintSidebar.init();
}
console.log('[Pawprint] Sidebar script loaded');
console.log("[Soleprint] Sidebar script loaded");
})();