+ {plainText} +
+ + {/* Matched interests */} + {post.matched_interests.length > 0 && ( +Resource type: {resource.type}
+
+ {JSON.stringify(resource.data, null, 2)}
+
+ diff --git a/.claude/plugins/test-automation/plugin.json b/.claude/plugins/test-automation/plugin.json
index 50c9a0b5..4a445fe0 100644
--- a/.claude/plugins/test-automation/plugin.json
+++ b/.claude/plugins/test-automation/plugin.json
@@ -10,6 +10,8 @@
"skills": [
"skills/spec.md",
"skills/qa-test-cases.md",
- "skills/automate-tests.md"
+ "skills/automate-tests.md",
+ "skills/debug-robot-browser.md",
+ "skills/debug-robot-api.md"
]
}
diff --git a/.claude/plugins/test-automation/skills/debug-robot-api.md b/.claude/plugins/test-automation/skills/debug-robot-api.md
new file mode 100644
index 00000000..7d8acab5
--- /dev/null
+++ b/.claude/plugins/test-automation/skills/debug-robot-api.md
@@ -0,0 +1,323 @@
+---
+name: debug-robot-api
+description: This skill should be used when the user asks to "debug robot api tests", "fix a failing api test", "diagnose a robot api test failure", "why is my robot api test failing", or "run api tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework API tests using RequestsLibrary.
+---
+
+## Purpose
+
+Drive a fast debug cycle for failing Robot Framework API tests:
+**run with debug logging → parse output for HTTP details → identify cause → fix → verify**.
+
+API tests fail at the HTTP layer (status codes, response bodies, auth tokens) — not the DOM. The debug workflow is entirely different from browser tests.
+
+---
+
+## Step 1 — Run with debug logging
+
+```bash
+# All API tests with verbose HTTP output
+cd robot_tests && pixi run robot --loglevel DEBUG api/
+
+# Single suite (fastest)
+cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot
+
+# Single test by name
+cd robot_tests && pixi run robot --loglevel DEBUG --test "TC-KC-001*" api/keycloak_auth.robot
+```
+
+`--loglevel DEBUG` logs the full request URL, headers, and response body for every HTTP call. Without it, you only see the status code.
+
+---
+
+## Step 2 — Parse the failure output
+
+### output.xml — status codes and response bodies
+
+```bash
+# All FAIL lines with surrounding context
+python3 -c "
+import xml.etree.ElementTree as ET
+try:
+ tree = ET.parse('robot_tests/output.xml')
+ for msg in tree.getroot().iter('msg'):
+ if msg.get('level') == 'FAIL' or (msg.text and 'status' in (msg.text or '').lower()):
+ print(msg.text[:300])
+ print('---')
+except: pass
+" 2>/dev/null | head -80
+
+# Quick grep for HTTP status codes in output
+grep -o 'status.*[0-9]\{3\}\|[0-9]\{3\}.*status\|HTTPError\|ConnectionError\|FAIL' \
+ robot_tests/output.xml | sort -u | head -20
+
+# Response body at failure point
+grep -o '.\{0,50\}response\|body\|detail.\{0,200\}' robot_tests/output.xml | head -20
+```
+
+### log.html — easiest to read
+
+Open in a browser for full formatted output with collapsible sections:
+```bash
+open robot_tests/log.html
+```
+
+### Backend container logs
+
+Often the clearest signal — shows the actual exception or validation error:
+```bash
+docker logs ushadow-test-backend-test-1 --tail 50
+
+# Follow while running tests
+docker logs ushadow-test-backend-test-1 -f
+```
+
+---
+
+## Step 3 — Identify the failure pattern
+
+### Pattern A: Connection refused / backend not ready
+
+**Symptom:**
+```
+ConnectionError: HTTPConnectionPool(host='localhost', port=8200): Max retries exceeded
+```
+
+**Cause:** Backend container not running or still starting.
+
+**Diagnosis:**
+```bash
+# Check container status
+docker ps | grep ushadow-test
+
+# Check if backend responds
+curl -s http://localhost:8200/health | python3 -m json.tool
+
+# Start containers if needed
+cd robot_tests && make start
+```
+
+**Fix:** Wait for containers before running. The suite setup handles this — if containers are already running it reuses them. If not, run `make start` first or ensure the suite setup keyword is included.
+
+---
+
+### Pattern B: 401 Unauthorized
+
+**Symptom:**
+```
+Expected status 200 but got 401
+```
+
+**Cause:** Missing auth token, expired token, or wrong client credentials.
+
+**Diagnosis:**
+```bash
+# Check if token endpoint works
+curl -s -X POST http://localhost:8181/realms/ushadow/protocol/openid-connect/token \
+ -d "grant_type=password&client_id=ushadow-frontend&username=kctest@example.com&password=TestKeycloak123!" \
+ | python3 -m json.tool
+
+# Check if test user exists in Keycloak
+TOKEN=$(curl -s -X POST http://localhost:8181/realms/master/protocol/openid-connect/token \
+ -d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \
+ | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
+curl -s -H "Authorization: Bearer $TOKEN" \
+ "http://localhost:8181/admin/realms/ushadow/users?email=kctest@example.com" | python3 -m json.tool
+```
+
+**Common fixes:**
+- Test user not created → suite setup should call `Ensure Keycloak Test User Exists`
+- Wrong client_id in token request → check `KEYCLOAK_CLIENT_ID` in `test_env.py` (should be `ushadow-frontend` or `ushadow-cli`)
+- Token expired mid-suite → refresh in suite setup or request a fresh token per test
+
+---
+
+### Pattern C: 403 Forbidden
+
+**Symptom:**
+```
+Expected status 200 but got 403
+```
+
+**Cause:** Token present but insufficient permissions, or using wrong client type.
+
+**Common case — introspection with public client:**
+Keycloak's token introspection endpoint (`/protocol/openid-connect/token/introspect`) requires a **confidential client** with client credentials. The `ushadow-frontend` client is public and will get 401/403.
+
+**Fix:**
+```robot
+# Check status gracefully — skip if introspection not supported
+${response}= POST On Session ... expected_status=any
+IF '${response.status_code}' in ['401', '403']
+ Skip Token introspection requires confidential client (public client limitation)
+END
+Should Be Equal As Integers ${response.status_code} 200
+```
+
+Or use the CLI client (`ushadow-cli`) if it's configured as confidential:
+```robot
+# Use CLI client for introspection
+${token}= Get Token Via CLI Client
+```
+
+---
+
+### Pattern D: 404 Not Found
+
+**Symptom:**
+```
+Expected status 200 but got 404
+```
+
+**Cause:** Endpoint path is wrong, or the route doesn't exist.
+
+**Diagnosis:**
+```bash
+# Check what routes are registered on the backend
+curl -s http://localhost:8200/openapi.json | python3 -c "
+import sys, json
+spec = json.load(sys.stdin)
+for path in sorted(spec.get('paths', {}).keys()):
+ print(path)
+" | grep -i auth
+
+# Or view Swagger UI
+open http://localhost:8200/docs
+```
+
+**Fix:** Correct the endpoint path in the robot file. Check the backend's router to confirm the exact path.
+
+---
+
+### Pattern E: Response body mismatch
+
+**Symptom:**
+```
+'expected_field' != 'actual_field'
+Should Be Equal As Strings FAIL
+```
+
+**Cause:** The response JSON structure changed, or the test is checking the wrong key.
+
+**Diagnosis:**
+```bash
+# Make the call manually and inspect the actual response
+curl -s -X POST http://localhost:8200/api/auth/token \
+ -H "Content-Type: application/json" \
+ -d '{"code":"test","code_verifier":"test","redirect_uri":"http://localhost:3001/oauth/callback"}' \
+ | python3 -m json.tool
+```
+
+Check the actual field names in the response vs what the test asserts. Common mismatches:
+- `access_token` vs `token`
+- `user_id` vs `id`
+- Nested vs flat structure (`user.email` vs `email`)
+
+---
+
+### Pattern F: Suite setup failure
+
+**Symptom:** Tests show as `NOT RUN` or the suite itself fails before any tests execute.
+
+**Diagnosis:**
+```bash
+# Check the first FAIL message
+grep -m5 'FAIL\|ERROR' robot_tests/output.xml
+
+# Check container health
+docker ps --format "table {{.Names}}\t{{.Status}}" | grep ushadow-test
+```
+
+**Common causes:**
+- MongoDB not cleared (`✓ MongoDB test database cleared` should appear in console)
+- Keycloak not reachable at `http://localhost:8181`
+- Backend health check failing at `http://localhost:8200/health`
+
+---
+
+## Step 4 — Common fixes reference
+
+### Auth keywords (from `resources/auth_keywords.robot`)
+
+```robot
+# Get a token for the test user
+${TOKEN}= Get Keycloak Token ${KEYCLOAK_TEST_EMAIL} ${KEYCLOAK_TEST_PASSWORD}
+
+# Ensure the test user exists (idempotent — safe to call in setup)
+Ensure Keycloak Test User Exists
+
+# Create auth header dict
+${HEADERS}= Create Dictionary Authorization=Bearer ${TOKEN}
+```
+
+### Session management
+
+```robot
+# Create a session pointing at the backend
+Create Session api ${BACKEND_URL} headers=${HEADERS}
+
+# Make a call
+${resp}= GET On Session api /api/auth/me expected_status=200
+
+# Log full response for debugging
+Log Response: ${resp.status_code} ${resp.text}
+```
+
+### Graceful skip vs fail
+
+```robot
+# Skip if feature not available (e.g. confidential client not configured)
+${resp}= POST On Session api ${ENDPOINT} expected_status=any
+Skip If '${resp.status_code}' == '501' Feature not implemented
+
+# Assert with helpful message
+Should Be Equal As Integers ${resp.status_code} 200
+... Expected 200 but got ${resp.status_code}: ${resp.text}
+```
+
+---
+
+## Step 5 — Fix and verify
+
+```bash
+# Re-run with debug logging to confirm fix
+cd robot_tests && pixi run robot --loglevel DEBUG api/keycloak_auth.robot
+```
+
+Expected output when passing:
+```
+TC-KC-001: Authenticate with Keycloak Direct Grant | PASS |
+TC-KC-002: OAuth Authorization Code Flow (skip) | SKIP |
+TC-KC-003: Validate Access Token | PASS |
+TC-KC-004: Introspect Access Token (skip) | SKIP |
+TC-KC-005: Refresh Access Token | PASS |
+TC-KC-006: Logout and Revoke Tokens | PASS |
+6 tests, 4 passed, 0 failed, 2 skipped
+```
+
+---
+
+## Environment quick reference
+
+| Service | Port | URL |
+|---------|------|-----|
+| Backend | 8200 | `http://localhost:8200` |
+| Keycloak (test) | 8181 | `http://localhost:8181` |
+| MongoDB (test) | 27118 | `mongodb://localhost:27118` |
+| Redis (test) | 6480 | `redis://localhost:6480` |
+
+Keycloak admin: `admin` / `admin`
+Test user: `kctest@example.com` / `TestKeycloak123!`
+Realm: `ushadow`
+Frontend client: `ushadow-frontend` (public)
+CLI client: `ushadow-cli` (confidential, for direct grant)
+
+## Key files
+
+| File | Purpose |
+|------|---------|
+| `robot_tests/api/keycloak_auth.robot` | Keycloak auth test suite |
+| `robot_tests/resources/auth_keywords.robot` | Shared auth keywords |
+| `robot_tests/resources/setup/test_env.py` | URLs, ports, credentials |
+| `robot_tests/resources/setup/suite_setup.robot` | Container start/stop |
+| `robot_tests/output.xml` | Last run results |
+| `robot_tests/log.html` | Human-readable log (open in browser) |
diff --git a/.claude/plugins/test-automation/skills/debug-robot-browser.md b/.claude/plugins/test-automation/skills/debug-robot-browser.md
new file mode 100644
index 00000000..5371bf40
--- /dev/null
+++ b/.claude/plugins/test-automation/skills/debug-robot-browser.md
@@ -0,0 +1,243 @@
+---
+name: debug-robot-browser
+description: This skill should be used when the user asks to "debug robot browser tests", "fix a failing browser test", "diagnose a robot test failure", "why is my robot test failing", or "run browser tests and fix them". Drives a fast run-parse-fix cycle for Robot Framework browser tests (RF Browser / Playwright wrapper).
+---
+
+## Purpose
+
+Drive a fast debug cycle for failing Robot Framework browser tests:
+**run with short timeout → parse failure output → identify cause → fix → verify**.
+
+Always prefer the reduced-timeout run for diagnosis. The default 15 s timeout means a single failing test wastes 15 s; 5 s surfaces the same failure 3× faster.
+
+---
+
+## Step 1 — Run with reduced timeout
+
+```bash
+# All browser tests (fastest diagnosis)
+cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s browser/
+
+# Single test by name pattern (even faster)
+cd robot_tests && pixi run robot -v HEADLESS:true -v TIMEOUT:5s --test "TC-BR-KC-002*" browser/
+```
+
+| Variable | Diagnosis | Normal CI |
+|----------|-----------|-----------|
+| `TIMEOUT` | `5s` | `15s` (default in robot file) |
+| `HEADLESS` | `true` | `true` |
+
+Do **not** edit the robot file to change the timeout — pass it via `-v`.
+
+---
+
+## Step 2 — Parse the failure output
+
+After the run, three files contain everything needed:
+
+### output.xml — what failed and where
+
+```bash
+# Failing URL at the moment of failure
+grep -o '.\{0,80\}Failed at URL.\{0,120\}' robot_tests/output.xml
+
+# Expected selector that timed out
+grep -o 'waiting for locator.*visible' robot_tests/output.xml | sort -u
+
+# Full test status summary
+grep 'status status=' robot_tests/output.xml | grep FAIL
+```
+
+### playwright-log.txt — page source at failure time
+
+The log contains the full page HTML right after every screenshot. Extract it:
+
+```bash
+# All Keycloak element IDs and classes (kc-*)
+grep -o 'kc-[a-zA-Z-]*' robot_tests/playwright-log.txt | sort -u
+
+# All data-testid values present on the page
+grep -o 'data-testid="[^"]*"' robot_tests/playwright-log.txt | sort -u
+
+# Full page source blob (first 4000 chars)
+grep -o '"msg":"[^"]*"' robot_tests/playwright-log.txt | head -c 4000
+
+# URL the browser was at when it failed
+grep 'Failed at URL\|navigate\|goto' robot_tests/playwright-log.txt | grep localhost | tail -20
+```
+
+### browser/screenshot/ — visual snapshot at failure
+
+```bash
+ls -lt robot_tests/browser/screenshot/*.png | head -5
+# Open the most recent: open robot_tests/browser/screenshot/ You'll be redirected to Keycloak for secure authentication {error} Completing sign-in.... Pass that code to
+ POST /api/feed/sources/mastodon/connect.
+ """
+ try:
+ url = await service.get_mastodon_auth_url(instance_url, redirect_uri)
+ return {"authorization_url": url}
+ except Exception as e:
+ logger.error(f"Mastodon auth URL error: {e}")
+ raise HTTPException(status_code=502, detail=str(e))
+
+
+@router.post("/sources/mastodon/connect", status_code=201)
+async def mastodon_connect(
+ data: MastodonConnectRequest,
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Exchange a Mastodon OAuth2 code for an access token and save the source.
+
+ Creates a new PostSource (or updates an existing one for the same instance)
+ with the access token. Future refreshes will pull from the authenticated
+ home timeline instead of public hashtag timelines.
+ """
+ user_id = get_user_email(current_user)
+ try:
+ source = await service.connect_mastodon(
+ user_id=user_id,
+ instance_url=data.instance_url,
+ code=data.code,
+ redirect_uri=data.redirect_uri,
+ name=data.name,
+ )
+ return source
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ logger.error(f"Mastodon connect error: {e}")
+ raise HTTPException(status_code=502, detail=str(e))
+
+
+@router.post("/sources", status_code=201)
+async def add_source(
+ data: SourceCreate,
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Add a content source (Mastodon instance or YouTube API key)."""
+ # Validate platform-specific required fields
+ if data.platform_type == "mastodon" and not data.instance_url:
+ raise HTTPException(
+ status_code=422, detail="instance_url is required for mastodon sources"
+ )
+ if data.platform_type == "youtube" and not data.api_key:
+ raise HTTPException(
+ status_code=422, detail="api_key is required for youtube sources"
+ )
+ if data.platform_type == "bluesky_timeline" and (
+ not data.handle or not data.api_key
+ ):
+ raise HTTPException(
+ status_code=422,
+ detail="bluesky_timeline sources require both handle and api_key (app password)",
+ )
+ if data.platform_type not in ("mastodon", "youtube", "bluesky", "bluesky_timeline"):
+ raise HTTPException(
+ status_code=422, detail=f"Unknown platform_type: {data.platform_type}"
+ )
+
+ user_id = get_user_email(current_user)
+ source = await service.add_source(user_id, data)
+ return source
+
+
+@router.delete("/sources/{source_id}")
+async def remove_source(
+ source_id: str,
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Remove a post source."""
+ user_id = get_user_email(current_user)
+ removed = await service.remove_source(user_id, source_id)
+ if not removed:
+ raise HTTPException(status_code=404, detail="Source not found")
+ return {"status": "removed"}
+
+
+# =========================================================================
+# Interests (read-only, derived from stored memories)
+# =========================================================================
+
+
+@router.get("/interests")
+async def get_interests(
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """View interests extracted from your stored memories."""
+ user_id = get_user_email(current_user)
+ interests = await service.get_interests(user_id)
+ return {"interests": [i.model_dump() for i in interests]}
+
+
+# =========================================================================
+# Feed
+# =========================================================================
+
+
+@router.get("/posts")
+async def get_feed(
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+ interest: Optional[str] = Query(default=None, description="Filter by interest name"),
+ show_seen: bool = Query(default=True),
+ platform_type: Optional[str] = Query(
+ default=None, description="Filter: mastodon | youtube"
+ ),
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Get ranked feed of posts, sorted by relevance to your interests."""
+ user_id = get_user_email(current_user)
+ return await service.get_feed(
+ user_id, page, page_size, interest, show_seen, platform_type
+ )
+
+
+@router.post("/refresh")
+async def refresh_feed(
+ platform_type: Optional[str] = Query(
+ default=None, description="Refresh only this platform: mastodon | youtube"
+ ),
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Trigger a feed refresh, optionally scoped to one platform."""
+ user_id = get_user_email(current_user)
+ result = await service.refresh(user_id, platform_type)
+ return result
+
+
+# =========================================================================
+# Post Actions
+# =========================================================================
+
+
+@router.post("/posts/{post_id}/seen")
+async def mark_post_seen(
+ post_id: str,
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Mark a specific post as seen."""
+ user_id = get_user_email(current_user)
+ ok = await service.mark_post_seen(user_id, post_id)
+ if not ok:
+ raise HTTPException(status_code=404, detail="Post not found")
+ return {"status": "seen"}
+
+
+@router.post("/posts/{post_id}/bookmark")
+async def bookmark_post(
+ post_id: str,
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Toggle bookmark on a specific post."""
+ user_id = get_user_email(current_user)
+ ok = await service.bookmark_post(user_id, post_id)
+ if not ok:
+ raise HTTPException(status_code=404, detail="Post not found")
+ return {"status": "toggled"}
+
+
+# =========================================================================
+# Bluesky — Compose (post & reply)
+# =========================================================================
+
+
+@router.post("/bluesky/post", status_code=201)
+async def bluesky_create_post(
+ data: BlueskyCompose,
+ bsky: BlueskyService = Depends(_get_bluesky_service),
+ current_user=Depends(get_current_user),
+):
+ """Publish a new post to Bluesky from a bluesky_timeline source."""
+ user_id = get_user_email(current_user)
+ try:
+ result = await bsky.create_post(data.source_id, user_id, data.text)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return result
+
+
+@router.post("/bluesky/reply/{post_id}", status_code=201)
+async def bluesky_reply(
+ post_id: str,
+ data: BlueskyCompose,
+ bsky: BlueskyService = Depends(_get_bluesky_service),
+ current_user=Depends(get_current_user),
+):
+ """Reply to a Bluesky post stored in the feed (requires post CID)."""
+ user_id = get_user_email(current_user)
+ try:
+ result = await bsky.reply_to_post(data.source_id, user_id, data.text, post_id)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return result
+
+
+# =========================================================================
+# Stats
+# =========================================================================
+
+
+@router.get("/stats")
+async def get_stats(
+ service: FeedService = Depends(get_feed_service),
+ current_user=Depends(get_current_user),
+):
+ """Get feed statistics."""
+ user_id = get_user_email(current_user)
+ return await service.get_stats(user_id)
diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
index 55c1aa47..53eee3fc 100644
--- a/ushadow/backend/src/routers/github_import.py
+++ b/ushadow/backend/src/routers/github_import.py
@@ -330,9 +330,9 @@ def generate_compose_from_dockerhub(
}
},
'networks': {
- 'infra-network': {
+ 'ushadow-network': {
'external': True,
- 'name': 'infra-network'
+ 'name': 'ushadow-network'
}
}
}
@@ -385,7 +385,7 @@ def generate_compose_from_dockerhub(
compose_data['volumes'] = volume_definitions
# Add network
- service_config['networks'] = ['infra-network']
+ service_config['networks'] = ['ushadow-network']
# Add extra_hosts for host.docker.internal
service_config['extra_hosts'] = ['host.docker.internal:host-gateway']
diff --git a/ushadow/backend/src/routers/health.py b/ushadow/backend/src/routers/health.py
index 05316fb8..2edce440 100644
--- a/ushadow/backend/src/routers/health.py
+++ b/ushadow/backend/src/routers/health.py
@@ -44,7 +44,8 @@ class HealthResponse(BaseModel):
async def check_mongodb_health(request: Request) -> ServiceHealth:
- """Check MongoDB connectivity and responsiveness."""
+ """Check MongoDB connectivity and responsiveness with 5s timeout."""
+ import asyncio
start = time.time()
try:
# Get MongoDB client from app state (set in lifespan)
@@ -57,8 +58,8 @@ async def check_mongodb_health(request: Request) -> ServiceHealth:
message="MongoDB client not initialized"
)
- # Ping the database
- await db.command("ping")
+ # Ping the database with 5-second timeout
+ await asyncio.wait_for(db.command("ping"), timeout=5.0)
latency_ms = (time.time() - start) * 1000
return ServiceHealth(
@@ -67,6 +68,16 @@ async def check_mongodb_health(request: Request) -> ServiceHealth:
critical=True,
latency_ms=round(latency_ms, 2)
)
+ except asyncio.TimeoutError:
+ latency_ms = (time.time() - start) * 1000
+ logger.warning("MongoDB health check timed out after 5s")
+ return ServiceHealth(
+ status="unhealthy",
+ healthy=False,
+ critical=True,
+ message="Connection timeout (5s)",
+ latency_ms=round(latency_ms, 2)
+ )
except Exception as e:
latency_ms = (time.time() - start) * 1000
logger.warning(f"MongoDB health check failed: {e}")
@@ -80,7 +91,8 @@ async def check_mongodb_health(request: Request) -> ServiceHealth:
async def check_redis_health(request: Request) -> ServiceHealth:
- """Check Redis connectivity and responsiveness."""
+ """Check Redis connectivity and responsiveness with 5s timeout."""
+ import asyncio
start = time.time()
try:
# Get Redis client from app state (set in lifespan)
@@ -89,10 +101,15 @@ async def check_redis_health(request: Request) -> ServiceHealth:
# Try to create a temporary connection for health check
import redis.asyncio as redis
redis_url = os.environ.get("REDIS_URL", "redis://redis:6379")
- redis_client = redis.from_url(redis_url, decode_responses=True)
+ redis_client = redis.from_url(
+ redis_url,
+ decode_responses=True,
+ socket_connect_timeout=5.0,
+ socket_timeout=5.0
+ )
- # Ping Redis
- await redis_client.ping()
+ # Ping Redis with 5-second timeout
+ await asyncio.wait_for(redis_client.ping(), timeout=5.0)
latency_ms = (time.time() - start) * 1000
# Close temporary connection if we created one
@@ -105,6 +122,16 @@ async def check_redis_health(request: Request) -> ServiceHealth:
critical=True,
latency_ms=round(latency_ms, 2)
)
+ except asyncio.TimeoutError:
+ latency_ms = (time.time() - start) * 1000
+ logger.warning("Redis health check timed out after 5s")
+ return ServiceHealth(
+ status="unhealthy",
+ healthy=False,
+ critical=True,
+ message="Connection timeout (5s)",
+ latency_ms=round(latency_ms, 2)
+ )
except Exception as e:
latency_ms = (time.time() - start) * 1000
logger.warning(f"Redis health check failed: {e}")
diff --git a/ushadow/backend/src/routers/kanban.py b/ushadow/backend/src/routers/kanban.py
new file mode 100644
index 00000000..5d3c09b4
--- /dev/null
+++ b/ushadow/backend/src/routers/kanban.py
@@ -0,0 +1,404 @@
+"""API routes for kanban ticket management.
+
+This router provides CRUD operations for tickets and epics, integrating with
+the launcher's tmux and worktree systems for context-aware task management.
+"""
+
+import logging
+from typing import List, Optional, Dict, Any
+
+from fastapi import APIRouter, HTTPException, Depends, Query
+from beanie import PydanticObjectId
+from pydantic import BaseModel
+
+from src.models.kanban import (
+ Ticket,
+ Epic,
+ TicketStatus,
+ TicketPriority,
+ TicketCreate,
+ TicketRead,
+ TicketUpdate,
+ EpicCreate,
+ EpicRead,
+ EpicUpdate,
+)
+from src.services.auth import get_current_user
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/kanban", tags=["kanban"])
+
+
+# =============================================================================
+# Epic Endpoints
+# =============================================================================
+
+@router.post("/epics", response_model=Dict[str, Any])
+async def create_epic(
+ epic_data: EpicCreate,
+ current_user: dict = Depends(get_current_user)
+):
+ """Create a new epic for grouping related tickets."""
+ try:
+ epic = Epic(
+ title=epic_data.title,
+ description=epic_data.description,
+ color=epic_data.color or "#3B82F6",
+ base_branch=epic_data.base_branch,
+ project_id=epic_data.project_id,
+ created_by=PydanticObjectId(current_user["id"])
+ )
+ await epic.save()
+
+ logger.info(f"Created epic: {epic.title} (ID: {epic.id})")
+ return epic.model_dump()
+ except Exception as e:
+ logger.error(f"Failed to create epic: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/epics", response_model=List[Dict[str, Any]])
+async def list_epics(
+ project_id: Optional[str] = Query(None),
+ current_user: dict = Depends(get_current_user)
+):
+ """List all epics, optionally filtered by project."""
+ try:
+ query = {}
+ if project_id:
+ query["project_id"] = project_id
+
+ epics = await Epic.find(query).to_list()
+ return [epic.model_dump() for epic in epics]
+ except Exception as e:
+ logger.error(f"Failed to list epics: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/epics/{epic_id}", response_model=Dict[str, Any])
+async def get_epic(
+ epic_id: str,
+ current_user: dict = Depends(get_current_user)
+):
+ """Get a specific epic by ID."""
+ try:
+ epic = await Epic.get(PydanticObjectId(epic_id))
+ if not epic:
+ raise HTTPException(status_code=404, detail="Epic not found")
+ return epic.model_dump()
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to get epic {epic_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/epics/{epic_id}", response_model=Dict[str, Any])
+async def update_epic(
+ epic_id: str,
+ update_data: EpicUpdate,
+ current_user: dict = Depends(get_current_user)
+):
+ """Update an epic."""
+ try:
+ epic = await Epic.get(PydanticObjectId(epic_id))
+ if not epic:
+ raise HTTPException(status_code=404, detail="Epic not found")
+
+ # Update fields
+ if update_data.title is not None:
+ epic.title = update_data.title
+ if update_data.description is not None:
+ epic.description = update_data.description
+ if update_data.color is not None:
+ epic.color = update_data.color
+ if update_data.branch_name is not None:
+ epic.branch_name = update_data.branch_name
+
+ await epic.save()
+ logger.info(f"Updated epic: {epic.title} (ID: {epic.id})")
+ return epic.model_dump()
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to update epic {epic_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/epics/{epic_id}")
+async def delete_epic(
+ epic_id: str,
+ current_user: dict = Depends(get_current_user)
+):
+ """Delete an epic. Tickets in the epic will have epic_id set to None."""
+ try:
+ epic = await Epic.get(PydanticObjectId(epic_id))
+ if not epic:
+ raise HTTPException(status_code=404, detail="Epic not found")
+
+ # Unlink tickets from epic
+ tickets = await Ticket.find(Ticket.epic_id == epic.id).to_list()
+ for ticket in tickets:
+ ticket.epic_id = None
+ ticket.epic = None
+ await ticket.save()
+
+ await epic.delete()
+ logger.info(f"Deleted epic: {epic.title} (ID: {epic.id})")
+ return {"status": "success", "deleted": str(epic.id)}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to delete epic {epic_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# =============================================================================
+# Ticket Endpoints
+# =============================================================================
+
+@router.post("/tickets", response_model=Dict[str, Any])
+async def create_ticket(
+ ticket_data: TicketCreate,
+ current_user: dict = Depends(get_current_user)
+):
+ """Create a new ticket."""
+ try:
+ # Validate epic exists if provided
+ epic_obj_id = None
+ if ticket_data.epic_id:
+ epic = await Epic.get(PydanticObjectId(ticket_data.epic_id))
+ if not epic:
+ raise HTTPException(status_code=400, detail="Epic not found")
+ epic_obj_id = epic.id
+
+ ticket = Ticket(
+ title=ticket_data.title,
+ description=ticket_data.description,
+ status=ticket_data.status,
+ priority=ticket_data.priority,
+ epic_id=epic_obj_id,
+ tags=ticket_data.tags,
+ color=ticket_data.color,
+ project_id=ticket_data.project_id,
+ assigned_to=PydanticObjectId(ticket_data.assigned_to) if ticket_data.assigned_to else None,
+ created_by=PydanticObjectId(current_user["id"])
+ )
+ await ticket.save()
+
+ logger.info(f"Created ticket: {ticket.title} (ID: {ticket.id})")
+ return ticket.model_dump()
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to create ticket: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/tickets", response_model=List[Dict[str, Any]])
+async def list_tickets(
+ project_id: Optional[str] = Query(None),
+ epic_id: Optional[str] = Query(None),
+ status: Optional[TicketStatus] = Query(None),
+ tags: Optional[str] = Query(None), # Comma-separated tags
+ assigned_to: Optional[str] = Query(None),
+ current_user: dict = Depends(get_current_user)
+):
+ """List tickets with optional filters."""
+ try:
+ query = {}
+ if project_id:
+ query["project_id"] = project_id
+ if epic_id:
+ query["epic_id"] = PydanticObjectId(epic_id)
+ if status:
+ query["status"] = status
+ if assigned_to:
+ query["assigned_to"] = PydanticObjectId(assigned_to)
+
+ # Tag filtering (find tickets with ANY of the specified tags)
+ if tags:
+ tag_list = [t.strip() for t in tags.split(",")]
+ query["tags"] = {"$in": tag_list}
+
+ tickets = await Ticket.find(query).sort("+order").to_list()
+ return [ticket.model_dump() for ticket in tickets]
+ except Exception as e:
+ logger.error(f"Failed to list tickets: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/tickets/{ticket_id}", response_model=Dict[str, Any])
+async def get_ticket(
+ ticket_id: str,
+ current_user: dict = Depends(get_current_user)
+):
+ """Get a specific ticket by ID."""
+ try:
+ ticket = await Ticket.get(PydanticObjectId(ticket_id))
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+ return ticket.model_dump()
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to get ticket {ticket_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/tickets/{ticket_id}", response_model=Dict[str, Any])
+async def update_ticket(
+ ticket_id: str,
+ update_data: TicketUpdate,
+ current_user: dict = Depends(get_current_user)
+):
+ """Update a ticket."""
+ try:
+ ticket = await Ticket.get(PydanticObjectId(ticket_id))
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ # Update fields
+ if update_data.title is not None:
+ ticket.title = update_data.title
+ if update_data.description is not None:
+ ticket.description = update_data.description
+ if update_data.status is not None:
+ ticket.status = update_data.status
+ if update_data.priority is not None:
+ ticket.priority = update_data.priority
+ if update_data.epic_id is not None:
+ # Validate epic exists
+ epic = await Epic.get(PydanticObjectId(update_data.epic_id))
+ if not epic:
+ raise HTTPException(status_code=400, detail="Epic not found")
+ ticket.epic_id = epic.id
+ if update_data.tags is not None:
+ ticket.tags = update_data.tags
+ if update_data.color is not None:
+ ticket.color = update_data.color
+ if update_data.assigned_to is not None:
+ ticket.assigned_to = PydanticObjectId(update_data.assigned_to) if update_data.assigned_to else None
+ if update_data.order is not None:
+ ticket.order = update_data.order
+
+ await ticket.save()
+ logger.info(f"Updated ticket: {ticket.title} (ID: {ticket.id})")
+ return ticket.model_dump()
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to update ticket {ticket_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/tickets/{ticket_id}")
+async def delete_ticket(
+ ticket_id: str,
+ current_user: dict = Depends(get_current_user)
+):
+ """Delete a ticket."""
+ try:
+ ticket = await Ticket.get(PydanticObjectId(ticket_id))
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ await ticket.delete()
+ logger.info(f"Deleted ticket: {ticket.title} (ID: {ticket.id})")
+ return {"status": "success", "deleted": str(ticket.id)}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to delete ticket {ticket_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# =============================================================================
+# Context Sharing Endpoints
+# =============================================================================
+
+@router.get("/tickets/{ticket_id}/related", response_model=List[Dict[str, Any]])
+async def get_related_tickets(
+ ticket_id: str,
+ current_user: dict = Depends(get_current_user)
+):
+ """Find tickets related to this one via epic or shared tags."""
+ try:
+ ticket = await Ticket.get(PydanticObjectId(ticket_id))
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Ticket not found")
+
+ related = []
+
+ # Find tickets in same epic
+ if ticket.epic_id:
+ epic_tickets = await Ticket.find(
+ Ticket.epic_id == ticket.epic_id,
+ Ticket.id != ticket.id
+ ).to_list()
+ related.extend(epic_tickets)
+
+ # Find tickets with shared tags
+ if ticket.tags:
+ tag_tickets = await Ticket.find(
+ Ticket.tags == {"$in": ticket.tags},
+ Ticket.id != ticket.id
+ ).to_list()
+ # Deduplicate
+ existing_ids = {t.id for t in related}
+ for t in tag_tickets:
+ if t.id not in existing_ids:
+ related.append(t)
+
+ return [t.model_dump() for t in related]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to get related tickets for {ticket_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# =============================================================================
+# Statistics Endpoints
+# =============================================================================
+
+@router.get("/stats", response_model=Dict[str, Any])
+async def get_kanban_stats(
+ project_id: Optional[str] = Query(None),
+ current_user: dict = Depends(get_current_user)
+):
+ """Get kanban board statistics."""
+ try:
+ query = {}
+ if project_id:
+ query["project_id"] = project_id
+
+ tickets = await Ticket.find(query).to_list()
+
+ stats = {
+ "total": len(tickets),
+ "by_status": {},
+ "by_priority": {},
+ "by_epic": {},
+ "with_tmux": sum(1 for t in tickets if t.tmux_window_name),
+ }
+
+ for status in TicketStatus:
+ stats["by_status"][status.value] = sum(1 for t in tickets if t.status == status)
+
+ for priority in TicketPriority:
+ stats["by_priority"][priority.value] = sum(1 for t in tickets if t.priority == priority)
+
+ # Count tickets per epic
+ epic_counts = {}
+ for ticket in tickets:
+ if ticket.epic_id:
+ epic_id_str = str(ticket.epic_id)
+ epic_counts[epic_id_str] = epic_counts.get(epic_id_str, 0) + 1
+ stats["by_epic"] = epic_counts
+
+ return stats
+ except Exception as e:
+ logger.error(f"Failed to get kanban stats: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/ushadow/backend/src/routers/keycloak_admin.py b/ushadow/backend/src/routers/keycloak_admin.py
new file mode 100644
index 00000000..dcbcfc1d
--- /dev/null
+++ b/ushadow/backend/src/routers/keycloak_admin.py
@@ -0,0 +1,267 @@
+"""
+Keycloak Admin Router
+
+Admin endpoints for managing Keycloak configuration.
+"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+import logging
+
+from src.services.keycloak_admin import get_keycloak_admin
+from src.config.keycloak_settings import get_keycloak_config
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+class KeycloakConfigResponse(BaseModel):
+ """Public Keycloak configuration for clients"""
+ public_url: str
+ realm: str
+ frontend_client_id: str
+ backend_client_id: str
+
+
+@router.get("/config", response_model=KeycloakConfigResponse)
+async def get_keycloak_public_config():
+ """
+ Get public Keycloak configuration for clients.
+
+ This endpoint returns non-sensitive configuration that clients
+ (like the ush CLI tool) need to authenticate with Keycloak.
+
+ Returns:
+ Public Keycloak configuration (no secrets)
+ """
+ config = get_keycloak_config()
+
+ # No redundant defaults - get_keycloak_config() already provides them
+ return KeycloakConfigResponse(
+ public_url=config["public_url"], # Dynamic from Tailscale or config
+ realm=config["realm"],
+ frontend_client_id=config["frontend_client_id"],
+ backend_client_id=config["backend_client_id"],
+ )
+
+
+class ClientUpdateResponse(BaseModel):
+ """Response for client update operations"""
+ success: bool
+ message: str
+ client_id: str
+
+
+@router.post("/clients/{client_id}/enable-pkce", response_model=ClientUpdateResponse)
+async def enable_pkce_for_client(client_id: str):
+ """
+ Enable PKCE (Proof Key for Code Exchange) for a Keycloak client.
+
+ This updates the client configuration to require PKCE with S256 code challenge method.
+ PKCE is required for secure authentication in public clients (like SPAs).
+
+ Args:
+ client_id: The Keycloak client ID (e.g., "ushadow-frontend")
+
+ Returns:
+ Success status and message
+ """
+ admin_client = get_keycloak_admin()
+
+ try:
+ # Get current client configuration
+ client = await admin_client.get_client_by_client_id(client_id)
+ if not client:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Client '{client_id}' not found in Keycloak"
+ )
+
+ client_uuid = client["id"]
+ logger.info(f"[KC-ADMIN] Enabling PKCE for client: {client_id} ({client_uuid})")
+
+ # Update client attributes to require PKCE
+ import httpx
+ from src.config.keycloak_settings import get_keycloak_config
+
+ token = await admin_client._get_admin_token()
+ config = get_keycloak_config()
+ keycloak_url = config["url"]
+ realm = config["realm"]
+
+ # Get full client config first
+ async with httpx.AsyncClient() as http_client:
+ get_response = await http_client.get(
+ f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=10.0
+ )
+
+ if get_response.status_code != 200:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to get client config: {get_response.text}"
+ )
+
+ full_client_config = get_response.json()
+
+ # Update attributes
+ if "attributes" not in full_client_config:
+ full_client_config["attributes"] = {}
+
+ full_client_config["attributes"]["pkce.code.challenge.method"] = "S256"
+
+ # Update client
+ update_response = await http_client.put(
+ f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json"
+ },
+ json=full_client_config,
+ timeout=10.0
+ )
+
+ if update_response.status_code != 204:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to update client: {update_response.text}"
+ )
+
+ logger.info(f"[KC-ADMIN] ✓ PKCE enabled for client: {client_id}")
+
+ return ClientUpdateResponse(
+ success=True,
+ message=f"PKCE (S256) enabled for client '{client_id}'",
+ client_id=client_id
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[KC-ADMIN] Failed to enable PKCE: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to enable PKCE: {str(e)}"
+ )
+
+
+@router.get("/clients/{client_id}/config")
+async def get_client_config(client_id: str):
+ """
+ Get Keycloak client configuration.
+
+ Args:
+ client_id: The Keycloak client ID
+
+ Returns:
+ Client configuration including attributes
+ """
+ admin_client = get_keycloak_admin()
+
+ client = await admin_client.get_client_by_client_id(client_id)
+ if not client:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Client '{client_id}' not found"
+ )
+
+ return {
+ "client_id": client.get("clientId"),
+ "id": client.get("id"),
+ "enabled": client.get("enabled"),
+ "publicClient": client.get("publicClient"),
+ "standardFlowEnabled": client.get("standardFlowEnabled"),
+ "directAccessGrantsEnabled": client.get("directAccessGrantsEnabled"),
+ "attributes": client.get("attributes", {}),
+ "redirectUris": client.get("redirectUris", []),
+ }
+
+
+@router.post("/clients/{client_id}/enable-direct-grant", response_model=ClientUpdateResponse)
+async def enable_direct_grant_for_client(client_id: str):
+ """
+ Enable Direct Access Grants (Resource Owner Password Credentials) for a Keycloak client.
+
+ This allows CLI tools and other non-browser clients to authenticate using username/password.
+
+ Args:
+ client_id: The Keycloak client ID (e.g., "ushadow-frontend")
+
+ Returns:
+ Success status and message
+ """
+ admin_client = get_keycloak_admin()
+
+ try:
+ # Get current client configuration
+ client = await admin_client.get_client_by_client_id(client_id)
+ if not client:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Client '{client_id}' not found in Keycloak"
+ )
+
+ client_uuid = client["id"]
+ logger.info(f"[KC-ADMIN] Enabling direct access grants for client: {client_id} ({client_uuid})")
+
+ # Update client to enable direct access grants
+ import httpx
+
+ token = await admin_client._get_admin_token()
+ config = get_keycloak_config()
+ keycloak_url = config["url"]
+ realm = config["realm"]
+
+ # Get full client config first
+ async with httpx.AsyncClient() as http_client:
+ get_response = await http_client.get(
+ f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=10.0
+ )
+
+ if get_response.status_code != 200:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to get client config: {get_response.text}"
+ )
+
+ full_client_config = get_response.json()
+
+ # Enable direct access grants
+ full_client_config["directAccessGrantsEnabled"] = True
+
+ # Update client
+ update_response = await http_client.put(
+ f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json"
+ },
+ json=full_client_config,
+ timeout=10.0
+ )
+
+ if update_response.status_code != 204:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to update client: {update_response.text}"
+ )
+
+ logger.info(f"[KC-ADMIN] ✓ Direct access grants enabled for client: {client_id}")
+
+ return ClientUpdateResponse(
+ success=True,
+ message=f"Direct access grants enabled for client '{client_id}'",
+ client_id=client_id
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[KC-ADMIN] Failed to enable direct access grants: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to enable direct access grants: {str(e)}"
+ )
diff --git a/ushadow/backend/src/routers/kubernetes.py b/ushadow/backend/src/routers/kubernetes.py
index 63414e1a..f2e0e56b 100644
--- a/ushadow/backend/src/routers/kubernetes.py
+++ b/ushadow/backend/src/routers/kubernetes.py
@@ -10,6 +10,7 @@
from src.models.kubernetes import (
KubernetesCluster,
KubernetesClusterCreate,
+ KubernetesClusterUpdate,
KubernetesDeploymentSpec,
KubernetesNode,
)
@@ -125,6 +126,33 @@ async def remove_cluster(
return {"success": True, "message": f"Cluster {cluster_id} removed"}
+@router.patch("/{cluster_id}", response_model=KubernetesCluster)
+async def update_cluster(
+ cluster_id: str,
+ update: KubernetesClusterUpdate,
+ current_user: User = Depends(get_current_user)
+):
+ """Update cluster configuration settings."""
+ k8s_manager = await get_kubernetes_manager()
+
+ # Build update dict with only provided fields
+ updates = {k: v for k, v in update.model_dump().items() if v is not None}
+
+ if not updates:
+ # No fields to update
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+ return cluster
+
+ updated_cluster = await k8s_manager.update_cluster(cluster_id, updates)
+
+ if not updated_cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ return updated_cluster
+
+
@router.get("/services/available")
async def get_available_services(
current_user: User = Depends(get_current_user)
@@ -210,6 +238,14 @@ async def scan_cluster_for_infra(
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
+ # Don't allow scanning the target namespace - it contains deployed services, not infrastructure
+ if request.namespace == cluster.namespace:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cannot scan target namespace '{cluster.namespace}' for infrastructure. "
+ f"This namespace contains deployed services. Scan a different namespace where infrastructure services are located."
+ )
+
results = await k8s_manager.scan_cluster_for_infra_services(
cluster_id,
request.namespace
@@ -229,6 +265,41 @@ async def scan_cluster_for_infra(
}
+@router.delete("/{cluster_id}/scan-infra/{namespace}")
+async def delete_infra_scan(
+ cluster_id: str,
+ namespace: str,
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Delete an infrastructure scan for a specific namespace.
+
+ Useful for removing stale or incorrect scan data.
+ """
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Check if scan exists
+ if not cluster.infra_scans or namespace not in cluster.infra_scans:
+ raise HTTPException(
+ status_code=404,
+ detail=f"No infrastructure scan found for namespace '{namespace}'"
+ )
+
+ # Remove the scan
+ await k8s_manager.delete_cluster_infra_scan(cluster_id, namespace)
+
+ return {
+ "cluster_id": cluster_id,
+ "namespace": namespace,
+ "message": f"Infrastructure scan for namespace '{namespace}' deleted successfully"
+ }
+
+
@router.post("/{cluster_id}/envmap")
async def create_or_update_envmap(
cluster_id: str,
@@ -316,8 +387,33 @@ async def deploy_service_to_cluster(
# TODO: Track deployment status in Deployment record, not ServiceConfig
# ServiceConfig no longer tracks deployment state (removed in architecture refactor)
- # Add node selector if node_name specified
+ # Auto-populate k8s_spec with cluster ingress defaults
k8s_spec = request.k8s_spec or KubernetesDeploymentSpec()
+
+ # Auto-configure ingress if cluster has ingress configured
+ if cluster.ingress_domain:
+ if k8s_spec.ingress is None:
+ # No ingress config from frontend - apply cluster defaults
+ if cluster.ingress_enabled_by_default:
+ # Auto-generate hostname
+ service_name = resolved_service.name.lower().replace(" ", "-").replace("_", "-")
+ hostname = f"{service_name}.{cluster.ingress_domain}"
+
+ k8s_spec.ingress = {
+ "enabled": True,
+ "host": hostname,
+ "path": "/",
+ "ingressClassName": cluster.ingress_class
+ }
+ logger.info(f"✓ Auto-configured ingress: {hostname}")
+ elif k8s_spec.ingress.get("enabled") and not k8s_spec.ingress.get("host"):
+ # Frontend enabled ingress but no hostname - auto-generate
+ service_name = resolved_service.name.lower().replace(" ", "-").replace("_", "-")
+ k8s_spec.ingress["host"] = f"{service_name}.{cluster.ingress_domain}"
+ k8s_spec.ingress["ingressClassName"] = cluster.ingress_class
+ logger.info(f"✓ Auto-generated ingress hostname: {k8s_spec.ingress['host']}")
+
+ # Add node selector if node_name specified
if request.node_name:
# Add node selector to ensure pod runs on specific node
if not k8s_spec.labels:
@@ -451,3 +547,257 @@ async def get_pod_events(
except Exception as e:
logger.error(f"Failed to get pod events: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# DNS Management Endpoints
+# ═══════════════════════════════════════════════════════════════════════════
+
+@router.get("/{cluster_id}/dns/status")
+async def get_dns_status(
+ cluster_id: str,
+ domain: Optional[str] = None,
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Get DNS configuration status for a cluster.
+
+ Returns CoreDNS IP, Ingress IP, cert-manager status, and current DNS mappings.
+ """
+ from src.models.kubernetes_dns import DNSConfig, DNSStatus
+ from src.services.kubernetes_dns_manager import KubernetesDNSManager
+
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Create DNS manager
+ dns_manager = KubernetesDNSManager(
+ kubectl_runner=lambda cmd: k8s_manager.run_kubectl_command(cluster_id, cmd)
+ )
+
+ # Build config if domain provided
+ config = None
+ if domain:
+ config = DNSConfig(
+ cluster_id=cluster_id,
+ domain=domain
+ )
+
+ try:
+ status = await dns_manager.get_dns_status(cluster_id, config)
+ return status
+ except Exception as e:
+ logger.error(f"Failed to get DNS status: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/{cluster_id}/dns/setup")
+async def setup_dns(
+ cluster_id: str,
+ request: 'DNSSetupRequest',
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Setup DNS system on a cluster.
+
+ This will:
+ 1. Install cert-manager (optional)
+ 2. Create Let's Encrypt ClusterIssuer
+ 3. Create DNS ConfigMap
+ 4. Patch CoreDNS configuration
+ 5. Patch CoreDNS deployment
+
+ After setup, you can add services with DNS names.
+ """
+ from src.models.kubernetes_dns import DNSConfig, DNSSetupRequest
+ from src.services.kubernetes_dns_manager import KubernetesDNSManager
+
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Create DNS manager
+ dns_manager = KubernetesDNSManager(
+ kubectl_runner=lambda cmd: k8s_manager.run_kubectl_command(cluster_id, cmd)
+ )
+
+ # Build config
+ config = DNSConfig(
+ cluster_id=cluster_id,
+ domain=request.domain,
+ acme_email=request.acme_email
+ )
+
+ try:
+ success, error = await dns_manager.setup_dns_system(
+ cluster_id,
+ config,
+ install_cert_manager=request.install_cert_manager
+ )
+
+ if not success:
+ raise HTTPException(status_code=500, detail=error)
+
+ return {
+ "success": True,
+ "message": f"DNS system setup complete for domain: {request.domain}",
+ "domain": request.domain,
+ "cert_manager_installed": request.install_cert_manager
+ }
+ except Exception as e:
+ logger.error(f"Failed to setup DNS: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/{cluster_id}/dns/services")
+async def add_service_dns(
+ cluster_id: str,
+ domain: str,
+ request: 'AddServiceDNSRequest',
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Add DNS entry for a service.
+
+ This will:
+ 1. Add DNS mapping to CoreDNS
+ 2. Create Ingress resource
+ 3. Setup TLS certificate (if enabled)
+
+ The service will be accessible via:
+ - FQDN: servicename.domain
+ - Short names: shortname1, shortname2, etc.
+ """
+ from src.models.kubernetes_dns import DNSConfig, DNSServiceMapping, AddServiceDNSRequest
+ from src.services.kubernetes_dns_manager import KubernetesDNSManager
+
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Create DNS manager
+ dns_manager = KubernetesDNSManager(
+ kubectl_runner=lambda cmd: k8s_manager.run_kubectl_command(cluster_id, cmd)
+ )
+
+ # Build config
+ config = DNSConfig(cluster_id=cluster_id, domain=domain)
+
+ # Build mapping
+ mapping = DNSServiceMapping(
+ service_name=request.service_name,
+ namespace=request.namespace,
+ shortnames=request.shortnames,
+ use_ingress=request.use_ingress,
+ enable_tls=request.enable_tls,
+ service_port=request.service_port
+ )
+
+ try:
+ success, error = await dns_manager.add_service_dns(cluster_id, config, mapping)
+
+ if not success:
+ raise HTTPException(status_code=500, detail=error)
+
+ fqdn = f"{request.shortnames[0]}.{domain}"
+ return {
+ "success": True,
+ "message": f"DNS added for service: {request.service_name}",
+ "service_name": request.service_name,
+ "fqdn": fqdn,
+ "shortnames": request.shortnames,
+ "tls_enabled": request.enable_tls
+ }
+ except Exception as e:
+ logger.error(f"Failed to add service DNS: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/{cluster_id}/dns/services/{service_name}")
+async def remove_service_dns(
+ cluster_id: str,
+ service_name: str,
+ domain: str,
+ namespace: str = "default",
+ current_user: User = Depends(get_current_user)
+):
+ """Remove DNS entry and Ingress for a service."""
+ from src.models.kubernetes_dns import DNSConfig
+ from src.services.kubernetes_dns_manager import KubernetesDNSManager
+
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Create DNS manager
+ dns_manager = KubernetesDNSManager(
+ kubectl_runner=lambda cmd: k8s_manager.run_kubectl_command(cluster_id, cmd)
+ )
+
+ # Build config
+ config = DNSConfig(cluster_id=cluster_id, domain=domain)
+
+ try:
+ success, error = await dns_manager.remove_service_dns(
+ cluster_id, config, service_name, namespace
+ )
+
+ if not success:
+ raise HTTPException(status_code=500, detail=error)
+
+ return {
+ "success": True,
+ "message": f"DNS removed for service: {service_name}"
+ }
+ except Exception as e:
+ logger.error(f"Failed to remove service DNS: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/{cluster_id}/dns/certificates")
+async def list_certificates(
+ cluster_id: str,
+ namespace: Optional[str] = None,
+ current_user: User = Depends(get_current_user)
+):
+ """
+ List TLS certificates managed by cert-manager.
+
+ Shows certificate status, expiration, and renewal time.
+ """
+ from src.services.kubernetes_dns_manager import KubernetesDNSManager
+
+ k8s_manager = await get_kubernetes_manager()
+
+ # Verify cluster exists
+ cluster = await k8s_manager.get_cluster(cluster_id)
+ if not cluster:
+ raise HTTPException(status_code=404, detail="Cluster not found")
+
+ # Create DNS manager
+ dns_manager = KubernetesDNSManager(
+ kubectl_runner=lambda cmd: k8s_manager.run_kubectl_command(cluster_id, cmd)
+ )
+
+ try:
+ certificates = await dns_manager.list_certificates(cluster_id, namespace)
+ return {
+ "certificates": certificates,
+ "total": len(certificates)
+ }
+ except Exception as e:
+ logger.error(f"Failed to list certificates: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/ushadow/backend/src/routers/memories.py b/ushadow/backend/src/routers/memories.py
new file mode 100644
index 00000000..93753ab8
--- /dev/null
+++ b/ushadow/backend/src/routers/memories.py
@@ -0,0 +1,646 @@
+"""
+Unified memory routing layer for ushadow.
+
+This module provides a single API for querying memories across different sources:
+- OpenMemory (shared between Chronicle and Mycelia)
+- Mycelia native memory system
+- Chronicle native memory system (Qdrant)
+
+The routing is source-aware and queries the appropriate backend(s).
+"""
+import logging
+from typing import List, Literal, Optional, Dict, Any
+from datetime import datetime, timedelta
+
+import httpx
+from fastapi import APIRouter, HTTPException, Depends, Query
+
+from src.utils.auth_helpers import get_user_email
+from pydantic import BaseModel
+
+from src.services.auth import get_current_user
+from src.models.user import User
+
+from src.config import get_localhost_proxy_url
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/memories", tags=["memories"])
+
+
+class MemoryItem(BaseModel):
+ """Unified memory response format"""
+ id: str
+ content: str
+ created_at: str
+ metadata: dict
+ source: Literal["openmemory", "mycelia", "chronicle"] # Which system it came from
+ score: Optional[float] = None
+
+
+class ConversationMemoriesResponse(BaseModel):
+ """Response for conversation memories query"""
+ conversation_id: str
+ conversation_source: Literal["chronicle", "mycelia"]
+ memories: List[MemoryItem]
+ count: int
+ sources_queried: List[str] # Which memory systems were checked
+
+
+class UserInterestsResponse(BaseModel):
+ """Response for user interests query"""
+ user_id: str
+ interests: List[str] # Top interests
+ sentiment: Dict[str, str] # Interest -> sentiment mapping
+ intensity: Dict[str, str] # Interest -> intensity mapping
+ trending: List[str] # Interests mentioned more recently
+ content_types: List[str] # Preferred content formats
+ interest_counts: Dict[str, int] # Interest -> count mapping
+ days_analyzed: int # Number of days analyzed
+
+
+class MemoriesFilterRequest(BaseModel):
+ """Request for filtering memories by metadata"""
+ user_id: Optional[str] = None
+ from_date: Optional[int] = None # Unix timestamp
+ to_date: Optional[int] = None # Unix timestamp
+ search_query: Optional[str] = None
+ app_ids: Optional[List[str]] = None
+ category_ids: Optional[List[str]] = None
+ page: int = 1
+ size: int = 100
+
+
+class MemoriesFilterResponse(BaseModel):
+ """Response for filtered memories query"""
+ items: List[MemoryItem]
+ total: int
+ page: int
+ size: int
+ pages: int
+
+
+@router.get("/{memory_id}")
+async def get_memory_by_id(
+ memory_id: str,
+ current_user: User = Depends(get_current_user)
+) -> MemoryItem:
+ """
+ Get a single memory by ID from any memory source.
+
+ Searches across all available memory backends (OpenMemory, Chronicle, Mycelia)
+ and returns the first match found.
+
+ Args:
+ memory_id: The memory ID to retrieve
+ current_user: Authenticated user (from JWT)
+
+ Returns:
+ Memory item with full details
+
+ Access Control:
+ - Regular users: Only their own memories
+ - Admins: All memories
+
+ Raises:
+ HTTPException: 404 if memory not found
+ """
+ # Try each memory source in priority order
+ sources_tried = []
+
+ # 1. Try OpenMemory first (most common source)
+ try:
+ openmemory_url = get_localhost_proxy_url("mem0")
+ logger.info(f"[MEMORIES] Querying OpenMemory for memory {memory_id}")
+ sources_tried.append("openmemory")
+
+ async with httpx.AsyncClient() as client:
+ # Get specific memory by ID
+ response = await client.get(
+ f"{openmemory_url}/api/v1/memories/{memory_id}",
+ params={"user_id": get_user_email(current_user), "output_format": "v1.1"}
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ # Validate access
+ metadata = data.get("metadata_", {})
+ memory_user_email = metadata.get("chronicle_user_email") or metadata.get("user_email")
+
+ if memory_user_email == get_user_email(current_user) or not memory_user_email:
+ logger.info(f"[MEMORIES] Found memory in OpenMemory")
+ # OpenMemory uses 'text' field for content
+ content = data.get("text") or data.get("content", "")
+ # Include categories in metadata if they exist
+ if "categories" in data and data["categories"]:
+ metadata["categories"] = data["categories"]
+ return MemoryItem(
+ id=str(data.get("id")),
+ content=content,
+ created_at=str(data.get("created_at", "")),
+ metadata=metadata,
+ source="openmemory",
+ score=None
+ )
+ except Exception as e:
+ logger.error(f"[MEMORIES] OpenMemory query failed: {e}", exc_info=True)
+
+ # 2. Try Chronicle native memory system
+ try:
+ chronicle_url = get_localhost_proxy_url("chronicle-backend")
+ logger.info(f"[MEMORIES] Querying Chronicle for memory {memory_id}")
+ sources_tried.append("chronicle")
+
+ async with httpx.AsyncClient() as client:
+ # Try Chronicle's memory endpoint if it exists
+ response = await client.get(f"{chronicle_url}/api/memories/{memory_id}")
+
+ if response.status_code == 200:
+ data = response.json()
+ logger.info(f"[MEMORIES] Found memory in Chronicle")
+ return MemoryItem(
+ id=data.get("id"),
+ content=data.get("content"),
+ created_at=data.get("created_at"),
+ metadata=data.get("metadata", {}),
+ source="chronicle",
+ score=data.get("score")
+ )
+ except Exception as e:
+ logger.error(f"[MEMORIES] Chronicle query failed: {e}", exc_info=True)
+
+ # 3. Try Mycelia native memory system
+ try:
+ mycelia_url = get_localhost_proxy_url("mycelia-backend")
+ logger.info(f"[MEMORIES] Querying Mycelia for memory {memory_id}")
+ sources_tried.append("mycelia")
+
+ async with httpx.AsyncClient() as client:
+ response = await client.get(f"{mycelia_url}/api/memories/{memory_id}")
+
+ if response.status_code == 200:
+ data = response.json()
+ logger.info(f"[MEMORIES] Found memory in Mycelia")
+ return MemoryItem(
+ id=data.get("id"),
+ content=data.get("content"),
+ created_at=data.get("created_at"),
+ metadata=data.get("metadata", {}),
+ source="mycelia",
+ score=data.get("score")
+ )
+ except Exception as e:
+ logger.error(f"[MEMORIES] Mycelia query failed: {e}", exc_info=True)
+
+ # Memory not found in any source
+ logger.warning(f"[MEMORIES] Memory {memory_id} not found in any source (tried: {sources_tried})")
+ raise HTTPException(
+ status_code=404,
+ detail=f"Memory {memory_id} not found (searched: {', '.join(sources_tried)})"
+ )
+
+
+@router.get("/by-conversation/{conversation_id}")
+async def get_memories_by_conversation(
+ conversation_id: str,
+ conversation_source: Literal["chronicle", "mycelia"] = Query(..., description="Which backend has the conversation"),
+ current_user: User = Depends(get_current_user)
+) -> ConversationMemoriesResponse:
+ """
+ Get all memories associated with a conversation across all memory sources.
+
+ This endpoint queries multiple memory backends and aggregates results:
+ 1. OpenMemory (if available) - checks source_id metadata
+ 2. Source-specific backend (Chronicle/Mycelia native)
+
+ Args:
+ conversation_id: The conversation ID to query
+ conversation_source: Which backend has this conversation ("chronicle" or "mycelia")
+ current_user: Authenticated user (from JWT)
+
+ Returns:
+ Aggregated memories from all sources with source attribution
+
+ Access Control:
+ - Regular users: Only their own conversation memories
+ - Admins: All conversation memories
+ """
+ all_memories = []
+ sources_queried = []
+
+ # Strategy: Query all available memory sources and aggregate
+
+ # 1. Try OpenMemory (shared memory system)
+ try:
+ # Use proxy URL - same method as frontend memoriesApi.getServerUrl()
+ openmemory_url = get_localhost_proxy_url("mem0")
+ logger.info(f"[MEMORIES] Querying OpenMemory via proxy at: {openmemory_url}")
+ sources_queried.append("openmemory")
+ openmemory_memories = await _query_openmemory_by_source_id(
+ openmemory_url,
+ conversation_id,
+ get_user_email(current_user) # OpenMemory uses email as user_id
+ )
+ logger.info(f"[MEMORIES] OpenMemory returned {len(openmemory_memories)} memories")
+ all_memories.extend(openmemory_memories)
+ except Exception as e:
+ # OpenMemory not available or query failed - continue with other sources
+ logger.error(f"[MEMORIES] OpenMemory query failed: {e}", exc_info=True)
+
+ # 2. Query conversation-source-specific backend
+ if conversation_source == "chronicle":
+ sources_queried.append("chronicle")
+ try:
+ # Use proxy URL - same method as frontend
+ chronicle_url = get_localhost_proxy_url("chronicle-backend")
+ logger.info(f"[MEMORIES] Querying Chronicle via proxy at: {chronicle_url}")
+ chronicle_memories = await _query_chronicle_memories(
+ chronicle_url,
+ conversation_id,
+ current_user
+ )
+ all_memories.extend(chronicle_memories)
+ except Exception as e:
+ # Chronicle query failed
+ logger.error(f"[MEMORIES] Chronicle query failed: {e}", exc_info=True)
+
+ elif conversation_source == "mycelia":
+ sources_queried.append("mycelia")
+ try:
+ # Use proxy URL - same method as frontend
+ mycelia_url = get_localhost_proxy_url("mycelia-backend")
+ logger.info(f"[MEMORIES] Querying Mycelia via proxy at: {mycelia_url}")
+ mycelia_memories = await _query_mycelia_memories(
+ mycelia_url,
+ conversation_id,
+ current_user
+ )
+ all_memories.extend(mycelia_memories)
+ except Exception as e:
+ # Mycelia query failed
+ logger.error(f"[MEMORIES] Mycelia query failed: {e}", exc_info=True)
+
+ return ConversationMemoriesResponse(
+ conversation_id=conversation_id,
+ conversation_source=conversation_source,
+ memories=all_memories,
+ count=len(all_memories),
+ sources_queried=sources_queried
+ )
+
+
+@router.post("/filter")
+async def filter_memories(
+ filter_request: MemoriesFilterRequest,
+ current_user: User = Depends(get_current_user)
+) -> MemoriesFilterResponse:
+ """
+ Filter memories by metadata with advanced search capabilities.
+
+ Supports filtering by:
+ - Date range (from_date, to_date as Unix timestamps)
+ - Search query (semantic search)
+ - App IDs (app_ids filter)
+ - Category IDs (category_ids filter)
+ - Pagination (page, size)
+
+ This endpoint leverages OpenMemory's v1.1 output format for enhanced metadata.
+
+ Args:
+ filter_request: Filter criteria
+ current_user: Authenticated user
+
+ Returns:
+ Paginated list of memories matching filter criteria
+
+ Access Control:
+ - Regular users: Only their own memories
+ - Admins: Can query all users if user_id specified
+ """
+ try:
+ openmemory_url = get_localhost_proxy_url("mem0")
+ user_email = filter_request.user_id or get_user_email(current_user)
+
+ logger.info(f"[MEMORIES] Filtering memories for user: {user_email}")
+
+ async with httpx.AsyncClient() as client:
+ # Build filter request for OpenMemory
+ payload = {
+ "user_id": user_email,
+ "page": filter_request.page,
+ "size": filter_request.size,
+ "output_format": "v1.1"
+ }
+
+ # Add optional filters
+ if filter_request.from_date:
+ payload["from_date"] = filter_request.from_date
+ if filter_request.to_date:
+ payload["to_date"] = filter_request.to_date
+ if filter_request.search_query:
+ payload["search_query"] = filter_request.search_query
+ if filter_request.app_ids:
+ payload["app_ids"] = filter_request.app_ids
+ if filter_request.category_ids:
+ payload["category_ids"] = filter_request.category_ids
+
+ response = await client.post(
+ f"{openmemory_url}/api/v1/memories/filter",
+ json=payload
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ # Convert to unified format
+ memories = []
+ for item in data.get("items", []):
+ metadata = item.get("metadata_", {})
+ content = item.get("text") or item.get("content", "")
+
+ # Include categories in metadata if they exist
+ if "categories" in item and item["categories"]:
+ metadata["categories"] = item["categories"]
+
+ memories.append(MemoryItem(
+ id=str(item.get("id")),
+ content=content,
+ created_at=str(item.get("created_at", "")),
+ metadata=metadata,
+ source="openmemory",
+ score=None
+ ))
+
+ return MemoriesFilterResponse(
+ items=memories,
+ total=data.get("total", len(memories)),
+ page=filter_request.page,
+ size=filter_request.size,
+ pages=data.get("pages", 1)
+ )
+
+ except Exception as e:
+ logger.error(f"[MEMORIES] Filter query failed: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to filter memories: {str(e)}"
+ )
+
+
+@router.get("/interests")
+async def get_user_interests(
+ days_recent: int = Query(30, description="Number of recent days to analyze"),
+ current_user: User = Depends(get_current_user)
+) -> UserInterestsResponse:
+ """
+ Extract and aggregate user interests from recent memories.
+
+ This endpoint analyzes recent memories to build a user interest profile for:
+ - Personalized feed ranking
+ - Content recommendations
+ - Trending topic detection
+
+ Based on the interest extraction pattern from docs/INTEREST_EXTRACTION.md,
+ this aggregates interests stored in memory metadata during write-time.
+
+ Args:
+ days_recent: Number of days to look back (default: 30)
+ current_user: Authenticated user
+
+ Returns:
+ Aggregated user interests with intensity, sentiment, and trending indicators
+
+ Access Control:
+ - Regular users: Only their own interests
+ - Admins: Can query specific user if user_id provided
+ """
+ try:
+ openmemory_url = get_localhost_proxy_url("mem0")
+ user_email = get_user_email(current_user)
+
+ logger.info(f"[MEMORIES] Extracting interests for user: {user_email} (last {days_recent} days)")
+
+ # Calculate date range
+ cutoff_date = int((datetime.now() - timedelta(days=days_recent)).timestamp())
+ mid_point_date = int((datetime.now() - timedelta(days=days_recent // 2)).timestamp())
+
+ async with httpx.AsyncClient() as client:
+ # Query recent memories
+ response = await client.post(
+ f"{openmemory_url}/api/v1/memories/filter",
+ json={
+ "user_id": user_email,
+ "from_date": cutoff_date,
+ "page": 1,
+ "size": 100,
+ "output_format": "v1.1"
+ }
+ )
+ response.raise_for_status()
+ data = response.json()
+ memories = data.get("items", [])
+
+ logger.info(f"[MEMORIES] Analyzing {len(memories)} memories for interests")
+
+ # Aggregate interests from metadata
+ from collections import Counter
+
+ all_interests = []
+ interest_sentiments = {}
+ interest_intensities = {}
+ content_types = set()
+ interest_timestamps: Dict[str, List[int]] = {}
+
+ for memory in memories:
+ metadata = memory.get("metadata_", {})
+ interests = metadata.get("interests", {})
+ timestamp = memory.get("created_at", cutoff_date)
+
+ # Convert timestamp to int if it's a string
+ if isinstance(timestamp, str):
+ try:
+ timestamp = int(datetime.fromisoformat(timestamp.replace('Z', '+00:00')).timestamp())
+ except:
+ timestamp = cutoff_date
+
+ # Collect specific interests
+ for interest in interests.get("specific", []):
+ all_interests.append(interest)
+
+ # Track when interest was mentioned
+ if interest not in interest_timestamps:
+ interest_timestamps[interest] = []
+ interest_timestamps[interest].append(timestamp)
+
+ # Collect sentiment
+ for topic, sentiment in interests.get("sentiment", {}).items():
+ interest_sentiments[topic] = sentiment
+
+ # Collect intensity
+ for topic, intensity in interests.get("intensity", {}).items():
+ interest_intensities[topic] = intensity
+
+ # Collect content types
+ content_types.update(interests.get("content_types", []))
+
+ # Count frequency
+ interest_counts = Counter(all_interests)
+ top_interests = [interest for interest, _ in interest_counts.most_common(10)]
+
+ # Calculate trending (mentioned more in recent half vs older half)
+ trending = []
+ for interest, timestamps in interest_timestamps.items():
+ recent_mentions = sum(1 for ts in timestamps if ts > mid_point_date)
+ older_mentions = sum(1 for ts in timestamps if ts <= mid_point_date)
+
+ # 50% more mentions recently indicates trending
+ if recent_mentions > older_mentions * 1.5 and older_mentions > 0:
+ trending.append(interest)
+
+ logger.info(f"[MEMORIES] Extracted {len(top_interests)} top interests, {len(trending)} trending")
+
+ return UserInterestsResponse(
+ user_id=user_email,
+ interests=top_interests,
+ sentiment=interest_sentiments,
+ intensity=interest_intensities,
+ trending=trending,
+ content_types=list(content_types),
+ interest_counts=dict(interest_counts),
+ days_analyzed=days_recent
+ )
+
+ except Exception as e:
+ logger.error(f"[MEMORIES] Interest extraction failed: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to extract user interests: {str(e)}"
+ )
+
+
+async def _query_openmemory_by_source_id(
+ openmemory_url: str,
+ source_id: str,
+ user_email: str
+) -> List[MemoryItem]:
+ """
+ Query OpenMemory for memories with specific source_id in metadata.
+
+ Access control: Validates chronicle_user_email in metadata matches current user.
+ """
+ memories = []
+
+ logger.info(f"[MEMORIES] _query_openmemory: url={openmemory_url}, source_id={source_id}, user={user_email}")
+
+ async with httpx.AsyncClient() as client:
+ # Query all memories for user
+ query_url = f"{openmemory_url}/api/v1/memories/"
+ params = {"user_id": user_email, "limit": 100, "output_format": "v1.1"}
+ logger.info(f"[MEMORIES] Querying: {query_url} with params: {params}")
+
+ response = await client.get(query_url, params=params)
+ logger.info(f"[MEMORIES] OpenMemory response status: {response.status_code}")
+ response.raise_for_status()
+ data = response.json()
+ logger.info(f"[MEMORIES] OpenMemory returned {len(data.get('items', []))} total memories")
+
+ # Filter by source_id in metadata
+ if "items" in data:
+ for item in data["items"]:
+ metadata = item.get("metadata_", {})
+
+ # Check if this memory belongs to the conversation
+ if metadata.get("source_id") == source_id:
+ # Validate access (check chronicle_user_email or user_id)
+ memory_user_email = metadata.get("chronicle_user_email") or metadata.get("user_email")
+ if memory_user_email == user_email or not memory_user_email:
+ # OpenMemory uses 'text' field for content
+ content = item.get("text") or item.get("content", "")
+ # Include categories in metadata if they exist
+ if "categories" in item and item["categories"]:
+ metadata["categories"] = item["categories"]
+ memories.append(MemoryItem(
+ id=str(item.get("id")),
+ content=content,
+ created_at=str(item.get("created_at", "")),
+ metadata=metadata,
+ source="openmemory",
+ score=None
+ ))
+
+ return memories
+
+
+async def _query_chronicle_memories(
+ chronicle_url: str,
+ conversation_id: str,
+ current_user: User
+) -> List[MemoryItem]:
+ """
+ Query Chronicle native memory system (via conversation endpoint).
+
+ Chronicle may use:
+ - Qdrant (native)
+ - OpenMemory (already queried above - will deduplicate)
+
+ Auth is handled by the service proxy.
+ """
+ memories = []
+
+ async with httpx.AsyncClient() as client:
+ # Chronicle has /api/conversations/{id}/memories endpoint
+ # Proxy handles authentication forwarding
+ response = await client.get(
+ f"{chronicle_url}/api/conversations/{conversation_id}/memories"
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ for mem in data.get("memories", []):
+ memories.append(MemoryItem(
+ id=mem.get("id"),
+ content=mem.get("content"),
+ created_at=mem.get("created_at"),
+ metadata=mem.get("metadata", {}),
+ source="chronicle",
+ score=mem.get("score")
+ ))
+
+ return memories
+
+
+async def _query_mycelia_memories(
+ mycelia_url: str,
+ conversation_id: str,
+ current_user: User
+) -> List[MemoryItem]:
+ """
+ Query Mycelia native memory system.
+
+ Mycelia may have its own memory endpoints or use OpenMemory.
+
+ Auth is handled by the service proxy.
+ """
+ memories = []
+
+ async with httpx.AsyncClient() as client:
+ # Try Mycelia's conversation memories endpoint if it exists
+ try:
+ response = await client.get(
+ f"{mycelia_url}/api/conversations/{conversation_id}/memories"
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ for mem in data.get("memories", []):
+ memories.append(MemoryItem(
+ id=mem.get("id"),
+ content=mem.get("content"),
+ created_at=mem.get("created_at"),
+ metadata=mem.get("metadata", {}),
+ source="mycelia",
+ score=mem.get("score")
+ ))
+ except:
+ # Mycelia might not have this endpoint yet
+ pass
+
+ return memories
diff --git a/ushadow/backend/src/routers/service_configs.py b/ushadow/backend/src/routers/service_configs.py
index 3919c38e..ace16f8f 100644
--- a/ushadow/backend/src/routers/service_configs.py
+++ b/ushadow/backend/src/routers/service_configs.py
@@ -18,6 +18,7 @@
from src.services.auth import get_current_user
from src.services.service_config_manager import get_service_config_manager
from src.config import get_settings
+from src.config.helpers import env_var_matches_setting
logger = logging.getLogger(__name__)
@@ -68,8 +69,14 @@ async def get_template_env_config(
Returns same format as /api/services/{name}/env for unified frontend handling.
"""
from src.config import get_settings, Source
+ from src.services.template_service import list_templates
- template = await get_template(template_id, current_user)
+ # Search provider templates specifically — compose templates share IDs (e.g. 'ollama')
+ # but have empty config_schema. Provider templates have the credential definitions.
+ provider_templates = await list_templates(source='provider')
+ template = next((t for t in provider_templates if t.id == template_id), None)
+ if template is None:
+ raise HTTPException(status_code=404, detail=f"Provider template not found: {template_id}")
settings_v2 = get_settings()
result = []
@@ -89,17 +96,15 @@ async def get_template_env_config(
# Get suggestions using Settings v2 API
suggestions = await settings_v2.get_suggestions(env_name)
- # Try to find a matching suggestion with a value for auto-mapping
+ # Try to find a matching suggestion with a value for auto-mapping.
+ # Use env_var_matches_setting which normalizes underscores to dots and
+ # requires a direct or suffix match — prevents false positives like
+ # matching WHISPER_SERVER_URL to keycloak.url just because both end in "url".
matching_suggestion = None
for s in suggestions:
- if s.has_value:
- # Check if suggestion path matches env var name pattern
- env_lower = env_name.lower()
- path_parts = s.path.lower().split('.')
- last_part = path_parts[-1] if path_parts else ''
- if env_lower.endswith(last_part) or last_part in env_lower:
- matching_suggestion = s
- break
+ if s.has_value and env_var_matches_setting(env_name, s.path):
+ matching_suggestion = s
+ break
# Determine source and setting_path based on matching suggestion
if matching_suggestion:
diff --git a/ushadow/backend/src/routers/services.py b/ushadow/backend/src/routers/services.py
index b6332c6b..26b4a3c7 100644
--- a/ushadow/backend/src/routers/services.py
+++ b/ushadow/backend/src/routers/services.py
@@ -451,7 +451,7 @@ async def get_service_connection_info(
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
# Import URL utilities
- from src.utils.service_urls import get_internal_proxy_url, get_relative_proxy_url
+ from src.config import get_docker_proxy_url, get_relative_proxy_url
# Proxy URL (for frontend REST API access through ushadow)
proxy_url = get_relative_proxy_url(name)
@@ -461,7 +461,7 @@ async def get_service_connection_info(
# Internal URL (for backend-to-service communication)
# Use proxy with full backend hostname for stable service discovery
- internal_url = get_internal_proxy_url(name)
+ internal_url = get_docker_proxy_url(name)
# Direct URL (for frontend WebSocket/streaming access)
# Use Tailscale hostname for web access (goes through Tailscale Serve routes)
@@ -554,11 +554,19 @@ async def proxy_service_request(
# First check deployments (user-deployed services override infrastructure)
all_deployments = await deployment_mgr.list_deployments()
- # Find deployment matching service name
+ # Find deployment matching service name, scoped to the current environment
+ project_name = os.getenv("COMPOSE_PROJECT_NAME", "ushadow")
matching_deployment = None
for deployment in all_deployments:
# Match by service_id (now just the service name, e.g., "chronicle-backend")
if deployment.service_id == name:
+ # Scope to this environment: accept project-prefixed containers (e.g. "ushadow-ollama")
+ # OR explicitly-named containers (e.g. container_name: ollama) which have no prefix
+ if deployment.container_name:
+ has_project_prefix = deployment.container_name.startswith(f"{project_name}-")
+ is_explicitly_named = deployment.container_name == name
+ if not has_project_prefix and not is_explicitly_named:
+ continue
# Prefer running deployments
if deployment.status == "running":
matching_deployment = deployment
@@ -595,20 +603,22 @@ async def proxy_service_request(
else:
internal_port = int(first_port)
- # Check if remote deployment
- # Get current hostname from ENV_NAME, COMPOSE_PROJECT_NAME, or UNODE_HOSTNAME
- current_hostname = (
- os.getenv("ENV_NAME") or
- os.getenv("COMPOSE_PROJECT_NAME", "").replace("ushadow-", "") or
- os.getenv("UNODE_HOSTNAME", "local")
- )
+ # Check if remote deployment using unode labels (more reliable than hostname matching)
+ # Fetch the unode to check its labels
+ from src.services.unode_manager import get_unode_manager
- # Normalize both to compare - remove ushadow- prefix if present
- deployment_host_normalized = (matching_deployment.unode_hostname or "").replace("ushadow-", "")
- current_host_normalized = current_hostname.replace("ushadow-", "")
+ try:
+ unode_mgr = await get_unode_manager()
+ unode = await unode_mgr.get_unode(matching_deployment.unode_hostname)
+ # Check for is_local label - if not present or "false", treat as remote
+ is_local = unode and unode.labels.get("is_local") == "true"
+ is_remote = not is_local
- logger.info(f"[PROXY] Deployment check: deployment={deployment_host_normalized}, current={current_host_normalized}")
- is_remote = deployment_host_normalized and deployment_host_normalized != current_host_normalized
+ logger.info(f"[PROXY] Deployment check: unode={matching_deployment.unode_hostname}, is_local={is_local}, labels={unode.labels if unode else 'N/A'}")
+ except Exception as e:
+ # If we can't fetch the unode, fall back to treating it as remote for safety
+ logger.warning(f"[PROXY] Could not fetch unode {matching_deployment.unode_hostname}: {e}")
+ is_remote = True
if is_remote:
# For remote deployments, proxy through the remote unode manager
@@ -726,6 +736,20 @@ async def proxy_service_request(
logger.info(f"[PROXY] Token payload: iss={payload.get('iss')}, aud={payload.get('aud')}, sub={payload.get('sub')}")
except Exception as e:
logger.debug(f"[PROXY] Could not decode token: {e}")
+
+ # Bridge Keycloak tokens to service tokens for Chronicle
+ from src.services.token_bridge import bridge_to_service_token
+ token_without_bearer = auth_header.replace("Bearer ", "")
+ service_token = await bridge_to_service_token(
+ token_without_bearer,
+ audiences=["ushadow", "chronicle"]
+ )
+ if service_token and service_token != token_without_bearer:
+ # Token was bridged (Keycloak → service token)
+ headers["authorization"] = f"Bearer {service_token}"
+ logger.info(f"[PROXY] ✓ Bridged Keycloak token to service token")
+ else:
+ logger.debug(f"[PROXY] Token passed through (already a service token or bridging failed)")
else:
logger.warning(f"[PROXY] No Authorization header in request to {name}")
diff --git a/ushadow/backend/src/routers/settings.py b/ushadow/backend/src/routers/settings.py
index 9dcfb236..e95d3a8b 100644
--- a/ushadow/backend/src/routers/settings.py
+++ b/ushadow/backend/src/routers/settings.py
@@ -43,11 +43,24 @@ async def get_settings_info():
@router.get("/config")
async def get_config():
- """Get merged configuration with secrets masked."""
+ """Get merged configuration with secrets masked.
+
+ Dynamically injects keycloak.public_url based on Tailscale configuration.
+ """
try:
+ from src.config.keycloak_settings import get_keycloak_config
+
settings = get_settings()
all_config = await settings.get_all()
+ # Inject dynamic Keycloak config (public_url determined from tailscale.hostname)
+ keycloak_config = get_keycloak_config()
+ if "keycloak" not in all_config:
+ all_config["keycloak"] = {}
+ all_config["keycloak"]["public_url"] = keycloak_config["public_url"]
+ all_config["keycloak"]["realm"] = keycloak_config["realm"]
+ all_config["keycloak"]["frontend_client_id"] = keycloak_config["frontend_client_id"]
+
# Recursively mask all sensitive values
masked_config = mask_dict_secrets(all_config)
diff --git a/ushadow/backend/src/routers/share.py b/ushadow/backend/src/routers/share.py
new file mode 100644
index 00000000..84dae4fa
--- /dev/null
+++ b/ushadow/backend/src/routers/share.py
@@ -0,0 +1,395 @@
+"""Share API endpoints for conversation and resource sharing.
+
+Provides HTTP endpoints for creating, accessing, and managing share tokens.
+Thin router layer that delegates to ShareService for business logic.
+"""
+
+import logging
+import os
+from typing import Any, Dict, List, Optional
+
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Request
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from ..database import get_database
+from .tailscale import _read_config as read_tailscale_config
+from ..models.share import (
+ ShareAccessLog,
+ ShareToken,
+ ShareTokenCreate,
+ ShareTokenResponse,
+)
+from ..models.user import User
+from ..services.auth import get_current_user, get_optional_current_user
+from ..services.share_service import ShareService
+
+from ..config import get_localhost_proxy_url
+
+logger = logging.getLogger(__name__)
+
+REQUEST_TIMEOUT = 10.0
+
+
+async def _fetch_resource_data(
+ resource_type: str,
+ resource_id: str,
+ auth_token: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Fetch actual resource data via the service proxy.
+
+ Uses the generic service proxy at /api/services/{name}/proxy/{path}
+ which handles service discovery and request forwarding.
+
+ Args:
+ resource_type: Type of resource (conversation, memory, etc.)
+ resource_id: ID of the resource
+ auth_token: Optional Bearer token for authenticated requests
+
+ Returns:
+ Resource data dict, or error info if fetch fails
+ """
+ # Map resource type to service and endpoint
+ if resource_type == "conversation":
+ # Conversations are stored in Mycelia
+ proxy_url = get_localhost_proxy_url("mycelia-backend")
+ path = f"/data/conversations/{resource_id}"
+ elif resource_type == "memory":
+ # Memories may be in OpenMemory (mem0) or Mycelia
+ proxy_url = get_localhost_proxy_url("mem0")
+ path = f"/api/v1/memories/{resource_id}"
+ else:
+ return {"error": f"Unknown resource type: {resource_type}"}
+
+ # Build headers - disable automatic decompression to avoid gzip mismatch issues
+ headers = {"Accept-Encoding": "identity"}
+ if auth_token:
+ headers["Authorization"] = f"Bearer {auth_token}"
+
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
+ try:
+ url = f"{proxy_url}{path}"
+ logger.debug(f"Fetching resource via proxy: {url}")
+ response = await client.get(url, headers=headers)
+
+ if response.status_code == 200:
+ logger.info(f"Successfully fetched {resource_type} {resource_id}")
+ return response.json()
+ elif response.status_code == 404:
+ logger.warning(f"Resource not found: {resource_type} {resource_id}")
+ return {"error": f"{resource_type.title()} not found"}
+ elif response.status_code in (401, 403):
+ logger.warning(f"Auth failed fetching resource: {response.status_code}")
+ return {"error": "Authentication required to view this resource"}
+ else:
+ logger.warning(f"Failed to fetch resource: {response.status_code}")
+ return {"error": f"Failed to fetch {resource_type}: HTTP {response.status_code}"}
+
+ except httpx.RequestError as e:
+ logger.error(f"Could not connect to service proxy: {e}")
+ return {"error": "Could not connect to data service"}
+
+
+router = APIRouter(prefix="/api/share", tags=["sharing"])
+
+
+def _get_share_base_url() -> str:
+ """Determine the base URL for share links.
+
+ Strategy hierarchy:
+ 1. SHARE_BASE_URL environment variable (highest priority)
+ 2. SHARE_PUBLIC_GATEWAY environment variable (for external sharing)
+ 3. Tailscale hostname (for Tailnet-only sharing)
+ 4. Fallback to localhost (development only)
+
+ Returns:
+ Base URL string (e.g., "https://ushadow.tail12345.ts.net" or "https://share.yourdomain.com")
+ """
+ # Explicit override (highest priority)
+ if base_url := os.getenv("SHARE_BASE_URL"):
+ logger.info(f"Using explicit SHARE_BASE_URL: {base_url}")
+ return base_url.rstrip("/")
+
+ # Public gateway for external sharing
+ if gateway_url := os.getenv("SHARE_PUBLIC_GATEWAY"):
+ logger.info(f"Using public gateway: {gateway_url}")
+ return gateway_url.rstrip("/")
+
+ # Use Tailscale hostname (works with or without Funnel)
+ try:
+ config = read_tailscale_config()
+ if config and config.hostname:
+ tailscale_url = f"https://{config.hostname}"
+ logger.info(f"Using Tailscale hostname: {tailscale_url}")
+ return tailscale_url
+ except Exception as e:
+ logger.warning(f"Failed to read Tailscale config: {e}")
+
+ # Fallback for development
+ logger.warning("Using localhost fallback - shares will only work locally!")
+ return "http://localhost:3000"
+
+
+def get_share_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> ShareService:
+ """Dependency injection for ShareService.
+
+ Args:
+ db: MongoDB database (injected)
+
+ Returns:
+ ShareService instance
+ """
+ base_url = _get_share_base_url()
+ logger.info(f"Share service initialized with base_url: {base_url}")
+ return ShareService(db=db, base_url=base_url)
+
+
+@router.post("/create", response_model=ShareTokenResponse, status_code=201)
+async def create_share_token(
+ data: ShareTokenCreate,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> ShareTokenResponse:
+ """Create a new share token for a resource.
+
+ Requires authentication. User must have permission to share the resource.
+
+ Args:
+ data: Share token creation parameters
+ current_user: Authenticated user
+ service: Share service instance
+
+ Returns:
+ Created share token with share URL
+
+ Raises:
+ 400: If resource doesn't exist or user lacks permission
+ 401: If not authenticated
+ """
+ try:
+ share_token = await service.create_share_token(data, current_user)
+ return service.to_response(share_token)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/{token}", response_model=dict)
+async def access_shared_resource(
+ token: str,
+ request: Request,
+ current_user: Optional[User] = Depends(get_optional_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> dict:
+ """Access a shared resource via share token.
+
+ Public endpoint - does not require authentication unless share requires it.
+ Records access in audit log.
+
+ Args:
+ token: Share token UUID
+ request: HTTP request (for IP address)
+ current_user: Optional authenticated user
+ service: Share service instance
+
+ Returns:
+ Shared resource data with permissions
+
+ Raises:
+ 403: If access denied (expired, limit exceeded, etc.)
+ 404: If share token not found
+ """
+ # Get user email if authenticated
+ from src.utils.auth_helpers import get_user_email
+ user_email = get_user_email(current_user) if current_user else None
+
+ # Get request IP for Tailscale validation
+ request_ip = request.client.host if request.client else None
+
+ # Extract auth token from request (for forwarding to service proxy)
+ auth_header = request.headers.get("authorization", "")
+ auth_token = auth_header[7:] if auth_header.startswith("Bearer ") else None
+
+ # Validate access
+ is_valid, share_token, reason = await service.validate_share_access(
+ token=token,
+ user_email=user_email,
+ request_ip=request_ip,
+ )
+
+ if not is_valid:
+ if share_token is None:
+ raise HTTPException(status_code=404, detail="Share token not found")
+ raise HTTPException(status_code=403, detail=reason)
+
+ # Record access
+ user_identifier = user_email or request_ip or "anonymous"
+ metadata = {
+ "ip": request_ip,
+ "user_agent": request.headers.get("user-agent"),
+ }
+ await service.record_share_access(
+ share_token=share_token,
+ user_identifier=user_identifier,
+ action="view",
+ metadata=metadata,
+ )
+
+ # Fetch actual resource data (pass auth token for authenticated shares)
+ resource_data = await _fetch_resource_data(
+ share_token.resource_type,
+ share_token.resource_id,
+ auth_token=auth_token,
+ )
+
+ return {
+ "share_token": service.to_response(share_token).dict(),
+ "resource": {
+ "type": share_token.resource_type,
+ "id": share_token.resource_id,
+ "data": resource_data,
+ },
+ "permissions": share_token.permissions,
+ }
+
+
+@router.delete("/{token}", status_code=204)
+async def revoke_share_token(
+ token: str,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+):
+ """Revoke a share token.
+
+ Requires authentication. User must be the creator or admin.
+
+ Args:
+ token: Share token to revoke
+ current_user: Authenticated user
+ service: Share service instance
+
+ Raises:
+ 403: If user lacks permission
+ 404: If share token not found
+ """
+ try:
+ revoked = await service.revoke_share_token(token, current_user)
+ if not revoked:
+ raise HTTPException(status_code=404, detail="Share token not found")
+ except ValueError as e:
+ raise HTTPException(status_code=403, detail=str(e))
+
+
+@router.get("/resource/{resource_type}/{resource_id}", response_model=List[ShareTokenResponse])
+async def list_shares_for_resource(
+ resource_type: str,
+ resource_id: str,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> List[ShareTokenResponse]:
+ """List all share tokens for a resource.
+
+ Requires authentication. User must have access to the resource.
+
+ Args:
+ resource_type: Type of resource (conversation, memory, etc.)
+ resource_id: ID of resource
+ current_user: Authenticated user
+ service: Share service instance
+
+ Returns:
+ List of share tokens for the resource
+ """
+ share_tokens = await service.list_shares_for_resource(
+ resource_type=resource_type,
+ resource_id=resource_id,
+ user=current_user,
+ )
+ return [service.to_response(token) for token in share_tokens]
+
+
+@router.get("/{token}/logs", response_model=List[ShareAccessLog])
+async def get_share_access_logs(
+ token: str,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> List[ShareAccessLog]:
+ """Get access logs for a share token.
+
+ Requires authentication. User must be creator or admin.
+
+ Args:
+ token: Share token
+ current_user: Authenticated user
+ service: Share service instance
+
+ Returns:
+ List of access log entries
+
+ Raises:
+ 403: If user lacks permission
+ 404: If share token not found
+ """
+ try:
+ return await service.get_share_access_logs(token, current_user)
+ except ValueError as e:
+ if "not found" in str(e).lower():
+ raise HTTPException(status_code=404, detail=str(e))
+ raise HTTPException(status_code=403, detail=str(e))
+
+
+# Convenience endpoints for specific resource types
+
+@router.post("/conversations/{conversation_id}", response_model=ShareTokenResponse, status_code=201)
+async def share_conversation(
+ conversation_id: str,
+ data: ShareTokenCreate,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> ShareTokenResponse:
+ """Convenience endpoint for sharing a conversation.
+
+ Automatically sets resource_type to 'conversation' and uses path parameter
+ for resource_id. Otherwise identical to POST /api/share/create.
+
+ Args:
+ conversation_id: ID of conversation to share
+ data: Share token parameters (resource_type/resource_id will be overridden)
+ current_user: Authenticated user
+ service: Share service instance
+
+ Returns:
+ Created share token with share URL
+ """
+ # Override resource type and ID from path
+ data.resource_type = "conversation"
+ data.resource_id = conversation_id
+
+ try:
+ share_token = await service.create_share_token(data, current_user)
+ return service.to_response(share_token)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/conversations/{conversation_id}/shares", response_model=List[ShareTokenResponse])
+async def list_conversation_shares(
+ conversation_id: str,
+ current_user: User = Depends(get_current_user),
+ service: ShareService = Depends(get_share_service),
+) -> List[ShareTokenResponse]:
+ """Convenience endpoint for listing shares of a conversation.
+
+ Args:
+ conversation_id: ID of conversation
+ current_user: Authenticated user
+ service: Share service instance
+
+ Returns:
+ List of share tokens for the conversation
+ """
+ share_tokens = await service.list_shares_for_resource(
+ resource_type="conversation",
+ resource_id=conversation_id,
+ user=current_user,
+ )
+ return [service.to_response(token) for token in share_tokens]
diff --git a/ushadow/backend/src/routers/tailscale.py b/ushadow/backend/src/routers/tailscale.py
index eea0366a..7a7a77bb 100644
--- a/ushadow/backend/src/routers/tailscale.py
+++ b/ushadow/backend/src/routers/tailscale.py
@@ -23,6 +23,7 @@
from src.config import get_settings
from src.utils.tailscale_serve import get_tailscale_status, _get_docker_client
from src.services.tailscale_manager import get_tailscale_manager
+from src.services.keycloak_startup import register_current_environment
# UNodeCapabilities moved to /api/unodes/leader/info endpoint
import logging
@@ -31,7 +32,12 @@
router = APIRouter(prefix="/api/tailscale", tags=["tailscale"])
# Docker client for container management (legacy - being phased out)
-docker_client = docker.from_env()
+# Only initialize if Docker socket is available (not in K8s)
+try:
+ docker_client = docker.from_env()
+except (docker.errors.DockerException, FileNotFoundError):
+ logger.warning("Docker socket not available (running in K8s?) - some Tailscale features may be limited")
+ docker_client = None
def get_environment_name() -> str:
"""Get the current environment name from COMPOSE_PROJECT_NAME or default to 'ushadow'"""
@@ -84,6 +90,7 @@ class DeploymentMode(BaseModel):
class TailscaleConfig(BaseModel):
"""Complete Tailscale configuration"""
hostname: str = Field(..., description="Tailscale hostname (e.g., machine-name.tail12345.ts.net)")
+ ip_address: Optional[str] = Field(None, description="Tailscale IP address (e.g., 100.105.225.45)")
deployment_mode: DeploymentMode
https_enabled: bool = True
use_caddy_proxy: bool = Field(..., description="True for multi-env, False for single-env")
@@ -409,6 +416,7 @@ async def generate_serve_config(config: TailscaleConfig) -> Dict[str, str]:
f"tailscale serve https / http://localhost:{frontend_port}",
f"tailscale serve https /api http://localhost:{backend_port}",
f"tailscale serve https /auth http://localhost:{backend_port}",
+ f"tailscale serve https /keycloak http://localhost:8081",
"",
"# To view current configuration:",
"tailscale serve status",
@@ -698,26 +706,63 @@ async def get_mobile_connection_qr(
config = get_settings()
api_port = config.get_sync("network.backend_public_port") or 8000
- # Build full API URL for leader info endpoint
- api_url = f"https://{status.hostname}/api/unodes/leader/info"
+ # Get unode manager to fetch unode hostname and envname
+ from src.services.unode_manager import get_unode_manager
+ from src.models.unode import UNodeRole
+
+ unode_manager = await get_unode_manager()
+ leader_unode = await unode_manager.get_unode_by_role(UNodeRole.LEADER)
+
+ if not leader_unode:
+ raise HTTPException(
+ status_code=500,
+ detail="Could not find leader unode. Please ensure unode is registered."
+ )
+
+ # Build full API URL for unode info endpoint
+ # Use unode hostname, not Tailscale hostname
+ api_url = f"https://{status.hostname}/api/unodes/{leader_unode.hostname}/info"
+
+ # Auto-register mobile redirect URIs in Keycloak
+ from src.services.keycloak_admin import get_keycloak_admin
+
+ try:
+ keycloak_admin = get_keycloak_admin()
+ mobile_uris = [
+ "ushadow://*", # Production mobile app
+ "exp://localhost:8081/--/oauth/callback", # Expo Go development
+ "exp://*", # Expo Go wildcard
+ ]
+ await keycloak_admin.update_client_redirect_uris(
+ client_id="ushadow-frontend",
+ redirect_uris=mobile_uris,
+ merge=True
+ )
+ logger.info("[Mobile-QR] Auto-registered mobile redirect URIs in Keycloak")
+ except Exception as e:
+ logger.warning(f"[Mobile-QR] Failed to auto-register mobile URIs: {e}")
+ # Non-fatal - continue with QR generation
# Generate auth token for mobile app (valid for ushadow and chronicle)
# Both services now share the same database (ushadow-blue) so user IDs match
+ from src.utils.auth_helpers import get_user_id, get_user_email
+
auth_token = generate_jwt_for_service(
- user_id=str(current_user.id),
- user_email=current_user.email,
+ user_id=get_user_id(current_user),
+ user_email=get_user_email(current_user),
audiences=["ushadow", "chronicle"]
)
- # Minimal connection data for QR code
+ # Connection data for QR code (v4 includes envname)
connection_data = {
"type": "ushadow-connect",
- "v": 3, # Version 3 includes auth token
- "hostname": status.hostname,
- "ip": status.ip_address,
+ "v": 4, # Version 4 includes envname
+ "hostname": leader_unode.hostname, # UNode hostname (e.g., "orion")
+ "ip": status.ip_address, # Tailscale IP
"port": api_port,
"api_url": api_url,
"auth_token": auth_token,
+ "envname": leader_unode.envname, # Environment name (e.g., "orange")
}
# Generate QR code
@@ -989,24 +1034,16 @@ async def start_tailscale_container(
# Container doesn't exist - create it using Docker SDK
logger.info(f"Creating Tailscale container '{container_name}' for environment '{env_name}'...")
- # Ensure infra network exists
+ # Ensure ushadow-network exists
try:
- infra_network = _get_docker_client().networks.get("infra-network")
+ ushadow_network = _get_docker_client().networks.get("ushadow-network")
+ logger.info(f"Found ushadow-network")
except docker.errors.NotFound:
raise HTTPException(
status_code=400,
- detail="infra-network not found. Please start infrastructure first."
+ detail="ushadow-network not found. Please start infrastructure first."
)
- # Get environment's compose network if it exists
- env_network_name = f"{env_name}_default"
- env_network = None
- try:
- env_network = _get_docker_client().networks.get(env_network_name)
- logger.info(f"Connecting to environment network: {env_network_name}")
- except docker.errors.NotFound:
- logger.debug(f"Environment network '{env_network_name}' not found - using infra-network only")
-
# Create volume if it doesn't exist (per-environment)
try:
_get_docker_client().volumes.get(volume_name)
@@ -1044,19 +1081,11 @@ async def start_tailscale_container(
f"{PROJECT_ROOT}/config": {"bind": "/config", "mode": "ro"},
},
cap_add=["NET_ADMIN", "NET_RAW"],
- network="infra-network",
+ network="ushadow-network", # All app containers and infrastructure on this network
restart_policy={"Name": "unless-stopped"},
command="sh -c 'tailscaled --tun=userspace-networking --statedir=/var/lib/tailscale & sleep infinity'"
)
- # Connect to environment's compose network for routing to backend/frontend
- if env_network:
- try:
- env_network.connect(container)
- logger.info(f"Connected Tailscale container to environment network '{env_network_name}'")
- except Exception as e:
- logger.warning(f"Failed to connect to environment network: {e}")
-
logger.info(f"Tailscale container '{container_name}' created with hostname '{ts_hostname}': {container.id}")
# Wait for tailscaled to be ready before returning
@@ -1352,10 +1381,21 @@ async def configure_tailscale_serve(
Sets up base routes: /api/* and /auth/* to backend, /* to frontend,
and WebSocket routes /ws_pcm and /ws_omi direct to Chronicle.
Also saves the Tailscale configuration to disk.
+
+ Additionally registers the Tailscale hostname with Keycloak to enable
+ OAuth callbacks from the Tailscale domain.
"""
try:
manager = get_tailscale_manager()
+ # Get container status to capture IP address
+ container_status = manager.get_container_status()
+ if container_status.ip_address:
+ config.ip_address = container_status.ip_address
+ logger.info(f"Captured Tailscale IP: {container_status.ip_address}")
+ else:
+ logger.warning("Could not capture Tailscale IP address")
+
# Save configuration to disk first
config_data = config.model_dump()
with open(TAILSCALE_CONFIG_FILE, 'w') as f:
@@ -1372,18 +1412,35 @@ async def configure_tailscale_serve(
# Get the current serve status to return actual routes
status = manager.get_serve_status() or ""
+ # Register Tailscale hostname with Keycloak for OAuth callbacks
+ # Reuse the same registration logic that runs on backend startup
+ keycloak_success = False
+ keycloak_message = "Keycloak registration skipped"
+ try:
+ await register_current_environment()
+ keycloak_success = True
+ keycloak_message = f"OAuth callbacks registered for {config.hostname}"
+ logger.info(f"[TAILSCALE] ✓ Registered Keycloak URIs for {config.hostname}")
+ except Exception as e:
+ logger.warning(f"[TAILSCALE] Failed to register Keycloak URIs: {e}")
+ keycloak_message = f"Failed to register OAuth callback URLs: {str(e)}"
+
if success:
return {
"status": "configured",
"message": "Tailscale serve configured successfully with base routes",
"routes": status,
- "hostname": config.hostname
+ "hostname": config.hostname,
+ "keycloak_registered": keycloak_success,
+ "keycloak_message": keycloak_message
}
else:
return {
"status": "partial",
"message": "Some routes may have failed to configure",
- "routes": status
+ "routes": status,
+ "keycloak_registered": keycloak_success,
+ "keycloak_message": keycloak_message
}
except Exception as e:
@@ -1520,3 +1577,96 @@ async def get_serve_status(
"routes": None,
"error": str(e)
}
+
+
+# ============================================================================
+# Tailscale Funnel Management
+# ============================================================================
+
+@router.get("/funnel/status")
+async def get_funnel_status(
+ current_user: User = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Get Tailscale Funnel status.
+
+ Funnel exposes services to the public internet (anyone can access without Tailscale).
+ Use this for sharing with people who don't have Tailscale installed.
+
+ Returns:
+ Funnel status with enabled state and public URL
+ """
+ try:
+ manager = get_tailscale_manager()
+ return manager.get_funnel_status()
+ except Exception as e:
+ logger.error(f"Error getting funnel status: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/funnel/enable")
+async def enable_funnel(
+ current_user: User = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Enable Tailscale Funnel for public internet access.
+
+ This makes your ushadow instance accessible to anyone on the internet
+ via HTTPS (not just Tailnet members). Use with caution and ensure
+ proper authentication is configured.
+
+ Requires:
+ - Tailscale Serve already configured
+ - Tailscale container running and authenticated
+
+ Returns:
+ Success status and public URL
+ """
+ try:
+ manager = get_tailscale_manager()
+ success, result = manager.enable_funnel()
+
+ if success:
+ return {
+ "status": "enabled",
+ "public_url": result,
+ "message": "Funnel enabled successfully. Your instance is now publicly accessible."
+ }
+ else:
+ raise HTTPException(status_code=400, detail=result)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error enabling funnel: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/funnel/disable")
+async def disable_funnel(
+ current_user: User = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Disable Tailscale Funnel.
+
+ This restricts access back to Tailnet-only (not publicly accessible).
+
+ Returns:
+ Success status
+ """
+ try:
+ manager = get_tailscale_manager()
+ success, error = manager.disable_funnel()
+
+ if success:
+ return {
+ "status": "disabled",
+ "message": "Funnel disabled successfully. Access restricted to Tailnet only."
+ }
+ else:
+ raise HTTPException(status_code=400, detail=error)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error disabling funnel: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
diff --git a/ushadow/backend/src/routers/unodes.py b/ushadow/backend/src/routers/unodes.py
index 51cbcdd0..06a58dab 100644
--- a/ushadow/backend/src/routers/unodes.py
+++ b/ushadow/backend/src/routers/unodes.py
@@ -2,7 +2,7 @@
import logging
import os
-from typing import List, Optional
+from typing import Dict, List, Optional
import httpx
from fastapi import APIRouter, HTTPException, Depends
@@ -34,6 +34,7 @@ class UNodeRegistrationRequest(BaseModel):
"""Request to register a u-node."""
token: str
hostname: str
+ envname: Optional[str] = None
tailscale_ip: str
platform: str = "unknown"
manager_version: str = "0.1.0"
@@ -122,6 +123,7 @@ async def register_unode(request: UNodeRegistrationRequest):
unode_create = UNodeCreate(
hostname=request.hostname,
+ envname=request.envname,
tailscale_ip=request.tailscale_ip,
platform=platform,
manager_version=request.manager_version,
@@ -175,6 +177,10 @@ async def list_unodes(
unode_manager = await get_unode_manager()
unodes = await unode_manager.list_unodes(status=status, role=role)
+ # Debug: log labels for each unode
+ for unode in unodes:
+ logger.info(f"UNode {unode.hostname}: labels={unode.labels}")
+
return UNodeListResponse(unodes=unodes, total=len(unodes))
@@ -375,6 +381,8 @@ class LeaderInfoResponse(BaseModel):
"""
# Leader info
hostname: str
+ envname: Optional[str] = None
+ display_name: Optional[str] = None
tailscale_ip: str
tailscale_hostname: Optional[str] = None # Full Tailscale DNS name
capabilities: UNodeCapabilities
@@ -383,6 +391,7 @@ class LeaderInfoResponse(BaseModel):
# API URLs for specific services
ushadow_api_url: str # Main ushadow backend API
chronicle_api_url: Optional[str] = None # Chronicle/OMI backend API (if running)
+ keycloak_url: Optional[str] = None # Keycloak authentication URL
# Streaming URLs (only available when Chronicle service is running)
ws_pcm_url: Optional[str] = None # WebSocket for PCM audio streaming
@@ -509,14 +518,21 @@ async def get_leader_info():
ws_omi_url=service_ws_omi_url,
))
+ # Build Keycloak URL for mobile devices (exposed on port 8081)
+ # Use HOST's Tailscale IP (leader's IP, not container's IP)
+ keycloak_url = f"http://{leader.tailscale_ip}:8081"
+
return LeaderInfoResponse(
hostname=leader.hostname,
+ envname=leader.envname,
+ display_name=leader.display_name,
tailscale_ip=leader.tailscale_ip,
tailscale_hostname=tailscale_hostname,
capabilities=leader.capabilities,
api_port=api_port,
ushadow_api_url=ushadow_api_url,
chronicle_api_url=chronicle_api_url,
+ keycloak_url=keycloak_url,
ws_pcm_url=ws_pcm_url,
ws_omi_url=ws_omi_url,
unodes=unodes,
@@ -524,13 +540,75 @@ async def get_leader_info():
)
+class UNodeInfoResponse(BaseModel):
+ """Public unode information including Keycloak configuration."""
+ hostname: str
+ envname: Optional[str]
+ role: UNodeRole
+ status: UNodeStatus
+ tailscale_ip: str
+ api_url: str
+ keycloak_config: Optional[dict] = None
+
+
+@router.get("/{hostname}/info", response_model=UNodeInfoResponse)
+async def get_unode_info(hostname: str):
+ """
+ Get public information about a specific u-node.
+
+ This endpoint does NOT require authentication and is used by:
+ - Mobile apps after scanning QR code
+ - External tools that need to discover Keycloak config
+
+ Returns unode details including Keycloak configuration.
+ """
+ unode_manager = await get_unode_manager()
+ unode = await unode_manager.get_unode(hostname)
+
+ if not unode:
+ raise HTTPException(status_code=404, detail="UNode not found")
+
+ # Build API URL for this unode
+ # Use Tailscale IP or public IP depending on context
+ port = os.getenv("BACKEND_PORT", "8000")
+ api_url = f"http://{unode.tailscale_ip}:{port}"
+
+ # Get Keycloak configuration
+ from src.config.keycloak_settings import get_keycloak_config
+
+ kc_config = get_keycloak_config()
+
+ # Mobile URL: explicit override > auto-derived from Tailscale IP + KC port
+ kc_port = os.getenv("KC_PORT", "8081")
+ mobile_url = kc_config.get("mobile_url") or f"http://{unode.tailscale_ip}:{kc_port}"
+
+ keycloak_config = {
+ "enabled": True, # If this endpoint is reached, Keycloak is configured
+ "public_url": kc_config.get("public_url"),
+ "mobile_url": mobile_url,
+ "realm": kc_config.get("realm"),
+ "frontend_client_id": kc_config.get("frontend_client_id"),
+ "mobile_client_id": "ushadow-mobile", # Dedicated mobile client
+ }
+
+ return UNodeInfoResponse(
+ hostname=unode.hostname,
+ envname=unode.envname,
+ role=unode.role,
+ status=unode.status,
+ tailscale_ip=unode.tailscale_ip,
+ api_url=api_url,
+ keycloak_config=keycloak_config,
+ )
+
+
@router.get("/{hostname}", response_model=UNode)
async def get_unode(
hostname: str,
current_user: User = Depends(get_current_user)
):
"""
- Get details of a specific u-node.
+ Get details of a specific u-node (authenticated).
"""
unode_manager = await get_unode_manager()
unode = await unode_manager.get_unode(hostname)
@@ -551,8 +629,9 @@ async def create_join_token(
Returns the token and a one-liner join command.
"""
unode_manager = await get_unode_manager()
+ from src.utils.auth_helpers import get_user_id
response = await unode_manager.create_join_token(
- user_id=current_user.id,
+ user_id=get_user_id(current_user),
request=request
)
@@ -733,3 +812,249 @@ async def upgrade_all_unodes(
results["failed"].append({"hostname": unode.hostname, "error": message})
return results
+
+
+# Create Public UNode
+class CreatePublicUNodeRequest(BaseModel):
+ """Request to create a virtual public unode."""
+ tailscale_auth_key: str
+ hostname: Optional[str] = None # Defaults to ushadow-{env}-public
+ labels: Dict[str, str] = {"zone": "public", "funnel": "enabled"}
+
+
+class CreatePublicUNodeResponse(BaseModel):
+ """Response from creating a public unode."""
+ success: bool
+ message: str
+ hostname: str
+ join_token: Optional[str] = None
+ public_url: Optional[str] = None
+ compose_project: Optional[str] = None
+
+
+class UpdateUNodeLabelsRequest(BaseModel):
+ """Request to update unode labels."""
+ labels: Dict[str, str]
+
+
+@router.patch("/{hostname}/labels", response_model=UNode)
+async def update_unode_labels(
+ hostname: str,
+ request: UpdateUNodeLabelsRequest,
+ current_user: User = Depends(get_current_user)
+):
+ """Update labels for a specific unode."""
+ unode_manager = await get_unode_manager()
+
+ # Get the unode
+ unodes = await unode_manager.list_unodes()
+ unode = next((n for n in unodes if n.hostname == hostname), None)
+
+ if not unode:
+ raise HTTPException(status_code=404, detail=f"UNode {hostname} not found")
+
+ # Update labels in database
+ result = await unode_manager.unodes_collection.find_one_and_update(
+ {"hostname": hostname},
+ {"$set": {"labels": request.labels}},
+ return_document=True
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail=f"Failed to update unode {hostname}")
+
+ return UNode(**result)
+
+
+@router.post("/create-public", response_model=CreatePublicUNodeResponse)
+async def create_public_unode(
+ request: CreatePublicUNodeRequest,
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Create a virtual public unode on the same physical machine as the leader.
+
+ This creates a separate Docker compose stack with its own Tailscale instance
+ (with Funnel enabled) that can host public-facing services.
+
+ Steps:
+ 1. Create join token
+ 2. Generate compose configuration
+ 3. Start public unode services
+ 4. Enable Tailscale Funnel
+ 5. Return status
+ """
+ import os
+ import subprocess
+ from pathlib import Path
+
+ # Get environment name from settings
+ from src.config import get_settings
+ settings = get_settings()
+ env_name = settings.get_sync("network.env_name", "orange")
+
+ # Generate hostname if not provided
+ hostname = request.hostname or f"ushadow-{env_name}-public"
+ compose_project = f"ushadow-{env_name}"
+
+ try:
+ # Step 1: Create join token
+ unode_manager = await get_unode_manager()
+
+ # Handle both dict (Keycloak) and User object
+ user_email = current_user.get('email') if isinstance(current_user, dict) else current_user.email
+
+ token_data = await unode_manager.create_join_token(
+ user_id=user_email,
+ request=JoinTokenCreate(role=UNodeRole.WORKER, max_uses=1, expires_in_hours=24)
+ )
+ join_token = token_data.token
+
+ logger.info(f"Created join token for public unode: {hostname}")
+
+ # Step 2: Get leader backend URL (Docker service name on shared network)
+ leader_url = f"http://ushadow-{env_name}-backend:8000"
+ logger.info(f"Using leader URL: {leader_url}")
+
+ # Step 3: Write .env file for public unode
+ # Write directly to /config which IS mounted from host
+ env_filename = "env.public-unode" # No leading dot to avoid being hidden
+ env_file_container = Path("/config") / env_filename
+
+ # Host paths for docker compose command
+ project_root_host = os.environ.get("PROJECT_ROOT", "/Users/stu/repos/worktrees/ushadow/orange")
+ env_file_host = Path(project_root_host) / "config" / env_filename
+ compose_file_host = Path(project_root_host) / "compose" / "public-unode-compose.yaml"
+
+ logger.info(f"Writing .env to container: {env_file_container}")
+ logger.info(f"Maps to host: {env_file_host}")
+
+ env_content = f"""# Public UNode Environment Configuration
+ENV_NAME={env_name}
+COMPOSE_PROJECT_NAME={compose_project}
+PUBLIC_UNODE_HOSTNAME={hostname}
+TAILSCALE_PUBLIC_HOSTNAME={hostname}
+PUBLIC_UNODE_JOIN_TOKEN={join_token}
+TAILSCALE_PUBLIC_AUTH_KEY={request.tailscale_auth_key}
+LEADER_URL={leader_url}
+"""
+
+ # Write to mounted config directory (syncs to host automatically)
+ with open(env_file_container, 'w') as f:
+ f.write(env_content)
+
+ logger.info(f"Created env file at {env_file_container} (host: {env_file_host})")
+
+ # Step 4: Start public unode services
+ # Check compose file exists in container
+ compose_file_container = Path("/compose") / "public-unode-compose.yaml"
+ if not compose_file_container.exists():
+ raise HTTPException(
+ status_code=500,
+ detail=f"Compose file not found: {compose_file_container}"
+ )
+
+ # Verify file exists in container (it's on a mounted volume)
+ if not env_file_container.exists():
+ raise HTTPException(
+ status_code=500,
+ detail=f"Env file not found in container: {env_file_container}"
+ )
+
+ # Parse env file and pass vars directly (docker compose via socket can't read host files)
+ env_vars = {}
+ with open(env_file_container, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ env_vars[key] = value
+
+ # Run docker compose with env vars directly (no --env-file needed)
+ cmd = [
+ "docker", "compose",
+ "-f", "/compose/public-unode-compose.yaml", # Use container path for compose file
+ "-p", compose_project, # Project name
+ "up", "-d"
+ ]
+ logger.info(f"Running: {' '.join(cmd)} with {len(env_vars)} env vars")
+
+ result = subprocess.run(
+ cmd,
+ cwd="/app",
+ capture_output=True,
+ text=True,
+ timeout=60,
+ env={**os.environ, **env_vars} # Merge with current env
+ )
+
+ if result.returncode != 0:
+ logger.error(f"Failed to start public unode: {result.stderr}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to start services: {result.stderr}"
+ )
+
+ logger.info(f"Started public unode services: {result.stdout}")
+
+ # Step 5: Register the public unode
+ # Wait briefly for manager to start
+ import asyncio
+ await asyncio.sleep(5)
+
+ # Get Tailscale IP from the manager container
+ try:
+ ts_ip_result = subprocess.run(
+ ["docker", "exec", f"{compose_project}-public-manager", "hostname", "-I"],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ # Get first IP (usually the Tailscale IP comes later, but we'll try)
+ tailscale_ip = ts_ip_result.stdout.strip().split()[0] if ts_ip_result.stdout else "100.0.0.1"
+ except:
+ tailscale_ip = "100.0.0.1" # Placeholder
+
+ # Register the unode with labels
+ from src.models.unode import UNodePlatform
+ unode_create = UNodeCreate(
+ hostname=hostname,
+ envname=env_name,
+ tailscale_ip=tailscale_ip,
+ platform=UNodePlatform.LINUX,
+ manager_version="0.1.0",
+ labels=request.labels # Include the public/funnel labels
+ )
+
+ success, unode, error = await unode_manager.register_unode(
+ join_token,
+ unode_create
+ )
+
+ if not success:
+ logger.warning(f"Failed to register public unode: {error}")
+ # Continue anyway - it may register on next heartbeat
+
+ # Step 6: The actual Funnel enabling happens via the Tailscale container
+
+ return CreatePublicUNodeResponse(
+ success=True,
+ message=f"Public unode '{hostname}' created and {'registered' if success else 'starting'}.",
+ hostname=hostname,
+ join_token=join_token[:20] + "...", # Show partial token
+ public_url=f"https://{hostname}.ts.net (pending Tailscale connection)",
+ compose_project=compose_project
+ )
+
+ except subprocess.TimeoutExpired:
+ logger.error("Docker compose command timed out")
+ raise HTTPException(
+ status_code=500,
+ detail="Service startup timed out. Check Docker daemon."
+ )
+ except Exception as e:
+ logger.error(f"Failed to create public unode: {e}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to create public unode: {str(e)}"
+ )
diff --git a/ushadow/backend/src/routers/wizard.py b/ushadow/backend/src/routers/wizard.py
index f76c2868..8ce01b54 100644
--- a/ushadow/backend/src/routers/wizard.py
+++ b/ushadow/backend/src/routers/wizard.py
@@ -63,11 +63,20 @@ class ServiceInfo(BaseModel):
description: Optional[str] = None
+class ServiceProfile(BaseModel):
+ """A named set of default services selectable in the wizard."""
+ name: str
+ display_name: str
+ services: List[str]
+
+
class QuickstartResponse(BaseModel):
"""Response for quickstart wizard - aggregated capability requirements."""
required_capabilities: List[CapabilityRequirement]
services: List[ServiceInfo] # Full service info, not just names
all_configured: bool
+ profiles: List[ServiceProfile] = []
+ active_profile: Optional[str] = None
class HuggingFaceStatusResponse(BaseModel):
@@ -407,21 +416,58 @@ async def get_quickstart_config() -> QuickstartResponse:
description=service.description,
))
else:
- # Fallback if service not found in registry
service_infos.append(ServiceInfo(
name=service_name,
display_name=service_name,
))
+ # Build profile list and detect active profile
+ raw_profiles = await settings.get("service_profiles") or {}
+ profiles = []
+ active_profile = None
+ for name, profile_data in raw_profiles.items():
+ profile_services = profile_data.get("services", []) if isinstance(profile_data, dict) else list(profile_data)
+ display_name = profile_data.get("display_name", name.title()) if isinstance(profile_data, dict) else name.title()
+ profiles.append(ServiceProfile(name=name, display_name=display_name, services=profile_services))
+ if sorted(profile_services) == sorted(default_services):
+ active_profile = name
+
return QuickstartResponse(
required_capabilities=[
CapabilityRequirement(**cap) for cap in requirements["required_capabilities"]
],
services=service_infos,
- all_configured=requirements["all_configured"]
+ all_configured=requirements["all_configured"],
+ profiles=profiles,
+ active_profile=active_profile,
)
+@router.post("/quickstart/profile")
+async def select_service_profile(body: Dict[str, str]) -> Dict[str, Any]:
+ """
+ Switch to a named service profile.
+
+ Writes the profile's services as default_services in config.overrides.yaml.
+ The wizard reloads after this to show the new service list.
+ """
+ profile_name = body.get("profile")
+ if not profile_name:
+ raise HTTPException(status_code=400, detail="profile is required")
+
+ settings = get_settings()
+ raw_profiles = await settings.get("service_profiles") or {}
+
+ if profile_name not in raw_profiles:
+ raise HTTPException(status_code=404, detail=f"Profile '{profile_name}' not found")
+
+ profile_data = raw_profiles[profile_name]
+ services = profile_data.get("services", []) if isinstance(profile_data, dict) else list(profile_data)
+
+ await settings.set("default_services", services)
+ return {"profile": profile_name, "services": services}
+
+
@router.post("/quickstart")
async def save_quickstart_config(key_values: Dict[str, str]) -> Dict[str, Any]:
"""
diff --git a/ushadow/backend/src/services/auth.py b/ushadow/backend/src/services/auth.py
index 11d2e8c7..5bee1742 100644
--- a/ushadow/backend/src/services/auth.py
+++ b/ushadow/backend/src/services/auth.py
@@ -240,8 +240,16 @@ async def read_token(
)
# User dependencies for protecting endpoints
-get_current_user = fastapi_users.current_user(active=True)
-get_optional_current_user = fastapi_users.current_user(active=True, optional=True)
+# Import hybrid auth dependencies that accept both legacy JWT and Keycloak tokens
+from src.services.keycloak_auth import get_current_user_hybrid, get_current_user_or_none
+
+# Use hybrid authentication for all endpoints (supports both legacy and Keycloak)
+get_current_user = get_current_user_hybrid
+get_optional_current_user = get_current_user_or_none
+
+# Legacy fastapi-users dependencies (kept for backwards compatibility if needed)
+_legacy_get_current_user = fastapi_users.current_user(active=True)
+_legacy_get_optional_current_user = fastapi_users.current_user(active=True, optional=True)
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
diff --git a/ushadow/backend/src/services/bluesky_service.py b/ushadow/backend/src/services/bluesky_service.py
new file mode 100644
index 00000000..6691343b
--- /dev/null
+++ b/ushadow/backend/src/services/bluesky_service.py
@@ -0,0 +1,160 @@
+"""BlueskyService — authenticated Bluesky post and reply operations.
+
+Uses the atproto AsyncClient with app password authentication.
+Client sessions are cached at the class level (process lifetime) to avoid
+re-authenticating on every post/reply request.
+
+Only bluesky_timeline sources can post/reply, since they carry credentials.
+"""
+
+import logging
+from typing import Dict, Optional
+
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from src.models.feed import Post, PostSource
+
+logger = logging.getLogger(__name__)
+
+BLUESKY_CHAR_LIMIT = 300
+
+
+class BlueskyService:
+ """Handles authenticated Bluesky operations: create posts and replies.
+
+ Session cache is class-level so it persists across the FastAPI process.
+ Sessions are recreated on login errors (e.g. expired app password).
+ """
+
+ _client_cache: Dict[str, object] = {} # source_id → atproto AsyncClient
+
+ def __init__(self, db: AsyncIOMotorDatabase) -> None:
+ self.db = db
+
+ async def _get_client(self, source_id: str, user_id: str) -> object:
+ """Return a cached authenticated atproto client for this source.
+
+ Creates and logs in a new client if not cached.
+ Raises ValueError if credentials are missing or login fails.
+ """
+ from atproto import AsyncClient as BskyClient # noqa: PLC0415
+
+ if source_id in BlueskyService._client_cache:
+ return BlueskyService._client_cache[source_id]
+
+ source = await PostSource.find_one(
+ PostSource.source_id == source_id,
+ PostSource.user_id == user_id,
+ )
+ if not source:
+ raise ValueError(f"Source '{source_id}' not found")
+ if source.platform_type != "bluesky_timeline":
+ raise ValueError("Only bluesky_timeline sources can post/reply")
+ if not source.handle or not source.api_key:
+ raise ValueError(
+ "bluesky_timeline source requires both handle and api_key (app password)"
+ )
+
+ client = BskyClient()
+ try:
+ await client.login(source.handle, source.api_key)
+ except Exception as e:
+ raise ValueError(f"Bluesky login failed for @{source.handle}: {e}") from e
+
+ BlueskyService._client_cache[source_id] = client
+ return client
+
+ def _invalidate_session(self, source_id: str) -> None:
+ """Remove a cached session (call after auth errors to force re-login)."""
+ BlueskyService._client_cache.pop(source_id, None)
+
+ async def create_post(
+ self, source_id: str, user_id: str, text: str
+ ) -> Dict[str, str]:
+ """Publish a new post to Bluesky.
+
+ Args:
+ source_id: The bluesky_timeline source to post from.
+ user_id: Must own the source.
+ text: Post text (max 300 characters).
+
+ Returns:
+ {"uri": str, "cid": str} of the created post.
+ """
+ if len(text) > BLUESKY_CHAR_LIMIT:
+ raise ValueError(
+ f"Post exceeds {BLUESKY_CHAR_LIMIT} character limit ({len(text)} chars)"
+ )
+
+ client = await self._get_client(source_id, user_id)
+ try:
+ response = await client.send_post(text)
+ except Exception as e:
+ self._invalidate_session(source_id)
+ raise ValueError(f"Failed to post: {e}") from e
+
+ logger.info("Created Bluesky post %s", response.uri)
+ return {"uri": response.uri, "cid": response.cid}
+
+ async def reply_to_post(
+ self,
+ source_id: str,
+ user_id: str,
+ text: str,
+ post_id: str,
+ ) -> Dict[str, str]:
+ """Reply to an existing Bluesky post stored in our feed.
+
+ Looks up the post by post_id to retrieve its AT URI and CID.
+ Uses the same ref for both parent and root (works for direct replies
+ to root posts; for deeply-nested threads the root would differ, but
+ this covers the common case).
+
+ Args:
+ source_id: The bluesky_timeline source to reply from.
+ user_id: Must own the source.
+ text: Reply text (max 300 characters).
+ post_id: Our internal post_id (used to look up AT URI + CID).
+
+ Returns:
+ {"uri": str, "cid": str} of the created reply.
+ """
+ from atproto import models as bsky_models # noqa: PLC0415
+
+ if len(text) > BLUESKY_CHAR_LIMIT:
+ raise ValueError(
+ f"Reply exceeds {BLUESKY_CHAR_LIMIT} character limit ({len(text)} chars)"
+ )
+
+ post = await Post.find_one(
+ Post.post_id == post_id,
+ Post.user_id == user_id,
+ )
+ if not post:
+ raise ValueError(f"Post '{post_id}' not found")
+ if not post.bluesky_cid:
+ raise ValueError("Post is missing CID — cannot construct reply ref")
+
+ parent_ref = bsky_models.ComAtprotoRepoStrongRef.Main(
+ uri=post.external_id,
+ cid=post.bluesky_cid,
+ )
+ reply_ref = bsky_models.AppBskyFeedPost.ReplyRef(
+ parent=parent_ref,
+ root=parent_ref, # Root = parent for top-level replies
+ )
+
+ client = await self._get_client(source_id, user_id)
+ try:
+ response = await client.send_post(text, reply_to=reply_ref)
+ except Exception as e:
+ self._invalidate_session(source_id)
+ raise ValueError(f"Failed to reply: {e}") from e
+
+ logger.info("Created Bluesky reply %s → %s", response.uri, post.external_id)
+ return {"uri": response.uri, "cid": response.cid}
+
+
+def get_bluesky_service(db: AsyncIOMotorDatabase) -> BlueskyService:
+ """Dependency provider for BlueskyService."""
+ return BlueskyService(db)
diff --git a/ushadow/backend/src/services/compose_registry.py b/ushadow/backend/src/services/compose_registry.py
index 7b7422f6..cbc03add 100644
--- a/ushadow/backend/src/services/compose_registry.py
+++ b/ushadow/backend/src/services/compose_registry.py
@@ -123,6 +123,8 @@ class DiscoveredService:
route_path: Optional[str] = None # Tailscale Serve route path (e.g., "/chronicle")
wizard: Optional[str] = None # Setup wizard ID from x-ushadow
exposes: List[Dict[str, Any]] = field(default_factory=list) # Exposed URLs from x-ushadow
+ tags: List[str] = field(default_factory=list) # Service tags from x-ushadow (e.g., ["audio", "gpu"])
+ environments: List[str] = field(default_factory=list) # Environments where service is visible (empty = all)
# Environment variables
required_env_vars: List[ComposeEnvVar] = field(default_factory=list)
@@ -236,8 +238,8 @@ def _discover_compose_files(self) -> None:
logger.warning(f"Compose directory not found: {self.compose_dir}")
return
- # Find compose files (pattern: *-compose.yaml or *-compose.yml)
- patterns = ["*-compose.yaml", "*-compose.yml"]
+ # Find all YAML files in compose directory
+ patterns = ["*.yaml", "*.yml"]
compose_files = []
for pattern in patterns:
compose_files.extend(self.compose_dir.glob(pattern))
@@ -286,6 +288,8 @@ def _load_compose_file(self, filepath: Path) -> None:
route_path=service.route_path,
wizard=service.wizard,
exposes=service.exposes,
+ tags=service.tags,
+ environments=service.environments,
required_env_vars=service.required_env_vars,
optional_env_vars=service.optional_env_vars,
)
diff --git a/ushadow/backend/src/services/dashboard_service.py b/ushadow/backend/src/services/dashboard_service.py
new file mode 100644
index 00000000..640bb434
--- /dev/null
+++ b/ushadow/backend/src/services/dashboard_service.py
@@ -0,0 +1,241 @@
+"""Dashboard service for aggregating Chronicle data.
+
+This service fetches recent conversations and memories from Chronicle
+and provides a unified dashboard view.
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Optional
+
+import httpx
+
+from src.models.dashboard import (
+ ActivityEvent,
+ ActivityType,
+ DashboardData,
+ DashboardStats,
+)
+
+logger = logging.getLogger(__name__)
+
+# Chronicle service configuration
+CHRONICLE_URL = "http://chronicle-backend:8000"
+CHRONICLE_TIMEOUT = 5.0
+
+
+class DashboardService:
+ """
+ Aggregates Chronicle data for the dashboard.
+
+ Fetches recent conversations and memories, providing
+ statistics and activity feeds.
+ """
+
+ async def get_dashboard_data(
+ self,
+ conversation_limit: int = 10,
+ memory_limit: int = 10,
+ ) -> DashboardData:
+ """
+ Get complete dashboard data.
+
+ Args:
+ conversation_limit: Max number of recent conversations
+ memory_limit: Max number of recent memories
+
+ Returns:
+ Complete dashboard data with stats and activities
+ """
+ # Fetch data from Chronicle
+ conversations = await self._fetch_conversations(limit=conversation_limit)
+ memories = await self._fetch_memories(limit=memory_limit)
+
+ # Convert to activity events
+ conversation_activities = self._conversations_to_activities(conversations)
+ memory_activities = self._memories_to_activities(memories)
+
+ # Calculate stats
+ stats = DashboardStats(
+ conversation_count=len(conversation_activities),
+ memory_count=len(memory_activities),
+ )
+
+ return DashboardData(
+ stats=stats,
+ recent_conversations=conversation_activities,
+ recent_memories=memory_activities,
+ last_updated=datetime.utcnow(),
+ )
+
+ # =========================================================================
+ # Chronicle data fetching
+ # =========================================================================
+
+ async def _fetch_conversations(self, limit: int = 10) -> List[dict]:
+ """
+ Fetch recent conversations from Chronicle.
+
+ Args:
+ limit: Maximum number of conversations to fetch
+
+ Returns:
+ List of conversation dicts from Chronicle API
+ """
+ try:
+ async with httpx.AsyncClient(timeout=CHRONICLE_TIMEOUT) as client:
+ response = await client.get(
+ f"{CHRONICLE_URL}/api/conversations",
+ params={"page": 1, "limit": limit},
+ )
+ if response.status_code == 200:
+ data = response.json()
+ return data.get("items", [])
+ except Exception as e:
+ logger.warning(f"Failed to fetch conversations: {e}")
+
+ return []
+
+ async def _fetch_memories(self, limit: int = 10) -> List[dict]:
+ """
+ Fetch recent memories from Chronicle.
+
+ Args:
+ limit: Maximum number of memories to fetch
+
+ Returns:
+ List of memory dicts from Chronicle API
+ """
+ try:
+ async with httpx.AsyncClient(timeout=CHRONICLE_TIMEOUT) as client:
+ response = await client.get(
+ f"{CHRONICLE_URL}/api/memories",
+ params={"limit": limit},
+ )
+ if response.status_code == 200:
+ data = response.json()
+ # Chronicle returns either a list or a dict with items
+ if isinstance(data, list):
+ return data
+ return data.get("items", [])
+ except Exception as e:
+ logger.warning(f"Failed to fetch memories: {e}")
+
+ return []
+
+ # =========================================================================
+ # Data transformation
+ # =========================================================================
+
+ def _conversations_to_activities(
+ self, conversations: List[dict]
+ ) -> List[ActivityEvent]:
+ """
+ Convert Chronicle conversations to activity events.
+
+ Args:
+ conversations: Raw conversation data from Chronicle
+
+ Returns:
+ List of ActivityEvent objects
+ """
+ activities = []
+
+ for conv in conversations:
+ # Parse timestamp
+ timestamp = self._parse_timestamp(
+ conv.get("created_at") or conv.get("timestamp")
+ )
+
+ # Create activity event
+ activities.append(
+ ActivityEvent(
+ id=f"conv-{conv.get('id', 'unknown')}",
+ type=ActivityType.CONVERSATION,
+ title=conv.get("title") or "Untitled Conversation",
+ description=conv.get("summary"),
+ timestamp=timestamp,
+ metadata={
+ "duration": conv.get("duration"),
+ "message_count": conv.get("message_count", 0),
+ },
+ source="chronicle",
+ )
+ )
+
+ return activities
+
+ def _memories_to_activities(self, memories: List[dict]) -> List[ActivityEvent]:
+ """
+ Convert Chronicle memories to activity events.
+
+ Args:
+ memories: Raw memory data from Chronicle
+
+ Returns:
+ List of ActivityEvent objects
+ """
+ activities = []
+
+ for mem in memories:
+ timestamp = self._parse_timestamp(mem.get("timestamp"))
+
+ # Truncate long content for title
+ content = mem.get("content", "")
+ title = content[:60] + "..." if len(content) > 60 else content
+
+ activities.append(
+ ActivityEvent(
+ id=f"mem-{mem.get('id', 'unknown')}",
+ type=ActivityType.MEMORY,
+ title=title,
+ description=content if len(content) > 60 else None,
+ timestamp=timestamp,
+ metadata={
+ "type": mem.get("type"),
+ "tags": mem.get("tags", []),
+ },
+ source="chronicle",
+ )
+ )
+
+ return activities
+
+ # =========================================================================
+ # Utilities
+ # =========================================================================
+
+ def _parse_timestamp(self, timestamp_str: Optional[str]) -> datetime:
+ """
+ Parse timestamp string to datetime.
+
+ Args:
+ timestamp_str: ISO format timestamp string
+
+ Returns:
+ Parsed datetime, or current time if parsing fails
+ """
+ if not timestamp_str:
+ return datetime.utcnow()
+
+ try:
+ # Try ISO format with timezone
+ return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ except Exception:
+ try:
+ # Try without timezone
+ return datetime.fromisoformat(timestamp_str)
+ except Exception:
+ logger.warning(f"Failed to parse timestamp: {timestamp_str}")
+ return datetime.utcnow()
+
+
+# Dependency injection
+async def get_dashboard_service() -> DashboardService:
+ """
+ Provide DashboardService instance.
+
+ Returns:
+ Configured DashboardService instance
+ """
+ return DashboardService()
diff --git a/ushadow/backend/src/services/deployment_backends.py b/ushadow/backend/src/services/deployment_backends.py
index f1161b0e..9a4cc3de 100644
--- a/ushadow/backend/src/services/deployment_backends.py
+++ b/ushadow/backend/src/services/deployment_backends.py
@@ -172,8 +172,32 @@ async def _deploy_local(
return deployment
except docker.errors.ImageNotFound as e:
- logger.error(f"Image not found: {resolved_service.image}")
- raise ValueError(f"Docker image not found: {resolved_service.image}")
+ logger.warning(f"Image not found locally: {resolved_service.image}, attempting to pull...")
+
+ try:
+ # Attempt to pull the image
+ logger.info(f"Pulling image: {resolved_service.image}")
+ docker_client.images.pull(resolved_service.image)
+ logger.info(f"✅ Successfully pulled image: {resolved_service.image}")
+
+ # Retry deployment after successful pull
+ logger.info(f"Retrying deployment after image pull...")
+ return await self._deploy_local(
+ unode,
+ resolved_service,
+ deployment_id,
+ container_name
+ )
+
+ except docker.errors.ImageNotFound as pull_error:
+ logger.error(f"Image not found in registry: {resolved_service.image}")
+ raise ValueError(f"Docker image not found: {resolved_service.image}. Image does not exist in registry.")
+ except docker.errors.APIError as pull_error:
+ logger.error(f"Failed to pull image: {pull_error}")
+ raise ValueError(f"Failed to pull Docker image {resolved_service.image}: {str(pull_error)}")
+ except Exception as pull_error:
+ logger.error(f"Error pulling image: {pull_error}")
+ raise ValueError(f"Failed to pull Docker image {resolved_service.image}: {str(pull_error)}")
except docker.errors.APIError as e:
logger.error(f"Docker API error: {e}")
raise ValueError(f"Docker deployment failed: {str(e)}")
diff --git a/ushadow/backend/src/services/deployment_manager.py b/ushadow/backend/src/services/deployment_manager.py
index a969a11c..97ac8101 100644
--- a/ushadow/backend/src/services/deployment_manager.py
+++ b/ushadow/backend/src/services/deployment_manager.py
@@ -176,11 +176,15 @@ async def resolve_service_for_deployment(
compose_registry = get_compose_registry()
+ logger.info(f"[DEBUG resolve_service_for_deployment] Called with service_id={service_id}, config_id={config_id}")
+
# Get service from compose registry
service = compose_registry.get_service(service_id)
if not service:
raise ValueError(f"Service not found: {service_id}")
+ logger.info(f"[DEBUG resolve_service_for_deployment] Found service: service_id={service.service_id}, service_name={service.service_name}")
+
# Use new Settings API to resolve environment variables
from src.config import get_settings
settings = get_settings()
@@ -241,6 +245,10 @@ async def resolve_service_for_deployment(
cmd = ["docker", "compose", "-f", str(compose_path)]
if project_name:
cmd.extend(["-p", project_name])
+ # Activate any profiles required by this service so profiled services
+ # are included in the resolved compose output
+ for profile in (service.profiles or []):
+ cmd.extend(["--profile", profile])
cmd.append("config")
logger.info(
@@ -350,12 +358,13 @@ async def resolve_service_for_deployment(
if isinstance(networks, list):
network = networks[0] if networks else None
elif isinstance(networks, dict):
- # Dict format: {"infra-network": null} - get first key
+ # Dict format: {"ushadow-network": null} - get first key
network = list(networks.keys())[0] if networks else None
else:
network = None
# Create ResolvedServiceDefinition
+ logger.info(f"[DEBUG resolve_service_for_deployment] Creating ResolvedServiceDefinition with service_id={service_id}, service_name={service.service_name}")
resolved = ResolvedServiceDefinition(
service_id=service_id,
name=service.service_name,
@@ -493,6 +502,7 @@ async def deploy_service(
unode_hostname: str,
config_id: str,
namespace: Optional[str] = None,
+ force_rebuild: bool = False,
) -> Deployment:
"""
Deploy a service to any deployment target (Docker unode or K8s cluster).
@@ -506,6 +516,8 @@ async def deploy_service(
config_id: ServiceConfig ID or Template ID (required) - references config to use
namespace: Optional K8s namespace (only used for K8s deployments)
"""
+ logger.info(f"[DEBUG deploy_service] Called with service_id={service_id}, config_id={config_id}")
+
# Resolve service with all variables substituted
try:
resolved_service = await self.resolve_service_for_deployment(
@@ -513,6 +525,7 @@ async def deploy_service(
deploy_target=unode_hostname,
config_id=config_id
)
+ logger.info(f"[DEBUG deploy_service] Resolved service has service_id={resolved_service.service_id}, name={resolved_service.name}")
except ValueError as e:
logger.error(f"Failed to resolve service {service_id}: {e}")
raise ValueError(f"Service resolution failed: {e}")
@@ -601,6 +614,19 @@ async def deploy_service(
else:
logger.info(f"No port conflicts detected for {resolved_service.service_id}")
+ # Start required infra services before deploying (local Docker only)
+ if unode.type != UNodeType.KUBERNETES and _is_local_deployment(unode_hostname):
+ _compose_registry = get_compose_registry()
+ _discovered = _compose_registry.get_service(service_id)
+ _infra_svcs = _discovered.infra_services if _discovered else []
+ if _infra_svcs:
+ logger.info(f"Starting infra services for {service_id}: {_infra_svcs}")
+ from src.services.docker_manager import get_docker_manager
+ _docker_mgr = get_docker_manager()
+ _ok, _msg = await _docker_mgr._start_infra_services(_infra_svcs)
+ if not _ok:
+ raise ValueError(f"Failed to start infrastructure services: {_msg}")
+
# Deploy using the platform
try:
deployment = await platform.deploy(
@@ -608,7 +634,8 @@ async def deploy_service(
resolved_service=resolved_service,
deployment_id=deployment_id,
namespace=namespace,
- config_id=config_id # Pass config_id to platform for Deployment model validation
+ config_id=config_id, # Pass config_id to platform for Deployment model validation
+ force_rebuild=force_rebuild
)
# For Docker deployments, optionally update tailscale serve routes (non-blocking)
@@ -853,11 +880,36 @@ async def update_deployment(
logger.info(f"Deployment updated with {len(overrides_only)} overrides")
return updated_deployment
+ async def _remove_orphaned_container(self, deployment_id: str) -> bool:
+ """
+ Remove a container by deployment_id label when the owning unode is no
+ longer registered. Used as a fallback from remove_deployment().
+ """
+ try:
+ import docker
+ docker_client = docker.from_env()
+ containers = docker_client.containers.list(
+ all=True,
+ filters={"label": [f"ushadow.deployment_id={deployment_id}"]}
+ )
+ if not containers:
+ return False
+ for container in containers:
+ if container.status in ("running", "restarting"):
+ container.stop(timeout=10)
+ container.remove(force=True)
+ logger.info(f"Removed orphaned container {container.name} (deployment {deployment_id})")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to remove orphaned deployment {deployment_id}: {e}")
+ return False
+
async def remove_deployment(self, deployment_id: str) -> bool:
"""Remove a deployment (stop and delete)."""
deployment = await self.get_deployment(deployment_id)
if not deployment:
- return False
+ # Unode may no longer be registered; try a direct label-based lookup
+ return await self._remove_orphaned_container(deployment_id)
unode_dict = await self.unodes_collection.find_one({
"hostname": deployment.unode_hostname
@@ -898,13 +950,13 @@ async def remove_deployment(self, deployment_id: str) -> bool:
# Get container
container = docker_client.containers.get(deployment.container_id or deployment.container_name)
- # Stop if running
- if container.status == "running":
+ # Stop if running or restarting
+ if container.status in ("running", "restarting"):
container.stop(timeout=10)
logger.info(f"Stopped local container {deployment.container_name}")
# Remove container
- container.remove()
+ container.remove(force=True)
logger.info(f"Removed local container {deployment.container_name}")
except Exception as e:
@@ -1020,17 +1072,17 @@ async def list_deployments(
if unode_hostname:
query["hostname"] = unode_hostname
- logger.info(f"[list_deployments] Querying unodes with: {query}")
+ logger.debug(f"[list_deployments] Querying unodes with: {query}")
cursor = self.unodes_collection.find(query)
unode_count = 0
async for unode_dict in cursor:
unode_count += 1
unode = UNode(**unode_dict)
- logger.info(f"[list_deployments] Found unode: hostname={unode.hostname}, status={unode.status.value}")
+ logger.debug(f"[list_deployments] Found unode: hostname={unode.hostname}, status={unode.status.value}")
# Skip if not online
if unode.status.value != "online":
- logger.info(f"[list_deployments] Skipping unode {unode.hostname} - not online")
+ logger.debug(f"[list_deployments] Skipping unode {unode.hostname} - not online")
continue
# Create deployment target
@@ -1055,10 +1107,10 @@ async def list_deployments(
# Query platform for deployments
platform = get_deploy_platform(target)
deployments = await platform.list_deployments(target, service_id=service_id)
- logger.info(f"[list_deployments] Platform returned {len(deployments)} deployments for unode {unode.hostname}")
+ logger.debug(f"[list_deployments] Platform returned {len(deployments)} deployments for unode {unode.hostname}")
all_deployments.extend(deployments)
- logger.info(f"[list_deployments] Checked {unode_count} unodes, returning {len(all_deployments)} total deployments")
+ logger.debug(f"[list_deployments] Checked {unode_count} unodes, returning {len(all_deployments)} total deployments")
return all_deployments
async def get_deployment_logs(
diff --git a/ushadow/backend/src/services/deployment_platforms.py b/ushadow/backend/src/services/deployment_platforms.py
index 16cce74d..e4646a06 100644
--- a/ushadow/backend/src/services/deployment_platforms.py
+++ b/ushadow/backend/src/services/deployment_platforms.py
@@ -80,6 +80,7 @@ async def deploy(
deployment_id: str,
namespace: Optional[str] = None,
config_id: Optional[str] = None,
+ force_rebuild: bool = False,
) -> Deployment:
"""
Deploy a service to this target.
@@ -168,24 +169,74 @@ async def _deploy_local(
container_name: str,
project_name: str,
config_id: Optional[str] = None,
+ force_rebuild: bool = False,
) -> Deployment:
"""Deploy directly to local Docker (bypasses unode manager)."""
try:
docker_client = docker.from_env()
- # Parse ports to Docker format
+ # Force rebuild if requested
+ if force_rebuild:
+ logger.info(f"Force rebuild requested for {resolved_service.image}")
+ compose_file = resolved_service.compose_file
+ service_name = resolved_service.compose_service_name
+
+ if compose_file and service_name:
+ from src.services.docker_manager import get_docker_manager
+ docker_mgr = get_docker_manager()
+
+ logger.info(f"Building image from compose: {compose_file}, service: {service_name}")
+ success, message = docker_mgr.build_image_from_compose(
+ compose_file=compose_file,
+ service_name=service_name,
+ tag=resolved_service.image
+ )
+
+ if success:
+ logger.info(f"✅ Force rebuild successful: {message}")
+ else:
+ raise ValueError(f"Force rebuild failed: {message}")
+ else:
+ logger.warning(f"Cannot force rebuild - no compose file information available for {resolved_service.service_id}")
+
+
+ # ===== PORT CONFIGURATION =====
+ # Parse all port-related configuration in one place
+ logger.info(f"[PORT DEBUG] Starting port parsing for {resolved_service.service_id}")
+ logger.info(f"[PORT DEBUG] Input ports from resolved_service.ports: {resolved_service.ports}")
+
port_bindings = {}
exposed_ports = {}
+ exposed_port = None # First host port for deployment tracking
+
for port_str in resolved_service.ports:
+ logger.info(f"[PORT DEBUG] Processing port_str: {port_str}")
if ":" in port_str:
host_port, container_port = port_str.split(":")
port_key = f"{container_port}/tcp"
port_bindings[port_key] = int(host_port)
exposed_ports[port_key] = {}
+
+ # Save first host port for deployment tracking
+ if exposed_port is None:
+ exposed_port = int(host_port)
+
+ logger.info(f"[PORT DEBUG] Mapped: host={host_port} -> container={container_port} (key={port_key})")
else:
port_key = f"{port_str}/tcp"
exposed_ports[port_key] = {}
+ # Save first port for deployment tracking
+ if exposed_port is None:
+ exposed_port = int(port_str)
+
+ logger.info(f"[PORT DEBUG] Exposed only: {port_key}")
+
+ logger.info(f"[PORT DEBUG] Final port_bindings: {port_bindings}")
+ logger.info(f"[PORT DEBUG] Final exposed_ports: {exposed_ports}")
+ logger.info(f"[PORT DEBUG] Tracking exposed_port: {exposed_port}")
+ # ===== END PORT CONFIGURATION =====
+
# Create container with ushadow labels for stateless tracking
from datetime import datetime, timezone
labels = {
@@ -196,6 +247,7 @@ async def _deploy_local(
"ushadow.deployed_at": datetime.now(timezone.utc).isoformat(),
"ushadow.backend_type": "docker",
"com.docker.compose.project": project_name,
+ "com.docker.compose.service": resolved_service.service_id, # Required for docker_manager to find services
}
# Use ushadow-network to communicate with infrastructure (mongo, redis, qdrant)
@@ -214,50 +266,42 @@ async def _deploy_local(
logger.info(f"Creating container {container_name} from image {resolved_service.image}")
- # Add service name as network alias so Docker DNS works
- # This allows containers to reach each other by service name (e.g., "mycelia-python-worker")
- # We use the low-level API to properly set network aliases
- networking_config = docker_client.api.create_networking_config({
- network: docker_client.api.create_endpoint_config(
- aliases=[resolved_service.service_id]
- )
- })
+ # Use high-level API which handles port format better
+ # High-level API expects ports dict like: {'8000/tcp': 8090} for host port mapping
+ logger.info(f"[PORT DEBUG] Creating container with high-level API")
+ logger.info(f"[PORT DEBUG] ports (high-level format): {port_bindings}")
- # Build host config for ports and restart policy
- host_config = docker_client.api.create_host_config(
- port_bindings=port_bindings,
- restart_policy={"Name": resolved_service.restart_policy or "unless-stopped"},
- binds=resolved_service.volumes if resolved_service.volumes else None,
- )
-
- # Create container using low-level API (properly supports networking_config)
- container_data = docker_client.api.create_container(
+ container = docker_client.containers.create(
image=resolved_service.image,
name=container_name,
labels=labels,
environment=resolved_service.environment,
- host_config=host_config,
command=resolved_service.command,
- networking_config=networking_config,
+ ports=port_bindings, # High-level API takes port_bindings directly as 'ports'
+ volumes={v.split(':')[0]: {'bind': v.split(':')[1], 'mode': v.split(':')[2] if len(v.split(':')) > 2 else 'rw'}
+ for v in (resolved_service.volumes or [])},
+ restart_policy={"Name": resolved_service.restart_policy or "unless-stopped"},
detach=True,
)
+ logger.info(f"[PORT DEBUG] Container created with ID: {container.id[:12]}")
- # Get container object and start it
- container = docker_client.containers.get(container_data['Id'])
+ # Connect to custom network with service name as alias BEFORE starting
+ # This allows containers to reach each other by service name (e.g., "mycelia-python-worker")
+ logger.info(f"[PORT DEBUG] Connecting container to network {network} with alias {resolved_service.service_id}")
+ network_obj = docker_client.networks.get(network)
+ network_obj.connect(container, aliases=[resolved_service.service_id])
+ logger.info(f"[PORT DEBUG] Connected to network {network}")
+
+ # Now start the container
+ logger.info(f"[PORT DEBUG] Starting container {container_name}...")
container.start()
+ # Reload to get updated port info
+ container.reload()
+ logger.info(f"[PORT DEBUG] Container started. Ports mapping: {container.ports}")
logger.info(f"Container {container_name} created and started: {container.id[:12]}")
- # Extract exposed port
- exposed_port = None
- if resolved_service.ports:
- first_port = resolved_service.ports[0]
- if ":" in first_port:
- exposed_port = int(first_port.split(":")[0])
- else:
- exposed_port = int(first_port)
-
- # Build deployment object
+ # Build deployment object (exposed_port was extracted during port parsing above)
hostname = target.identifier # Use standardized field (hostname for Docker targets)
deployment = Deployment(
id=deployment_id,
@@ -284,8 +328,66 @@ async def _deploy_local(
return deployment
except docker.errors.ImageNotFound as e:
- logger.error(f"Image not found: {resolved_service.image}")
- raise ValueError(f"Docker image not found: {resolved_service.image}")
+ logger.warning(f"Image not found locally: {resolved_service.image}, attempting to pull...")
+
+ try:
+ # Attempt to pull the image
+ logger.info(f"Pulling image: {resolved_service.image}")
+ docker_client.images.pull(resolved_service.image)
+ logger.info(f"✅ Successfully pulled image: {resolved_service.image}")
+
+ # Retry deployment after successful pull
+ logger.info(f"Retrying deployment after image pull...")
+ return await self._deploy_local(
+ target,
+ resolved_service,
+ deployment_id,
+ container_name,
+ project_name,
+ config_id
+ )
+
+ except docker.errors.ImageNotFound as pull_error:
+ # Image not in registry - try to build using DockerManager
+ logger.warning(f"Image not found in registry, attempting to build: {resolved_service.image}")
+
+ compose_file = resolved_service.compose_file
+ service_name = resolved_service.compose_service_name
+
+ if compose_file and service_name:
+ from src.services.docker_manager import get_docker_manager
+ docker_mgr = get_docker_manager()
+
+ success, message = docker_mgr.build_image_from_compose(
+ compose_file=compose_file,
+ service_name=service_name,
+ tag=resolved_service.image
+ )
+
+ if success:
+ logger.info(f"✅ {message}")
+ # Retry deployment after successful build
+ return await self._deploy_local(
+ target, resolved_service, deployment_id,
+ container_name, project_name, config_id
+ )
+ else:
+ # Provide helpful fallback command
+ user_compose_path = compose_file
+ if compose_file.startswith("/compose/"):
+ user_compose_path = f"compose/{compose_file[9:]}"
+ raise ValueError(
+ f"{message}. "
+ f"Try manually: docker compose -f {user_compose_path} build {service_name}"
+ )
+ else:
+ raise ValueError(f"Docker image not found: {resolved_service.image}. No build context available.")
+ except docker.errors.APIError as pull_error:
+ logger.error(f"Failed to pull image: {pull_error}")
+ raise ValueError(f"Failed to pull Docker image {resolved_service.image}: {str(pull_error)}")
+ except Exception as pull_error:
+ logger.error(f"Error pulling image: {pull_error}")
+ raise ValueError(f"Failed to pull Docker image {resolved_service.image}: {str(pull_error)}")
except docker.errors.APIError as e:
logger.error(f"Docker API error: {e}")
raise ValueError(f"Docker deployment failed: {str(e)}")
@@ -300,6 +402,7 @@ async def deploy(
deployment_id: str,
namespace: Optional[str] = None,
config_id: Optional[str] = None,
+ force_rebuild: bool = False,
) -> Deployment:
"""Deploy to a Docker host via unode manager API or local Docker."""
hostname = target.identifier # Use standardized field (hostname for Docker targets)
@@ -321,7 +424,8 @@ async def deploy(
deployment_id,
container_name,
project_name,
- config_id
+ config_id,
+ force_rebuild
)
# Build deploy payload for remote unode manager
@@ -333,6 +437,7 @@ async def deploy(
"ushadow.unode_hostname": hostname,
"ushadow.deployed_at": datetime.now(timezone.utc).isoformat(),
"ushadow.backend_type": "docker",
+ "com.docker.compose.service": resolved_service.service_id, # Required for docker_manager to find services
}
payload = {
@@ -510,20 +615,27 @@ async def list_deployments(
deployments = []
try:
- if self._is_local_deployment(target.identifier):
- # Query local Docker
- docker_client = docker.from_env()
- filters = {"label": [
- "ushadow.deployment_id",
- f"ushadow.unode_hostname={target.identifier}"
- ]}
+ # Query local Docker for containers (works for both local and "remote" unodes on same host)
+ docker_client = docker.from_env()
+ filters = {"label": [
+ "ushadow.deployment_id",
+ f"ushadow.unode_hostname={target.identifier}"
+ ]}
+
+ if service_id:
+ filters["label"].append(f"ushadow.service_id={service_id}")
- if service_id:
- filters["label"].append(f"ushadow.service_id={service_id}")
+ containers = docker_client.containers.list(all=True, filters=filters)
- containers = docker_client.containers.list(all=True, filters=filters)
+ if containers and not self._is_local_deployment(target.identifier):
+ logger.info(f"[list_deployments] Found {len(containers)} local containers for remote unode {target.identifier}")
+ elif not containers and not self._is_local_deployment(target.identifier):
+ # No local containers found for remote unode
+ logger.warning(f"No local containers found for {target.identifier}. Remote deployment listing not yet implemented.")
+ return deployments
- for container in containers:
+ # Process all found containers
+ for container in containers:
labels = container.labels
# Extract deployment info from labels
@@ -617,11 +729,6 @@ async def list_deployments(
deployments.append(deployment)
- else:
- # Query remote unode manager
- # TODO: Implement remote query via unode manager API
- logger.warning(f"Remote deployment listing not yet implemented for {target.identifier}")
-
except Exception as e:
logger.error(f"Failed to list deployments: {e}")
@@ -664,6 +771,7 @@ async def deploy(
deployment_id: str,
namespace: Optional[str] = None,
config_id: Optional[str] = None,
+ force_rebuild: bool = False,
) -> Deployment:
"""Deploy to a Kubernetes cluster."""
# Use standardized fields
diff --git a/ushadow/backend/src/services/docker_manager.py b/ushadow/backend/src/services/docker_manager.py
index b3bce417..a907a291 100644
--- a/ushadow/backend/src/services/docker_manager.py
+++ b/ushadow/backend/src/services/docker_manager.py
@@ -11,6 +11,7 @@
import os
import re
import subprocess
+import yaml
from pathlib import Path
from enum import Enum
from typing import Dict, List, Optional, Any
@@ -60,6 +61,7 @@ def _extract_port_env_vars(ports: List[Dict[str, Any]]) -> Dict[str, int]:
return result
+
def check_port_in_use(port: int, host: str = "0.0.0.0", exclude_container: Optional[str] = None) -> Optional[str]:
"""
Check if a port is in use on the host.
@@ -358,16 +360,26 @@ def MANAGEABLE_SERVICES(self) -> Dict[str, Any]:
Combines hardcoded CORE_SERVICES with services discovered from
compose/*-compose.yaml files via ComposeServiceRegistry.
+
+ Cached after first access to avoid repeated compose registry lookups.
"""
+ # Return cached value if available
+ if hasattr(self, '_manageable_services_cache'):
+ return self._manageable_services_cache
+
# Start with core services
services = dict(self.CORE_SERVICES)
# Load services from ComposeServiceRegistry (compose-first approach)
try:
compose_registry = get_compose_registry()
- for service in compose_registry.get_services():
+ all_compose_services = list(compose_registry.get_services())
+ logger.info(f"[MANAGEABLE_SERVICES] Found {len(all_compose_services)} services from compose registry")
+ logger.info(f"[MANAGEABLE_SERVICES] Service names: {[s.service_name for s in all_compose_services]}")
+ for service in all_compose_services:
# Skip if already in core services
if service.service_name in services:
+ logger.debug(f"[MANAGEABLE_SERVICES] Skipping {service.service_name} (already in core)")
continue
# Use service_name as the key
@@ -396,10 +408,18 @@ def MANAGEABLE_SERVICES(self) -> Dict[str, Any]:
logger.warning(f"Failed to load services from compose registry: {e}")
logger.debug(f"Loaded {len(services)} manageable services")
+
+ # Cache the result to avoid repeated lookups
+ self._manageable_services_cache = services
return services
def reload_services(self) -> None:
- """Force reload services from ComposeServiceRegistry."""
+ """Force reload services from ComposeServiceRegistry and clear cache."""
+ # Clear cache
+ if hasattr(self, '_manageable_services_cache'):
+ delattr(self, '_manageable_services_cache')
+
+ # Reload compose registry
registry = get_compose_registry()
registry.reload()
logger.info("ComposeServiceRegistry reloaded")
@@ -1186,17 +1206,34 @@ async def _start_infra_services(self, infra_services: list[str]) -> tuple[bool,
return False, "Infrastructure compose file not found"
import os
+ import yaml as _yaml
+
subprocess_env = os.environ.copy()
subprocess_env["COMPOSE_IGNORE_ORPHANS"] = "true"
+ # Parse infra compose to look up per-service profiles.
+ # Some Docker Compose v2 versions require --profile to be explicitly
+ # passed even when a service is named directly on the command line,
+ # so we always add the profiles the service declares.
+ try:
+ with open(infra_compose_path) as _f:
+ _infra_cfg = _yaml.safe_load(_f)
+ _infra_svc_cfg = _infra_cfg.get("services", {})
+ except Exception as _e:
+ logger.warning(f"Could not parse infra compose for profiles: {_e}")
+ _infra_svc_cfg = {}
+
for service in infra_services:
logger.info(f"Starting infra service: {service}")
+ svc_profiles = _infra_svc_cfg.get(service, {}).get("profiles", [])
cmd = [
"docker", "compose",
"-f", str(infra_compose_path),
"-p", "infra",
- "up", "-d", service
]
+ for profile in svc_profiles:
+ cmd.extend(["--profile", profile])
+ cmd.extend(["up", "-d", service])
result = subprocess.run(
cmd,
@@ -1287,6 +1324,7 @@ async def _start_service_via_compose(self, service_name: str, compose_file: str,
# Get docker service name from the discovered service
docker_service_name = discovered.service_name if discovered else service_name
+ logger.info(f"[DEBUG] Deploying service_name={service_name} -> docker_service_name={docker_service_name}, discovered={discovered.service_id if discovered else None}")
# Build environment variables from service configuration
# All env vars are passed via subprocess_env for compose ${VAR} substitution
@@ -1298,9 +1336,7 @@ async def _start_service_via_compose(self, service_name: str, compose_file: str,
logger.info(f"━━━ Starting {service_name} with {len(container_env)} environment variables ━━━")
- # Build docker compose command with explicit env var passing
- # Using --env-file /dev/null to clear default .env loading
- # All env vars come from subprocess_env for ${VAR} substitution
+ # Build docker compose command
cmd = ["docker", "compose", "-f", str(compose_path)]
if project_name:
cmd.extend(["-p", project_name])
@@ -1318,7 +1354,7 @@ async def _start_service_via_compose(self, service_name: str, compose_file: str,
cwd=str(compose_dir),
capture_output=True,
text=True,
- timeout=180 # Increased to 3 minutes for services that need building
+ timeout=180
)
if result.returncode == 0:
@@ -1554,6 +1590,183 @@ def add_dynamic_service(
logger.info(f"Added dynamic service: {service_name}")
return True, f"Service '{service_name}' registered successfully"
+ def build_image_from_compose(
+ self,
+ compose_file: str,
+ service_name: str,
+ tag: Optional[str] = None
+ ) -> tuple[bool, str]:
+ """
+ Build a Docker image from a compose file's build configuration.
+
+ Handles path translation between container and host paths when running
+ inside a container with Docker socket mounted.
+
+ Args:
+ compose_file: Path to compose file (container path like /compose/file.yml)
+ service_name: Service name in the compose file
+ tag: Optional tag for the built image (defaults to service_name:latest)
+
+ Returns:
+ Tuple of (success, message_or_error)
+ """
+ project_root = os.environ.get("PROJECT_ROOT", "")
+ if not project_root:
+ return False, "PROJECT_ROOT not set - cannot determine host paths for build"
+
+ try:
+ # Read compose file from container mount
+ with open(compose_file, 'r') as f:
+ compose_data = yaml.safe_load(f)
+
+ service_def = compose_data.get('services', {}).get(service_name, {})
+ if not service_def:
+ return False, f"Service '{service_name}' not found in {compose_file}"
+
+ build_config = service_def.get('build')
+ if not build_config:
+ # Build config may live in the dev override (overrides/{name}-dev.yml)
+ compose_path = Path(compose_file)
+ dev_override = compose_path.parent / "overrides" / f"{compose_path.stem.replace('-compose', '')}-dev.yml"
+ if dev_override.exists():
+ with open(dev_override) as f:
+ override_data = yaml.safe_load(f)
+ build_config = (override_data.get('services') or {}).get(service_name, {}).get('build')
+ if not build_config:
+ return False, f"No build configuration found for {service_name} (checked base and {dev_override})"
+
+ # Parse build config
+ if isinstance(build_config, str):
+ build_context = build_config
+ dockerfile = "Dockerfile"
+ elif isinstance(build_config, dict):
+ build_context = build_config.get('context', '.')
+ dockerfile = build_config.get('dockerfile', 'Dockerfile')
+ else:
+ return False, f"Invalid build configuration for {service_name}"
+
+ # Expand environment variables in build context (e.g., ${PROJECT_ROOT:-..})
+ def expand_env_vars(s: str) -> str:
+ """Expand ${VAR:-default} style environment variables.
+
+ Uses shell-like semantics: if var is unset OR empty, use default.
+ """
+ import re
+ pattern = r'\$\{([^}:]+)(?::-([^}]*))?\}'
+ def replace(match):
+ var_name = match.group(1)
+ default = match.group(2) or ''
+ value = os.environ.get(var_name, '')
+ # Shell semantics: use default if var is unset OR empty
+ return value if value else default
+ return re.sub(pattern, replace, s)
+
+ logger.info(f"Build context before expansion: {build_context}")
+ logger.info(f"PROJECT_ROOT env: {project_root!r}")
+
+ build_context = expand_env_vars(build_context)
+ dockerfile = expand_env_vars(dockerfile)
+
+ logger.info(f"Build context after expansion: {build_context}")
+
+ # Convert compose file path to host path
+ if compose_file.startswith("/compose/"):
+ host_compose_file = f"{project_root}/compose/{compose_file[9:]}"
+ elif compose_file.startswith("/"):
+ host_compose_file = f"{project_root}{compose_file}"
+ else:
+ host_compose_file = f"{project_root}/{compose_file}"
+
+ logger.info(f"Host compose file: {host_compose_file}")
+
+ # Resolve build context path relative to compose file (on host)
+ host_compose_dir = os.path.dirname(host_compose_file)
+ logger.info(f"Host compose dir: {host_compose_dir}")
+
+ # If build_context is already absolute, use it directly
+ if os.path.isabs(build_context):
+ host_build_context = build_context
+ else:
+ host_build_context = os.path.normpath(os.path.join(host_compose_dir, build_context))
+
+ logger.info(f"Host build context (resolved): {host_build_context}")
+
+ # Note: Can't validate if path exists here because we're in container,
+ # but Docker daemon runs on host. Docker SDK will return proper error if path invalid.
+
+ # Determine image tag
+ image_tag = tag or f"{service_name}:latest"
+
+ logger.info(f"Building {image_tag} from context: {host_build_context!r}, dockerfile: {dockerfile!r}")
+ logger.info(f"[BUILD DEBUG] path type: {type(host_build_context)}, value: {host_build_context!r}, is_empty: {not host_build_context}")
+
+ # Validate build_context is not empty
+ if not host_build_context or host_build_context.strip() == '':
+ return False, f"Build context is empty after path resolution. Original: {build_context!r}"
+
+ # Build using docker compose CLI instead of SDK
+ # The SDK checks if path exists from container perspective, but we need host perspective
+ # docker compose properly handles build context resolution
+ logger.info(f"[BUILD] Using docker compose build: docker compose -f {compose_file} build {service_name}")
+
+ import subprocess
+ try:
+ # Run docker compose build - use container path for compose file
+ # Docker compose reads from mounted /compose directory
+ # PROJECT_ROOT env var ensures build contexts resolve to host paths
+ cmd = ["docker", "compose", "-f", compose_file, "build", service_name]
+ logger.info(f"[BUILD] Running: {' '.join(cmd)} from cwd=/ with PROJECT_ROOT={project_root}")
+
+ # Use environment with PROJECT_ROOT set to host path
+ env = os.environ.copy()
+ env['PROJECT_ROOT'] = project_root
+
+ result = subprocess.run(
+ cmd,
+ cwd="/", # Use root of container filesystem
+ env=env, # Use environment with PROJECT_ROOT set to host path
+ capture_output=True,
+ text=True,
+ timeout=600 # 10 minute timeout for builds
+ )
+
+ # Log build output
+ if result.stdout:
+ logger.info(f"Build stdout:\n{result.stdout}")
+ if result.stderr:
+ logger.info(f"Build stderr:\n{result.stderr}")
+
+ if result.returncode != 0:
+ return False, f"Docker compose build failed (exit {result.returncode}): {result.stderr}"
+
+ except subprocess.TimeoutExpired:
+ return False, "Build timed out after 10 minutes"
+ except Exception as e:
+ logger.error(f"Build subprocess error: {e}", exc_info=True)
+ return False, f"Build subprocess failed: {str(e)}"
+
+ logger.info(f"Successfully built image: {image_tag}")
+ return True, f"Successfully built {image_tag}"
+
+ except FileNotFoundError:
+ return False, f"Compose file not found: {compose_file}"
+ except docker.errors.BuildError as e:
+ return False, f"Docker build failed: {str(e)}"
+ except Exception as e:
+ logger.error(f"Build error: {e}", exc_info=True)
+ return False, f"Build failed: {str(e)}"
+
+ def image_exists(self, image: str) -> bool:
+ """Check if a Docker image exists locally."""
+ try:
+ self._client.images.get(image)
+ return True
+ except docker.errors.ImageNotFound:
+ return False
+ except Exception as e:
+ logger.warning(f"Error checking image {image}: {e}")
+ return False
+
# Global instance
_docker_manager: Optional[DockerManager] = None
diff --git a/ushadow/backend/src/services/feed_service.py b/ushadow/backend/src/services/feed_service.py
new file mode 100644
index 00000000..e206adaa
--- /dev/null
+++ b/ushadow/backend/src/services/feed_service.py
@@ -0,0 +1,345 @@
+"""Feed Service - Orchestrates interest extraction, post fetching, scoring, and storage.
+
+Business logic layer for the personalized multi-platform feed feature.
+Router -> FeedService -> InterestExtractor / PostFetcher / PostScorer / MongoDB
+
+Source storage: SettingsStore (config.overrides.yaml under feed.sources).
+YouTube API key: SettingsStore (secrets.yaml under api_keys.youtube_api_key).
+Posts: MongoDB via Beanie.
+"""
+
+import logging
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from src.config.store import SettingsStore
+from src.models.feed import (
+ Interest,
+ Post,
+ PostSource,
+ SourceCreate,
+)
+from src.services.interest_extractor import InterestExtractor
+from src.services.mastodon_oauth import MastodonOAuthService
+from src.services.post_fetcher import PostFetcher
+from src.services.post_scorer import PostScorer
+
+logger = logging.getLogger(__name__)
+
+_SOURCES_KEY = "feed.sources"
+_YOUTUBE_KEY = "api_keys.youtube_api_key"
+
+
+class FeedService:
+ """Orchestrates the personalized feed pipeline."""
+
+ def __init__(self, db: AsyncIOMotorDatabase, settings: SettingsStore):
+ self.db = db
+ self._settings = settings
+ self._extractor = InterestExtractor()
+ self._fetcher = PostFetcher()
+ self._scorer = PostScorer()
+
+ # =========================================================================
+ # Sources
+ # =========================================================================
+
+ async def add_source(self, user_id: str, data: SourceCreate) -> PostSource:
+ """Add a content source.
+
+ Mastodon instance_url is saved to config.overrides.yaml.
+ YouTube api_key is saved to secrets.yaml (api_keys.youtube_api_key).
+ """
+ source = PostSource(
+ user_id=user_id,
+ name=data.name,
+ platform_type=data.platform_type,
+ instance_url=data.instance_url.rstrip("/") if data.instance_url else None,
+ )
+
+ if data.api_key and data.platform_type == "youtube":
+ await self._settings.update(
+ {"api_keys": {"youtube_api_key": data.api_key}}
+ )
+
+ existing = await self._settings.get(_SOURCES_KEY, default=[]) or []
+ source_dict = source.model_dump()
+ source_dict["created_at"] = source_dict["created_at"].isoformat()
+ existing.append(source_dict)
+ await self._settings.update({"feed": {"sources": existing}})
+
+ logger.info(
+ f"Added {data.platform_type} source '{data.name}' for user {user_id}"
+ )
+ return source
+
+ async def list_sources(self, user_id: str) -> List[PostSource]:
+ """List all configured post sources for a user (from config)."""
+ all_sources = await self._settings.get(_SOURCES_KEY, default=[]) or []
+ return [
+ PostSource(**s)
+ for s in all_sources
+ if s.get("user_id") == user_id
+ ]
+
+ async def get_mastodon_auth_url(
+ self, instance_url: str, redirect_uri: str
+ ) -> str:
+ """Register app (or reuse cached) and return Mastodon authorization URL."""
+ oauth = MastodonOAuthService()
+ return await oauth.get_authorization_url(instance_url, redirect_uri)
+
+ async def connect_mastodon(
+ self,
+ user_id: str,
+ instance_url: str,
+ code: str,
+ redirect_uri: str,
+ name: str,
+ ) -> PostSource:
+ """Exchange OAuth code for a token and create/update a Mastodon source.
+
+ If a source already exists for this user + instance, the token is
+ refreshed in-place. Otherwise a new PostSource is created.
+ """
+ oauth = MastodonOAuthService()
+ access_token = await oauth.exchange_code(instance_url, code, redirect_uri)
+
+ normalised_url = instance_url.rstrip("/")
+ existing = await PostSource.find_one(
+ PostSource.user_id == user_id,
+ PostSource.platform_type == "mastodon",
+ PostSource.instance_url == normalised_url,
+ )
+ if existing:
+ existing.access_token = access_token
+ existing.name = name
+ await existing.save()
+ logger.info(f"Updated Mastodon token for {user_id} on {normalised_url}")
+ return existing
+
+ source = PostSource(
+ user_id=user_id,
+ name=name,
+ platform_type="mastodon",
+ instance_url=normalised_url,
+ access_token=access_token,
+ )
+ await source.insert()
+ logger.info(f"Connected Mastodon account for {user_id} on {normalised_url}")
+ return source
+
+ async def remove_source(self, user_id: str, source_id: str) -> bool:
+ """Remove a post source from config."""
+ all_sources = await self._settings.get(_SOURCES_KEY, default=[]) or []
+ updated = [
+ s for s in all_sources
+ if not (s.get("user_id") == user_id and s.get("source_id") == source_id)
+ ]
+ if len(updated) == len(all_sources):
+ return False
+ await self._settings.update({"feed": {"sources": updated}})
+ logger.info(f"Removed source {source_id} for user {user_id}")
+ return True
+
+ # =========================================================================
+ # Interests (read-only, derived from OpenMemory graph)
+ # =========================================================================
+
+ async def get_interests(self, user_id: str) -> List[Interest]:
+ """Extract and return current interests from the user's knowledge graph."""
+ return await self._extractor.extract_interests(user_id)
+
+ # =========================================================================
+ # Feed Refresh Pipeline
+ # =========================================================================
+
+ async def refresh(
+ self, user_id: str, platform_type: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """Full pipeline: extract interests -> fetch posts -> score -> save.
+
+ Args:
+ user_id: Owner email.
+ platform_type: If set, only refresh sources of this platform.
+
+ Returns summary of what was fetched and stored.
+ """
+ # 1. Clear cache and extract fresh interests from memories
+ self._extractor.clear_cache(user_id)
+ interests = await self._extractor.extract_interests(user_id)
+ if not interests:
+ return {
+ "status": "no_interests",
+ "message": "No interests found in your knowledge graph. "
+ "Add more memories to build your interest profile.",
+ "interests_count": 0,
+ "posts_fetched": 0,
+ "posts_new": 0,
+ }
+
+ # 2. Get configured sources (optionally filtered by platform)
+ sources = await self.list_sources(user_id)
+ sources = await self._inject_api_keys(sources)
+ if platform_type:
+ sources = [s for s in sources if s.platform_type == platform_type]
+ if not sources:
+ return {
+ "status": "no_sources",
+ "message": f"No {platform_type or 'post'} sources configured.",
+ "interests_count": len(interests),
+ "posts_fetched": 0,
+ "posts_new": 0,
+ }
+
+ # 3. Fetch posts from all platforms (returns List[Post])
+ posts = await self._fetcher.fetch_for_interests(
+ sources, interests, user_id
+ )
+
+ # 4. Score posts against interests
+ scored_posts = self._scorer.score_posts(posts, interests)
+
+ # 5. Save new posts to DB (skip duplicates)
+ new_count = 0
+ for post in scored_posts:
+ # Check for existing by external_id
+ existing = await Post.find_one(
+ Post.user_id == user_id,
+ Post.external_id == post.external_id,
+ )
+ if existing:
+ # Update score if post already exists (interests may have changed)
+ existing.relevance_score = post.relevance_score
+ existing.matched_interests = post.matched_interests
+ existing.fetched_at = datetime.utcnow()
+ await existing.save()
+ else:
+ await post.insert()
+ new_count += 1
+
+ logger.info(
+ f"Feed refresh for {user_id}: {len(interests)} interests, "
+ f"{len(posts)} fetched, {new_count} new posts saved"
+ )
+
+ return {
+ "status": "ok",
+ "interests_count": len(interests),
+ "interests_used": [
+ {"name": i.name, "hashtags": i.hashtags, "weight": i.relationship_count}
+ for i in interests[:10]
+ ],
+ "posts_fetched": len(posts),
+ "posts_scored": len(scored_posts),
+ "posts_new": new_count,
+ }
+
+ # =========================================================================
+ # Feed Read
+ # =========================================================================
+
+ async def get_feed(
+ self,
+ user_id: str,
+ page: int = 1,
+ page_size: int = 20,
+ filter_interest: Optional[str] = None,
+ show_seen: bool = True,
+ platform_type: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Get the ranked feed of posts for a user.
+
+ Returns paginated posts sorted by relevance_score descending.
+ Optional platform_type filter for tab-based UI (social vs videos).
+ """
+ filters: Dict[str, Any] = {"user_id": user_id}
+
+ if not show_seen:
+ filters["seen"] = False
+
+ if filter_interest:
+ filters["matched_interests"] = filter_interest
+
+ if platform_type:
+ filters["platform_type"] = platform_type
+
+ query = Post.find(filters)
+
+ total = await query.count()
+ posts = (
+ await query.sort(-Post.relevance_score)
+ .skip((page - 1) * page_size)
+ .limit(page_size)
+ .to_list()
+ )
+
+ return {
+ "posts": posts,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": max(1, -(-total // page_size)), # ceil division
+ }
+
+ # =========================================================================
+ # Post Actions (per-post)
+ # =========================================================================
+
+ async def mark_post_seen(self, user_id: str, post_id: str) -> bool:
+ """Mark a specific post as seen."""
+ post = await Post.find_one(
+ Post.user_id == user_id, Post.post_id == post_id
+ )
+ if not post:
+ return False
+ post.seen = True
+ await post.save()
+ return True
+
+ async def bookmark_post(self, user_id: str, post_id: str) -> bool:
+ """Toggle bookmark on a specific post."""
+ post = await Post.find_one(
+ Post.user_id == user_id, Post.post_id == post_id
+ )
+ if not post:
+ return False
+ post.bookmarked = not post.bookmarked
+ await post.save()
+ return True
+
+ # =========================================================================
+ # Internal helpers
+ # =========================================================================
+
+ async def _inject_api_keys(self, sources: List[PostSource]) -> List[PostSource]:
+ """Inject secrets-backed api_key into YouTube sources at fetch time."""
+ youtube_key = await self._settings.get(_YOUTUBE_KEY)
+ for source in sources:
+ if source.platform_type == "youtube":
+ source.api_key = youtube_key
+ return sources
+
+ # =========================================================================
+ # Stats
+ # =========================================================================
+
+ async def get_stats(self, user_id: str) -> Dict[str, Any]:
+ """Get feed statistics for the user."""
+ total = await Post.find(Post.user_id == user_id).count()
+ unseen = await Post.find(
+ Post.user_id == user_id, Post.seen == False # noqa: E712
+ ).count()
+ bookmarked = await Post.find(
+ Post.user_id == user_id, Post.bookmarked == True # noqa: E712
+ ).count()
+ sources_list = await self.list_sources(user_id)
+
+ return {
+ "total_posts": total,
+ "unseen_posts": unseen,
+ "bookmarked_posts": bookmarked,
+ "sources_count": len(sources_list),
+ }
diff --git a/ushadow/backend/src/services/interest_extractor.py b/ushadow/backend/src/services/interest_extractor.py
new file mode 100644
index 00000000..00a3aa31
--- /dev/null
+++ b/ushadow/backend/src/services/interest_extractor.py
@@ -0,0 +1,521 @@
+"""Interest Extractor - Derives user interests from OpenMemory's stored memories.
+
+Fetches user facts via mem0's /api/v1/memories/filter/enriched endpoint,
+which returns graph-enriched data with entity types (PERSON, LOCATION, etc.)
+and relationships. Uses entity types to filter out private/irrelevant entities.
+
+Two layers of signal:
+ - Categories (broad): "ai, ml & technology" → #ai #ml #technology
+ - Entities (specific, type-filtered): "Mac mini" → #macmini #apple
+"""
+
+import hashlib
+import logging
+import re
+import time
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Set, Tuple
+
+import httpx
+
+from src.config import get_localhost_proxy_url
+from src.models.feed import Interest
+
+logger = logging.getLogger(__name__)
+
+# Categories too personal/broad to produce useful fediverse search results
+EXCLUDED_CATEGORIES: Set[str] = {
+ # Too personal
+ "personal", "relationships", "health", "finance",
+ # Too broad — no useful fediverse hashtag signal
+ "preferences", "work", "daily life", "communication",
+ "lifestyle", "general", "other", "miscellaneous",
+ "activities", "hobbies", "interests", "entertainment",
+ "education", "learning", "professional", "career",
+ "social", "culture", "news", "media",
+ "goals", "projects", "products", "shopping",
+ "home", "organization", "company affiliation",
+ "technical support", "customer support",
+}
+
+# Entity types from Neo4j graph that are NOT useful for fediverse content discovery
+EXCLUDED_ENTITY_TYPES: Set[str] = {
+ "PERSON", "DATE", "EVENT", "ADDRESS",
+ "__USER__", "USER",
+}
+
+# Simple in-memory cache: user_id → (timestamp, interests)
+_interest_cache: Dict[str, Tuple[float, List[Interest]]] = {}
+CACHE_TTL_SECONDS = 300 # 5 minutes
+
+# Maximum number of interests to return (drops low-signal tail)
+MAX_INTERESTS = 25
+
+# Known product/brand → hashtag expansions (poor man's LLM)
+PRODUCT_HASHTAGS: Dict[str, List[str]] = {
+ "strix halo": ["strixhalo", "amd", "ryzen"],
+ "strix halo box": ["strixhalo", "amd", "ryzen"],
+ "mac mini": ["macmini", "apple", "homelab"],
+ "raspberry pi": ["raspberrypi", "homelab", "sbc"],
+ "home assistant": ["homeassistant", "smarthome", "iot"],
+}
+
+# Common abbreviation expansions
+ABBREVIATIONS: Dict[str, str] = {
+ "artificial intelligence": "ai",
+ "machine learning": "ml",
+ "deep learning": "dl",
+ "reinforcement learning": "rl",
+ "natural language processing": "nlp",
+ "large language model": "llm",
+ "large language models": "llm",
+ "language model": "llm",
+ "language models": "llm",
+ "lms": "llm",
+ "kubernetes": "k8s",
+ "javascript": "js",
+ "typescript": "ts",
+ "open source": "opensource",
+ "self hosted": "selfhosted",
+ "home lab": "homelab",
+ "home server": "homeserver",
+ "mac mini": "macmini",
+ "raspberry pi": "raspberrypi",
+}
+
+# Words too generic to be useful as standalone hashtags
+# (only filtered when splitting multi-word names into individual words)
+GENERIC_SUBWORDS: Set[str] = {
+ "box", "the", "and", "for", "old", "new", "big", "set",
+ "pro", "max", "mini", "road", "street", "house", "office",
+ "post", "party", "blue", "red", "green", "white", "black",
+}
+
+
+class InterestExtractor:
+ """Extracts user interests from OpenMemory's stored memories."""
+
+ async def extract_interests(
+ self, user_id: str, limit: int = 100
+ ) -> List[Interest]:
+ """Extract interests from the user's stored memories.
+
+ 1. Fetch recent active memories from mem0
+ 2. Aggregate categories (breadth) and entities (specificity)
+ 3. Compute weighted scores based on mention count + recency
+ 4. Derive hashtags from interest names
+ 5. Return sorted by weight descending
+ """
+ # Check cache first
+ now = time.time()
+ cached = _interest_cache.get(user_id)
+ if cached and (now - cached[0]) < CACHE_TTL_SECONDS:
+ logger.debug(f"Returning {len(cached[1])} cached interests for {user_id}")
+ return cached[1]
+
+ memories = await self._fetch_memories(user_id, limit)
+ if not memories:
+ logger.warning("No memories returned from OpenMemory")
+ return []
+
+ # Two aggregation passes
+ category_interests = self._aggregate_categories(memories)
+ entity_interests = self._aggregate_entities(memories)
+
+ # Merge: entities override categories if same name
+ merged: Dict[str, Interest] = {}
+ for interest in category_interests + entity_interests:
+ key = interest.name.lower()
+ existing = merged.get(key)
+ if existing is None or interest.relationship_count > existing.relationship_count:
+ merged[key] = interest
+
+ interests = sorted(
+ merged.values(),
+ key=lambda i: i.relationship_count,
+ reverse=True,
+ )[:MAX_INTERESTS]
+
+ logger.info(
+ f"Extracted {len(interests)} interests from {len(memories)} memories "
+ f"({len(category_interests)} categories, {len(entity_interests)} entities, "
+ f"capped at {MAX_INTERESTS})"
+ )
+
+ # Update cache
+ _interest_cache[user_id] = (now, interests)
+ return interests
+
+ def clear_cache(self, user_id: str) -> None:
+ """Clear cached interests for a user (e.g., on refresh)."""
+ _interest_cache.pop(user_id, None)
+
+ # ------------------------------------------------------------------
+ # Data fetching
+ # ------------------------------------------------------------------
+
+ async def _fetch_memories(
+ self, user_id: str, limit: int
+ ) -> List[Dict[str, Any]]:
+ """Fetch user memories from mem0 via the backend proxy.
+
+ Uses the /filter/enriched endpoint when available, which returns
+ graph-enriched data with entity types and relationships.
+ Falls back to /filter if enrichment fails.
+ """
+ proxy_url = get_localhost_proxy_url("mem0")
+ # mem0's Params model enforces size <= 100
+ body = {
+ "user_id": user_id,
+ "size": min(limit, 100),
+ "sort_column": "created_at",
+ "sort_direction": "desc",
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ # Try enriched endpoint first (includes entity types from graph)
+ resp = await client.post(
+ f"{proxy_url}/api/v1/memories/filter/enriched",
+ json=body,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ items = data.get("items", [])
+ if items:
+ enriched_count = sum(
+ 1 for m in items if m.get("graph_enriched")
+ )
+ logger.info(
+ f"Fetched {len(items)} memories "
+ f"({enriched_count} graph-enriched)"
+ )
+ return items
+ except httpx.HTTPError as e:
+ logger.warning(f"Enriched endpoint failed, falling back: {e}")
+
+ # Fallback to plain filter
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{proxy_url}/api/v1/memories/filter",
+ json=body,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data.get("items", [])
+ except httpx.HTTPError as e:
+ logger.error(f"Failed to fetch memories: {e}")
+ return []
+
+ # ------------------------------------------------------------------
+ # Aggregation
+ # ------------------------------------------------------------------
+
+ def _aggregate_categories(
+ self, memories: List[Dict[str, Any]]
+ ) -> List[Interest]:
+ """Aggregate memory categories into broad interests."""
+ # category_name → {count, latest_timestamp}
+ agg: Dict[str, Dict[str, Any]] = {}
+
+ for mem in memories:
+ categories = mem.get("categories", [])
+ ts = _parse_created_at(mem.get("created_at"))
+
+ for raw_cat in categories:
+ cat = raw_cat.strip().lower()
+ if not cat or cat in EXCLUDED_CATEGORIES:
+ continue
+
+ if cat not in agg:
+ agg[cat] = {"count": 0, "latest": ts}
+ agg[cat]["count"] += 1
+ if ts and (agg[cat]["latest"] is None or ts > agg[cat]["latest"]):
+ agg[cat]["latest"] = ts
+
+ interests = []
+ for name, info in agg.items():
+ weight = _compute_weight(info["count"], info["latest"], is_entity=False)
+ if weight <= 0:
+ continue
+
+ hashtags = self._category_to_hashtags(name)
+ if not hashtags:
+ continue
+
+ interests.append(
+ Interest(
+ name=name,
+ node_id=_deterministic_id(name),
+ labels=["category"],
+ relationship_count=int(round(weight)),
+ last_active=info["latest"],
+ hashtags=hashtags,
+ )
+ )
+
+ return interests
+
+ def _aggregate_entities(
+ self, memories: List[Dict[str, Any]]
+ ) -> List[Interest]:
+ """Aggregate entities into specific interests.
+
+ Uses two sources of entity data:
+ 1. Graph-enriched entities (from /filter/enriched) — have type info
+ 2. Flat metadata entities (fallback) — no type info, heuristic filter
+ """
+ agg: Dict[str, Dict[str, Any]] = {}
+
+ # Build a type lookup from graph-enriched entity data
+ entity_types: Dict[str, str] = {} # name_lower → type (e.g. "PERSON")
+ for mem in memories:
+ for ent in mem.get("entities", []):
+ name = ent.get("name", "").strip()
+ etype = ent.get("type", "ENTITY").upper()
+ if name:
+ entity_types[name.lower()] = etype
+
+ for mem in memories:
+ ts = _parse_created_at(mem.get("created_at"))
+
+ # Prefer graph-enriched entities (have type info)
+ graph_entities = mem.get("entities", [])
+ if graph_entities:
+ for ent in graph_entities:
+ name = ent.get("name", "").strip()
+ etype = ent.get("type", "ENTITY").upper()
+ if not name or len(name) < 2:
+ continue
+ if etype in EXCLUDED_ENTITY_TYPES:
+ continue
+ key = name.lower()
+ if key not in agg:
+ agg[key] = {
+ "count": 0, "latest": ts,
+ "original": name, "type": etype,
+ }
+ agg[key]["count"] += 1
+ if ts and (agg[key]["latest"] is None or ts > agg[key]["latest"]):
+ agg[key]["latest"] = ts
+ else:
+ # Fallback: flat metadata entities (no type info)
+ metadata = mem.get("metadata_", {}) or {}
+ entities = metadata.get("entities", [])
+
+ if isinstance(entities, list):
+ entity_list = entities
+ elif isinstance(entities, dict):
+ entity_list = []
+ for names in entities.values():
+ if isinstance(names, list):
+ entity_list.extend(names)
+ else:
+ continue
+
+ for entity in entity_list:
+ if not isinstance(entity, str) or len(entity.strip()) < 2:
+ continue
+ name = entity.strip()
+ key = name.lower()
+
+ # Use graph type lookup if available
+ etype = entity_types.get(key, "ENTITY")
+ if etype in EXCLUDED_ENTITY_TYPES:
+ continue
+
+ # Heuristic filter for un-typed entities
+ if etype == "ENTITY" and _is_likely_private(name):
+ continue
+
+ if key not in agg:
+ agg[key] = {
+ "count": 0, "latest": ts,
+ "original": name, "type": etype,
+ }
+ agg[key]["count"] += 1
+ if ts and (agg[key]["latest"] is None or ts > agg[key]["latest"]):
+ agg[key]["latest"] = ts
+
+ interests = []
+ for key, info in agg.items():
+ weight = _compute_weight(info["count"], info["latest"], is_entity=True)
+ if weight <= 0:
+ continue
+
+ hashtags = self._name_to_hashtags(info["original"])
+ if not hashtags:
+ continue
+
+ interests.append(
+ Interest(
+ name=info["original"],
+ node_id=_deterministic_id(key),
+ labels=["entity", info.get("type", "ENTITY").lower()],
+ relationship_count=int(round(weight)),
+ last_active=info["latest"],
+ hashtags=hashtags,
+ )
+ )
+
+ return interests
+
+ # ------------------------------------------------------------------
+ # Hashtag derivation
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _category_to_hashtags(category: str) -> List[str]:
+ """Convert a category string to hashtags.
+
+ 'ai, ml & technology' → ['ai', 'ml', 'technology']
+ """
+ # Split on commas, ampersands, 'and'
+ parts = re.split(r"[,&]+|\band\b", category)
+ hashtags: List[str] = []
+
+ for part in parts:
+ clean = re.sub(r"[^a-zA-Z0-9\s]", "", part).strip().lower()
+ if not clean:
+ continue
+
+ joined = clean.replace(" ", "")
+ if len(joined) >= 2 and joined not in hashtags:
+ hashtags.append(joined)
+
+ # Check abbreviations for the sub-part
+ abbrev = ABBREVIATIONS.get(clean)
+ if abbrev and abbrev not in hashtags:
+ hashtags.append(abbrev)
+
+ return hashtags
+
+ @staticmethod
+ def _name_to_hashtags(name: str) -> List[str]:
+ """Convert an entity/interest name to fediverse hashtags.
+
+ 'Mac mini' → ['macmini', 'apple', 'homelab']
+ 'LMs' → ['lms', 'llm']
+ 'Kubernetes' → ['kubernetes', 'k8s']
+ 'Strix Halo box' → ['strixhalobox', 'strixhalo', 'amd', 'ryzen']
+ """
+ clean = re.sub(r"[^a-zA-Z0-9\s]", "", name).strip().lower()
+ joined = clean.replace(" ", "")
+
+ hashtags: List[str] = []
+ if joined and len(joined) >= 2:
+ hashtags.append(joined)
+
+ # Individual words for multi-word names (skip generic subwords)
+ words = clean.split()
+ if len(words) > 1:
+ for word in words:
+ if (
+ len(word) >= 3
+ and word not in hashtags
+ and word not in GENERIC_SUBWORDS
+ ):
+ hashtags.append(word)
+
+ # Common abbreviations
+ abbrev = ABBREVIATIONS.get(clean)
+ if abbrev and abbrev not in hashtags:
+ hashtags.append(abbrev)
+
+ # Known product/brand expansions — try full name then partial matches
+ product_tags = PRODUCT_HASHTAGS.get(clean, [])
+ if not product_tags:
+ # Try matching a known product prefix (e.g. "strix halo box" → "strix halo")
+ for product_key, tags in PRODUCT_HASHTAGS.items():
+ if clean.startswith(product_key) or product_key.startswith(clean):
+ product_tags = tags
+ break
+ for tag in product_tags:
+ if tag not in hashtags:
+ hashtags.append(tag)
+
+ return hashtags
+
+
+# ======================================================================
+# Module-level helpers
+# ======================================================================
+
+
+def _compute_weight(
+ mention_count: int,
+ latest: Optional[datetime],
+ is_entity: bool,
+) -> float:
+ """Compute interest weight from mention count, recency, and source type.
+
+ weight = mention_count × recency_multiplier × source_bonus
+ """
+ if mention_count <= 0:
+ return 0.0
+
+ # Recency multiplier based on how recent the latest memory is
+ recency = 1.0
+ if latest:
+ try:
+ now = datetime.now(timezone.utc)
+ if latest.tzinfo is None:
+ latest = latest.replace(tzinfo=timezone.utc)
+ age_days = (now - latest).total_seconds() / 86400
+ if age_days <= 7:
+ recency = 2.0
+ elif age_days <= 30:
+ recency = 1.5
+ elif age_days <= 90:
+ recency = 1.0
+ else:
+ recency = 0.5
+ except (TypeError, ValueError):
+ recency = 1.0
+
+ source_bonus = 1.5 if is_entity else 1.0
+
+ return mention_count * recency * source_bonus
+
+
+def _deterministic_id(name: str) -> str:
+ """Generate a stable short ID from a name string."""
+ return hashlib.md5(name.lower().encode()).hexdigest()[:12]
+
+
+_PRIVATE_PATTERNS = re.compile(
+ r"\b(road|street|avenue|lane|drive|court|place|blvd|way)\b"
+ r"|\b(party|birthday|wedding|anniversary|funeral)\b",
+ re.IGNORECASE,
+)
+
+
+def _is_likely_private(name: str) -> bool:
+ """Heuristic: is this entity likely a private person/place/event?
+
+ Used as fallback when graph entity types are not available.
+ """
+ # Very short single-word names are often first names (ambiguous)
+ if len(name) <= 3 and " " not in name:
+ return True
+ # Address/event patterns
+ if _PRIVATE_PATTERNS.search(name):
+ return True
+ return False
+
+
+def _parse_created_at(value: Any) -> Optional[datetime]:
+ """Parse a created_at value (unix timestamp or ISO string)."""
+ if value is None:
+ return None
+ try:
+ if isinstance(value, datetime):
+ return value
+ if isinstance(value, (int, float)):
+ return datetime.fromtimestamp(value, tz=timezone.utc)
+ if isinstance(value, str):
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (ValueError, TypeError, OSError):
+ pass
+ return None
diff --git a/ushadow/backend/src/services/keycloak_admin.py b/ushadow/backend/src/services/keycloak_admin.py
new file mode 100644
index 00000000..ccb4297b
--- /dev/null
+++ b/ushadow/backend/src/services/keycloak_admin.py
@@ -0,0 +1,368 @@
+"""
+Keycloak Admin API Service
+
+Refactored to use official python-keycloak KeycloakAdmin.
+Provides backward-compatible wrapper for existing code.
+
+Primary use case: Dynamic redirect URI registration for multi-environment worktrees.
+
+Each Ushadow environment (worktree) runs on a different port:
+- ushadow: 3010 (PORT_OFFSET=10)
+- ushadow-orange: 3020 (PORT_OFFSET=20)
+- ushadow-yellow: 3030 (PORT_OFFSET=30)
+
+This service ensures Keycloak accepts redirects from all active environments.
+"""
+
+import os
+import logging
+from typing import Optional, List, Dict, Any
+
+from keycloak import KeycloakAdmin
+from keycloak.exceptions import KeycloakError
+
+logger = logging.getLogger(__name__)
+
+
+class KeycloakAdminClient:
+ """
+ Keycloak Admin API client wrapper.
+
+ Provides backward-compatible interface using official python-keycloak library.
+ This wrapper maintains the existing API while using the official KeycloakAdmin underneath.
+ """
+
+ def __init__(self, admin: KeycloakAdmin):
+ """
+ Initialize wrapper with official KeycloakAdmin instance.
+
+ Args:
+ admin: KeycloakAdmin instance from keycloak_settings
+ """
+ self.admin = admin
+ logger.debug("[KC-ADMIN] Initialized KeycloakAdminClient wrapper")
+
+ async def get_client_by_client_id(self, client_id: str) -> Optional[dict]:
+ """
+ Get Keycloak client configuration by client_id.
+
+ Args:
+ client_id: The client_id (e.g., "ushadow-frontend")
+
+ Returns:
+ Client configuration dict if found, None otherwise
+ """
+ try:
+ # get_clients() returns all clients - we filter manually
+ all_clients = self.admin.get_clients()
+
+ # Filter by clientId
+ for client in all_clients:
+ if client.get("clientId") == client_id:
+ return client
+
+ logger.warning(f"[KC-ADMIN] Client '{client_id}' not found")
+ return None
+
+ except KeycloakError as e:
+ logger.error(f"[KC-ADMIN] Failed to get client: {e}")
+ return None
+
+ async def update_client_redirect_uris(
+ self,
+ client_id: str,
+ redirect_uris: List[str],
+ web_origins: Optional[List[str]] = None,
+ merge: bool = True
+ ) -> bool:
+ """
+ Update redirect URIs and webOrigins (CORS) for a Keycloak client.
+
+ Args:
+ client_id: The client_id (e.g., "ushadow-frontend")
+ redirect_uris: List of redirect URIs to set
+ web_origins: Optional list of web origins (CORS). If not provided, extracted from redirect URIs.
+ merge: If True, merge with existing URIs. If False, replace entirely.
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ # Get current client configuration
+ client = await self.get_client_by_client_id(client_id)
+ if not client:
+ logger.error(f"[KC-ADMIN] Cannot update redirect URIs - client '{client_id}' not found")
+ return False
+
+ client_uuid = client["id"] # Internal UUID, not the client_id
+
+ # Merge or replace redirect URIs
+ if merge:
+ existing_uris = set(client.get("redirectUris", []))
+ new_uris = existing_uris.union(set(redirect_uris))
+ final_uris = list(new_uris)
+ logger.info(f"[KC-ADMIN] Merging redirect URIs: {len(existing_uris)} existing + {len(redirect_uris)} new = {len(final_uris)} total")
+ else:
+ final_uris = redirect_uris
+ logger.info(f"[KC-ADMIN] Replacing redirect URIs with {len(final_uris)} URIs")
+
+ # Get webOrigins (CORS)
+ if web_origins is not None:
+ # Use provided web origins
+ final_origins_set = set(web_origins)
+ if merge:
+ existing_origins = set(client.get("webOrigins", []))
+ final_origins_set = final_origins_set.union(existing_origins)
+ final_origins = list(final_origins_set)
+ logger.info(f"[KC-ADMIN] Using {len(final_origins)} provided webOrigins")
+ else:
+ # Extract origins from redirect URIs for CORS
+ origins_set = set()
+ for uri in final_uris:
+ # Extract origin from redirect URI (e.g., http://localhost:3020/oauth/callback -> http://localhost:3020)
+ if uri.startswith("http"):
+ from urllib.parse import urlparse
+ parsed = urlparse(uri)
+ origin = f"{parsed.scheme}://{parsed.netloc}"
+ origins_set.add(origin)
+
+ # Merge with existing webOrigins if merge=True
+ if merge:
+ existing_origins = set(client.get("webOrigins", []))
+ origins_set = origins_set.union(existing_origins)
+
+ final_origins = list(origins_set)
+ logger.info(f"[KC-ADMIN] Extracted {len(final_origins)} webOrigins from redirect URIs")
+
+ # Update client using official library method
+ # IMPORTANT: Must update the full client object, not partial update
+ # Partial updates cause Hibernate to try INSERT instead of REPLACE,
+ # leading to duplicate key violations on redirectUris
+ client["redirectUris"] = final_uris
+ client["webOrigins"] = final_origins
+
+ self.admin.update_client(client_uuid, client)
+
+ logger.info(f"[KC-ADMIN] ✓ Updated redirect URIs for client '{client_id}'")
+ for uri in final_uris:
+ logger.info(f"[KC-ADMIN] - {uri}")
+ return True
+
+ except KeycloakError as e:
+ logger.error(f"[KC-ADMIN] Failed to update client: {e}")
+ return False
+
+ async def register_redirect_uri(self, client_id: str, redirect_uri: str) -> bool:
+ """
+ Register a single redirect URI for a client (merges with existing).
+
+ Args:
+ client_id: The client_id (e.g., "ushadow-frontend")
+ redirect_uri: The redirect URI to add (e.g., "http://localhost:3010/auth/callback")
+
+ Returns:
+ True if successful, False otherwise
+ """
+ return await self.update_client_redirect_uris(
+ client_id=client_id,
+ redirect_uris=[redirect_uri],
+ merge=True
+ )
+
+ async def update_post_logout_redirect_uris(
+ self,
+ client_id: str,
+ post_logout_redirect_uris: List[str],
+ merge: bool = True
+ ) -> bool:
+ """
+ Update post-logout redirect URIs for a Keycloak client.
+
+ Args:
+ client_id: The client_id (e.g., "ushadow-frontend")
+ post_logout_redirect_uris: List of post-logout redirect URIs to set
+ merge: If True, merge with existing URIs. If False, replace entirely.
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ # Get client UUID
+ client = await self.get_client_by_client_id(client_id)
+ if not client:
+ logger.error(f"[KC-ADMIN] Client '{client_id}' not found")
+ return False
+
+ client_uuid = client["id"]
+
+ # Merge or replace post-logout redirect URIs
+ if merge:
+ existing_uris = set(client.get("attributes", {}).get("post.logout.redirect.uris", "").split("##"))
+ # Remove empty strings from the set
+ existing_uris = {uri for uri in existing_uris if uri}
+ new_uris = existing_uris.union(set(post_logout_redirect_uris))
+ final_uris = list(new_uris)
+ logger.info(f"[KC-ADMIN] Merging post-logout redirect URIs: {len(existing_uris)} existing + {len(post_logout_redirect_uris)} new = {len(final_uris)} total")
+ else:
+ final_uris = post_logout_redirect_uris
+ logger.info(f"[KC-ADMIN] Replacing post-logout redirect URIs with {len(final_uris)} URIs")
+
+ # Post-logout redirect URIs are stored as a ## delimited string in attributes
+ # Update full client object to avoid Hibernate collection merge issues
+ if "attributes" not in client:
+ client["attributes"] = {}
+ client["attributes"]["post.logout.redirect.uris"] = "##".join(final_uris)
+
+ # Update using official library with full client object
+ self.admin.update_client(client_uuid, client)
+
+ logger.info(f"[KC-ADMIN] ✓ Updated post-logout redirect URIs for client '{client_id}'")
+ for uri in final_uris:
+ logger.info(f"[KC-ADMIN] - {uri}")
+ return True
+
+ except KeycloakError as e:
+ logger.error(f"[KC-ADMIN] Failed to update post-logout redirect URIs: {e}")
+ return False
+
+ def update_realm_browser_security_headers(self, headers: dict) -> None:
+ """
+ Update realm's browser security headers (CSP, X-Frame-Options, etc.).
+
+ Args:
+ headers: Dictionary of browser security headers to update
+ """
+ from ..config.keycloak_settings import get_keycloak_config
+
+ try:
+ # Get realm from config
+ config = get_keycloak_config()
+ realm = config["realm"]
+
+ # Get current realm configuration
+ realm_config = self.admin.get_realm(realm)
+
+ # Update browserSecurityHeaders
+ realm_config["browserSecurityHeaders"] = headers
+
+ # Update realm
+ self.admin.update_realm(realm, realm_config)
+
+ logger.info(f"[KC-ADMIN] ✓ Updated realm browser security headers for realm: {realm}")
+ for key, value in headers.items():
+ logger.info(f"[KC-ADMIN] {key}: {value[:50]}...") # Truncate long values
+
+ except KeycloakError as e:
+ logger.error(f"[KC-ADMIN] Failed to update realm: {e}")
+ raise
+
+
+async def register_current_environment_redirect_uri() -> bool:
+ """
+ Register this environment's redirect URIs with Keycloak.
+
+ Registers both local (localhost/127.0.0.1) and Tailscale URIs if available.
+ Uses PORT_OFFSET to determine the correct frontend port.
+ Called during backend startup to ensure Keycloak accepts redirects from this environment.
+
+ Example:
+ - ushadow (PORT_OFFSET=10): Registers http://localhost:3010/auth/callback
+ - ushadow-orange (PORT_OFFSET=20): Registers http://localhost:3020/auth/callback
+ - With Tailscale: Also registers https://ushadow.spangled-kettle.ts.net/auth/callback
+ """
+ from src.config.keycloak_settings import get_keycloak_config, get_keycloak_admin
+
+ # Get configuration from settings
+ config = get_keycloak_config()
+ keycloak_client_id = config["frontend_client_id"]
+
+ # Calculate frontend port from PORT_OFFSET
+ port_offset = int(os.getenv("PORT_OFFSET", "0"))
+ frontend_port = 3000 + port_offset
+
+ # Build redirect URIs - start with local URIs
+ redirect_uris = [
+ f"http://localhost:{frontend_port}/oauth/callback",
+ f"http://127.0.0.1:{frontend_port}/oauth/callback",
+ ]
+
+ post_logout_redirect_uris = [
+ f"http://localhost:{frontend_port}/",
+ f"http://127.0.0.1:{frontend_port}/",
+ ]
+
+ # Check if Tailscale is configured and add Tailscale URIs
+ try:
+ from src.config import get_settings_store
+ settings = get_settings_store()
+ ts_hostname = settings.get_sync("tailscale.hostname")
+
+ if ts_hostname:
+ # Add Tailscale URIs (HTTPS through Tailscale serve)
+ tailscale_redirect_uri = f"https://{ts_hostname}/oauth/callback"
+ tailscale_logout_uri = f"https://{ts_hostname}/"
+
+ redirect_uris.append(tailscale_redirect_uri)
+ post_logout_redirect_uris.append(tailscale_logout_uri)
+
+ logger.info(f"[KC-ADMIN] Detected Tailscale hostname: {ts_hostname}")
+ except Exception as e:
+ logger.debug(f"[KC-ADMIN] Could not detect Tailscale hostname: {e}")
+
+ logger.info(f"[KC-ADMIN] Registering redirect URIs for environment:")
+ for uri in redirect_uris:
+ logger.info(f"[KC-ADMIN] - {uri}")
+ logger.info(f"[KC-ADMIN] Registering post-logout redirect URIs:")
+ for uri in post_logout_redirect_uris:
+ logger.info(f"[KC-ADMIN] - {uri}")
+
+ # Get official KeycloakAdmin and wrap it
+ admin = get_keycloak_admin()
+ admin_client = KeycloakAdminClient(admin)
+
+ # Register login redirect URIs
+ success = await admin_client.update_client_redirect_uris(
+ client_id=keycloak_client_id,
+ redirect_uris=redirect_uris,
+ merge=True # Merge with existing URIs (don't break other environments)
+ )
+
+ if not success:
+ logger.error(f"[KC-ADMIN] ❌ Failed to register redirect URIs for port {frontend_port}")
+ return False
+
+ # Register post-logout redirect URIs
+ success = await admin_client.update_post_logout_redirect_uris(
+ client_id=keycloak_client_id,
+ post_logout_redirect_uris=post_logout_redirect_uris,
+ merge=True # Merge with existing URIs (don't break other environments)
+ )
+
+ if success:
+ logger.info(f"[KC-ADMIN] ✓ Successfully registered all redirect URIs for port {frontend_port}")
+ else:
+ logger.warning(f"[KC-ADMIN] ⚠️ Failed to register redirect URIs - Keycloak login may not work on port {frontend_port}")
+
+ return success
+
+
+# Singleton getter for dependency injection
+_keycloak_admin_client: Optional[KeycloakAdminClient] = None
+
+
+def get_keycloak_admin() -> KeycloakAdminClient:
+ """
+ Get the Keycloak admin client singleton (backward-compatible wrapper).
+
+ Returns wrapped official KeycloakAdmin for existing code compatibility.
+ """
+ from src.config.keycloak_settings import get_keycloak_admin as get_official_admin
+
+ global _keycloak_admin_client
+
+ if _keycloak_admin_client is None:
+ # Get official KeycloakAdmin and wrap it
+ official_admin = get_official_admin()
+ _keycloak_admin_client = KeycloakAdminClient(official_admin)
+
+ return _keycloak_admin_client
diff --git a/ushadow/backend/src/services/keycloak_auth.py b/ushadow/backend/src/services/keycloak_auth.py
new file mode 100644
index 00000000..cb0ff73f
--- /dev/null
+++ b/ushadow/backend/src/services/keycloak_auth.py
@@ -0,0 +1,243 @@
+"""
+Keycloak Token Validation
+
+Validates Keycloak JWT access tokens with signature verification but issuer-agnostic.
+This allows the app to work from any domain (localhost, Tailscale, public URLs).
+"""
+
+import logging
+from typing import Optional, Union
+import jwt
+from jwt import PyJWKClient
+from jwt.exceptions import PyJWKClientError
+
+from fastapi import HTTPException, status, Depends
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+
+from .keycloak_client import get_keycloak_client
+
+logger = logging.getLogger(__name__)
+
+# Security scheme for extracting Bearer tokens
+security = HTTPBearer(auto_error=False)
+
+# Cache for JWKS client (fetches Keycloak's public keys)
+_jwks_client: Optional[PyJWKClient] = None
+
+
+def get_jwks_client() -> PyJWKClient:
+ """Get cached JWKS client for fetching Keycloak's public keys."""
+ global _jwks_client
+ if _jwks_client is None:
+ import os
+ from src.config import get_settings_store
+
+ settings = get_settings_store()
+ app_realm = settings.get_sync("keycloak.realm", "ushadow")
+
+ # IMPORTANT: Backend must use internal URL for JWKS, never external/proxy URLs
+ # Priority: KC_URL env var (via OmegaConf keycloak.url) > Docker default
+ internal_url = (
+ settings.get_sync("keycloak.url") or
+ "http://keycloak:8080"
+ )
+
+ # Construct JWKS URL from internal Keycloak URL
+ # IMPORTANT: Use application realm (ushadow), not admin realm (master)
+ jwks_url = f"{internal_url}/realms/{app_realm}/protocol/openid-connect/certs"
+ _jwks_client = PyJWKClient(jwks_url)
+ logger.info(f"[KC-AUTH] Initialized JWKS client for realm '{app_realm}': {jwks_url}")
+ return _jwks_client
+
+
+def clear_jwks_cache() -> None:
+ """Clear the JWKS client cache. Call this when realm keys change."""
+ global _jwks_client
+ _jwks_client = None
+ logger.info("[KC-AUTH] Cleared JWKS cache")
+
+
+def validate_keycloak_token(token: str) -> Optional[dict]:
+ """
+ Validate a Keycloak JWT access token with signature verification but issuer-agnostic.
+
+ This approach:
+ - ✅ Verifies JWT signature using Keycloak's public keys (JWKS)
+ - ✅ Checks token expiration
+ - ✅ Works from ANY domain (localhost, Tailscale, public URLs)
+ - ✅ No backend client or introspection permissions needed
+ - ✅ Fast (no network call after JWKS cached)
+
+ The issuer check is skipped to allow multi-domain deployments where
+ users access the app from different URLs (localhost:3000, tailscale, etc).
+
+ Args:
+ token: JWT access token from Keycloak
+
+ Returns:
+ Decoded token payload if valid, None if invalid/expired
+ """
+ try:
+ # Get JWKS client (fetches Keycloak's public keys for signature verification)
+ jwks_client = get_jwks_client()
+
+ # Get the signing key from JWKS
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
+
+ # Decode and validate JWT
+ # - Verify signature using Keycloak's public key (RS256 via JWKS)
+ # - Check expiration
+ # - Skip issuer check: tokens may be issued by localhost:8081 or Tailscale URLs
+ # - Verify audience: token must be intended for "ushadow-backend"
+ # (configured via audience mapper on ushadow-frontend / ushadow-mobile clients)
+ from src.config import get_settings_store
+ settings = get_settings_store()
+ backend_client_id = settings.get_sync("keycloak.backend_client_id", "ushadow-backend")
+
+ payload = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256"],
+ options={"verify_iss": False}, # Allow any issuer (multi-domain support)
+ audience=backend_client_id,
+ )
+
+ logger.debug(f"[KC-AUTH] ✓ Token validated for user: {payload.get('preferred_username')}")
+ return payload
+
+ except jwt.ExpiredSignatureError:
+ logger.warning("[KC-AUTH] Token expired")
+ return None
+
+ except PyJWKClientError as e:
+ logger.warning(f"[KC-AUTH] Signing key not found (kid mismatch or realm reset): {e}")
+ return None
+
+ except jwt.InvalidTokenError as e:
+ logger.warning(f"[KC-AUTH] Invalid token: {e}")
+ return None
+
+ except Exception as e:
+ # Unexpected errors still get logged with full trace
+ logger.error(f"[KC-AUTH] Unexpected error validating token: {e}", exc_info=True)
+ return None
+
+
+def get_keycloak_user_from_token(token: str) -> Optional[dict]:
+ """
+ Extract user info from a Keycloak token.
+
+ Args:
+ token: JWT access token from Keycloak
+
+ Returns:
+ User info dict with keys: email, name, sub (user ID), etc.
+ """
+ payload = validate_keycloak_token(token)
+ if not payload:
+ return None
+
+ # Get name from token, fallback to building it from given_name + family_name
+ name = payload.get("name")
+ if not name:
+ given_name = payload.get("given_name", "")
+ family_name = payload.get("family_name", "")
+ if given_name or family_name:
+ name = f"{given_name} {family_name}".strip()
+ logger.debug(f"[KC-AUTH] Built name from given_name + family_name: {name}")
+ else:
+ logger.debug(f"[KC-AUTH] Using name from token: {name}")
+
+ return {
+ "sub": payload.get("sub"),
+ "email": payload.get("email"),
+ "name": name,
+ "preferred_username": payload.get("preferred_username"),
+ "email_verified": payload.get("email_verified", False),
+ # Mark as Keycloak user for backend logic
+ "auth_type": "keycloak",
+ }
+
+
+async def get_current_user_hybrid(
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
+) -> Union[dict, None]:
+ """
+ Hybrid authentication dependency that accepts EITHER legacy OR Keycloak tokens.
+
+ This is a FastAPI dependency that can be used in place of the legacy get_current_user.
+ It tries to validate the token as:
+ 1. Keycloak access token (using python-keycloak with proper signature validation)
+ 2. Legacy Ushadow JWT (via fastapi-users)
+
+ Args:
+ credentials: HTTP Authorization credentials (Bearer token)
+
+ Returns:
+ User info dict if authenticated, raises 401 if not
+
+ Raises:
+ HTTPException: 401 if no valid authentication found
+ """
+ if not credentials:
+ logger.warning("[AUTH] No credentials provided")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated"
+ )
+
+ token = credentials.credentials
+ token_preview = token[:20] + "..." if len(token) > 20 else token
+ logger.debug(f"[AUTH] Validating token: {token_preview}")
+
+ # Try Keycloak token validation first (with proper signature validation)
+ keycloak_user = get_keycloak_user_from_token(token)
+ if keycloak_user:
+ logger.debug(f"[AUTH] ✅ Keycloak authentication successful: {keycloak_user.get('email')}")
+ return keycloak_user
+
+ # Try legacy auth validation
+ # TODO: Add legacy token validation here if needed
+ # For now, we'll just check if it's a Keycloak token
+ # The existing fastapi-users middleware will handle legacy tokens
+ logger.warning(f"[AUTH] ❌ Token validation failed - neither Keycloak nor legacy token")
+
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token"
+ )
+
+
+async def get_current_user_or_none(
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
+) -> Union[dict, None]:
+ """
+ Optional hybrid authentication dependency.
+
+ Same as get_current_user_hybrid but returns None instead of raising
+ 401 when no credentials are provided or token is invalid.
+ Use this for endpoints that work with or without authentication.
+
+ Args:
+ credentials: HTTP Authorization credentials (Bearer token)
+
+ Returns:
+ User info dict if authenticated, None otherwise
+ """
+ if not credentials:
+ logger.debug("[AUTH] No credentials provided (optional auth)")
+ return None
+
+ token = credentials.credentials
+ token_preview = token[:20] + "..." if len(token) > 20 else token
+ logger.debug(f"[AUTH] Optional auth - validating token: {token_preview}")
+
+ # Try Keycloak token validation first
+ keycloak_user = get_keycloak_user_from_token(token)
+ if keycloak_user:
+ logger.debug(f"[AUTH] ✅ Optional auth - Keycloak user: {keycloak_user.get('email')}")
+ return keycloak_user
+
+ # Token provided but invalid - return None for optional auth
+ logger.debug("[AUTH] Optional auth - token invalid, returning None")
+ return None
diff --git a/ushadow/backend/src/services/keycloak_client.py b/ushadow/backend/src/services/keycloak_client.py
new file mode 100644
index 00000000..1b082a50
--- /dev/null
+++ b/ushadow/backend/src/services/keycloak_client.py
@@ -0,0 +1,290 @@
+"""
+Keycloak Client Service
+
+Standard OAuth2/OIDC implementation using python-keycloak library.
+Handles token exchange, refresh, validation, and user info retrieval.
+
+Refactored to use shared KeycloakOpenID instance from keycloak_settings.
+"""
+
+import logging
+from typing import Optional, Dict, Any
+from urllib.parse import urlparse
+
+import httpx
+from keycloak import KeycloakOpenID
+from keycloak.exceptions import KeycloakError
+
+logger = logging.getLogger(__name__)
+
+
+class KeycloakClient:
+ """
+ Keycloak OpenID Connect client using python-keycloak library.
+
+ Follows standard OAuth2/OIDC conventions for:
+ - Authorization code exchange
+ - Token refresh
+ - Token introspection
+ - User info retrieval
+
+ Uses shared KeycloakOpenID instance from keycloak_settings for consistency.
+ """
+
+ def __init__(self):
+ """Initialize Keycloak OpenID client from shared settings."""
+ from src.config.keycloak_settings import get_keycloak_openid, get_keycloak_connection
+
+ # Use shared KeycloakOpenID instance (follows DRY principle)
+ self.keycloak_openid = get_keycloak_openid()
+
+ # Get server URL from connection object for logging
+ connection = get_keycloak_connection()
+
+ logger.info(
+ f"[KC-CLIENT] ✅ Initialized Keycloak client for realm "
+ f"'{connection.realm_name}' at {connection.server_url}"
+ )
+
+ def exchange_code_for_tokens(
+ self,
+ code: str,
+ redirect_uri: str,
+ code_verifier: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ Exchange authorization code for access/refresh tokens.
+
+ Standard OAuth2 authorization code flow with optional PKCE.
+
+ Args:
+ code: Authorization code from Keycloak
+ redirect_uri: Redirect URI used in authorization request
+ code_verifier: PKCE code verifier (if PKCE was used)
+ client_id: OAuth client ID (must match the one used in authorization request)
+
+ Returns:
+ Token response with access_token, refresh_token, id_token, etc.
+
+ Raises:
+ KeycloakError: If token exchange fails
+ """
+ try:
+ logger.info(f"[KC-CLIENT] Exchanging authorization code for tokens (client_id={client_id})")
+
+ # Build token request parameters
+ token_params = {
+ "code": code,
+ "redirect_uri": redirect_uri,
+ }
+
+ # Add PKCE code_verifier if provided
+ if code_verifier:
+ token_params["code_verifier"] = code_verifier
+ logger.debug("[KC-CLIENT] Using PKCE code_verifier")
+
+ # Use client-specific KeycloakOpenID instance if client_id provided
+ if client_id:
+ from src.config.keycloak_settings import get_keycloak_openid
+ keycloak_openid = get_keycloak_openid(client_id=client_id)
+ else:
+ keycloak_openid = self.keycloak_openid
+
+ # Exchange code for tokens
+ tokens = keycloak_openid.token(
+ grant_type="authorization_code",
+ **token_params
+ )
+
+ logger.info("[KC-CLIENT] ✅ Token exchange successful")
+ return tokens
+
+ except KeycloakError as e:
+ logger.error(f"[KC-CLIENT] Token exchange failed: {e}")
+ raise
+
+ def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
+ """
+ Refresh access token using refresh token.
+
+ Calls the internal Keycloak URL (http://keycloak:8080) but sets X-Forwarded-Host
+ and X-Forwarded-Proto headers to match the public URL. This makes Keycloak compute
+ the issuer as the public URL, matching the iss claim in the refresh token — which
+ was issued when the user logged in via Tailscale (the public URL).
+
+ Without this, Keycloak sees http://keycloak:8080 as the issuer on refresh, which
+ doesn't match the token's iss (https://orange.spangled-kettle.ts.net), causing
+ "Invalid token issuer" errors.
+ """
+ from src.config.keycloak_settings import get_keycloak_config
+
+ config = get_keycloak_config()
+ internal_url = config["url"] # http://keycloak:8080
+ public_url = config["public_url"] # https://orange.spangled-kettle.ts.net
+ realm = config["realm"]
+ client_id = config["frontend_client_id"]
+
+ parsed = urlparse(public_url)
+ token_url = f"{internal_url}/realms/{realm}/protocol/openid-connect/token"
+
+ logger.info(f"[KC-CLIENT] Refreshing token via {token_url} (forwarding as {public_url})")
+
+ try:
+ response = httpx.post(
+ token_url,
+ data={
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": client_id,
+ },
+ headers={
+ "X-Forwarded-Host": parsed.netloc,
+ "X-Forwarded-Proto": parsed.scheme,
+ },
+ timeout=10.0,
+ )
+ response.raise_for_status()
+ tokens = response.json()
+ logger.info("[KC-CLIENT] ✅ Token refresh successful")
+ return tokens
+
+ except httpx.HTTPStatusError as e:
+ logger.error(f"[KC-CLIENT] Token refresh failed: {e.response.status_code}: {e.response.text}")
+ raise KeycloakError(e.response.text) from e
+ except Exception as e:
+ logger.error(f"[KC-CLIENT] Token refresh failed: {e}")
+ raise KeycloakError(str(e)) from e
+
+ def introspect_token(self, token: str, token_type_hint: str = "access_token") -> Dict[str, Any]:
+ """
+ Introspect token to check validity and get token metadata.
+
+ Standard OAuth2 token introspection (RFC 7662).
+
+ Args:
+ token: Access or refresh token to introspect
+ token_type_hint: Type of token ("access_token" or "refresh_token")
+
+ Returns:
+ Introspection result with 'active' flag and token metadata
+
+ Raises:
+ KeycloakError: If introspection fails
+ """
+ try:
+ result = self.keycloak_openid.introspect(token, token_type_hint=token_type_hint)
+
+ if result.get("active"):
+ logger.debug(f"[KC-CLIENT] Token is active (expires in {result.get('exp', 0) - result.get('iat', 0)}s)")
+ else:
+ logger.warning("[KC-CLIENT] Token is inactive/expired")
+
+ return result
+
+ except KeycloakError as e:
+ logger.error(f"[KC-CLIENT] Token introspection failed: {e}")
+ raise
+
+ def get_userinfo(self, access_token: str) -> Dict[str, Any]:
+ """
+ Get user information from access token.
+
+ Standard OIDC UserInfo endpoint.
+
+ Args:
+ access_token: Valid access token
+
+ Returns:
+ User information (sub, email, name, etc.)
+
+ Raises:
+ KeycloakError: If userinfo retrieval fails
+ """
+ try:
+ userinfo = self.keycloak_openid.userinfo(access_token)
+
+ logger.debug(f"[KC-CLIENT] Retrieved userinfo for: {userinfo.get('email', userinfo.get('sub'))}")
+ return userinfo
+
+ except KeycloakError as e:
+ logger.error(f"[KC-CLIENT] Userinfo retrieval failed: {e}")
+ raise
+
+ def decode_token(self, token: str, validate: bool = True) -> Dict[str, Any]:
+ """
+ Decode and optionally validate JWT token.
+
+ Args:
+ token: JWT token to decode
+ validate: Whether to validate signature and expiration
+
+ Returns:
+ Decoded token payload
+
+ Raises:
+ KeycloakError: If token is invalid or expired
+ """
+ try:
+ if validate:
+ # Decode and validate token signature + expiration
+ decoded = self.keycloak_openid.decode_token(
+ token,
+ validate=True
+ )
+ logger.debug("[KC-CLIENT] Token validated successfully")
+ else:
+ # Decode without validation (for debugging)
+ decoded = self.keycloak_openid.decode_token(
+ token,
+ validate=False
+ )
+ logger.debug("[KC-CLIENT] Token decoded (no validation)")
+
+ return decoded
+
+ except KeycloakError as e:
+ logger.error(f"[KC-CLIENT] Token decode failed: {e}")
+ raise
+
+ def logout(self, refresh_token: str) -> None:
+ """
+ Logout user by revoking refresh token.
+
+ Standard OIDC logout.
+
+ Args:
+ refresh_token: Refresh token to revoke
+
+ Raises:
+ KeycloakError: If logout fails
+ """
+ try:
+ logger.info("[KC-CLIENT] Logging out user (revoking refresh token)")
+
+ self.keycloak_openid.logout(refresh_token)
+
+ logger.info("[KC-CLIENT] ✅ Logout successful")
+
+ except KeycloakError as e:
+ logger.error(f"[KC-CLIENT] Logout failed: {e}")
+ raise
+
+
+# Singleton instance
+_keycloak_client: Optional[KeycloakClient] = None
+
+
+def get_keycloak_client() -> KeycloakClient:
+ """
+ Get singleton Keycloak client instance.
+
+ Returns:
+ Initialized KeycloakClient
+ """
+ global _keycloak_client
+
+ if _keycloak_client is None:
+ _keycloak_client = KeycloakClient()
+
+ return _keycloak_client
diff --git a/ushadow/backend/src/services/keycloak_startup.py b/ushadow/backend/src/services/keycloak_startup.py
new file mode 100644
index 00000000..c72044df
--- /dev/null
+++ b/ushadow/backend/src/services/keycloak_startup.py
@@ -0,0 +1,313 @@
+"""
+Keycloak Startup Registration
+
+Automatically registers the current environment's redirect URIs with Keycloak
+when the backend starts. This ensures multi-worktree setups work without
+manual Keycloak configuration.
+"""
+
+import asyncio
+import logging
+import os
+from typing import List, Optional
+
+from .keycloak_admin import get_keycloak_admin
+from .tailscale_manager import TailscaleManager
+
+logger = logging.getLogger(__name__)
+
+# Lock to prevent concurrent registration (avoids duplicate key errors in Keycloak)
+_registration_lock = asyncio.Lock()
+
+
+def get_tailscale_hostname() -> Optional[str]:
+ """
+ Get the full Tailscale hostname for the current environment.
+
+ Returns:
+ Full hostname like "orange.spangled-kettle.ts.net" or None
+ """
+ try:
+ manager = TailscaleManager()
+ tailnet_suffix = manager.get_tailnet_suffix()
+
+ if not tailnet_suffix:
+ return None
+
+ # Get environment name (e.g., "orange", "purple")
+ env_name = os.getenv("ENV_NAME", "ushadow")
+
+ # Construct full hostname: {env}.{tailnet}
+ return f"{env_name}.{tailnet_suffix}"
+ except Exception as e:
+ logger.debug(f"[KC-STARTUP] Could not get Tailscale hostname: {e}")
+ return None
+
+
+def get_current_redirect_uris() -> List[str]:
+ """
+ Generate redirect URIs for the current environment.
+
+ Returns URIs based on:
+ - PORT_OFFSET environment variable (for multi-worktree support)
+ - FRONTEND_URL environment variable (for custom domains)
+ - Tailscale hostname detection (for .ts.net domains)
+ - Mobile app URIs (ushadow://* for React Native)
+
+ Returns:
+ List of redirect URIs to register
+ """
+ redirect_uris = []
+
+ # Get port offset (default 10 for main environment)
+ port_offset = int(os.getenv("PORT_OFFSET", "10"))
+ frontend_port = 3000 + port_offset
+
+ # Localhost redirect
+ localhost_uri = f"http://localhost:{frontend_port}/oauth/callback"
+ redirect_uris.append(localhost_uri)
+
+ # Custom frontend URL (e.g., for production domains)
+ frontend_url = os.getenv("FRONTEND_URL")
+ if frontend_url:
+ custom_uri = f"{frontend_url.rstrip('/')}/oauth/callback"
+ redirect_uris.append(custom_uri)
+
+ # Tailscale hostname (auto-detect using TailscaleManager)
+ tailscale_hostname = get_tailscale_hostname()
+ if tailscale_hostname:
+ # Only HTTPS for Tailscale (HTTP doesn't work with Tailscale Serve)
+ ts_uri_https = f"https://{tailscale_hostname}/oauth/callback"
+ redirect_uris.append(ts_uri_https)
+ logger.info(f"[KC-STARTUP] 📡 Adding Tailscale URI: {tailscale_hostname}")
+
+ # NOTE: Mobile app URIs are registered in a SEPARATE client (ushadow-mobile)
+ # See register_mobile_client() - mobile apps should use their own client to avoid
+ # redirect URI conflicts with web clients
+
+ return redirect_uris
+
+
+def get_current_post_logout_uris() -> List[str]:
+ """
+ Generate post-logout redirect URIs for the current environment.
+
+ Returns:
+ List of post-logout redirect URIs to register
+ """
+ post_logout_uris = []
+
+ # Get port offset
+ port_offset = int(os.getenv("PORT_OFFSET", "10"))
+ frontend_port = 3000 + port_offset
+
+ # Localhost
+ post_logout_uris.append(f"http://localhost:{frontend_port}")
+ post_logout_uris.append(f"http://localhost:{frontend_port}/")
+
+ # Custom frontend URL
+ frontend_url = os.getenv("FRONTEND_URL")
+ if frontend_url:
+ base_url = frontend_url.rstrip('/')
+ post_logout_uris.append(base_url)
+ post_logout_uris.append(base_url + "/")
+
+ # Tailscale hostname (auto-detect using TailscaleManager)
+ tailscale_hostname = get_tailscale_hostname()
+ if tailscale_hostname:
+ post_logout_uris.append(f"http://{tailscale_hostname}")
+ post_logout_uris.append(f"http://{tailscale_hostname}/")
+ post_logout_uris.append(f"https://{tailscale_hostname}")
+ post_logout_uris.append(f"https://{tailscale_hostname}/")
+
+ # NOTE: Mobile logout URIs are registered in separate client (ushadow-mobile)
+
+ return post_logout_uris
+
+
+def get_web_origins() -> List[str]:
+ """
+ Get allowed web origins (CORS) from settings.
+
+ Uses security.cors_origins from OmegaConf settings which is already
+ configured for the backend's CORS middleware.
+
+ Returns:
+ List of allowed origins for Keycloak webOrigins (CORS)
+ """
+ try:
+ from ..config import get_settings
+ settings = get_settings()
+ cors_origins = settings.get_sync("security.cors_origins", "")
+
+ if cors_origins and cors_origins.strip():
+ # Split comma-separated origins and strip whitespace
+ origins = [origin.strip() for origin in cors_origins.split(",") if origin.strip()]
+ logger.info(f"[KC_STARTUP] CORS: {cors_origins}")
+ logger.info(f"[KC-STARTUP] Using {len(origins)} web origins from settings")
+ return origins
+ except Exception as e:
+ logger.warning(f"[KC-STARTUP] Could not get CORS origins from settings: {e}")
+
+ # Fallback to defaults
+ logger.warning("[KC-STARTUP] Using default web origins")
+ port_offset = int(os.getenv("PORT_OFFSET", "10"))
+ frontend_port = 3000 + port_offset
+ return [f"http://localhost:{frontend_port}"]
+
+
+async def register_mobile_client():
+ """
+ Register redirect URIs for the mobile client.
+
+ Mobile client is defined in realm export with base configuration.
+ This function adds any environment-specific redirect URIs at startup.
+
+ Note: Mobile uses a SEPARATE client from web because:
+ 1. Different redirect URI schemes (ushadow:// vs http://localhost)
+ 2. PKCE is mandatory for mobile (public clients can't protect secrets)
+ 3. Avoids redirect URI conflicts (Keycloak may default to first URI)
+ """
+ try:
+ admin_client = get_keycloak_admin()
+
+ # Mobile-specific redirect URIs (already in realm export, but add any dynamic ones here)
+ mobile_redirect_uris = [
+ "ushadow://oauth/callback", # Production mobile app
+ # Add any environment-specific mobile URIs here if needed
+ ]
+
+ mobile_logout_uris = [
+ "ushadow://logout/callback",
+ ]
+
+ logger.info("[KC-STARTUP] 📱 Registering mobile redirect URIs...")
+
+ # Update mobile client redirect URIs (merge with existing from realm export)
+ success = await admin_client.update_client_redirect_uris(
+ client_id="ushadow-mobile",
+ redirect_uris=mobile_redirect_uris,
+ merge=True # Add to existing URIs from realm export
+ )
+
+ if success:
+ # Update post-logout URIs
+ await admin_client.update_post_logout_redirect_uris(
+ client_id="ushadow-mobile",
+ post_logout_redirect_uris=mobile_logout_uris,
+ merge=True
+ )
+ logger.info("[KC-STARTUP] ✅ Mobile redirect URIs registered")
+ logger.info(f"[KC-STARTUP] Client ID: ushadow-mobile")
+ logger.info(f"[KC-STARTUP] Redirect URIs: {mobile_redirect_uris}")
+ else:
+ logger.warning("[KC-STARTUP] ⚠️ Failed to register mobile redirect URIs")
+
+ except Exception as e:
+ logger.warning(f"[KC-STARTUP] ⚠️ Failed to register mobile client: {e}")
+
+
+async def _do_register() -> bool:
+ """
+ Single registration attempt. Returns True on success, False otherwise.
+ """
+ async with _registration_lock:
+ try:
+ admin_client = get_keycloak_admin()
+
+ redirect_uris = get_current_redirect_uris()
+ post_logout_uris = get_current_post_logout_uris()
+ web_origins = get_web_origins()
+
+ logger.info("[KC-STARTUP] 🔐 Registering redirect URIs with Keycloak...")
+ logger.info(f"[KC-STARTUP] Environment: PORT_OFFSET={os.getenv('PORT_OFFSET', '10')}")
+ for uri in redirect_uris:
+ logger.info(f"[KC-STARTUP] redirect: {uri}")
+
+ success = await admin_client.update_client_redirect_uris(
+ client_id="ushadow-frontend",
+ redirect_uris=redirect_uris,
+ web_origins=web_origins,
+ merge=True,
+ )
+
+ if not success:
+ return False
+
+ logout_success = await admin_client.update_post_logout_redirect_uris(
+ client_id="ushadow-frontend",
+ post_logout_redirect_uris=post_logout_uris,
+ merge=True,
+ )
+
+ if not logout_success:
+ logger.warning("[KC-STARTUP] ⚠️ Failed to register post-logout redirect URIs")
+
+ try:
+ headers = {
+ "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self' http: https: tauri:; object-src 'none';",
+ "xContentTypeOptions": "nosniff",
+ "xRobotsTag": "none",
+ "xFrameOptions": "",
+ "xXSSProtection": "1; mode=block",
+ "strictTransportSecurity": "max-age=31536000; includeSubDomains",
+ }
+ admin_client.update_realm_browser_security_headers(headers)
+ except Exception as csp_error:
+ logger.warning(f"[KC-STARTUP] ⚠️ Failed to update realm CSP: {csp_error}")
+
+ await register_mobile_client()
+ return True
+
+ except Exception as e:
+ logger.warning(f"[KC-STARTUP] ⚠️ Registration attempt failed: {e}")
+ return False
+
+
+async def _register_with_retry():
+ """
+ Retry registration until Keycloak is ready.
+
+ On fresh installs Keycloak can take 30-60s to start and import its realm.
+ Because backend and Keycloak live in separate compose projects, depends_on
+ cannot enforce ordering, so we handle it here instead.
+
+ Tries every 10s for up to ~2 minutes, then logs the manual fallback URL.
+ """
+ max_attempts = 12
+ delay_seconds = 10
+
+ for attempt in range(1, max_attempts + 1):
+ logger.info(f"[KC-STARTUP] Registration attempt {attempt}/{max_attempts}...")
+ if await _do_register():
+ logger.info("[KC-STARTUP] ✅ Redirect URIs registered successfully")
+ return
+ if attempt < max_attempts:
+ logger.info(f"[KC-STARTUP] Keycloak not ready - retrying in {delay_seconds}s...")
+ await asyncio.sleep(delay_seconds)
+
+ port = 3000 + int(os.getenv("PORT_OFFSET", "10"))
+ admin_url = os.getenv("KC_HOSTNAME_URL", "http://localhost:8081")
+ logger.warning(
+ "[KC-STARTUP] ⚠️ All registration attempts failed. "
+ "Manually add http://localhost:%d/oauth/callback to the ushadow-frontend "
+ "client at %s/admin",
+ port,
+ admin_url,
+ )
+
+
+async def register_current_environment():
+ """
+ Register the current environment's redirect URIs with Keycloak.
+
+ Spawns a background task that retries until Keycloak is ready so the
+ backend startup is never blocked by Keycloak availability.
+
+ Skip if KEYCLOAK_AUTO_REGISTER=false.
+ """
+ if os.getenv("KEYCLOAK_AUTO_REGISTER", "true").lower() == "false":
+ logger.info("[KC-STARTUP] Keycloak auto-registration disabled via KEYCLOAK_AUTO_REGISTER=false")
+ return
+
+ asyncio.create_task(_register_with_retry())
diff --git a/ushadow/backend/src/services/keycloak_user_sync.py b/ushadow/backend/src/services/keycloak_user_sync.py
new file mode 100644
index 00000000..89eaa337
--- /dev/null
+++ b/ushadow/backend/src/services/keycloak_user_sync.py
@@ -0,0 +1,128 @@
+"""
+Keycloak User Synchronization
+
+Syncs Keycloak users to MongoDB User collection for Chronicle compatibility.
+Chronicle requires MongoDB ObjectIds for user_id, but Keycloak uses UUIDs.
+
+This module creates/updates MongoDB User records for Keycloak-authenticated users.
+"""
+
+import logging
+from typing import Optional
+from beanie import PydanticObjectId
+
+from src.models.user import User
+
+logger = logging.getLogger(__name__)
+
+
+async def get_or_create_user_from_keycloak(
+ keycloak_sub: str,
+ email: str,
+ name: Optional[str] = None
+) -> User:
+ """
+ Get or create a MongoDB User record for a Keycloak user.
+
+ This ensures Keycloak users have a corresponding MongoDB ObjectId that
+ Chronicle can use. The Keycloak subject ID is stored in keycloak_id field.
+
+ Args:
+ keycloak_sub: Keycloak user ID (UUID format)
+ email: User's email address
+ name: User's full name (optional)
+
+ Returns:
+ User: MongoDB User document with ObjectId
+
+ Example:
+ >>> user = await get_or_create_user_from_keycloak(
+ ... keycloak_sub="f47ac10b-58cc-4372-a567-0e02b2c3d479",
+ ... email="alice@example.com",
+ ... name="Alice Smith"
+ ... )
+ >>> str(user.id) # MongoDB ObjectId: "507f1f77bcf86cd799439011"
+ """
+ # Try to find existing user by Keycloak ID
+ user = await User.find_one(User.keycloak_id == keycloak_sub)
+
+ if user:
+ logger.info(f"[KC-USER-SYNC] Found existing user: {email} (MongoDB ID: {user.id})")
+ logger.info(f"[KC-USER-SYNC] Name from Keycloak: '{name}', Current display_name: '{user.display_name}'")
+
+ # Update display_name if it changed
+ if name and user.display_name != name:
+ logger.info(f"[KC-USER-SYNC] Updating display_name: {user.display_name} → {name}")
+ user.display_name = name
+ await user.save()
+ elif not name:
+ logger.warning(f"[KC-USER-SYNC] ⚠️ No name provided from Keycloak for {email}")
+ else:
+ logger.debug(f"[KC-USER-SYNC] Display name already matches, no update needed")
+
+ return user
+
+ # Try to find by email (might be a legacy user who logged in via Keycloak)
+ user = await User.find_one(User.email == email)
+
+ if user:
+ logger.info(f"[KC-USER-SYNC] Found legacy user by email: {email}")
+ logger.info(f"[KC-USER-SYNC] Linking to Keycloak ID: {keycloak_sub}")
+
+ # Link to Keycloak
+ user.keycloak_id = keycloak_sub
+ # Update display_name if we have a proper name from Keycloak
+ # (even if display_name was previously set to email)
+ if name and (not user.display_name or user.display_name == email):
+ logger.info(f"[KC-USER-SYNC] Updating display_name: {user.display_name} → {name}")
+ user.display_name = name
+ await user.save()
+
+ return user
+
+ # Create new user
+ logger.info(f"[KC-USER-SYNC] Creating new user for Keycloak account: {email}")
+
+ user = User(
+ email=email,
+ display_name=name or email, # Fallback to email if no name provided
+ keycloak_id=keycloak_sub,
+ is_active=True,
+ is_verified=True, # Keycloak users are pre-verified
+ is_superuser=False, # Keycloak users are not admins by default
+ hashed_password="", # No password - auth is via Keycloak
+ )
+
+ await user.create()
+
+ logger.info(f"[KC-USER-SYNC] ✓ Created user: {email} (MongoDB ID: {user.id})")
+
+ return user
+
+
+async def get_mongodb_user_id_for_keycloak_user(
+ keycloak_sub: str,
+ email: str,
+ name: Optional[str] = None
+) -> str:
+ """
+ Get MongoDB ObjectId string for a Keycloak user.
+
+ This is a convenience wrapper around get_or_create_user_from_keycloak
+ that returns just the ObjectId as a string (for use in JWT tokens).
+
+ Args:
+ keycloak_sub: Keycloak user ID (UUID)
+ email: User's email
+ name: User's full name (optional)
+
+ Returns:
+ str: MongoDB ObjectId as string (24 hex chars)
+ """
+ user = await get_or_create_user_from_keycloak(
+ keycloak_sub=keycloak_sub,
+ email=email,
+ name=name
+ )
+
+ return str(user.id)
diff --git a/ushadow/backend/src/services/kubernetes_dns_manager.py b/ushadow/backend/src/services/kubernetes_dns_manager.py
new file mode 100644
index 00000000..22a4a722
--- /dev/null
+++ b/ushadow/backend/src/services/kubernetes_dns_manager.py
@@ -0,0 +1,706 @@
+"""Kubernetes DNS management service with cert-manager support."""
+
+import logging
+import tempfile
+import yaml
+from typing import Optional, List, Dict, Tuple
+from pathlib import Path
+
+from src.models.kubernetes_dns import (
+ DNSConfig,
+ DNSMapping,
+ DNSStatus,
+ DNSServiceMapping,
+ CertificateStatus,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class KubernetesDNSManager:
+ """Manages CoreDNS configuration and TLS certificates for Kubernetes services."""
+
+ def __init__(self, kubectl_runner):
+ """
+ Initialize DNS manager.
+
+ Args:
+ kubectl_runner: Function to run kubectl commands
+ """
+ self.kubectl = kubectl_runner
+
+ async def get_dns_status(self, cluster_id: str, config: Optional[DNSConfig] = None) -> DNSStatus:
+ """Get current DNS configuration status."""
+ try:
+ # Get CoreDNS IP
+ coredns_ip = await self._get_coredns_ip()
+
+ # Get Ingress IP
+ ingress_ip = await self._get_ingress_ip(
+ config.ingress_namespace if config else "ingress-nginx"
+ )
+
+ # Check if cert-manager is installed
+ cert_manager_installed = await self._is_cert_manager_installed()
+
+ # Check if DNS is configured
+ configured = False
+ domain = None
+ mappings = []
+
+ if config:
+ # Check if ConfigMap exists
+ configmap_name = config.dns_configmap_name
+ namespace = config.coredns_namespace
+
+ hosts_content = await self._get_dns_configmap_content(
+ configmap_name, namespace, config.hosts_filename
+ )
+
+ if hosts_content:
+ configured = True
+ domain = config.domain
+ mappings = self._parse_hosts_file(hosts_content, config.domain)
+
+ return DNSStatus(
+ configured=configured,
+ domain=domain,
+ coredns_ip=coredns_ip,
+ ingress_ip=ingress_ip,
+ cert_manager_installed=cert_manager_installed,
+ mappings=mappings,
+ total_services=len(mappings)
+ )
+
+ except Exception as e:
+ logger.error(f"Error getting DNS status: {e}")
+ return DNSStatus(configured=False)
+
+ async def setup_dns_system(
+ self,
+ cluster_id: str,
+ config: DNSConfig,
+ install_cert_manager: bool = True
+ ) -> Tuple[bool, Optional[str]]:
+ """
+ Setup DNS system on cluster.
+
+ Returns: (success, error_message)
+ """
+ try:
+ # 1. Install cert-manager if requested
+ if install_cert_manager:
+ success, error = await self._install_cert_manager()
+ if not success:
+ return False, f"Failed to install cert-manager: {error}"
+
+ # 2. Create ClusterIssuer for Let's Encrypt
+ if config.acme_email:
+ success, error = await self._create_cert_issuer(
+ config.cert_issuer,
+ config.acme_email
+ )
+ if not success:
+ return False, f"Failed to create cert issuer: {error}"
+
+ # 3. Create DNS ConfigMap (empty initially)
+ success, error = await self._create_dns_configmap(config)
+ if not success:
+ return False, f"Failed to create DNS ConfigMap: {error}"
+
+ # 4. Patch CoreDNS Corefile
+ success, error = await self._patch_coredns_config(config)
+ if not success:
+ return False, f"Failed to patch CoreDNS config: {error}"
+
+ # 5. Patch CoreDNS Deployment
+ success, error = await self._patch_coredns_deployment(config)
+ if not success:
+ return False, f"Failed to patch CoreDNS deployment: {error}"
+
+ logger.info(f"DNS system setup complete for cluster {cluster_id}")
+ return True, None
+
+ except Exception as e:
+ logger.error(f"Error setting up DNS system: {e}")
+ return False, str(e)
+
+ async def add_service_dns(
+ self,
+ cluster_id: str,
+ config: DNSConfig,
+ mapping: DNSServiceMapping
+ ) -> Tuple[bool, Optional[str]]:
+ """
+ Add DNS entry for a service and create Ingress with optional TLS.
+
+ Returns: (success, error_message)
+ """
+ try:
+ # 1. Get service IP or ingress IP
+ if mapping.use_ingress:
+ ip = await self._get_ingress_ip(config.ingress_namespace)
+ if not ip:
+ return False, "Ingress controller not found"
+ else:
+ ip = await self._get_service_ip(mapping.service_name, mapping.namespace)
+ if not ip:
+ return False, f"Service {mapping.service_name} not found"
+
+ # 2. Update DNS ConfigMap
+ success, error = await self._add_dns_mapping(
+ config, ip, mapping.shortnames
+ )
+ if not success:
+ return False, f"Failed to add DNS mapping: {error}"
+
+ # 3. Create Ingress resource
+ success, error = await self._create_ingress(
+ config, mapping
+ )
+ if not success:
+ return False, f"Failed to create Ingress: {error}"
+
+ logger.info(f"Added DNS for service {mapping.service_name}")
+ return True, None
+
+ except Exception as e:
+ logger.error(f"Error adding service DNS: {e}")
+ return False, str(e)
+
+ async def remove_service_dns(
+ self,
+ cluster_id: str,
+ config: DNSConfig,
+ service_name: str,
+ namespace: str
+ ) -> Tuple[bool, Optional[str]]:
+ """Remove DNS entry and Ingress for a service."""
+ try:
+ # 1. Remove from DNS ConfigMap
+ success, error = await self._remove_dns_mapping(config, service_name)
+ if not success:
+ logger.warning(f"Failed to remove DNS mapping: {error}")
+
+ # 2. Delete Ingress
+ ingress_name = f"{service_name}-ingress"
+ await self.kubectl(
+ f"delete ingress {ingress_name} -n {namespace} --ignore-not-found=true"
+ )
+
+ logger.info(f"Removed DNS for service {service_name}")
+ return True, None
+
+ except Exception as e:
+ logger.error(f"Error removing service DNS: {e}")
+ return False, str(e)
+
+ async def list_certificates(
+ self,
+ cluster_id: str,
+ namespace: Optional[str] = None
+ ) -> List[CertificateStatus]:
+ """List TLS certificates managed by cert-manager."""
+ try:
+ cmd = "get certificates -o json"
+ if namespace:
+ cmd += f" -n {namespace}"
+ else:
+ cmd += " -A"
+
+ result = await self.kubectl(cmd)
+ certs_data = yaml.safe_load(result)
+
+ certificates = []
+ for cert in certs_data.get("items", []):
+ metadata = cert.get("metadata", {})
+ spec = cert.get("spec", {})
+ status = cert.get("status", {})
+
+ conditions = status.get("conditions", [])
+ ready = any(
+ c.get("type") == "Ready" and c.get("status") == "True"
+ for c in conditions
+ )
+
+ certificates.append(CertificateStatus(
+ name=metadata.get("name"),
+ namespace=metadata.get("namespace"),
+ ready=ready,
+ secret_name=spec.get("secretName"),
+ issuer_name=spec.get("issuerRef", {}).get("name"),
+ dns_names=spec.get("dnsNames", []),
+ not_before=status.get("notBefore"),
+ not_after=status.get("notAfter"),
+ renewal_time=status.get("renewalTime")
+ ))
+
+ return certificates
+
+ except Exception as e:
+ logger.error(f"Error listing certificates: {e}")
+ return []
+
+ # Private helper methods
+
+ async def _get_coredns_ip(self) -> Optional[str]:
+ """Get CoreDNS service IP."""
+ try:
+ result = await self.kubectl(
+ "get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}'"
+ )
+ return result.strip() if result else None
+ except Exception as e:
+ logger.error(f"Error getting CoreDNS IP: {e}")
+ return None
+
+ async def _get_ingress_ip(self, namespace: str) -> Optional[str]:
+ """Get Ingress controller IP."""
+ try:
+ result = await self.kubectl(
+ f"get svc ingress-nginx-controller -n {namespace} "
+ "-o jsonpath='{.spec.clusterIP}'"
+ )
+ return result.strip() if result else None
+ except Exception as e:
+ logger.error(f"Error getting Ingress IP: {e}")
+ return None
+
+ async def _get_service_ip(self, service: str, namespace: str) -> Optional[str]:
+ """Get service IP (LoadBalancer or ClusterIP)."""
+ try:
+ # Try LoadBalancer IP first
+ result = await self.kubectl(
+ f"get svc {service} -n {namespace} "
+ "-o jsonpath='{.status.loadBalancer.ingress[0].ip}'"
+ )
+ if result and result.strip() and result.strip() != "None":
+ return result.strip()
+
+ # Fall back to ClusterIP
+ result = await self.kubectl(
+ f"get svc {service} -n {namespace} "
+ "-o jsonpath='{.spec.clusterIP}'"
+ )
+ return result.strip() if result else None
+ except Exception as e:
+ logger.error(f"Error getting service IP: {e}")
+ return None
+
+ async def _is_cert_manager_installed(self) -> bool:
+ """Check if cert-manager is installed."""
+ try:
+ result = await self.kubectl("get namespace cert-manager")
+ return "cert-manager" in result
+ except:
+ return False
+
+ async def _install_cert_manager(self) -> Tuple[bool, Optional[str]]:
+ """Install cert-manager using official manifest."""
+ try:
+ logger.info("Installing cert-manager...")
+
+ # Install cert-manager CRDs and components
+ cert_manager_version = "v1.14.2" # Latest stable
+ manifest_url = (
+ f"https://github.com/cert-manager/cert-manager/releases/download/"
+ f"{cert_manager_version}/cert-manager.yaml"
+ )
+
+ await self.kubectl(f"apply -f {manifest_url}")
+
+ # Wait for cert-manager to be ready
+ await self.kubectl(
+ "wait --for=condition=available --timeout=300s "
+ "deployment/cert-manager -n cert-manager"
+ )
+
+ logger.info("cert-manager installed successfully")
+ return True, None
+
+ except Exception as e:
+ logger.error(f"Error installing cert-manager: {e}")
+ return False, str(e)
+
+ async def _create_cert_issuer(
+ self,
+ issuer_name: str,
+ email: str
+ ) -> Tuple[bool, Optional[str]]:
+ """Create Let's Encrypt ClusterIssuer."""
+ try:
+ issuer_yaml = f"""
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: {issuer_name}
+spec:
+ acme:
+ server: https://acme-v02.api.letsencrypt.org/directory
+ email: {email}
+ privateKeySecretRef:
+ name: {issuer_name}
+ solvers:
+ - http01:
+ ingress:
+ class: nginx
+"""
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ f.write(issuer_yaml)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(f"apply -f {temp_file}")
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error creating cert issuer: {e}")
+ return False, str(e)
+
+ async def _get_dns_configmap_content(
+ self,
+ configmap_name: str,
+ namespace: str,
+ filename: str
+ ) -> Optional[str]:
+ """Get DNS ConfigMap content."""
+ try:
+ result = await self.kubectl(
+ f"get configmap {configmap_name} -n {namespace} "
+ f"-o jsonpath='{{.data.{filename}}}'"
+ )
+ return result if result else None
+ except:
+ return None
+
+ def _parse_hosts_file(self, content: str, domain: str) -> List[DNSMapping]:
+ """Parse hosts file into DNS mappings."""
+ mappings = []
+
+ for line in content.split('\n'):
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ parts = line.split()
+ if len(parts) >= 2:
+ ip = parts[0]
+ fqdn = parts[1]
+ shortnames = parts[2:] if len(parts) > 2 else []
+
+ mappings.append(DNSMapping(
+ ip=ip,
+ fqdn=fqdn,
+ shortnames=shortnames,
+ has_tls=False, # TODO: Check for certificate
+ cert_ready=False
+ ))
+
+ return mappings
+
+ async def _create_dns_configmap(self, config: DNSConfig) -> Tuple[bool, Optional[str]]:
+ """Create DNS ConfigMap."""
+ try:
+ initial_content = f"# {config.domain} DNS Mappings\n# Managed by Ushadow\n"
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.hosts', delete=False) as f:
+ f.write(initial_content)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(
+ f"create configmap {config.dns_configmap_name} "
+ f"--from-file={config.hosts_filename}={temp_file} "
+ f"--namespace={config.coredns_namespace} "
+ "--dry-run=client -o yaml | kubectl apply -f -"
+ )
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error creating DNS ConfigMap: {e}")
+ return False, str(e)
+
+ async def _patch_coredns_config(self, config: DNSConfig) -> Tuple[bool, Optional[str]]:
+ """Patch CoreDNS Corefile to include hosts plugin."""
+ try:
+ # Get current Corefile
+ result = await self.kubectl(
+ f"get configmap coredns -n {config.coredns_namespace} "
+ "-o jsonpath='{.data.Corefile}'"
+ )
+
+ if not result:
+ return False, "CoreDNS Corefile not found"
+
+ corefile = result
+
+ # Check if already patched
+ hosts_line = f"hosts /etc/custom-hosts/{config.hosts_filename}"
+ if hosts_line in corefile:
+ logger.info("CoreDNS already configured")
+ return True, None
+
+ # Insert after 'ready'
+ lines = corefile.split('\n')
+ new_lines = []
+
+ for line in lines:
+ new_lines.append(line)
+ if line.strip() == 'ready':
+ new_lines.extend([
+ f" hosts /etc/custom-hosts/{config.hosts_filename} {{",
+ " fallthrough",
+ " }"
+ ])
+
+ new_corefile = '\n'.join(new_lines)
+
+ # Create ConfigMap YAML
+ configmap = {
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": {
+ "name": "coredns",
+ "namespace": config.coredns_namespace
+ },
+ "data": {
+ "Corefile": new_corefile
+ }
+ }
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ yaml.dump(configmap, f)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(f"apply -f {temp_file}")
+ logger.info("CoreDNS Corefile patched")
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error patching CoreDNS config: {e}")
+ return False, str(e)
+
+ async def _patch_coredns_deployment(self, config: DNSConfig) -> Tuple[bool, Optional[str]]:
+ """Mount DNS ConfigMap in CoreDNS deployment."""
+ try:
+ # Get current deployment
+ result = await self.kubectl(
+ f"get deployment coredns -n {config.coredns_namespace} -o json"
+ )
+
+ deployment = yaml.safe_load(result)
+
+ # Check if already configured
+ volumes = deployment["spec"]["template"]["spec"].get("volumes", [])
+ if any(v.get("name") == "custom-hosts" for v in volumes):
+ logger.info("CoreDNS deployment already configured")
+ return True, None
+
+ # Add volume
+ volumes.append({
+ "name": "custom-hosts",
+ "configMap": {
+ "name": config.dns_configmap_name
+ }
+ })
+ deployment["spec"]["template"]["spec"]["volumes"] = volumes
+
+ # Add volume mount
+ containers = deployment["spec"]["template"]["spec"]["containers"]
+ for container in containers:
+ if container["name"] == "coredns":
+ if "volumeMounts" not in container:
+ container["volumeMounts"] = []
+ container["volumeMounts"].append({
+ "name": "custom-hosts",
+ "mountPath": "/etc/custom-hosts",
+ "readOnly": True
+ })
+
+ # Apply
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ yaml.dump(deployment, f)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(f"apply -f {temp_file}")
+ logger.info("CoreDNS deployment patched")
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error patching CoreDNS deployment: {e}")
+ return False, str(e)
+
+ async def _add_dns_mapping(
+ self,
+ config: DNSConfig,
+ ip: str,
+ shortnames: List[str]
+ ) -> Tuple[bool, Optional[str]]:
+ """Add DNS mapping to ConfigMap."""
+ try:
+ # Get current content
+ current = await self._get_dns_configmap_content(
+ config.dns_configmap_name,
+ config.coredns_namespace,
+ config.hosts_filename
+ ) or ""
+
+ # Remove existing entries for this FQDN
+ fqdn = f"{shortnames[0]}.{config.domain}"
+ lines = [
+ line for line in current.split('\n')
+ if fqdn not in line
+ ]
+
+ # Add new entry
+ all_names = [fqdn] + shortnames
+ new_line = f"{ip} {' '.join(all_names)}"
+ lines.append(new_line)
+
+ new_content = '\n'.join(lines)
+
+ # Update ConfigMap
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.hosts', delete=False) as f:
+ f.write(new_content)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(
+ f"create configmap {config.dns_configmap_name} "
+ f"--from-file={config.hosts_filename}={temp_file} "
+ f"--namespace={config.coredns_namespace} "
+ "--dry-run=client -o yaml | kubectl apply -f -"
+ )
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error adding DNS mapping: {e}")
+ return False, str(e)
+
+ async def _remove_dns_mapping(
+ self,
+ config: DNSConfig,
+ service_name: str
+ ) -> Tuple[bool, Optional[str]]:
+ """Remove DNS mapping from ConfigMap."""
+ try:
+ # Get current content
+ current = await self._get_dns_configmap_content(
+ config.dns_configmap_name,
+ config.coredns_namespace,
+ config.hosts_filename
+ ) or ""
+
+ # Remove lines containing service name
+ lines = [
+ line for line in current.split('\n')
+ if service_name not in line
+ ]
+
+ new_content = '\n'.join(lines)
+
+ # Update ConfigMap
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.hosts', delete=False) as f:
+ f.write(new_content)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(
+ f"create configmap {config.dns_configmap_name} "
+ f"--from-file={config.hosts_filename}={temp_file} "
+ f"--namespace={config.coredns_namespace} "
+ "--dry-run=client -o yaml | kubectl apply -f -"
+ )
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error removing DNS mapping: {e}")
+ return False, str(e)
+
+ async def _create_ingress(
+ self,
+ config: DNSConfig,
+ mapping: DNSServiceMapping
+ ) -> Tuple[bool, Optional[str]]:
+ """Create Ingress resource with optional TLS."""
+ try:
+ ingress_name = f"{mapping.service_name}-ingress"
+ fqdn = f"{mapping.shortnames[0]}.{config.domain}"
+
+ # Build host rules
+ hosts = [fqdn] + mapping.shortnames
+ rules = []
+
+ for host in hosts:
+ rules.append({
+ "host": host,
+ "http": {
+ "paths": [{
+ "path": "/",
+ "pathType": "Prefix",
+ "backend": {
+ "service": {
+ "name": mapping.service_name,
+ "port": {
+ "number": mapping.service_port or 80
+ }
+ }
+ }
+ }]
+ }
+ })
+
+ ingress = {
+ "apiVersion": "networking.k8s.io/v1",
+ "kind": "Ingress",
+ "metadata": {
+ "name": ingress_name,
+ "namespace": mapping.namespace,
+ "annotations": {
+ "nginx.ingress.kubernetes.io/rewrite-target": "/"
+ }
+ },
+ "spec": {
+ "ingressClassName": "nginx",
+ "rules": rules
+ }
+ }
+
+ # Add TLS if enabled
+ if mapping.enable_tls and config.acme_email:
+ ingress["metadata"]["annotations"]["cert-manager.io/cluster-issuer"] = config.cert_issuer
+ ingress["spec"]["tls"] = [{
+ "hosts": hosts,
+ "secretName": f"{mapping.service_name}-tls"
+ }]
+
+ # Apply Ingress
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ yaml.dump(ingress, f)
+ temp_file = f.name
+
+ try:
+ await self.kubectl(f"apply -f {temp_file}")
+ logger.info(f"Created Ingress {ingress_name}")
+ return True, None
+ finally:
+ Path(temp_file).unlink()
+
+ except Exception as e:
+ logger.error(f"Error creating Ingress: {e}")
+ return False, str(e)
diff --git a/ushadow/backend/src/services/kubernetes_manager.py b/ushadow/backend/src/services/kubernetes_manager.py
index 39cc8f69..5b3b1be7 100644
--- a/ushadow/backend/src/services/kubernetes_manager.py
+++ b/ushadow/backend/src/services/kubernetes_manager.py
@@ -34,7 +34,9 @@ class KubernetesManager:
def __init__(self, db: AsyncIOMotorDatabase):
self.db = db
self.clusters_collection = db.kubernetes_clusters
- self._kubeconfig_dir = Path("/config/kubeconfigs")
+ # Store kubeconfigs in writable directory (configurable via env var)
+ kubeconfig_dir_str = os.getenv("KUBECONFIG_DIR", "/app/data/kubeconfigs")
+ self._kubeconfig_dir = Path(kubeconfig_dir_str)
self._kubeconfig_dir.mkdir(parents=True, exist_ok=True)
# Initialize encryption for kubeconfig files
self._fernet = self._init_fernet()
@@ -88,6 +90,33 @@ async def add_cluster(
# Generate cluster ID
cluster_id = secrets.token_hex(8)
+ # Validate YAML syntax before proceeding
+ try:
+ yaml.safe_load(kubeconfig_yaml)
+ except yaml.YAMLError as e:
+ error_msg = "Invalid YAML in kubeconfig"
+ logger.error(f"YAML validation failed: {e}")
+
+ # Try to provide helpful context about the error location
+ if hasattr(e, 'problem_mark'):
+ mark = e.problem_mark
+ error_msg += f" at line {mark.line + 1}, column {mark.column + 1}"
+
+ # Add specific problem description
+ if hasattr(e, 'problem'):
+ error_msg += f": {e.problem}"
+
+ # Add helpful tips for common issues
+ error_details = str(e).lower()
+ if "tab" in error_details or "\\t" in error_details:
+ error_msg += ". Tip: Replace tab characters with spaces (YAML doesn't allow tabs)"
+ elif ":" in error_details or "colon" in error_details:
+ error_msg += ". Tip: Check that all keys have colons (key: value)"
+ elif "indent" in error_details:
+ error_msg += ". Tip: Ensure consistent indentation (use 2 spaces)"
+
+ return False, None, error_msg
+
# Write to temp file for validation (kubernetes client needs a file)
temp_kubeconfig_path = self._kubeconfig_dir / f".tmp_{cluster_id}.yaml"
temp_kubeconfig_path.write_text(kubeconfig_yaml)
@@ -95,10 +124,13 @@ async def add_cluster(
os.chmod(temp_kubeconfig_path, 0o600)
# Load config and extract info
- kube_config = config.load_kube_config(
- config_file=str(temp_kubeconfig_path),
- context=cluster_data.context
- )
+ try:
+ kube_config = config.load_kube_config(
+ config_file=str(temp_kubeconfig_path),
+ context=cluster_data.context
+ )
+ except config.ConfigException as e:
+ return False, None, f"Invalid kubeconfig format: {str(e)}"
# Get cluster info
api_client = client.ApiClient()
@@ -307,6 +339,58 @@ async def update_cluster_infra_scan(
logger.error(f"Error updating cluster infra scan: {e}")
return False
+ async def delete_cluster_infra_scan(
+ self,
+ cluster_id: str,
+ namespace: str
+ ) -> bool:
+ """
+ Delete cached infrastructure scan for a specific namespace.
+
+ Args:
+ cluster_id: The cluster ID
+ namespace: The namespace scan to delete
+
+ Returns:
+ True if deletion was successful
+ """
+ try:
+ result = await self.clusters_collection.update_one(
+ {"cluster_id": cluster_id},
+ {"$unset": {f"infra_scans.{namespace}": ""}}
+ )
+ return result.modified_count > 0
+ except Exception as e:
+ logger.error(f"Error deleting cluster infra scan: {e}")
+ return False
+
+ async def update_cluster(
+ self,
+ cluster_id: str,
+ updates: Dict[str, Any]
+ ) -> Optional[KubernetesCluster]:
+ """Update cluster configuration fields."""
+ try:
+ # Validate cluster exists
+ cluster = await self.get_cluster(cluster_id)
+ if not cluster:
+ return None
+
+ # Update MongoDB document
+ result = await self.clusters_collection.update_one(
+ {"cluster_id": cluster_id},
+ {"$set": updates}
+ )
+
+ if result.modified_count == 0 and result.matched_count == 0:
+ return None
+
+ # Return updated cluster
+ return await self.get_cluster(cluster_id)
+ except Exception as e:
+ logger.error(f"Error updating cluster: {e}")
+ return None
+
async def remove_cluster(self, cluster_id: str) -> bool:
"""Remove a cluster and its kubeconfig."""
# Delete encrypted kubeconfig file
@@ -359,6 +443,64 @@ def _get_kube_client(self, cluster_id: str) -> Tuple[client.CoreV1Api, client.Ap
else:
raise FileNotFoundError(f"Kubeconfig not found for cluster {cluster_id}")
+ async def run_kubectl_command(self, cluster_id: str, command: str) -> str:
+ """
+ Run kubectl command for a cluster.
+
+ Args:
+ cluster_id: The cluster ID
+ command: kubectl command (without 'kubectl' prefix)
+
+ Returns:
+ Command output as string
+
+ Raises:
+ Exception: If command fails
+ """
+ import subprocess
+
+ encrypted_path = self._kubeconfig_dir / f"{cluster_id}.enc"
+ legacy_path = self._kubeconfig_dir / f"{cluster_id}.yaml"
+
+ # Get kubeconfig path
+ temp_path = None
+ try:
+ # Try encrypted file first
+ if encrypted_path.exists():
+ encrypted_data = encrypted_path.read_bytes()
+ kubeconfig_yaml = self._decrypt_kubeconfig(encrypted_data)
+
+ # Write to temp file
+ temp_path = self._kubeconfig_dir / f".tmp_kubectl_{cluster_id}.yaml"
+ temp_path.write_text(kubeconfig_yaml)
+ os.chmod(temp_path, 0o600)
+ kubeconfig_file = str(temp_path)
+
+ elif legacy_path.exists():
+ kubeconfig_file = str(legacy_path)
+ else:
+ raise FileNotFoundError(f"Kubeconfig not found for cluster {cluster_id}")
+
+ # Run kubectl command
+ cmd = f"kubectl --kubeconfig={kubeconfig_file} {command}"
+ result = subprocess.run(
+ cmd,
+ shell=True,
+ capture_output=True,
+ text=True,
+ check=True
+ )
+
+ return result.stdout
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"kubectl command failed: {e.stderr}")
+ raise Exception(f"kubectl command failed: {e.stderr}")
+ finally:
+ # Clean up temp file
+ if temp_path and temp_path.exists():
+ temp_path.unlink()
+
def _resolve_image_variables(self, image: str, environment: Dict[str, str]) -> str:
"""
Resolve environment variables in Docker image names.
@@ -477,111 +619,94 @@ async def compile_service_to_k8s(
else:
config_data[key] = str(value)
- # Parse volumes - separate config files from persistent volumes
+ # Parse volumes - every Docker volume becomes a PVC.
+ # Bind-mount volumes are seeded from local paths on deploy.
# Volumes can be:
+ # - Named volumes: "volume_name:/container/path" (create service-scoped PVC)
# - Bind mounts: "/host/path:/container/path:ro" or "${VAR}/path:/container/path"
- # - Named volumes: "volume_name:/container/path" (creates PVC)
- config_files = {} # Files to include in ConfigMap
- volume_mounts = [] # Volume mounts for container
- k8s_volumes = [] # Volume definitions for pod
- pvcs_to_create = [] # PVCs to create as manifests
+ # - Well-known shared volumes (config, compose) use fixed PVC names
+ # - Other bind mounts use service-scoped PVC names
+ volume_mounts = [] # Volume mounts for container
+ k8s_volumes = [] # Volume definitions for pod
+ pvcs_to_create = [] # {claim_name, volume_name, storage} PVCs to create
+ volumes_to_seed = [] # {source_path, pvc_claim_name} bind-mounts to seed on deploy
for volume_def in volumes:
- if isinstance(volume_def, str):
- # Parse "source:dest" or "source:dest:options" format
- parts = volume_def.split(":")
- if len(parts) >= 2:
- source, dest = parts[0], parts[1]
- is_readonly = len(parts) > 2 and 'ro' in parts[2]
-
- # Resolve environment variables in source path
- import os
- source = os.path.expandvars(source)
-
- # Detect volume type:
- # - Named volume: simple name without "/" or "." prefix (e.g., "ushadow-config")
- # - Path-based: starts with "/" or "." or contains "/" (e.g., "/config", "./data", "host/path")
- is_named_volume = not source.startswith(('/', '.')) and '/' not in source
-
- # Check if source is a file (for config files) or directory (for data volumes)
- from pathlib import Path
- source_path = Path(source)
-
- if source_path.is_file():
- # Config file - add to ConfigMap
- try:
- with open(source_path, 'r') as f:
- file_content = f.read()
- file_name = source_path.name
- config_files[file_name] = file_content
- logger.info(f"Adding config file {file_name} to ConfigMap (source: {source})")
-
- # Add volume mount for this file
- volume_mounts.append({
- "name": "config-files",
- "mountPath": dest,
- "subPath": file_name,
- "readOnly": is_readonly
- })
- except Exception as e:
- logger.warning(f"Could not read config file {source}: {e}")
-
- elif is_named_volume:
- # Named volume - create PVC for persistent storage
- # Sanitize volume name: replace dots, underscores with hyphens (K8s requirement)
- volume_name = source.replace("_", "-").replace(".", "-")
-
- # Only add PVC if not already added
- if not any(v.get("name") == volume_name for v in k8s_volumes):
- # Add PVC to list for manifest creation
- pvcs_to_create.append({
- "name": volume_name,
- "storage": "10Gi" # Default size, could be configurable
- })
-
- # Add PVC reference to pod volumes
- k8s_volumes.append({
- "name": volume_name,
- "persistentVolumeClaim": {
- "claimName": f"{name}-{volume_name}"
- }
- })
-
- volume_mounts.append({
- "name": volume_name,
- "mountPath": dest,
- "readOnly": is_readonly
- })
- logger.info(f"Adding PVC volume {volume_name} mounted at {dest}")
-
- elif source_path.is_dir() or not source_path.exists():
- # Directory or non-existent path - use emptyDir (non-persistent scratch)
- # Note: Named volumes are handled above and create PVCs
- # Sanitize volume name: replace dots, underscores with hyphens (K8s requirement)
- raw_name = source_path.name if source_path.name else "data"
- volume_name = raw_name.replace("_", "-").replace(".", "-")
-
- if not any(v.get("name") == volume_name for v in k8s_volumes):
- k8s_volumes.append({
- "name": volume_name,
- "emptyDir": {}
- })
-
- volume_mounts.append({
- "name": volume_name,
- "mountPath": dest,
- "readOnly": is_readonly
- })
- logger.info(f"Adding emptyDir volume {volume_name} mounted at {dest}")
-
- # Add config-files volume if we have config files
- if config_files:
- k8s_volumes.append({
- "name": "config-files",
- "configMap": {
- "name": f"{name}-files"
- }
+ if not isinstance(volume_def, str):
+ continue
+ parts = volume_def.split(":")
+ if len(parts) < 2:
+ continue
+
+ source, dest = parts[0], parts[1]
+ is_readonly = len(parts) > 2 and 'ro' in parts[2]
+
+ # Resolve environment variables in source path
+ import os
+ source = os.path.expandvars(source)
+
+ # Named volume: simple name without "/" or "." (e.g., "chronicle_audio")
+ from pathlib import Path
+ source_path = Path(source)
+ is_named_volume = not source.startswith(('/', '.')) and '/' not in source
+
+ if is_named_volume:
+ # Named volume → service-scoped PVC
+ volume_name = source.replace("_", "-").replace(".", "-")
+ claim_name = f"{name}-{volume_name}"
+ storage = "10Gi"
+ else:
+ # Bind mount → PVC with seeding
+ # Well-known shared volumes use fixed claim names so all services share them.
+ # Other bind mounts get service-scoped names derived from destination path.
+ dest_lower = dest.lower()
+ if "config" in dest_lower:
+ # All services share one config PVC (e.g., ../config:/app/config:ro)
+ volume_name = "ushadow-config"
+ claim_name = "ushadow-config"
+ storage = "1Gi"
+ elif "compose" in dest_lower or dest.rstrip("/") == "/compose":
+ # Compose files shared PVC
+ volume_name = "ushadow-compose"
+ claim_name = "ushadow-compose"
+ storage = "1Gi"
+ else:
+ # Service-specific bind mount: derive name from destination
+ dest_name = dest.strip("/").replace("/", "-").replace("_", "-").replace(".", "-") or "data"
+ volume_name = dest_name
+ claim_name = f"{name}-{dest_name}"
+ storage = "10Gi"
+
+ # Track for seeding if source exists locally
+ if source_path.exists():
+ volumes_to_seed.append({
+ "source_path": str(source_path.resolve()),
+ "pvc_claim_name": claim_name,
+ })
+
+ # Add PVC manifest (skip if already added for shared volumes)
+ if not any(p["claim_name"] == claim_name for p in pvcs_to_create):
+ pvcs_to_create.append({
+ "claim_name": claim_name,
+ "volume_name": volume_name,
+ "storage": storage,
+ })
+
+ # Add pod volume reference (skip if already added for shared volumes)
+ if not any(v.get("name") == volume_name for v in k8s_volumes):
+ k8s_volumes.append({
+ "name": volume_name,
+ "persistentVolumeClaim": {
+ "claimName": claim_name,
+ },
+ })
+
+ volume_mounts.append({
+ "name": volume_name,
+ "mountPath": dest,
+ "readOnly": is_readonly,
})
+ logger.info(f"Volume {source!r} → PVC {claim_name!r} mounted at {dest!r}")
# Generate manifests matching friend-lite pattern
labels = {
@@ -620,18 +745,32 @@ async def compile_service_to_k8s(
"data": secret_data
}
- # ConfigMap for config files (separate from env var ConfigMap)
- if config_files:
- manifests["config_files_map"] = {
- "apiVersion": "v1",
- "kind": "ConfigMap",
- "metadata": {
- "name": f"{name}-files",
- "namespace": namespace,
- "labels": labels
- },
- "data": config_files
- }
+ # TODO: Add deployment-config.yaml volume mount once ConfigMap generation is deployed
+ # Temporarily disabled - requires full implementation in get_or_create_envmap
+ # if config_data:
+ # # Add volume for deployment config
+ # if not any(v.get("name") == "deployment-config" for v in k8s_volumes):
+ # k8s_volumes.append({
+ # "name": "deployment-config",
+ # "configMap": {
+ # "name": f"{name}-config",
+ # "items": [{
+ # "key": "deployment-config.yaml",
+ # "path": "deployment-config.yaml"
+ # }]
+ # }
+ # })
+ # logger.info(f"Added deployment-config volume from {name}-config ConfigMap")
+ #
+ # # Add volume mount for deployment config
+ # if not any(m.get("name") == "deployment-config" for m in volume_mounts):
+ # volume_mounts.append({
+ # "name": "deployment-config",
+ # "mountPath": "/app/config/deployment-config.yaml",
+ # "subPath": "deployment-config.yaml",
+ # "readOnly": True
+ # })
+ # logger.info("Added deployment-config.yaml volume mount at /app/config/deployment-config.yaml")
# Debug: Log volumes before creating deployment
logger.info(f"Final k8s_volumes list ({len(k8s_volumes)} volumes):")
@@ -641,28 +780,33 @@ async def compile_service_to_k8s(
for idx, mount in enumerate(volume_mounts):
logger.info(f" [{idx}] name={mount['name']}, mountPath={mount['mountPath']}")
- # PersistentVolumeClaims for named volumes
- for i, pvc_info in enumerate(pvcs_to_create):
- pvc_name = pvc_info["name"]
- logger.info(f"Creating PVC manifest for: {pvc_name} (claim name: {name}-{pvc_name})")
- manifests[f"pvc_{pvc_name}"] = {
+ # PersistentVolumeClaims for all volumes
+ for pvc_info in pvcs_to_create:
+ claim_name = pvc_info["claim_name"]
+ volume_name = pvc_info["volume_name"]
+ logger.info(f"Creating PVC manifest: {claim_name!r}")
+ manifests[f"pvc_{volume_name}"] = {
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": {
- "name": f"{name}-{pvc_name}",
+ "name": claim_name,
"namespace": namespace,
- "labels": labels
+ "labels": labels,
},
"spec": {
"accessModes": ["ReadWriteOnce"],
"resources": {
"requests": {
- "storage": pvc_info["storage"]
+ "storage": pvc_info["storage"],
}
- }
- }
+ },
+ },
}
+ # Seed metadata: bind-mount volumes that need populating on deploy
+ if volumes_to_seed:
+ manifests["_volumes_to_seed"] = volumes_to_seed
+
# Deployment
manifests["deployment"] = {
"apiVersion": "apps/v1",
@@ -912,6 +1056,7 @@ async def scan_cluster_for_infra_services(
"postgres": {"names": ["postgres", "postgresql"], "port": 5432},
"qdrant": {"names": ["qdrant"], "port": 6333},
"neo4j": {"names": ["neo4j"], "port": 7687},
+ "keycloak": {"names": ["keycloak"], "port": 8080}
}
# Common namespaces where infrastructure might be deployed
@@ -1145,6 +1290,70 @@ async def get_pod_events(
logger.error(f"Error getting pod events: {e}")
raise
+ def _generate_deployment_config_yaml(self, env_vars: Dict[str, str]) -> str:
+ """
+ Generate deployment-specific config.yaml from environment variables.
+
+ This creates an OmegaConf-compatible YAML config that maps env vars
+ to structured settings, allowing each deployment to have independent config.
+
+ Args:
+ env_vars: Environment variables from deployment
+
+ Returns:
+ YAML string with deployment configuration
+ """
+ import yaml
+
+ config = {
+ "# Deployment-specific configuration": None,
+ "# Auto-generated from environment variables": None,
+ }
+
+ # Map Keycloak env vars to config structure
+ keycloak_config = {}
+ if 'KEYCLOAK_ENABLED' in env_vars:
+ keycloak_config['enabled'] = env_vars['KEYCLOAK_ENABLED'].lower() in ('true', '1', 'yes')
+ if 'KEYCLOAK_PUBLIC_URL' in env_vars:
+ keycloak_config['public_url'] = env_vars['KEYCLOAK_PUBLIC_URL']
+ if 'KEYCLOAK_URL' in env_vars:
+ keycloak_config['url'] = env_vars['KEYCLOAK_URL']
+ if 'KEYCLOAK_REALM' in env_vars:
+ keycloak_config['realm'] = env_vars['KEYCLOAK_REALM']
+ if 'KEYCLOAK_FRONTEND_CLIENT_ID' in env_vars:
+ keycloak_config['frontend_client_id'] = env_vars['KEYCLOAK_FRONTEND_CLIENT_ID']
+ if 'KEYCLOAK_BACKEND_CLIENT_ID' in env_vars:
+ keycloak_config['backend_client_id'] = env_vars['KEYCLOAK_BACKEND_CLIENT_ID']
+ if 'KEYCLOAK_ADMIN_USER' in env_vars:
+ keycloak_config['admin_user'] = env_vars['KEYCLOAK_ADMIN_USER']
+
+ if keycloak_config:
+ config['keycloak'] = keycloak_config
+
+ # Map MongoDB env vars to config structure
+ mongodb_config = {}
+ if 'MONGODB_HOST' in env_vars:
+ mongodb_config['host'] = env_vars['MONGODB_HOST']
+ if 'MONGODB_PORT' in env_vars:
+ mongodb_config['port'] = int(env_vars['MONGODB_PORT'])
+ if 'MONGODB_DATABASE' in env_vars:
+ mongodb_config['database'] = env_vars['MONGODB_DATABASE']
+
+ if mongodb_config:
+ if 'infrastructure' not in config:
+ config['infrastructure'] = {}
+ config['infrastructure']['mongodb'] = mongodb_config
+
+ # Add other common configs as needed
+ if 'COMPOSE_PROJECT_NAME' in env_vars:
+ if 'environment' not in config:
+ config['environment'] = {}
+ config['environment']['name'] = env_vars['COMPOSE_PROJECT_NAME']
+
+ # Generate YAML with comments
+ yaml_str = yaml.dump(config, default_flow_style=False, sort_keys=False)
+ return yaml_str
+
async def get_or_create_envmap(
self,
cluster_id: str,
@@ -1156,6 +1365,7 @@ async def get_or_create_envmap(
Get or create ConfigMap and Secret for service environment variables.
Separates sensitive (keys, passwords) from non-sensitive values.
+ Also generates deployment-specific config.yaml for OmegaConf.
Returns tuple of (configmap_name, secret_name).
"""
try:
@@ -1177,6 +1387,11 @@ async def get_or_create_envmap(
else:
config_data[key] = str(value)
+ # Generate deployment config YAML from env vars
+ deployment_config_yaml = self._generate_deployment_config_yaml(env_vars)
+ config_data['deployment-config.yaml'] = deployment_config_yaml
+ logger.info(f"Generated deployment config for {service_name}")
+
configmap_name = f"{service_name}-config"
secret_name = f"{service_name}-secrets"
@@ -1247,6 +1462,181 @@ async def get_or_create_envmap(
logger.error(f"Error creating envmap: {e}")
raise
+ async def _seed_pvc_from_path(
+ self,
+ cluster_id: str,
+ namespace: str,
+ pvc_claim_name: str,
+ source_path: str,
+ skip_if_not_empty: bool = True,
+ ) -> bool:
+ """
+ Seed a PVC with files from a local path using a temporary Kubernetes pod.
+
+ Creates a busybox pod that mounts the PVC, streams file content via the
+ Kubernetes exec API using base64 encoding, then deletes the pod. Requires
+ only the Kubernetes Python SDK — no kubectl needed.
+
+ Args:
+ cluster_id: Kubernetes cluster ID
+ namespace: Target namespace
+ pvc_claim_name: Name of the PVC to seed
+ source_path: Local file or directory to copy into the PVC root
+ skip_if_not_empty: Skip seeding if the PVC already has files
+
+ Returns True if seeding succeeded (or was skipped because PVC has content).
+ """
+ import asyncio
+ import base64
+
+ source = Path(source_path)
+ if not source.exists():
+ logger.warning(f"Seed source {source_path!r} does not exist, skipping PVC {pvc_claim_name!r}")
+ return False
+
+ # Collect files relative to source root
+ files_to_copy: Dict[str, bytes] = {}
+ if source.is_file():
+ files_to_copy[source.name] = source.read_bytes()
+ else:
+ for f in source.rglob("*"):
+ if f.is_file():
+ rel = str(f.relative_to(source))
+ try:
+ files_to_copy[rel] = f.read_bytes()
+ except Exception as exc:
+ logger.warning(f"Could not read {f}: {exc}")
+
+ if not files_to_copy:
+ logger.info(f"No files in {source_path!r}, nothing to seed into PVC {pvc_claim_name!r}")
+ return True
+
+ logger.info(f"Seeding PVC {pvc_claim_name!r} with {len(files_to_copy)} files from {source_path!r}")
+
+ pod_name = f"seed-{pvc_claim_name[:18]}-{secrets.token_hex(4)}"
+ core_api, _ = self._get_kube_client(cluster_id)
+
+ seed_pod = {
+ "apiVersion": "v1",
+ "kind": "Pod",
+ "metadata": {
+ "name": pod_name,
+ "namespace": namespace,
+ "labels": {
+ "app.kubernetes.io/managed-by": "ushadow",
+ "ushadow/role": "pvc-seeder",
+ },
+ },
+ "spec": {
+ "restartPolicy": "Never",
+ "containers": [{
+ "name": "seeder",
+ "image": "busybox:1.36",
+ "command": ["sh", "-c", "sleep 600"],
+ "volumeMounts": [{"name": "pvc", "mountPath": "/seed-data"}],
+ }],
+ "volumes": [{
+ "name": "pvc",
+ "persistentVolumeClaim": {"claimName": pvc_claim_name},
+ }],
+ },
+ }
+
+ loop = asyncio.get_event_loop()
+ try:
+ await loop.run_in_executor(
+ None,
+ lambda: core_api.create_namespaced_pod(namespace=namespace, body=seed_pod),
+ )
+ logger.info(f"Created seeder pod {pod_name!r} for PVC {pvc_claim_name!r}")
+
+ # Wait up to 120s for the pod to be Running
+ for _ in range(120):
+ pod = await loop.run_in_executor(
+ None,
+ lambda: core_api.read_namespaced_pod(name=pod_name, namespace=namespace),
+ )
+ phase = pod.status.phase
+ if phase == "Running":
+ break
+ if phase in ("Failed", "Unknown"):
+ raise RuntimeError(f"Seeder pod {pod_name!r} entered phase {phase!r}")
+ await asyncio.sleep(1)
+ else:
+ raise RuntimeError(f"Seeder pod {pod_name!r} did not become Running within 120s")
+
+ from kubernetes.stream import stream as k8s_stream
+
+ # Optionally check if PVC already has content
+ if skip_if_not_empty:
+ check = await loop.run_in_executor(
+ None,
+ lambda: k8s_stream(
+ core_api.connect_get_namespaced_pod_exec,
+ pod_name, namespace,
+ command=["sh", "-c", "ls /seed-data | wc -l"],
+ stderr=True, stdin=False, stdout=True, tty=False,
+ ),
+ )
+ if check and check.strip() != "0":
+ logger.info(f"PVC {pvc_claim_name!r} already has content, skipping seed")
+ return True
+
+ # Copy files via base64 encoding to avoid shell escaping issues
+ for rel_path, content in files_to_copy.items():
+ dest_path = f"/seed-data/{rel_path}"
+ dest_dir = str(Path(dest_path).parent)
+
+ await loop.run_in_executor(
+ None,
+ lambda d=dest_dir: k8s_stream(
+ core_api.connect_get_namespaced_pod_exec,
+ pod_name, namespace,
+ command=["mkdir", "-p", d],
+ stderr=True, stdin=False, stdout=True, tty=False,
+ ),
+ )
+
+ # base64 only uses [A-Za-z0-9+/=] — safe inside single-quoted shell strings
+ encoded = base64.b64encode(content).decode()
+ # Split into 32KB chunks to stay within exec argument limits
+ chunks = [encoded[i:i + 32768] for i in range(0, len(encoded), 32768)]
+ # First chunk: create file; subsequent chunks: append
+ cmd = f"printf '%s' '{chunks[0]}' | base64 -d > {dest_path}"
+ for chunk in chunks[1:]:
+ cmd += f" && printf '%s' '{chunk}' | base64 -d >> {dest_path}"
+
+ await loop.run_in_executor(
+ None,
+ lambda c=cmd: k8s_stream(
+ core_api.connect_get_namespaced_pod_exec,
+ pod_name, namespace,
+ command=["sh", "-c", c],
+ stderr=True, stdin=False, stdout=True, tty=False,
+ ),
+ )
+ logger.debug(f"Seeded {rel_path!r} ({len(content)} bytes) into PVC {pvc_claim_name!r}")
+
+ logger.info(f"Seeded {len(files_to_copy)} files into PVC {pvc_claim_name!r}")
+ return True
+
+ except Exception as exc:
+ logger.error(f"Failed to seed PVC {pvc_claim_name!r}: {exc}")
+ return False
+ finally:
+ try:
+ await loop.run_in_executor(
+ None,
+ lambda: core_api.delete_namespaced_pod(
+ name=pod_name,
+ namespace=namespace,
+ body=client.V1DeleteOptions(grace_period_seconds=0),
+ ),
+ )
+ logger.info(f"Deleted seeder pod {pod_name!r}")
+ except Exception as cleanup_exc:
+ logger.warning(f"Failed to delete seeder pod {pod_name!r}: {cleanup_exc}")
+
async def deploy_to_kubernetes(
self,
cluster_id: str,
@@ -1293,6 +1683,8 @@ async def deploy_to_kubernetes(
manifest_dir = Path("/tmp/k8s-manifests") / cluster_id / namespace
manifest_dir.mkdir(parents=True, exist_ok=True)
for manifest_type, manifest in manifests.items():
+ if manifest_type.startswith("_"):
+ continue # Skip internal metadata keys
manifest_file = manifest_dir / f"{service_name}-{manifest_type}.yaml"
with open(manifest_file, 'w') as f:
yaml.dump(manifest, f, default_flow_style=False)
@@ -1338,26 +1730,6 @@ async def deploy_to_kubernetes(
else:
raise
- # Apply ConfigMap for config files
- if "config_files_map" in manifests:
- try:
- core_api.create_namespaced_config_map(
- namespace=namespace,
- body=manifests["config_files_map"]
- )
- logger.info(f"Created ConfigMap for config files")
- except ApiException as e:
- if e.status == 409: # Already exists, update it
- name = manifests["config_files_map"]["metadata"]["name"]
- core_api.patch_namespaced_config_map(
- name=name,
- namespace=namespace,
- body=manifests["config_files_map"]
- )
- logger.info(f"Updated ConfigMap for config files")
- else:
- raise
-
# Apply PersistentVolumeClaims (must exist before Deployment references them)
for manifest_key, manifest in manifests.items():
if manifest_key.startswith("pvc_"):
@@ -1374,6 +1746,16 @@ async def deploy_to_kubernetes(
else:
raise
+ # Seed bind-mount volumes with local files (skip if PVC already has content)
+ for seed_info in manifests.get("_volumes_to_seed", []):
+ await self._seed_pvc_from_path(
+ cluster_id=cluster_id,
+ namespace=namespace,
+ pvc_claim_name=seed_info["pvc_claim_name"],
+ source_path=seed_info["source_path"],
+ skip_if_not_empty=True,
+ )
+
# Apply Deployment
deployment_name = manifests["deployment"]["metadata"]["name"]
deployment_volumes = manifests["deployment"]["spec"]["template"]["spec"].get("volumes", [])
@@ -1448,8 +1830,6 @@ async def deploy_to_kubernetes(
deployed_resources.append(f"ConfigMap/{manifests['config_map']['metadata']['name']}")
if "secret" in manifests:
deployed_resources.append(f"Secret/{manifests['secret']['metadata']['name']}")
- if "config_files_map" in manifests:
- deployed_resources.append(f"ConfigMap/{manifests['config_files_map']['metadata']['name']}")
deployed_resources.append(f"Deployment/{deployment_name}")
deployed_resources.append(f"Service/{service_name}")
if "ingress" in manifests:
diff --git a/ushadow/backend/src/services/mastodon_oauth.py b/ushadow/backend/src/services/mastodon_oauth.py
new file mode 100644
index 00000000..43e6deb1
--- /dev/null
+++ b/ushadow/backend/src/services/mastodon_oauth.py
@@ -0,0 +1,132 @@
+"""Mastodon OAuth2 service — app registration and token exchange.
+
+Uses Mastodon.py for the OAuth dance (app registration, URL generation,
+code exchange). The library's synchronous calls are run via asyncio.to_thread
+so they don't block the event loop.
+
+Flow:
+ 1. GET /api/feed/sources/mastodon/auth-url?instance_url=...&redirect_uri=...
+ → registers app (or reuses cached credentials)
+ → returns authorization URL to open in-browser
+ 2. User authorises → redirect to redirect_uri?code=xxx
+ 3. POST /api/feed/sources/mastodon/connect
+ { instance_url, code, redirect_uri, name }
+ → exchanges code for access_token
+ → saves PostSource with token
+"""
+
+import asyncio
+import logging
+
+from mastodon import Mastodon
+
+from src.models.feed import MastodonAppCredential
+
+logger = logging.getLogger(__name__)
+
+_SCOPES = ["read"]
+_APP_NAME = "Ushadow"
+
+
+class MastodonOAuthService:
+ """Handles OAuth2 registration and token exchange with Mastodon instances."""
+
+ async def get_authorization_url(
+ self, instance_url: str, redirect_uri: str
+ ) -> str:
+ """Return the Mastodon authorization URL for the given instance.
+
+ Registers an OAuth2 app on the instance if not already cached.
+ """
+ instance_url = _normalise(instance_url)
+ cred = await self._get_or_register_app(instance_url, redirect_uri)
+
+ def _build() -> str:
+ m = Mastodon(
+ client_id=cred.client_id,
+ client_secret=cred.client_secret,
+ api_base_url=instance_url,
+ )
+ return m.auth_request_url(
+ redirect_uris=redirect_uri,
+ scopes=_SCOPES,
+ )
+
+ url: str = await asyncio.to_thread(_build)
+ logger.info(f"Generated Mastodon auth URL for {instance_url}")
+ return url
+
+ async def exchange_code(
+ self, instance_url: str, code: str, redirect_uri: str
+ ) -> str:
+ """Exchange an authorization code for an access token.
+
+ Returns:
+ The access token string.
+
+ Raises:
+ ValueError: If no app is registered for this instance.
+ """
+ instance_url = _normalise(instance_url)
+ cred = await MastodonAppCredential.find_one(
+ MastodonAppCredential.instance_url == instance_url
+ )
+ if not cred:
+ raise ValueError(
+ f"No app registered for {instance_url}. "
+ "Call get_authorization_url first."
+ )
+
+ def _exchange() -> str:
+ m = Mastodon(
+ client_id=cred.client_id,
+ client_secret=cred.client_secret,
+ api_base_url=instance_url,
+ )
+ token: str = m.log_in(
+ code=code,
+ redirect_uri=redirect_uri,
+ scopes=_SCOPES,
+ )
+ return token
+
+ token = await asyncio.to_thread(_exchange)
+ logger.info(f"Exchanged OAuth code for token on {instance_url}")
+ return token
+
+ async def _get_or_register_app(
+ self, instance_url: str, redirect_uri: str
+ ) -> MastodonAppCredential:
+ """Return cached credentials, or register a new app on the instance."""
+ existing = await MastodonAppCredential.find_one(
+ MastodonAppCredential.instance_url == instance_url
+ )
+ if existing:
+ return existing
+
+ def _register() -> tuple[str, str]:
+ return Mastodon.create_app(
+ _APP_NAME,
+ api_base_url=instance_url,
+ redirect_uris=redirect_uri,
+ scopes=_SCOPES,
+ to_file=None,
+ )
+
+ client_id, client_secret = await asyncio.to_thread(_register)
+ cred = MastodonAppCredential(
+ instance_url=instance_url,
+ client_id=client_id,
+ client_secret=client_secret,
+ )
+ await cred.insert()
+ logger.info(f"Registered Mastodon OAuth2 app for {instance_url}")
+ return cred
+
+
+def _normalise(url: str) -> str:
+ """Ensure consistent URL format (https, no trailing slash)."""
+ url = url.strip().rstrip("/")
+ if not url.startswith(("http://", "https://")):
+ url = f"https://{url}"
+ return url
diff --git a/ushadow/backend/src/services/platforms/__init__.py b/ushadow/backend/src/services/platforms/__init__.py
new file mode 100644
index 00000000..74884299
--- /dev/null
+++ b/ushadow/backend/src/services/platforms/__init__.py
@@ -0,0 +1,54 @@
+"""Platform strategies for multi-source content fetching.
+
+Each platform (Mastodon, YouTube, etc.) implements PlatformFetcher to handle
+its own API calls and data transformation. The generic PostScorer then ranks
+all posts uniformly regardless of source platform.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List
+
+from src.models.feed import Interest, Post
+
+
+class PlatformFetcher(ABC):
+ """Abstract base for platform-specific content fetchers.
+
+ Implementors handle:
+ - Fetching raw content from the platform API
+ - Transforming platform-specific JSON into Post objects
+ """
+
+ @abstractmethod
+ async def fetch_for_interests(
+ self, interests: List[Interest], config: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Fetch raw content items matching user interests.
+
+ Args:
+ interests: User interests with hashtags/keywords.
+ config: Platform-specific config (instance_url, api_key, etc.)
+
+ Returns:
+ List of raw platform-specific dicts (to be transformed by to_post).
+ """
+
+ @abstractmethod
+ def to_post(
+ self,
+ raw: Dict[str, Any],
+ source_id: str,
+ user_id: str,
+ interests: List[Interest],
+ ) -> Post | None:
+ """Transform a raw platform item into a Post document.
+
+ Args:
+ raw: Raw API response item.
+ source_id: The PostSource.source_id this came from.
+ user_id: The user who owns this feed.
+ interests: Used for initial relevance scoring.
+
+ Returns:
+ Post object, or None if the item can't be parsed.
+ """
diff --git a/ushadow/backend/src/services/platforms/bluesky.py b/ushadow/backend/src/services/platforms/bluesky.py
new file mode 100644
index 00000000..ab54e2b4
--- /dev/null
+++ b/ushadow/backend/src/services/platforms/bluesky.py
@@ -0,0 +1,326 @@
+"""Bluesky / AT Protocol platform strategies.
+
+BlueskyFetcher — unauthenticated interest-based search via public AppView:
+ GET https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts
+ ?tag={hashtag}&tag={hashtag2}&limit=100&sort=latest
+ No credentials, no algorithm — pure hashtag relevance.
+
+BlueskyTimelineFetcher — authenticated personal Following feed via atproto SDK:
+ Uses app passwords and the AT Protocol client to fetch the user's home
+ timeline (accounts they follow), keeping it strictly separate from the
+ interest search feed.
+"""
+
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Set
+
+import httpx
+
+from src.models.feed import Interest, Post
+from src.services.platforms import PlatformFetcher
+
+logger = logging.getLogger(__name__)
+
+PUBLIC_APPVIEW_URL = "https://public.api.bsky.app"
+SEARCH_PATH = "/xrpc/app.bsky.feed.searchPosts"
+MAX_RESULTS = 100 # API maximum per request
+MAX_HASHTAGS = 20
+
+
+class BlueskyFetcher(PlatformFetcher):
+ """Fetches posts from the Bluesky public AppView via tag search.
+
+ Uses a single batched request with all interest hashtags (OR semantics),
+ unlike Mastodon which requires one request per hashtag.
+ No credentials needed — public.api.bsky.app is openly accessible.
+ """
+
+ async def fetch_for_interests(
+ self, interests: List[Interest], config: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Fetch posts matching interest hashtags from the Bluesky AppView.
+
+ Args:
+ interests: User interests with derived hashtags.
+ config: May contain 'instance_url' (defaults to public AppView).
+ """
+ hashtags = _collect_hashtags(interests, MAX_HASHTAGS)
+ if not hashtags:
+ logger.info("No hashtags derived from interests — skipping Bluesky fetch")
+ return []
+
+ # Prefer a custom AppView URL if configured, fall back to public
+ base_url = (config.get("instance_url") or PUBLIC_APPVIEW_URL).rstrip("/")
+
+ source_id = config.get("source_id", "")
+ raw_posts = await _search_by_tags(base_url, hashtags)
+
+ # Stamp source_id for use in to_post()
+ for post in raw_posts:
+ post["_source_id"] = source_id
+
+ logger.info(
+ f"Fetched {len(raw_posts)} posts from Bluesky "
+ f"({len(hashtags)} hashtags)"
+ )
+ return raw_posts
+
+ def to_post(
+ self,
+ raw: Dict[str, Any],
+ source_id: str,
+ user_id: str,
+ interests: List[Interest],
+ ) -> Optional[Post]:
+ """Transform a Bluesky searchPosts result item into a Post document."""
+ try:
+ author = raw.get("author", {})
+ record = raw.get("record", {})
+
+ handle = author.get("handle", "unknown")
+ display_name = author.get("displayName", "") or handle
+ avatar = author.get("avatar")
+
+ content = record.get("text", "")
+ created_at = record.get("createdAt", "")
+ published_at = _parse_datetime(created_at)
+
+ # Extract hashtags from record facets (AT Protocol structured data)
+ hashtags = _extract_hashtags(record)
+
+ # Build a web URL from the AT URI
+ uri = raw.get("uri", "")
+ url = _at_uri_to_web_url(uri, handle)
+
+ return Post(
+ user_id=user_id,
+ source_id=source_id,
+ external_id=uri or raw.get("cid", ""),
+ platform_type="bluesky",
+ author_handle=f"@{handle}",
+ author_display_name=display_name,
+ author_avatar=avatar,
+ content=content,
+ url=url,
+ published_at=published_at,
+ hashtags=hashtags,
+ language=record.get("langs", [None])[0] if record.get("langs") else None,
+ # Bluesky engagement metrics (shared with timeline)
+ boosts_count=raw.get("repostCount", 0),
+ favourites_count=raw.get("likeCount", 0),
+ replies_count=raw.get("replyCount", 0),
+ # CID is required to construct reply refs
+ bluesky_cid=raw.get("cid"),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to parse Bluesky post: {e}")
+ return None
+
+
+# ======================================================================
+# Module-level helpers
+# ======================================================================
+
+
+def _collect_hashtags(interests: List[Interest], max_count: int) -> List[str]:
+ """Collect unique hashtags from interests, ordered by interest weight."""
+ hashtags: List[str] = []
+ seen: Set[str] = set()
+ for interest in interests:
+ for tag in interest.hashtags:
+ if tag not in seen:
+ seen.add(tag)
+ hashtags.append(tag)
+ if len(hashtags) >= max_count:
+ return hashtags
+ return hashtags
+
+
+async def _search_by_tags(base_url: str, hashtags: List[str]) -> List[Dict[str, Any]]:
+ """Search Bluesky for posts matching any of the given hashtags.
+
+ The `tag` parameter accepts multiple values (OR semantics), so one
+ request covers all interests — far more efficient than per-tag requests.
+ """
+ url = f"{base_url}{SEARCH_PATH}"
+ params: List[tuple[str, str]] = [("limit", str(MAX_RESULTS)), ("sort", "latest")]
+ for tag in hashtags:
+ params.append(("tag", tag))
+
+ try:
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ resp = await client.get(url, params=params)
+ resp.raise_for_status()
+ data = resp.json()
+ posts = data.get("posts", [])
+ logger.debug(f"Bluesky search returned {len(posts)} posts")
+ return posts
+ except httpx.HTTPError as e:
+ logger.warning(f"Bluesky search failed: {e}")
+ return []
+
+
+def _extract_hashtags(record: Dict[str, Any]) -> List[str]:
+ """Extract hashtag strings from AT Protocol facets.
+
+ Facets are structured annotations on text ranges. A tag facet has:
+ {"$type": "app.bsky.richtext.facet#tag", "tag": "kubernetes"}
+ """
+ tags: List[str] = []
+ for facet in record.get("facets", []):
+ for feature in facet.get("features", []):
+ if feature.get("$type") == "app.bsky.richtext.facet#tag":
+ tag = feature.get("tag", "")
+ if tag:
+ tags.append(tag.lower())
+ return tags
+
+
+def _at_uri_to_web_url(uri: str, handle: str) -> str:
+ """Convert an AT URI to a bsky.app web URL.
+
+ AT URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
+ Web URL format: https://bsky.app/profile/{handle}/post/{rkey}
+ """
+ try:
+ # uri = "at://did:plc:xxx/app.bsky.feed.post/rkey"
+ parts = uri.split("/")
+ rkey = parts[-1] # last segment is the record key
+ return f"https://bsky.app/profile/{handle}/post/{rkey}"
+ except Exception:
+ return f"https://bsky.app/profile/{handle}"
+
+
+def _parse_datetime(value: str) -> datetime:
+ """Parse ISO datetime string, falling back to now(UTC)."""
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return datetime.now(timezone.utc)
+
+
+# ======================================================================
+# Authenticated Following Timeline
+# ======================================================================
+
+
+class BlueskyTimelineFetcher(PlatformFetcher):
+ """Fetches the authenticated user's personal Following timeline.
+
+ Uses the atproto AsyncClient with Bluesky app password authentication.
+ Returns posts from accounts the user follows — no interest filtering,
+ no Bluesky algorithm. Posts appear in chronological order as fetched.
+
+ Platform type: "bluesky_timeline" — always stored separately from
+ the interest-based "bluesky" feed so the two never blend.
+ """
+
+ async def fetch_for_interests(
+ self, interests: List[Interest], config: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Fetch the Following timeline for an authenticated Bluesky account.
+
+ Interests are intentionally ignored here — this feed reflects what
+ the user has chosen to follow, not algorithmic interest inference.
+
+ Args:
+ interests: Unused (timeline is follow-based, not interest-based).
+ config: Must contain 'handle' and 'api_key' (app password).
+ """
+ from atproto import AsyncClient as BskyClient # noqa: PLC0415
+
+ handle = config.get("handle", "")
+ app_password = config.get("api_key", "")
+ source_id = config.get("source_id", "")
+
+ if not handle or not app_password:
+ logger.warning(
+ "bluesky_timeline source %s missing handle or app_password — skipping",
+ source_id,
+ )
+ return []
+
+ try:
+ client = BskyClient()
+ await client.login(handle, app_password)
+ response = await client.get_timeline(limit=100)
+ except Exception as e:
+ logger.warning("Bluesky timeline fetch failed for %s: %s", handle, e)
+ return []
+
+ items: List[Dict[str, Any]] = []
+ for feed_view in response.feed:
+ post = feed_view.post
+ # Skip reposts — show only original posts from followed accounts
+ if feed_view.reason is not None:
+ continue
+ raw = _post_view_to_dict(post, source_id)
+ items.append(raw)
+
+ logger.info(
+ "Fetched %d Following posts for @%s", len(items), handle
+ )
+ return items
+
+ def to_post(
+ self,
+ raw: Dict[str, Any],
+ source_id: str,
+ user_id: str,
+ interests: List[Interest],
+ ) -> Optional[Post]:
+ """Transform a timeline post dict into a Post document.
+
+ Reuses the BlueskyFetcher transformation, then overrides platform_type
+ to 'bluesky_timeline' so the two feeds stay separate in MongoDB.
+ """
+ post = BlueskyFetcher().to_post(raw, source_id, user_id, interests)
+ if post:
+ post.platform_type = "bluesky_timeline"
+ return post
+
+
+def _post_view_to_dict(post: Any, source_id: str) -> Dict[str, Any]:
+ """Convert an atproto PostView typed object into our canonical raw dict.
+
+ The dict shape matches what the public search API returns, so
+ BlueskyFetcher.to_post() can process both without duplication.
+ """
+ author = post.author
+ record = post.record # AppBskyFeedPost.Main
+
+ # Extract facet-based hashtags from the typed record
+ facets_list: List[Dict[str, Any]] = []
+ if hasattr(record, "facets") and record.facets:
+ for facet in record.facets:
+ features = []
+ if hasattr(facet, "features") and facet.features:
+ for feat in facet.features:
+ features.append({
+ "$type": getattr(feat, "py_type", ""),
+ "tag": getattr(feat, "tag", ""),
+ })
+ facets_list.append({"features": features})
+
+ langs = getattr(record, "langs", None) or []
+
+ return {
+ "uri": post.uri,
+ "cid": post.cid,
+ "author": {
+ "handle": author.handle,
+ "displayName": author.display_name or "",
+ "avatar": author.avatar,
+ },
+ "record": {
+ "text": getattr(record, "text", ""),
+ "createdAt": getattr(record, "created_at", ""),
+ "facets": facets_list,
+ "langs": langs,
+ },
+ "replyCount": post.reply_count or 0,
+ "repostCount": post.repost_count or 0,
+ "likeCount": post.like_count or 0,
+ "_source_id": source_id,
+ }
diff --git a/ushadow/backend/src/services/platforms/mastodon.py b/ushadow/backend/src/services/platforms/mastodon.py
new file mode 100644
index 00000000..9f7f9e04
--- /dev/null
+++ b/ushadow/backend/src/services/platforms/mastodon.py
@@ -0,0 +1,201 @@
+"""Mastodon platform strategy — fetches posts from hashtag timelines.
+
+Uses the public Mastodon API:
+ GET /api/v1/timelines/tag/{hashtag}?limit=40
+No authentication required for public timelines.
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Set
+
+import httpx
+
+from src.models.feed import Interest, Post
+from src.services.platforms import PlatformFetcher
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_LIMIT = 40
+MAX_CONCURRENT = 5
+MAX_HASHTAGS = 20
+
+
+class MastodonFetcher(PlatformFetcher):
+ """Fetches posts from Mastodon-compatible hashtag timelines."""
+
+ async def fetch_for_interests(
+ self, interests: List[Interest], config: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Fetch posts from Mastodon.
+
+ If an OAuth access_token is configured, fetches from the user's
+ authenticated home timeline (all accounts they follow).
+ Otherwise falls back to public hashtag timelines derived from
+ the user's interests.
+
+ Args:
+ interests: User interests with derived hashtags.
+ config: Must contain 'instance_url'; optionally 'access_token'.
+ """
+ instance_url = config["instance_url"]
+ access_token = config.get("access_token") or ""
+ source_id = config.get("source_id", "")
+
+ if access_token:
+ return await _fetch_home_timeline(instance_url, access_token, source_id)
+
+ # Unauthenticated fallback: public hashtag timelines
+ hashtags = _collect_hashtags(interests, MAX_HASHTAGS)
+ if not hashtags:
+ return []
+
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT)
+
+ async def _bounded_fetch(hashtag: str) -> List[Dict[str, Any]]:
+ async with semaphore:
+ return await _fetch_hashtag_timeline(instance_url, hashtag)
+
+ tasks = [_bounded_fetch(tag) for tag in hashtags]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ seen_ids: Set[str] = set()
+ posts: List[Dict[str, Any]] = []
+
+ for result in results:
+ if isinstance(result, Exception):
+ logger.warning(f"Mastodon fetch failed: {result}")
+ continue
+ for status in result:
+ ext_id = status.get("uri") or status.get("id", "")
+ if ext_id and ext_id not in seen_ids:
+ seen_ids.add(ext_id)
+ status["_source_id"] = source_id
+ status["_source_instance"] = instance_url
+ posts.append(status)
+
+ logger.info(
+ f"Fetched {len(posts)} unique posts from {instance_url} "
+ f"({len(tasks)} hashtag requests)"
+ )
+ return posts
+
+ def to_post(
+ self,
+ raw: Dict[str, Any],
+ source_id: str,
+ user_id: str,
+ interests: List[Interest],
+ ) -> Post | None:
+ """Transform a Mastodon Status JSON into a Post document."""
+ try:
+ account = raw.get("account", {})
+ tags = raw.get("tags", [])
+
+ acct = account.get("acct", "unknown")
+ if "@" not in acct:
+ instance_url = raw.get("_source_instance", "")
+ domain = (
+ instance_url.replace("https://", "")
+ .replace("http://", "")
+ .rstrip("/")
+ )
+ acct = f"{acct}@{domain}" if domain else acct
+
+ published_at = _parse_datetime(raw.get("created_at", ""))
+
+ return Post(
+ user_id=user_id,
+ source_id=source_id,
+ external_id=raw.get("uri") or raw.get("id", ""),
+ platform_type="mastodon",
+ author_handle=f"@{acct}",
+ author_display_name=account.get("display_name", ""),
+ author_avatar=account.get("avatar"),
+ content=raw.get("content", ""),
+ url=raw.get("url") or raw.get("uri", ""),
+ published_at=published_at,
+ hashtags=[t.get("name", "") for t in tags if t.get("name")],
+ language=raw.get("language"),
+ boosts_count=raw.get("reblogs_count", 0),
+ favourites_count=raw.get("favourites_count", 0),
+ replies_count=raw.get("replies_count", 0),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to parse Mastodon status: {e}")
+ return None
+
+
+# ======================================================================
+# Module-level helpers
+# ======================================================================
+
+
+def _collect_hashtags(interests: List[Interest], max_count: int) -> List[str]:
+ """Collect unique hashtags from interests, ordered by interest weight."""
+ hashtags: List[str] = []
+ seen: Set[str] = set()
+ for interest in interests:
+ for tag in interest.hashtags:
+ if tag not in seen:
+ seen.add(tag)
+ hashtags.append(tag)
+ if len(hashtags) >= max_count:
+ return hashtags
+ return hashtags
+
+
+async def _fetch_home_timeline(
+ instance_url: str, access_token: str, source_id: str
+) -> List[Dict[str, Any]]:
+ """Fetch the authenticated user's home timeline (accounts they follow)."""
+ url = f"{instance_url.rstrip('/')}/api/v1/timelines/home"
+ headers = {"Authorization": f"Bearer {access_token}"}
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ url, headers=headers, params={"limit": DEFAULT_LIMIT}
+ )
+ resp.raise_for_status()
+ statuses = resp.json()
+ for s in statuses:
+ s["_source_id"] = source_id
+ s["_source_instance"] = instance_url
+ logger.debug(
+ f"Fetched {len(statuses)} posts from home timeline at {instance_url}"
+ )
+ return statuses
+ except httpx.HTTPError as e:
+ logger.warning(f"Failed to fetch home timeline from {instance_url}: {e}")
+ return []
+
+
+async def _fetch_hashtag_timeline(
+ instance_url: str, hashtag: str
+) -> List[Dict[str, Any]]:
+ """Fetch public posts for a hashtag from a Mastodon instance."""
+ url = f"{instance_url.rstrip('/')}/api/v1/timelines/tag/{hashtag}"
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(url, params={"limit": DEFAULT_LIMIT})
+ resp.raise_for_status()
+ statuses = resp.json()
+ logger.debug(
+ f"Fetched {len(statuses)} posts for #{hashtag} "
+ f"from {instance_url}"
+ )
+ return statuses
+ except httpx.HTTPError as e:
+ logger.warning(
+ f"Failed to fetch #{hashtag} from {instance_url}: {e}"
+ )
+ return []
+
+
+def _parse_datetime(value: str) -> datetime:
+ """Parse ISO datetime string, falling back to now(UTC)."""
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return datetime.now(timezone.utc)
diff --git a/ushadow/backend/src/services/platforms/youtube.py b/ushadow/backend/src/services/platforms/youtube.py
new file mode 100644
index 00000000..387a1528
--- /dev/null
+++ b/ushadow/backend/src/services/platforms/youtube.py
@@ -0,0 +1,281 @@
+"""YouTube platform strategy — fetches videos via YouTube Data API v3.
+
+Uses two API endpoints:
+ - search.list (100 quota units each) — finds video IDs matching interests
+ - videos.list (1 quota unit per 50 videos) — fetches details (thumbnails, stats)
+
+Quota budget: 5 searches × 100 = 500 + 1 details call = 501 units per refresh.
+Free tier is 10,000 units/day → ~19 refreshes/day.
+"""
+
+import asyncio
+import logging
+import re
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional, Set
+
+import httpx
+
+from src.models.feed import Interest, Post
+from src.services.platforms import PlatformFetcher
+
+logger = logging.getLogger(__name__)
+
+SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
+VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
+
+MAX_QUERIES = 5
+MAX_RESULTS_PER_QUERY = 10
+MAX_CONCURRENT = 3
+PUBLISHED_AFTER_DAYS = 30
+
+
+class YouTubeFetcher(PlatformFetcher):
+ """Fetches videos from YouTube Data API v3."""
+
+ async def fetch_for_interests(
+ self, interests: List[Interest], config: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Search YouTube for videos matching user interests.
+
+ 1. Convert top interests → search query strings
+ 2. Run searches concurrently (bounded)
+ 3. Batch-fetch video details (thumbnails, stats, duration)
+ 4. Deduplicate by video ID
+ """
+ api_key = config.get("api_key", "")
+ if not api_key:
+ logger.warning("YouTube source has no API key")
+ return []
+
+ queries = _interests_to_queries(interests, MAX_QUERIES)
+ if not queries:
+ return []
+
+ # Phase 1: Search for video IDs
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT)
+ published_after = (
+ datetime.now(timezone.utc) - timedelta(days=PUBLISHED_AFTER_DAYS)
+ ).isoformat()
+
+ async def _bounded_search(query: str) -> List[str]:
+ async with semaphore:
+ return await _search_videos(query, api_key, published_after)
+
+ tasks = [_bounded_search(q) for q in queries]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # Collect unique video IDs
+ seen_ids: Set[str] = set()
+ video_ids: List[str] = []
+ for result in results:
+ if isinstance(result, Exception):
+ logger.warning(f"YouTube search failed: {result}")
+ continue
+ for vid_id in result:
+ if vid_id not in seen_ids:
+ seen_ids.add(vid_id)
+ video_ids.append(vid_id)
+
+ if not video_ids:
+ return []
+
+ # Phase 2: Batch-fetch video details
+ videos = await _get_video_details(video_ids, api_key)
+
+ logger.info(
+ f"Fetched {len(videos)} YouTube videos "
+ f"({len(queries)} queries, {len(video_ids)} unique IDs)"
+ )
+
+ # Tag each video with source metadata
+ source_id = config.get("source_id", "")
+ for video in videos:
+ video["_source_id"] = source_id
+
+ return videos
+
+ def to_post(
+ self,
+ raw: Dict[str, Any],
+ source_id: str,
+ user_id: str,
+ interests: List[Interest],
+ ) -> Post | None:
+ """Transform a YouTube video JSON into a Post document."""
+ try:
+ snippet = raw.get("snippet", {})
+ stats = raw.get("statistics", {})
+ content_details = raw.get("contentDetails", {})
+ video_id = raw.get("id", "")
+
+ published_at = _parse_datetime(snippet.get("publishedAt", ""))
+ title = snippet.get("title", "")
+ description = snippet.get("description", "")
+
+ # Use highest-quality thumbnail available
+ thumbnails = snippet.get("thumbnails", {})
+ thumbnail_url = (
+ thumbnails.get("high", {}).get("url")
+ or thumbnails.get("medium", {}).get("url")
+ or thumbnails.get("default", {}).get("url")
+ )
+
+ # Extract hashtags from title + description
+ hashtags = _extract_hashtags(f"{title} {description}")
+
+ channel_title = snippet.get("channelTitle", "")
+
+ return Post(
+ user_id=user_id,
+ source_id=source_id,
+ external_id=f"yt:{video_id}",
+ platform_type="youtube",
+ author_handle=channel_title,
+ author_display_name=channel_title,
+ author_avatar=None,
+ content=f"{title}
{description[:500]}",
+ url=f"https://www.youtube.com/watch?v={video_id}",
+ published_at=published_at,
+ hashtags=hashtags,
+ language=snippet.get("defaultAudioLanguage"),
+ # YouTube-specific fields
+ thumbnail_url=thumbnail_url,
+ video_id=video_id,
+ channel_title=channel_title,
+ view_count=_safe_int(stats.get("viewCount")),
+ like_count=_safe_int(stats.get("likeCount")),
+ duration=_format_duration(content_details.get("duration", "")),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to parse YouTube video: {e}")
+ return None
+
+
+# ======================================================================
+# Module-level helpers
+# ======================================================================
+
+
+def _interests_to_queries(
+ interests: List[Interest], max_queries: int
+) -> List[str]:
+ """Convert top interests into YouTube search queries.
+
+ Joins the top 2-3 hashtags per interest into a search string.
+ Example: Interest(hashtags=["kubernetes", "k8s"]) → "kubernetes k8s"
+ """
+ queries: List[str] = []
+ for interest in interests[:max_queries]:
+ keywords = " ".join(interest.hashtags[:3])
+ if keywords.strip():
+ queries.append(keywords)
+ return queries
+
+
+async def _search_videos(
+ query: str, api_key: str, published_after: str
+) -> List[str]:
+ """Search YouTube for video IDs matching a query string."""
+ params = {
+ "part": "id",
+ "q": query,
+ "type": "video",
+ "maxResults": MAX_RESULTS_PER_QUERY,
+ "order": "relevance",
+ "publishedAfter": published_after,
+ "key": api_key,
+ }
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(SEARCH_URL, params=params)
+ resp.raise_for_status()
+ data = resp.json()
+ return [
+ item["id"]["videoId"]
+ for item in data.get("items", [])
+ if item.get("id", {}).get("videoId")
+ ]
+ except httpx.HTTPError as e:
+ logger.warning(f"YouTube search failed for '{query}': {e}")
+ return []
+
+
+async def _get_video_details(
+ video_ids: List[str], api_key: str
+) -> List[Dict[str, Any]]:
+ """Batch-fetch video details (snippet, stats, contentDetails).
+
+ YouTube allows up to 50 IDs per request (1 quota unit).
+ """
+ all_videos: List[Dict[str, Any]] = []
+
+ # Process in batches of 50
+ for i in range(0, len(video_ids), 50):
+ batch = video_ids[i : i + 50]
+ params = {
+ "part": "snippet,statistics,contentDetails",
+ "id": ",".join(batch),
+ "key": api_key,
+ }
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(VIDEOS_URL, params=params)
+ resp.raise_for_status()
+ data = resp.json()
+ all_videos.extend(data.get("items", []))
+ except httpx.HTTPError as e:
+ logger.warning(f"YouTube video details failed: {e}")
+
+ return all_videos
+
+
+def _extract_hashtags(text: str) -> List[str]:
+ """Extract #hashtags from YouTube title/description."""
+ tags = re.findall(r"#(\w{2,})", text.lower())
+ # Deduplicate while preserving order
+ seen: Set[str] = set()
+ result: List[str] = []
+ for tag in tags:
+ if tag not in seen:
+ seen.add(tag)
+ result.append(tag)
+ return result[:10]
+
+
+def _parse_datetime(value: str) -> datetime:
+ """Parse ISO datetime string, falling back to now(UTC)."""
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return datetime.now(timezone.utc)
+
+
+def _safe_int(value: Optional[str]) -> Optional[int]:
+ """Convert string numeric value to int, or None."""
+ if value is None:
+ return None
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ return None
+
+
+_ISO_DURATION_RE = re.compile(
+ r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"
+)
+
+
+def _format_duration(iso_duration: str) -> Optional[str]:
+ """Convert ISO 8601 duration (PT1H2M30S) to human-readable (1:02:30)."""
+ match = _ISO_DURATION_RE.match(iso_duration)
+ if not match:
+ return None
+
+ hours = int(match.group(1) or 0)
+ minutes = int(match.group(2) or 0)
+ seconds = int(match.group(3) or 0)
+
+ if hours:
+ return f"{hours}:{minutes:02d}:{seconds:02d}"
+ return f"{minutes}:{seconds:02d}"
diff --git a/ushadow/backend/src/services/post_fetcher.py b/ushadow/backend/src/services/post_fetcher.py
new file mode 100644
index 00000000..bf925d95
--- /dev/null
+++ b/ushadow/backend/src/services/post_fetcher.py
@@ -0,0 +1,89 @@
+"""Post Fetcher - Dispatches content fetching to platform strategies.
+
+Groups sources by platform_type and delegates to the appropriate
+PlatformFetcher implementation (MastodonFetcher, YouTubeFetcher, etc.).
+"""
+
+import logging
+from typing import Any, Dict, List, Type
+
+from src.models.feed import Interest, Post, PostSource
+from src.services.platforms import PlatformFetcher
+from src.services.platforms.bluesky import BlueskyFetcher, BlueskyTimelineFetcher
+from src.services.platforms.mastodon import MastodonFetcher
+from src.services.platforms.youtube import YouTubeFetcher
+
+logger = logging.getLogger(__name__)
+
+# Registry: platform_type → fetcher class
+_STRATEGIES: Dict[str, Type[PlatformFetcher]] = {
+ "mastodon": MastodonFetcher,
+ "youtube": YouTubeFetcher,
+ "bluesky": BlueskyFetcher,
+ "bluesky_timeline": BlueskyTimelineFetcher,
+}
+
+
+def register_platform(name: str, cls: Type[PlatformFetcher]) -> None:
+ """Register a new platform fetcher (called at import time)."""
+ _STRATEGIES[name] = cls
+
+
+class PostFetcher:
+ """Dispatches content fetching to platform-specific strategies."""
+
+ async def fetch_for_interests(
+ self,
+ sources: List[PostSource],
+ interests: List[Interest],
+ user_id: str,
+ ) -> List[Post]:
+ """Fetch and transform posts from all active sources.
+
+ Groups sources by platform_type, dispatches to the registered
+ strategy, transforms raw items to Post objects via to_post().
+
+ Returns:
+ List of Post objects (not yet scored).
+ """
+ active = [s for s in sources if s.enabled]
+ if not active:
+ logger.info("No active sources configured")
+ return []
+
+ all_posts: List[Post] = []
+
+ for source in active:
+ strategy_cls = _STRATEGIES.get(source.platform_type)
+ if not strategy_cls:
+ logger.warning(
+ f"No strategy for platform '{source.platform_type}'"
+ )
+ continue
+
+ strategy = strategy_cls()
+ config = _source_to_config(source)
+
+ raw_items = await strategy.fetch_for_interests(interests, config)
+ for raw in raw_items:
+ post = strategy.to_post(
+ raw, source.source_id, user_id, interests
+ )
+ if post:
+ all_posts.append(post)
+
+ logger.info(
+ f"Fetched {len(all_posts)} posts from {len(active)} sources"
+ )
+ return all_posts
+
+
+def _source_to_config(source: PostSource) -> Dict[str, Any]:
+ """Convert a PostSource document to a strategy config dict."""
+ return {
+ "source_id": source.source_id,
+ "instance_url": source.instance_url or "",
+ "api_key": source.api_key or "",
+ "handle": source.handle or "",
+ "platform_type": source.platform_type,
+ }
diff --git a/ushadow/backend/src/services/post_scorer.py b/ushadow/backend/src/services/post_scorer.py
new file mode 100644
index 00000000..8ad2c01e
--- /dev/null
+++ b/ushadow/backend/src/services/post_scorer.py
@@ -0,0 +1,142 @@
+"""Post Scorer - Ranks posts by relevance to the user's interest graph.
+
+Platform-agnostic scoring: works on Post objects regardless of whether
+they came from Mastodon, YouTube, or any future platform.
+
+Scoring signals:
+- Hashtag overlap with interest keywords (direct match)
+- Interest weight (more connected interests rank higher)
+- Interest recency (recently-active interests boost more)
+- Post recency (newer posts get a time decay boost)
+- Content keyword matching (post text contains interest terms)
+"""
+
+import logging
+import math
+import re
+from datetime import datetime, timezone
+from typing import Dict, List, Set
+
+from src.models.feed import Interest, Post
+
+logger = logging.getLogger(__name__)
+
+_HTML_TAG_RE = re.compile(r"<[^>]+>")
+
+
+class PostScorer:
+ """Scores posts against the user's interest graph."""
+
+ def score_posts(
+ self,
+ posts: List[Post],
+ interests: List[Interest],
+ ) -> List[Post]:
+ """Score pre-transformed Post objects against user interests.
+
+ For each post:
+ 1. Find which interests match (hashtag overlap + content keywords)
+ 2. Compute relevance_score from matched interest weights
+ 3. Add recency boost
+ 4. Return sorted by relevance_score descending
+ """
+ if not interests:
+ logger.info("No interests to score against")
+ return posts
+
+ # Build lookups
+ tag_to_interests = _build_tag_lookup(interests)
+ kw_to_interests = _build_keyword_lookup(interests)
+
+ now = datetime.now(timezone.utc)
+
+ for post in posts:
+ matched: Set[str] = set()
+ score = 0.0
+
+ # 1. Hashtag matching
+ for tag in post.hashtags:
+ for interest in tag_to_interests.get(tag.lower(), []):
+ if interest.name not in matched:
+ matched.add(interest.name)
+ score += _interest_score(interest, now)
+
+ # 2. Content keyword matching (weaker signal)
+ plain = _strip_html(post.content).lower()
+ for keyword, kw_interests in kw_to_interests.items():
+ if keyword in plain:
+ for interest in kw_interests:
+ if interest.name not in matched:
+ matched.add(interest.name)
+ score += _interest_score(interest, now) * 0.5
+
+ # 3. Post recency boost
+ score += _recency_boost(post.published_at, now)
+
+ post.relevance_score = round(score, 3)
+ post.matched_interests = sorted(matched)
+
+ posts.sort(key=lambda p: p.relevance_score, reverse=True)
+
+ logger.info(
+ f"Scored {len(posts)} posts, "
+ f"top score: {posts[0].relevance_score if posts else 0}"
+ )
+ return posts
+
+
+# ======================================================================
+# Helpers
+# ======================================================================
+
+
+def _build_tag_lookup(
+ interests: List[Interest],
+) -> Dict[str, List[Interest]]:
+ """Map hashtag → list of interests that use it."""
+ lookup: Dict[str, List[Interest]] = {}
+ for interest in interests:
+ for tag in interest.hashtags:
+ lookup.setdefault(tag.lower(), []).append(interest)
+ return lookup
+
+
+def _build_keyword_lookup(
+ interests: List[Interest],
+) -> Dict[str, List[Interest]]:
+ """Map interest name words → list of interests (for text matching)."""
+ lookup: Dict[str, List[Interest]] = {}
+ for interest in interests:
+ for word in interest.name.lower().split():
+ if len(word) >= 3:
+ lookup.setdefault(word, []).append(interest)
+ return lookup
+
+
+def _interest_score(interest: Interest, now: datetime) -> float:
+ """Score contribution from a single matched interest.
+
+ log2(relationship_count + 1) + recency bonus if active recently.
+ """
+ base = math.log(interest.relationship_count + 1, 2)
+
+ recency_bonus = 0.0
+ if interest.last_active:
+ days_since = (now - interest.last_active).total_seconds() / 86400
+ if days_since < 7:
+ recency_bonus = 2.0 * (1.0 - days_since / 7.0)
+
+ return base + recency_bonus
+
+
+def _recency_boost(published_at: datetime, now: datetime) -> float:
+ """Boost for recent posts — decays logarithmically over hours."""
+ hours_old = max((now - published_at).total_seconds() / 3600, 0)
+ if hours_old < 1:
+ return 1.5
+ return 1.0 / math.log2(hours_old + 1)
+
+
+def _strip_html(html: str) -> str:
+ """Remove HTML tags for plain text matching."""
+ return _HTML_TAG_RE.sub(" ", html).strip()
diff --git a/ushadow/backend/src/services/service_config_manager.py b/ushadow/backend/src/services/service_config_manager.py
index a3f21b80..20aa02ac 100644
--- a/ushadow/backend/src/services/service_config_manager.py
+++ b/ushadow/backend/src/services/service_config_manager.py
@@ -41,16 +41,12 @@ def _get_config_dir() -> Path:
if config_dir:
return Path(config_dir)
- # Default: look for config dir relative to this file
+ # Default: walk up from this file looking for a config/ dir that contains service_configs.yaml
current = Path(__file__).resolve()
for parent in current.parents:
candidate = parent / "config"
if candidate.exists() and (candidate / "service_configs.yaml").exists():
return candidate
- # Also check parent (for repo root)
- candidate = parent.parent / "config"
- if candidate.exists():
- return candidate
# Fallback
return Path(__file__).resolve().parents[4] / "config"
@@ -280,6 +276,7 @@ async def list_service_configs_async(self) -> List[ServiceConfigSummary]:
name=config.name,
provides=provides,
description=config.description,
+ config=dict(config.config) if config.config else None,
))
config_template_ids.add(config.template_id)
@@ -380,6 +377,7 @@ def create_service_config(self, data: ServiceConfigCreate) -> ServiceConfig:
name=data.name,
description=data.description,
config=ConfigValues(values=data.config),
+ deployment_labels=data.deployment_labels,
created_at=now,
updated_at=now,
)
@@ -415,7 +413,10 @@ def update_service_config(self, config_id: str, data: ServiceConfigUpdate) -> Se
elif config_id in self._omegaconf_configs:
# Config cleared, remove raw config too
del self._omegaconf_configs[config_id]
-
+ if data.deployment_labels is not None:
+ config.deployment_labels = data.deployment_labels
+ if data.route is not None:
+ config.route = data.route
config.updated_at = datetime.now(timezone.utc)
self._save_service_configs()
diff --git a/ushadow/backend/src/services/service_orchestrator.py b/ushadow/backend/src/services/service_orchestrator.py
index 8381dbbb..58672b31 100644
--- a/ushadow/backend/src/services/service_orchestrator.py
+++ b/ushadow/backend/src/services/service_orchestrator.py
@@ -231,14 +231,41 @@ def settings(self) -> 'Settings':
# Discovery Methods
# =========================================================================
+ def _is_service_visible_in_environment(self, service: DiscoveredService) -> bool:
+ """
+ Check if a service should be visible in the current environment.
+
+ Logic:
+ - If service.environments is empty: visible in ALL environments
+ - If service.environments has values: only visible if current ENV_NAME is in the list
+
+ Examples:
+ - environments: [] -> visible everywhere (default)
+ - environments: ["blue"] -> only visible in "blue" env
+ - environments: ["orange", "blue"] -> visible in both
+ """
+ import os
+
+ # If no environments specified, service is visible everywhere
+ if not service.environments:
+ return True
+
+ # Get current environment name
+ current_env = os.getenv("ENV_NAME", "default")
+
+ # Service is only visible if current env is in its list
+ return current_env in service.environments
+
async def list_installed_services(self) -> List[Dict[str, Any]]:
"""Get all installed services with basic info and status."""
installed_names, removed_names = await self._get_installed_service_names()
all_services = self.compose_registry.get_services()
+ # Filter by environment and installation status
installed_services = [
s for s in all_services
if self._service_matches_installed(s, installed_names, removed_names)
+ and self._is_service_visible_in_environment(s)
]
return [
@@ -253,6 +280,10 @@ async def list_catalog(self) -> List[Dict[str, Any]]:
results = []
for service in all_services:
+ # Filter by environment
+ if not self._is_service_visible_in_environment(service):
+ continue
+
is_installed = self._service_matches_installed(service, installed_names, removed_names)
summary = await self._build_service_summary(service, installed=is_installed)
results.append(summary.to_dict())
@@ -833,14 +864,6 @@ def _service_matches_installed(self, service: DiscoveredService, installed_names
if compose_base in installed_names:
return True
- # If ANY service from the same compose file is installed, show all services from that file
- # This handles multi-service compose files like mycelia (backend, frontend, worker)
- all_services = self.compose_registry.get_services()
- same_file_services = [s for s in all_services if s.compose_file == service.compose_file]
- for sibling in same_file_services:
- if sibling.service_name in installed_names:
- return True
-
return False
async def _build_service_summary(self, service: DiscoveredService, installed: bool) -> ServiceSummary:
diff --git a/ushadow/backend/src/services/share_service.py b/ushadow/backend/src/services/share_service.py
new file mode 100644
index 00000000..714e33c2
--- /dev/null
+++ b/ushadow/backend/src/services/share_service.py
@@ -0,0 +1,458 @@
+"""Share service for conversation and resource sharing.
+
+Implements business logic for creating, validating, and managing share tokens
+with Keycloak Fine-Grained Authorization (FGA) integration.
+"""
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional, Union
+from uuid import uuid4
+
+from beanie import PydanticObjectId
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from ..models.share import (
+ KeycloakPolicy,
+ ResourceType,
+ ShareAccessLog,
+ SharePermission,
+ ShareToken,
+ ShareTokenCreate,
+ ShareTokenResponse,
+)
+from ..models.user import User
+from ..utils.auth_helpers import get_user_id, get_user_email, is_superuser
+
+logger = logging.getLogger(__name__)
+
+
+class ShareService:
+ """Service for managing share tokens and access control.
+
+ Coordinates share token creation, validation, and Keycloak FGA integration.
+ Implements business rules for expiration, view limits, and permission checking.
+ """
+
+ def __init__(self, db: AsyncIOMotorDatabase, base_url: str = "http://localhost:3000"):
+ """Initialize share service.
+
+ Args:
+ db: MongoDB database instance
+ base_url: Base URL for generating share links (e.g., "https://ushadow.example.com")
+ """
+ self.db = db
+ self.base_url = base_url.rstrip("/")
+
+ async def create_share_token(
+ self,
+ data: ShareTokenCreate,
+ created_by: Union[User, dict],
+ ) -> ShareToken:
+ """Create a new share token.
+
+ Args:
+ data: Share token creation parameters
+ created_by: User creating the share (User object or Keycloak dict)
+
+ Returns:
+ Created share token
+
+ Raises:
+ ValueError: If resource doesn't exist or user lacks permission
+ """
+ # TODO: Validate resource exists and user has permission to share it
+ # This is a business logic decision point - should we verify ownership here?
+ # Consider: strict ownership check vs. allowing sharing of any accessible resource
+ await self._validate_resource_exists(data.resource_type, data.resource_id)
+ await self._validate_user_can_share(created_by, data.resource_type, data.resource_id)
+
+ # Calculate expiration
+ expires_at = None
+ if data.expires_in_days:
+ expires_at = datetime.utcnow() + timedelta(days=data.expires_in_days)
+
+ # Build Keycloak-compatible policies
+ policies = self._build_keycloak_policies(
+ resource_type=data.resource_type.value,
+ resource_id=data.resource_id,
+ permissions=[p.value for p in data.permissions],
+ )
+
+ # Create share token
+ user_id = get_user_id(created_by)
+ share_token = ShareToken(
+ token=str(uuid4()),
+ resource_type=data.resource_type.value,
+ resource_id=data.resource_id,
+ created_by=user_id,
+ policies=policies,
+ permissions=[p.value for p in data.permissions],
+ require_auth=data.require_auth,
+ tailscale_only=data.tailscale_only,
+ allowed_emails=data.allowed_emails,
+ expires_at=expires_at,
+ max_views=data.max_views,
+ )
+
+ await share_token.insert()
+
+ # TODO: Register with Keycloak FGA if enabled
+ # await self._register_with_keycloak(share_token)
+
+ logger.info(
+ f"Created share token {share_token.token} for {data.resource_type}:{data.resource_id} "
+ f"by user {get_user_email(created_by)}"
+ )
+
+ return share_token
+
+ async def get_share_token(self, token: str) -> Optional[ShareToken]:
+ """Get share token by token string.
+
+ Args:
+ token: Share token UUID
+
+ Returns:
+ ShareToken if found, None otherwise
+ """
+ return await ShareToken.find_one(ShareToken.token == token)
+
+ async def validate_share_access(
+ self,
+ token: str,
+ user_email: Optional[str] = None,
+ request_ip: Optional[str] = None,
+ ) -> tuple[bool, Optional[ShareToken], str]:
+ """Validate access to a shared resource.
+
+ Args:
+ token: Share token string
+ user_email: Email of user trying to access (None for anonymous)
+ request_ip: IP address of request (for Tailscale validation)
+
+ Returns:
+ Tuple of (is_valid, share_token, reason)
+ """
+ share_token = await self.get_share_token(token)
+ if not share_token:
+ return False, None, "Invalid share token"
+
+ # Check access permissions
+ can_access, reason = share_token.can_access(user_email)
+ if not can_access:
+ return False, share_token, reason
+
+ # TODO: Validate Tailscale network if required
+ # This is a decision point - how should we verify Tailscale access?
+ # Options: check IP ranges, validate via Tailscale API, trust reverse proxy headers
+ if share_token.tailscale_only:
+ is_tailscale = await self._validate_tailscale_access(request_ip)
+ if not is_tailscale:
+ return False, share_token, "Access restricted to Tailscale network"
+
+ return True, share_token, "Access granted"
+
+ async def record_share_access(
+ self,
+ share_token: ShareToken,
+ user_identifier: str,
+ action: str = "view",
+ metadata: Optional[Dict[str, Any]] = None,
+ ):
+ """Record access to shared resource for audit trail.
+
+ Args:
+ share_token: Share token being accessed
+ user_identifier: Email or IP of accessor
+ action: Action performed (view, edit, etc.)
+ metadata: Additional context (user agent, IP, etc.)
+ """
+ await share_token.record_access(user_identifier, action, metadata)
+ logger.info(
+ f"Recorded {action} access to share {share_token.token} "
+ f"by {user_identifier} (view {share_token.view_count})"
+ )
+
+ async def revoke_share_token(self, token: str, user: Union[User, dict]) -> bool:
+ """Revoke a share token.
+
+ Args:
+ token: Share token to revoke
+ user: User attempting to revoke (User object or Keycloak dict)
+
+ Returns:
+ True if revoked, False if not found or permission denied
+
+ Raises:
+ ValueError: If user lacks permission to revoke
+ """
+ share_token = await self.get_share_token(token)
+ if not share_token:
+ return False
+
+ # Verify user can revoke (must be creator or admin)
+ user_id = get_user_id(user)
+ if str(share_token.created_by) != user_id and not is_superuser(user):
+ raise ValueError("Only the creator or admin can revoke share tokens")
+
+ # TODO: Unregister from Keycloak FGA if enabled
+ # await self._unregister_from_keycloak(share_token)
+
+ await share_token.delete()
+ logger.info(f"Revoked share token {token} by user {get_user_email(user)}")
+ return True
+
+ async def list_shares_for_resource(
+ self,
+ resource_type: str,
+ resource_id: str,
+ user: Union[User, dict],
+ ) -> List[ShareToken]:
+ """List all share tokens for a resource.
+
+ Args:
+ resource_type: Type of resource
+ resource_id: ID of resource
+ user: User requesting list (User object or Keycloak dict)
+
+ Returns:
+ List of share tokens
+ """
+ # TODO: Validate user has access to resource
+ # await self._validate_user_can_access(user, resource_type, resource_id)
+
+ return await ShareToken.find(
+ ShareToken.resource_type == resource_type,
+ ShareToken.resource_id == resource_id,
+ ).to_list()
+
+ async def get_share_access_logs(
+ self,
+ token: str,
+ user: Union[User, dict],
+ ) -> List[ShareAccessLog]:
+ """Get access logs for a share token.
+
+ Args:
+ token: Share token
+ user: User requesting logs (User object or Keycloak dict)
+
+ Returns:
+ List of access log entries
+
+ Raises:
+ ValueError: If user lacks permission
+ """
+ share_token = await self.get_share_token(token)
+ if not share_token:
+ raise ValueError("Share token not found")
+
+ # Verify permission
+ user_id = get_user_id(user)
+ if str(share_token.created_by) != user_id and not is_superuser(user):
+ raise ValueError("Only the creator or admin can view access logs")
+
+ return [ShareAccessLog(**log) for log in share_token.access_log]
+
+ def to_response(self, share_token: ShareToken) -> ShareTokenResponse:
+ """Convert ShareToken to API response model.
+
+ Args:
+ share_token: Share token document
+
+ Returns:
+ ShareTokenResponse for API
+ """
+ return ShareTokenResponse(
+ token=share_token.token,
+ share_url=f"{self.base_url}/share/{share_token.token}",
+ resource_type=share_token.resource_type,
+ resource_id=share_token.resource_id,
+ permissions=share_token.permissions,
+ expires_at=share_token.expires_at,
+ max_views=share_token.max_views,
+ view_count=share_token.view_count,
+ require_auth=share_token.require_auth,
+ tailscale_only=share_token.tailscale_only,
+ created_at=share_token.created_at,
+ )
+
+ # Private helper methods
+
+ def _build_keycloak_policies(
+ self,
+ resource_type: str,
+ resource_id: str,
+ permissions: List[str],
+ ) -> List[KeycloakPolicy]:
+ """Build Keycloak FGA policies from permissions.
+
+ Args:
+ resource_type: Type of resource
+ resource_id: ID of resource
+ permissions: List of permission strings (read, write, etc.)
+
+ Returns:
+ List of Keycloak-compatible policies
+ """
+ # Resource identifier format: "type:id" (e.g., "conversation:123")
+ resource = f"{resource_type}:{resource_id}"
+
+ return [
+ KeycloakPolicy(
+ resource=resource,
+ action=permission,
+ effect="allow",
+ )
+ for permission in permissions
+ ]
+
+ async def _validate_resource_exists(
+ self,
+ resource_type: ResourceType,
+ resource_id: str,
+ ):
+ """Validate that resource exists and is accessible.
+
+ Args:
+ resource_type: Type of resource
+ resource_id: ID of resource
+
+ Raises:
+ ValueError: If resource doesn't exist
+ """
+ import httpx
+ import os
+
+ # Configuration: Enable/disable strict validation
+ ENABLE_VALIDATION = os.getenv("SHARE_VALIDATE_RESOURCES", "false").lower() == "true"
+
+ if not ENABLE_VALIDATION:
+ # Lazy validation - skip check for faster share creation
+ logger.debug(f"Skipping validation for {resource_type}:{resource_id} (SHARE_VALIDATE_RESOURCES=false)")
+ return
+
+ # Strict validation - verify resource exists
+ logger.debug(f"Validating resource {resource_type}:{resource_id}")
+
+ # TODO: YOUR IMPLEMENTATION (5-10 lines)
+ # Implement validation logic based on your backend choice:
+ #
+ # For Mycelia (resource-based API):
+ # POST to /api/resource/tech.mycelia.objects with action: "get", id: resource_id
+ #
+ # For Chronicle (REST API):
+ # GET /api/conversations/{resource_id}
+ #
+ # Example structure:
+ # if resource_type == ResourceType.CONVERSATION:
+ # # Your validation code here
+ # pass
+ # elif resource_type == ResourceType.MEMORY:
+ # # Memory validation
+ # pass
+
+ # Placeholder: Log that validation needs implementation
+ logger.warning(
+ f"Resource validation is enabled but not implemented for {resource_type}. "
+ f"Add validation logic in share_service.py:_validate_resource_exists()"
+ )
+
+ async def _validate_user_can_share(
+ self,
+ user: Union[User, dict],
+ resource_type: ResourceType,
+ resource_id: str,
+ ):
+ """Validate user has permission to share resource.
+
+ Business rule: If user can view the resource, they can share it.
+ Access control is enforced at the view level, so authenticated users
+ who can see a resource are allowed to share it.
+
+ Args:
+ user: User attempting to share (User object or Keycloak dict)
+ resource_type: Type of resource
+ resource_id: ID of resource
+ """
+ user_email = get_user_email(user)
+ logger.debug(
+ f"User {user_email} sharing {resource_type}:{resource_id} - "
+ f"access already verified at view level"
+ )
+
+ async def _validate_tailscale_access(self, request_ip: Optional[str]) -> bool:
+ """Validate request is from Tailscale network.
+
+ Args:
+ request_ip: IP address of request
+
+ Returns:
+ True if from Tailscale, False otherwise
+ """
+ import ipaddress
+ import os
+
+ # Configuration: Enable/disable Tailscale validation
+ ENABLE_TAILSCALE_CHECK = os.getenv("SHARE_VALIDATE_TAILSCALE", "false").lower() == "true"
+
+ if not ENABLE_TAILSCALE_CHECK:
+ # Disabled - allow all IPs (useful for testing or when not using Tailscale)
+ logger.debug(f"Tailscale validation disabled (SHARE_VALIDATE_TAILSCALE=false)")
+ return True
+
+ if not request_ip:
+ logger.warning("No request IP provided for Tailscale validation")
+ return False
+
+ # TODO: YOUR IMPLEMENTATION (5-10 lines)
+ # Choose your Tailscale validation strategy based on your setup:
+ #
+ # Option A - IP Range Check (if ushadow runs directly on Tailscale):
+ # try:
+ # ip = ipaddress.ip_address(request_ip)
+ # tailscale_range = ipaddress.ip_network("100.64.0.0/10")
+ # is_tailscale = ip in tailscale_range
+ # logger.debug(f"IP {request_ip} {'is' if is_tailscale else 'is NOT'} in Tailscale range")
+ # return is_tailscale
+ # except ValueError:
+ # logger.warning(f"Invalid IP address: {request_ip}")
+ # return False
+ #
+ # Option B - Trust Tailscale Serve Headers (if using Tailscale Serve):
+ # # This requires passing the Request object instead of just IP
+ # # tailscale_user = request.headers.get("X-Tailscale-User")
+ # # return tailscale_user is not None
+ #
+ # For now, log a warning and allow (fail open for testing)
+ logger.warning(
+ f"Tailscale validation enabled but not implemented. "
+ f"Add logic in share_service.py:_validate_tailscale_access(). "
+ f"IP: {request_ip}"
+ )
+ return True # Fail open until implemented
+
+ async def _register_with_keycloak(self, share_token: ShareToken):
+ """Register share token with Keycloak FGA.
+
+ Args:
+ share_token: Share token to register
+ """
+ # TODO: Implement Keycloak FGA registration
+ # This should:
+ # 1. Create Keycloak resource for the shared item
+ # 2. Create Keycloak authorization policies
+ # 3. Store keycloak_policy_id and keycloak_resource_id on share_token
+ logger.debug(f"Keycloak FGA registration for token {share_token.token}")
+
+ async def _unregister_from_keycloak(self, share_token: ShareToken):
+ """Unregister share token from Keycloak FGA.
+
+ Args:
+ share_token: Share token to unregister
+ """
+ # TODO: Implement Keycloak FGA cleanup
+ # This should delete the Keycloak resource and policies
+ if share_token.keycloak_policy_id:
+ logger.debug(f"Keycloak FGA cleanup for policy {share_token.keycloak_policy_id}")
diff --git a/ushadow/backend/src/services/tailscale_manager.py b/ushadow/backend/src/services/tailscale_manager.py
index a9e8be40..c85bce48 100644
--- a/ushadow/backend/src/services/tailscale_manager.py
+++ b/ushadow/backend/src/services/tailscale_manager.py
@@ -9,9 +9,8 @@
Architecture:
- Layer 1 (Tailscale Serve): External HTTPS → Internal containers
- - /api/* → backend (REST APIs)
+ - /api/* → backend (REST APIs, includes /ws/audio/relay for WebSockets)
- /auth/* → backend (authentication)
- - /ws_pcm, /ws_omi → chronicle (WebSockets, direct for low latency)
- /* → frontend (SPA catch-all)
- Layer 2 (Generic Proxy): Backend routes REST to services via /api/services/{name}/proxy/*
@@ -188,23 +187,58 @@ def start_container(self) -> Dict[str, Any]:
except docker.errors.NotFound:
# Container doesn't exist - create it
- # TODO: Get image, network, ports from settings/config
- # For now, use defaults
+ # Match configuration from compose/tailscale-compose.yml
+
+ # First, ensure networks exist
+ try:
+ ushadow_net = self.docker_client.networks.get("ushadow-network")
+ logger.info("Found ushadow-network")
+ except docker.errors.NotFound:
+ logger.error("ushadow-network not found! Container will use default network.")
+ ushadow_net = None
+
+ try:
+ infra_net = self.docker_client.networks.get("infra-network")
+ logger.info("Found infra-network")
+ except docker.errors.NotFound:
+ logger.warning("infra-network not found")
+ infra_net = None
+
+ # Create networking_config for multiple networks
+ from docker.types import EndpointConfig, NetworkingConfig
+
+ networking_config = NetworkingConfig(
+ endpoints_config={
+ "ushadow-network": EndpointConfig() if ushadow_net else None,
+ "infra-network": EndpointConfig() if infra_net else None,
+ }
+ )
+
container = self.docker_client.containers.run(
image="tailscale/tailscale:latest",
name=container_name,
+ hostname=container_name,
detach=True,
- network_mode="host",
environment={
"TS_STATE_DIR": "/var/lib/tailscale",
- "TS_SOCKET": "/var/run/tailscale/tailscaled.sock",
+ "TS_USERSPACE": "true",
+ "TS_ACCEPT_DNS": "true",
+ "TS_EXTRA_ARGS": "--advertise-tags=tag:container",
},
volumes={
volume_name: {"bind": "/var/lib/tailscale", "mode": "rw"}
},
cap_add=["NET_ADMIN", "NET_RAW"],
+ networking_config=networking_config,
+ command=[
+ "sh", "-c",
+ f"tailscaled --tun=userspace-networking --statedir=/var/lib/tailscale & "
+ f"sleep 2 && tailscale up --hostname={self.env_name} && sleep infinity"
+ ],
)
+ logger.info(f"Created {container_name} on ushadow-network and infra-network")
+
return {
"status": "created",
"message": "Tailscale container created and started"
@@ -446,6 +480,38 @@ def _get_tailscale_status_from_container(self) -> Dict[str, Any]:
return status
+ def get_peer_ip_by_hostname(self, hostname: str) -> Optional[str]:
+ """Get a peer's Tailscale IP address by hostname.
+
+ Args:
+ hostname: Tailscale hostname of the peer (case-insensitive)
+
+ Returns:
+ IPv4 address or None if not found
+ """
+ try:
+ exit_code, stdout, stderr = self.exec_command("tailscale status --json", timeout=5)
+
+ if exit_code == 0 and stdout.strip():
+ data = json.loads(stdout)
+ peers = data.get("Peer", {})
+
+ # Search peers (case-insensitive)
+ for peer_data in peers.values():
+ peer_hostname = peer_data.get("HostName", "")
+ if peer_hostname.lower() == hostname.lower():
+ # Extract IPv4
+ for ip in peer_data.get("TailscaleIPs", []):
+ if "." in ip: # IPv4
+ logger.info(f"Found peer '{hostname}' with IP {ip}")
+ return ip
+
+ logger.debug(f"Peer '{hostname}' not found in Tailscale peers")
+ except Exception as e:
+ logger.debug(f"Failed to query Tailscale peers: {e}")
+
+ return None
+
def get_tailnet_suffix(self) -> Optional[str]:
"""Get the tailnet suffix from hostname.
@@ -634,7 +700,6 @@ def logout(self) -> bool:
def configure_base_routes(self,
backend_container: Optional[str] = None,
frontend_container: Optional[str] = None,
- chronicle_container: Optional[str] = None,
backend_port: int = 8000,
frontend_port: Optional[int] = None) -> bool:
"""Configure base infrastructure routes (Layer 1).
@@ -642,14 +707,15 @@ def configure_base_routes(self,
Sets up:
- /api/* → backend (REST APIs through generic proxy)
- /auth/* → backend (authentication)
- - /ws_pcm → chronicle (WebSocket, direct for low latency)
- - /ws_omi → chronicle (WebSocket, direct for low latency)
+ - /keycloak/* → keycloak (OIDC authentication)
- /* → frontend (SPA catch-all)
+ Note: Chronicle and other deployed services are accessed via their own ports,
+ not through Tailscale routing.
+
Args:
backend_container: Backend container name (default: {env}-backend)
frontend_container: Frontend container name (default: {env}-webui)
- chronicle_container: Chronicle container name (default: {env}-chronicle-backend)
backend_port: Backend internal port (default: 8000)
frontend_port: Frontend internal port (auto-detect if None)
@@ -661,8 +727,6 @@ def configure_base_routes(self,
backend_container = f"{self.env_name}-backend"
if not frontend_container:
frontend_container = f"{self.env_name}-webui"
- if not chronicle_container:
- chronicle_container = f"{self.env_name}-chronicle-backend"
# Auto-detect frontend port based on dev/prod mode
if frontend_port is None:
@@ -671,7 +735,6 @@ def configure_base_routes(self,
backend_base = f"http://{backend_container}:{backend_port}"
frontend_target = f"http://{frontend_container}:{frontend_port}"
- chronicle_base = f"http://{chronicle_container}:{backend_port}"
success = True
@@ -683,12 +746,13 @@ def configure_base_routes(self,
if not self.add_serve_route(route, target):
success = False
- # WebSocket routes - direct to Chronicle for low latency (legacy/mobile)
- ws_routes = ["/ws_pcm", "/ws_omi"]
- for route in ws_routes:
- target = f"{chronicle_base}{route}"
- if not self.add_serve_route(route, target):
- success = False
+ # Keycloak authentication service
+ keycloak_target = "http://keycloak:8080"
+ if not self.add_serve_route("/keycloak", keycloak_target):
+ success = False
+
+ # Chronicle WebSocket routes removed - Chronicle is now a deployed service
+ # accessed via its own port (e.g., http://localhost:8090)
# Frontend catches everything else
if not self.add_serve_route("/", frontend_target):
@@ -1004,6 +1068,183 @@ def get_tailnet_settings(self) -> Optional[TailnetSettings]:
logger.error(f"Error getting tailnet settings: {e}")
return None
+ # ========================================================================
+ # Tailscale Funnel Management
+ # ========================================================================
+
+ def get_funnel_status(self) -> Dict[str, Any]:
+ """Get Tailscale Funnel status.
+
+ Returns:
+ Dict with funnel status information:
+ - enabled: Whether funnel is currently enabled
+ - port: Port being funneled (usually 443)
+ - public_url: Public URL if funnel is enabled
+ - error: Error message if status check failed
+ """
+ try:
+ exit_code, stdout, stderr = self.exec_command("tailscale funnel status", timeout=5)
+
+ # Check if funnel is enabled by parsing output
+ # New format: "# Funnel on:\n# - https://hostname.ts.net"
+ # or "https://hostname.ts.net (Funnel on)"
+ output = stdout + stderr
+ is_enabled = "funnel on" in output.lower()
+
+ result = {
+ "enabled": is_enabled,
+ "port": 443 if is_enabled else None,
+ "public_url": None,
+ }
+
+ # Extract public URL if enabled
+ if is_enabled:
+ # Look for "# Funnel on:" section or "(Funnel on)" line
+ for line in output.split('\n'):
+ line = line.strip()
+ # Check for URL in comment or regular line
+ if 'https://' in line:
+ # Extract URL (might have (Funnel on) suffix)
+ import re
+ match = re.search(r'https://[^\s)]+', line)
+ if match:
+ result["public_url"] = match.group(0)
+ break
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error getting funnel status: {e}")
+ return {
+ "enabled": False,
+ "port": None,
+ "public_url": None,
+ "error": str(e)
+ }
+
+ def enable_funnel(self, port: int = 443) -> Tuple[bool, Optional[str]]:
+ """Enable Tailscale Funnel for public internet access.
+
+ Funnel exposes your Tailscale service to the public internet,
+ allowing users without Tailscale to access it via HTTPS.
+
+ Note: Funnel shares routes with Serve. This reconfigures all existing
+ serve routes to use funnel instead (making them publicly accessible).
+
+ Args:
+ port: Port to funnel (default: 443 for HTTPS)
+
+ Returns:
+ Tuple of (success, error_message)
+ """
+ try:
+ # Get current serve status to preserve routes
+ serve_status = self.get_serve_status()
+ if not serve_status or not serve_status.strip():
+ return False, "Tailscale Serve must be configured before enabling Funnel"
+
+ # Reconfigure routes with funnel (maintains all existing routes)
+ # This is needed because the new CLI merges serve/funnel route tables
+ success = self.configure_layer1_routes_with_funnel()
+
+ if not success:
+ return False, "Failed to reconfigure routes for funnel"
+
+ logger.info(f"Tailscale Funnel enabled on port {port}")
+
+ # Get funnel status to extract public URL
+ status = self.get_funnel_status()
+ return True, status.get("public_url")
+
+ except Exception as e:
+ logger.error(f"Error enabling Funnel: {e}")
+ return False, str(e)
+
+ def configure_layer1_routes_with_funnel(
+ self,
+ backend_container: Optional[str] = None,
+ frontend_container: Optional[str] = None,
+ backend_port: int = 8000,
+ frontend_port: Optional[int] = None,
+ ) -> bool:
+ """Configure Layer 1 routes using funnel (public internet access).
+
+ Same as configure_layer1_routes but uses 'tailscale funnel' instead
+ of 'tailscale serve', making routes publicly accessible.
+
+ Args:
+ backend_container: Backend container name
+ frontend_container: Frontend container name
+ backend_port: Backend internal port (default: 8000)
+ frontend_port: Frontend internal port (auto-detect if None)
+
+ Returns:
+ True if all routes configured successfully
+ """
+ # Use defaults if not provided
+ if not backend_container:
+ backend_container = f"{self.env_name}-backend"
+ if not frontend_container:
+ frontend_container = f"{self.env_name}-webui"
+
+ # Auto-detect frontend port
+ if frontend_port is None:
+ dev_mode = os.getenv("DEV_MODE", "false").lower() == "true"
+ frontend_port = 5173 if dev_mode else 80
+
+ backend_base = f"http://{backend_container}:{backend_port}"
+ frontend_target = f"http://{frontend_container}:{frontend_port}"
+
+ success = True
+
+ # Backend API routes - use funnel command
+ backend_routes = ["/api", "/auth", "/ws"]
+ for route in backend_routes:
+ target = f"{backend_base}{route}"
+ exit_code, _, stderr = self.exec_command(
+ f"tailscale funnel --bg --set-path {route} {target}",
+ timeout=10
+ )
+ if exit_code != 0:
+ logger.error(f"Failed to add funnel route {route}: {stderr}")
+ success = False
+
+ # Frontend root route
+ exit_code, _, stderr = self.exec_command(
+ f"tailscale funnel --bg --set-path / {frontend_target}",
+ timeout=10
+ )
+ if exit_code != 0:
+ logger.error(f"Failed to add funnel route /: {stderr}")
+ success = False
+
+ return success
+
+ def disable_funnel(self, port: int = 443) -> Tuple[bool, Optional[str]]:
+ """Disable Tailscale Funnel.
+
+ Args:
+ port: Port to disable funnel for (default: 443)
+
+ Returns:
+ Tuple of (success, error_message)
+ """
+ try:
+ cmd = f"tailscale funnel --https={port} off"
+ exit_code, stdout, stderr = self.exec_command(cmd, timeout=10)
+
+ if exit_code == 0:
+ logger.info(f"Tailscale Funnel disabled on port {port}")
+ return True, None
+ else:
+ error_msg = stderr or stdout or "Unknown error"
+ logger.error(f"Failed to disable Funnel: {error_msg}")
+ return False, error_msg
+
+ except Exception as e:
+ logger.error(f"Error disabling Funnel: {e}")
+ return False, str(e)
+
# ============================================================================
# Singleton Instance
diff --git a/ushadow/backend/src/services/template_service.py b/ushadow/backend/src/services/template_service.py
index 97fefa70..4a0025dc 100644
--- a/ushadow/backend/src/services/template_service.py
+++ b/ushadow/backend/src/services/template_service.py
@@ -67,6 +67,9 @@ async def list_templates(source: Optional[str] = None) -> List[Template]:
logger.info(f"Loading templates - defaults: {default_services}, user_installed: {user_installed}, removed: {removed_services}")
logger.info(f"Loading templates - final installed_names: {installed_names}")
+ from src.services.service_orchestrator import get_service_orchestrator
+ orchestrator = get_service_orchestrator()
+
for service in registry.get_services():
if source and source != "compose":
continue
@@ -83,7 +86,9 @@ async def list_templates(source: Optional[str] = None) -> List[Template]:
is_installed = True
# Debug logging
- logger.info(f"Service: {service.service_name}, installed: {is_installed}, installed_names: {installed_names}")
+ logger.debug(f"Service: {service.service_name}, installed: {is_installed}, installed_names: {installed_names}")
+
+ needs_setup = await orchestrator._check_needs_setup(service)
templates.append(Template(
id=service.service_id,
@@ -97,7 +102,10 @@ async def list_templates(source: Optional[str] = None) -> List[Template]:
compose_file=str(service.namespace) if service.namespace else None,
service_name=service.service_name,
mode="local",
+ tags=service.tags,
installed=is_installed,
+ needs_setup=needs_setup,
+ wizard=service.wizard,
))
except Exception as e:
logger.warning(f"Failed to load compose templates: {e}")
diff --git a/ushadow/backend/src/services/token_bridge.py b/ushadow/backend/src/services/token_bridge.py
new file mode 100644
index 00000000..4412f36a
--- /dev/null
+++ b/ushadow/backend/src/services/token_bridge.py
@@ -0,0 +1,128 @@
+"""
+Token Bridge Utility
+
+Automatically converts Keycloak OIDC tokens to service-compatible JWT tokens.
+This allows proxy and audio relay to transparently bridge authentication.
+
+Usage:
+ token = extract_token_from_request(request)
+ service_token = await bridge_to_service_token(token, audiences=["chronicle"])
+"""
+
+import logging
+from typing import Optional
+from fastapi import Request
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from .keycloak_auth import get_keycloak_user_from_token
+from .keycloak_user_sync import get_mongodb_user_id_for_keycloak_user
+from .auth import generate_jwt_for_service
+
+logger = logging.getLogger(__name__)
+security = HTTPBearer(auto_error=False)
+
+
+def extract_token_from_request(request: Request) -> Optional[str]:
+ """
+ Extract Bearer token from Authorization header or query parameter.
+
+ Args:
+ request: FastAPI request object
+
+ Returns:
+ Token string if found, None otherwise
+ """
+ # Try Authorization header first
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.startswith("Bearer "):
+ return auth_header[7:] # Remove "Bearer " prefix
+
+ # Try query parameter (for WebSocket connections)
+ token = request.query_params.get("token")
+ if token:
+ return token
+
+ return None
+
+
+async def bridge_to_service_token(
+ token: str,
+ audiences: Optional[list[str]] = None
+) -> Optional[str]:
+ """
+ Convert a Keycloak token to a service-compatible JWT token.
+
+ If the token is already a service token (not a Keycloak token),
+ returns it unchanged. Otherwise, validates the Keycloak token
+ and generates a new service token.
+
+ Args:
+ token: Token to bridge (Keycloak or service token)
+ audiences: Audiences for the service token (defaults to ["ushadow", "chronicle"])
+
+ Returns:
+ Service token if bridging succeeded, None if token is invalid
+ """
+ if not token:
+ return None
+
+ # Try to validate as Keycloak token
+ keycloak_user = get_keycloak_user_from_token(token)
+
+ if not keycloak_user:
+ # Not a valid Keycloak token
+ # Could be a service token already, or invalid
+ # Let it through and let the downstream service validate
+ logger.debug("[TOKEN-BRIDGE] Token is not a Keycloak token, passing through")
+ return token
+
+ # It's a Keycloak token - bridge it
+ user_email = keycloak_user.get("email")
+ keycloak_sub = keycloak_user.get("sub")
+ user_name = keycloak_user.get("name")
+
+ logger.debug(f"[TOKEN-BRIDGE] Extracted from token - email: {user_email}, name: '{user_name}', sub: {keycloak_sub}")
+
+ if not user_email or not keycloak_sub:
+ logger.error(f"[TOKEN-BRIDGE] Missing user info: email={user_email}, keycloak_sub={keycloak_sub}")
+ return None
+
+ # Sync Keycloak user to MongoDB (creates User record if needed)
+ # This gives us a MongoDB ObjectId that Chronicle can use
+ try:
+ mongodb_user_id = await get_mongodb_user_id_for_keycloak_user(
+ keycloak_sub=keycloak_sub,
+ email=user_email,
+ name=user_name
+ )
+ logger.debug(f"[TOKEN-BRIDGE] Keycloak {keycloak_sub} → MongoDB {mongodb_user_id}")
+ except Exception as e:
+ logger.error(f"[TOKEN-BRIDGE] Failed to sync Keycloak user to MongoDB: {e}", exc_info=True)
+ return None
+
+ # Generate service token with MongoDB ObjectId
+ audiences = audiences or ["ushadow", "chronicle"]
+ service_token = generate_jwt_for_service(
+ user_id=mongodb_user_id, # Use MongoDB ObjectId, not Keycloak UUID
+ user_email=user_email,
+ audiences=audiences
+ )
+
+ logger.info(f"[TOKEN-BRIDGE] ✓ Bridged Keycloak token for {user_email} → service token (MongoDB ID: {mongodb_user_id})")
+ logger.debug(f"[TOKEN-BRIDGE] Audiences: {audiences}, token: {service_token[:30]}...")
+
+ return service_token
+
+
+def is_keycloak_token(token: str) -> bool:
+ """
+ Check if a token is a Keycloak token (vs service token).
+
+ Args:
+ token: JWT token to check
+
+ Returns:
+ True if token is from Keycloak, False otherwise
+ """
+ keycloak_user = get_keycloak_user_from_token(token)
+ return keycloak_user is not None
diff --git a/ushadow/backend/src/services/unode_manager.py b/ushadow/backend/src/services/unode_manager.py
index b9168fc7..64e644ae 100644
--- a/ushadow/backend/src/services/unode_manager.py
+++ b/ushadow/backend/src/services/unode_manager.py
@@ -133,9 +133,10 @@ async def initialize(self):
async def _register_self_as_leader(self):
"""Register the current u-node as the cluster leader."""
- import os
+ import socket as socket_module
hostname = None
+ envname = None
tailscale_ip = None
status_data = None
@@ -193,7 +194,7 @@ async def _register_self_as_leader(self):
except Exception as e:
logger.warning(f"Docker API exec method failed for leader registration: {e}")
- # Method 3: Try local tailscale CLI
+ # Method 3: Try local tailscale CLI (uses fixed command, no user input)
if not status_data:
try:
result = await asyncio.create_subprocess_exec(
@@ -221,35 +222,51 @@ async def _register_self_as_leader(self):
if not tailscale_ip:
tailscale_ip = os.environ.get("TAILSCALE_IP")
- # Use COMPOSE_PROJECT_NAME as hostname (matches the deployment identity)
- hostname = os.environ.get("COMPOSE_PROJECT_NAME")
+ # Get environment name from ENV_NAME
+ envname = os.environ.get("ENV_NAME")
+
+ # Get hostname: prefer Tailscale DNSName (short form), then HOST_HOSTNAME env var
+ if status_data:
+ self_info = status_data.get("Self", {})
+ dns_name = self_info.get("DNSName", "")
+ if dns_name:
+ # Extract short hostname from "blue.spangled-kettle.ts.net."
+ hostname = dns_name.split(".")[0]
+
if not hostname:
- # Fall back to Tailscale DNSName
- if status_data:
- self_info = status_data.get("Self", {})
- dns_name = self_info.get("DNSName", "")
- if dns_name:
- hostname = dns_name.split(".")[0]
+ # HOST_HOSTNAME is set by setup/run.py from the host machine's friendly name
+ hostname = os.environ.get("HOST_HOSTNAME")
+ if hostname:
+ logger.info(f"Using HOST_HOSTNAME env var: {hostname}")
+
if not hostname:
- import socket
- hostname = socket.gethostname()
- logger.warning(f"Could not determine hostname, using socket hostname: {hostname}")
+ # Last resort - inside Docker this will be container ID
+ hostname = socket_module.gethostname()
+ logger.warning(f"Using socket hostname (may be container ID): {hostname}")
- logger.info(f"Leader registration: hostname={hostname}, tailscale_ip={tailscale_ip}")
+ # Generate display_name as hostname-envname
+ if envname:
+ display_name = f"{hostname}-{envname}"
+ else:
+ display_name = hostname
+
+ logger.info(f"Leader registration: hostname={hostname}, envname={envname}, display_name={display_name}, tailscale_ip={tailscale_ip}")
# Remove any old leader entries and keep only one
+ # Match on display_name (hostname-envname) which is unique per environment
await self.unodes_collection.delete_many({
"role": UNodeRole.LEADER.value,
- "hostname": {"$ne": hostname}
+ "display_name": {"$ne": display_name}
})
- # Check if we already exist
- existing = await self.unodes_collection.find_one({"hostname": hostname})
+ # Check if we already exist (match on display_name which is unique)
+ existing = await self.unodes_collection.find_one({"display_name": display_name})
now = datetime.now(timezone.utc)
unode_data = {
"hostname": hostname,
- "display_name": f"{hostname} (Leader)",
+ "envname": envname,
+ "display_name": display_name,
"role": UNodeRole.LEADER.value,
"status": UNodeStatus.ONLINE.value,
"tailscale_ip": tailscale_ip,
@@ -258,7 +275,7 @@ async def _register_self_as_leader(self):
"last_seen": now,
"manager_version": "0.1.0",
"services": self._detect_running_services(),
- "labels": {"type": "leader"},
+ "labels": {"type": "leader", "is_local": "true"},
"metadata": {"is_origin": True},
}
@@ -614,10 +631,17 @@ async def register_unode(
now = datetime.now(timezone.utc)
unode_id = secrets.token_hex(16)
+ # Generate display_name as hostname-envname if envname is provided
+ if unode_data.envname:
+ display_name = f"{unode_data.hostname}-{unode_data.envname}"
+ else:
+ display_name = unode_data.hostname
+
unode_doc = {
"id": unode_id,
"hostname": unode_data.hostname,
- "display_name": unode_data.hostname,
+ "envname": unode_data.envname,
+ "display_name": display_name,
"tailscale_ip": unode_data.tailscale_ip,
"platform": unode_data.platform.value,
"role": token_doc.role.value,
@@ -627,7 +651,7 @@ async def register_unode(
"last_seen": now,
"manager_version": unode_data.manager_version,
"services": [],
- "labels": {},
+ "labels": unode_data.labels, # Use labels from UNodeCreate
"metadata": {},
"unode_secret_hash": unode_secret_hash,
"unode_secret_encrypted": unode_secret_encrypted,
@@ -669,6 +693,10 @@ async def _update_existing_unode(
if unode_data.capabilities:
update_data["capabilities"] = unode_data.capabilities.model_dump()
+ # Update labels if provided (don't clear existing labels if not provided)
+ if unode_data.labels:
+ update_data["labels"] = unode_data.labels
+
await self.unodes_collection.update_one(
{"hostname": unode_data.hostname},
{"$set": update_data}
@@ -743,15 +771,33 @@ async def list_unodes(
status: Optional[UNodeStatus] = None,
role: Optional[UNodeRole] = None
) -> List[UNode]:
- """List all u-nodes, optionally filtered by status or role."""
+ """List all u-nodes, optionally filtered by status or role.
+
+ If multiple records exist for the same hostname (duplicates), returns only the latest.
+ """
query = {}
if status:
query["status"] = status.value
if role:
query["role"] = role.value
+ # Use aggregation to get only the latest record per hostname
+ pipeline = [
+ {"$match": query},
+ {"$sort": {"registered_at": -1}}, # Sort by registration date, newest first
+ {"$group": {
+ "_id": "$hostname", # Group by hostname
+ "doc": {"$first": "$$ROOT"} # Take the first (latest) document
+ }},
+ {"$replaceRoot": {"newRoot": "$doc"}} # Flatten back to original structure
+ ]
+
unodes = []
- async for doc in self.unodes_collection.find(query):
+ async for doc in self.unodes_collection.aggregate(pipeline):
+ # Debug: log what MongoDB returns
+ if doc.get("hostname") == "ushadow-orange-public":
+ logger.info(f"MongoDB doc for ushadow-orange-public: labels={doc.get('labels', 'MISSING')}")
+
unodes.append(UNode(**{k: v for k, v in doc.items() if k != "unode_secret_hash"}))
return unodes
@@ -882,10 +928,20 @@ async def claim_unode(
actual_platform = (worker_info or {}).get("platform", platform)
actual_version = (worker_info or {}).get("manager_version", manager_version)
+ # Get envname from worker info if available
+ actual_envname = (worker_info or {}).get("envname")
+
+ # Generate display_name as hostname-envname if envname is available
+ if actual_envname:
+ display_name = f"{hostname}-{actual_envname}"
+ else:
+ display_name = hostname
+
unode_doc = {
"id": unode_id,
"hostname": hostname,
- "display_name": hostname,
+ "envname": actual_envname,
+ "display_name": display_name,
"tailscale_ip": tailscale_ip,
"platform": actual_platform,
"role": UNodeRole.WORKER.value,
@@ -1409,8 +1465,11 @@ async def get_join_script(self, token: str) -> str:
install_tailscale
connect_tailscale
-# Get u-node info
-NODE_HOSTNAME=$(hostname)
+# Get u-node info - use friendly hostname
+case "$(uname -s)" in
+ Darwin*) NODE_HOSTNAME=$(scutil --get ComputerName 2>/dev/null || hostname -s);;
+ *) NODE_HOSTNAME=$(hostname -s 2>/dev/null || hostname);;
+esac
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "")
if [ -z "$TAILSCALE_IP" ]; then
diff --git a/ushadow/backend/src/utils/auth_helpers.py b/ushadow/backend/src/utils/auth_helpers.py
new file mode 100644
index 00000000..515b644e
--- /dev/null
+++ b/ushadow/backend/src/utils/auth_helpers.py
@@ -0,0 +1,71 @@
+"""
+Authentication helper utilities for handling both Keycloak and legacy user formats.
+"""
+
+from typing import Union, Optional
+
+
+def get_user_id(user: Union[dict, object]) -> str:
+ """
+ Safely extract user ID from either Keycloak dict or legacy User object.
+
+ Args:
+ user: Either Keycloak user dict (with 'sub' field) or legacy User object (with 'id' attribute)
+
+ Returns:
+ User ID as string
+ """
+ if isinstance(user, dict):
+ return user.get("sub", "")
+ return str(getattr(user, "id", ""))
+
+
+def get_user_email(user: Union[dict, object]) -> str:
+ """
+ Safely extract user email from either Keycloak dict or legacy User object.
+
+ Args:
+ user: Either Keycloak user dict (with 'email' field) or legacy User object (with 'email' attribute)
+
+ Returns:
+ User email as string
+ """
+ if isinstance(user, dict):
+ return user.get("email", "")
+ return getattr(user, "email", "")
+
+
+def get_user_name(user: Union[dict, object]) -> Optional[str]:
+ """
+ Safely extract user name from either Keycloak dict or legacy User object.
+
+ Args:
+ user: Either Keycloak user dict (with 'name' field) or legacy User object (with 'display_name' attribute)
+
+ Returns:
+ User name as string or None
+ """
+ if isinstance(user, dict):
+ return user.get("name") or user.get("preferred_username")
+ return getattr(user, "display_name", None) or getattr(user, "email", None)
+
+
+def is_superuser(user: Union[dict, object]) -> bool:
+ """
+ Check if user is a superuser/admin.
+
+ For Keycloak users, checks for 'admin' role in realm_access.roles.
+ For legacy User objects, checks the is_superuser attribute.
+
+ Args:
+ user: Either Keycloak user dict or legacy User object
+
+ Returns:
+ True if user is a superuser/admin
+ """
+ if isinstance(user, dict):
+ # Keycloak tokens store roles in realm_access.roles
+ realm_access = user.get("realm_access", {})
+ roles = realm_access.get("roles", [])
+ return "admin" in roles or "superuser" in roles
+ return getattr(user, "is_superuser", False)
diff --git a/ushadow/backend/src/utils/environment.py b/ushadow/backend/src/utils/environment.py
index a21a7bed..88bc51b1 100644
--- a/ushadow/backend/src/utils/environment.py
+++ b/ushadow/backend/src/utils/environment.py
@@ -119,12 +119,28 @@ def is_local_deployment(self, hostname: str) -> bool:
Check if a hostname refers to the local environment.
Args:
- hostname: Hostname to check
+ hostname: Hostname to check (can be env_name, compose_project_name,
+ HOST_HOSTNAME, or display_name format like "Orion-orange")
Returns:
True if hostname matches current environment, False otherwise
"""
- return hostname in [self.env_name, self.compose_project_name, "localhost", "local"]
+ # Basic matches
+ local_names = [self.env_name, self.compose_project_name, "localhost", "local"]
+
+ # Add HOST_HOSTNAME if set
+ host_hostname = os.getenv("HOST_HOSTNAME", "").strip()
+ if host_hostname:
+ local_names.append(host_hostname)
+ # Also add display_name format: {HOST_HOSTNAME}-{env_name}
+ local_names.append(f"{host_hostname}-{self.env_name}")
+
+ # Virtual unodes on same machine (e.g., ushadow-orange-public)
+ # Pattern: ushadow-{env_name}-{suffix}
+ if hostname.startswith(f"ushadow-{self.env_name}-"):
+ return True
+
+ return hostname in local_names
def get_container_labels(self) -> dict:
"""
diff --git a/ushadow/backend/src/utils/mongodb.py b/ushadow/backend/src/utils/mongodb.py
new file mode 100644
index 00000000..e70ac892
--- /dev/null
+++ b/ushadow/backend/src/utils/mongodb.py
@@ -0,0 +1,96 @@
+"""MongoDB URI construction utilities."""
+
+import os
+from typing import Optional
+from urllib.parse import quote_plus
+
+
+def build_mongodb_uri_from_env() -> Optional[str]:
+ """
+ Construct MongoDB URI from component environment variables.
+
+ Reads individual MongoDB configuration from environment:
+ - MONGODB_HOST (default: mongo)
+ - MONGODB_PORT (default: 27017)
+ - MONGODB_USER (optional)
+ - MONGODB_PASSWORD (optional)
+ - MONGODB_DATABASE (optional, for default database in URI)
+ - MONGODB_AUTH_SOURCE (default: admin, only used with authentication)
+
+ Returns:
+ Constructed MongoDB URI string, or None if MONGODB_HOST not set
+
+ Examples:
+ Without authentication:
+ mongodb://mongo:27017
+ mongodb://mongo:27017/ushadow
+
+ With authentication:
+ mongodb://user:pass@mongo:27017/ushadow?authSource=admin
+ """
+ host = os.environ.get("MONGODB_HOST")
+ if not host:
+ return None
+
+ port = os.environ.get("MONGODB_PORT", "27017")
+ user = os.environ.get("MONGODB_USER", "")
+ password = os.environ.get("MONGODB_PASSWORD", "")
+ database = os.environ.get("MONGODB_DATABASE", "")
+ auth_source = os.environ.get("MONGODB_AUTH_SOURCE", "admin")
+
+ # URL-encode credentials to handle special characters
+ if user:
+ user = quote_plus(user)
+ if password:
+ password = quote_plus(password)
+
+ # Build URI components
+ if user and password:
+ # Authenticated connection
+ credentials = f"{user}:{password}@"
+ query_params = f"?authSource={auth_source}"
+ else:
+ # No authentication
+ credentials = ""
+ query_params = ""
+
+ # Build base URI
+ uri = f"mongodb://{credentials}{host}:{port}"
+
+ # Add database if specified
+ if database:
+ uri += f"/{database}"
+
+ # Add query parameters (only for authenticated connections)
+ uri += query_params
+
+ return uri
+
+
+def get_mongodb_uri(fallback: str = "mongodb://mongo:27017") -> str:
+ """
+ Get MongoDB URI from environment.
+
+ Priority:
+ 1. MONGODB_URI environment variable (complete URI)
+ 2. Construct from MONGODB_HOST, MONGODB_PORT, etc. (component variables)
+ 3. Fallback value
+
+ Args:
+ fallback: Default URI if neither MONGODB_URI nor components are set
+
+ Returns:
+ MongoDB connection URI string
+ """
+ # Check for complete URI first
+ uri = os.environ.get("MONGODB_URI")
+ if uri:
+ return uri
+
+ # Try to build from components
+ uri = build_mongodb_uri_from_env()
+ if uri:
+ return uri
+
+ # Use fallback
+ return fallback
diff --git a/ushadow/backend/src/utils/service_urls.py b/ushadow/backend/src/utils/service_urls.py
deleted file mode 100644
index 13925e7e..00000000
--- a/ushadow/backend/src/utils/service_urls.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""
-Service URL utilities.
-
-Functions for constructing service URLs in different contexts.
-"""
-
-import os
-
-
-def get_internal_proxy_url(service_name: str) -> str:
- """
- Get the internal proxy URL for a service (for backend-to-service communication).
-
- This URL goes through the ushadow backend proxy, providing:
- - Stable hostname (no hash-suffixed container names)
- - Unified routing logic
- - Works across environment changes
-
- Args:
- service_name: Service name (e.g., "mem0", "chronicle-backend")
-
- Returns:
- Internal proxy URL (e.g., "http://ushadow-orange-backend:8360/api/services/mem0/proxy")
- """
- backend_port = os.getenv("BACKEND_PORT", "8001")
- project_name = os.getenv("COMPOSE_PROJECT_NAME", "ushadow")
- return f"http://{project_name}-backend:{backend_port}/api/services/{service_name}/proxy"
-
-
-def get_relative_proxy_url(service_name: str) -> str:
- """
- Get the relative proxy URL for a service (for frontend API calls).
-
- Args:
- service_name: Service name (e.g., "mem0", "chronicle-backend")
-
- Returns:
- Relative proxy URL (e.g., "/api/services/mem0/proxy")
- """
- return f"/api/services/{service_name}/proxy"
diff --git a/ushadow/backend/src/utils/tailscale_serve.py b/ushadow/backend/src/utils/tailscale_serve.py
index e8f16529..4e45f3b1 100644
--- a/ushadow/backend/src/utils/tailscale_serve.py
+++ b/ushadow/backend/src/utils/tailscale_serve.py
@@ -302,6 +302,93 @@ def get_serve_status() -> Optional[str]:
return None
+# =============================================================================
+# Funnel Routes (Public Internet Access)
+# =============================================================================
+
+def add_funnel_route(path: str, target: str) -> bool:
+ """Add a route to tailscale funnel (public internet access).
+
+ Uses the modern Tailscale Funnel CLI syntax which automatically:
+ 1. Enables Funnel on the hostname (if not already enabled)
+ 2. Adds/updates the specified route
+
+ Note: Enabling Funnel makes ALL routes on the hostname publicly accessible,
+ not just the route being added. This is a Tailscale Funnel behavior.
+
+ Args:
+ path: URL path (e.g., "/share", "/api", or "/" for root)
+ target: Backend target (e.g., "http://share-dmz:8000")
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Modern Tailscale Funnel syntax: use --bg for background mode
+ # Do NOT use --https=443 as it tries to create a new listener
+ if path == "/":
+ # Root route - no --set-path
+ cmd = f"tailscale funnel --bg {target}"
+ else:
+ cmd = f"tailscale funnel --bg --set-path {path} {target}"
+
+ exit_code, stdout, stderr = exec_tailscale_command(cmd)
+
+ if exit_code == 0:
+ logger.info(f"Added tailscale funnel route: {path} -> {target}")
+ return True
+ else:
+ logger.error(f"Failed to add funnel route {path}: {stderr}")
+ return False
+
+
+def remove_funnel_route(path: str) -> bool:
+ """Remove a route from tailscale funnel.
+
+ This removes the route entirely. It will no longer be accessible
+ (neither publicly via funnel nor privately via serve).
+
+ Note: Funnel remains enabled on the hostname for other routes.
+ To completely disable funnel for all routes, use: tailscale funnel reset
+
+ Args:
+ path: URL path to remove (e.g., "/share")
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Use funnel command to remove the route
+ if path == "/":
+ cmd = "tailscale funnel off"
+ else:
+ cmd = f"tailscale funnel --set-path {path} off"
+
+ exit_code, stdout, stderr = exec_tailscale_command(cmd)
+
+ if exit_code == 0:
+ logger.info(f"Removed tailscale funnel route: {path}")
+ return True
+ else:
+ logger.error(f"Failed to remove funnel route {path}: {stderr}")
+ return False
+
+
+def get_funnel_status() -> Optional[str]:
+ """Get current tailscale funnel status.
+
+ Returns:
+ Status string or None if error
+ """
+ exit_code, stdout, stderr = exec_tailscale_command("tailscale funnel status")
+
+ if exit_code == 0:
+ return stdout
+ return None
+
+
+# =============================================================================
+# Base Routes Configuration
+# =============================================================================
+
def configure_base_routes(
backend_container: str = None,
frontend_container: str = None,
@@ -313,10 +400,11 @@ def configure_base_routes(
Sets up:
- /api/* -> backend/api (path preserved)
- /auth/* -> backend/auth (path preserved)
- - /ws_pcm -> chronicle-backend/ws_pcm (websocket - direct to Chronicle)
- - /ws_omi -> chronicle-backend/ws_omi (websocket - direct to Chronicle)
- /* -> frontend
+ Note: Audio WebSockets use /ws/audio/relay (part of /api/* routing)
+ The relay handles forwarding to Chronicle/Mycelia internally
+
Note: Tailscale serve strips the path prefix, so we include it in the
target URL to preserve the full path at the service.
@@ -361,22 +449,12 @@ def configure_base_routes(
if not add_serve_route(route, target):
success = False
- # Configure Chronicle WebSocket routes - these go directly to Chronicle for low latency
- # (REST APIs use /api/services/chronicle-backend/proxy/* through ushadow backend)
- chronicle_container = f"{env_name}-chronicle-backend"
- chronicle_port = 8000 # Chronicle's internal port
- chronicle_base = f"http://{chronicle_container}:{chronicle_port}"
-
- websocket_routes = ["/ws_pcm", "/ws_omi"]
- for route in websocket_routes:
- target = f"{chronicle_base}{route}"
- if not add_serve_route(route, target):
- success = False
+ # NOTE: Audio WebSockets are handled by the audio relay at /ws/audio/relay
+ # The relay forwards to Chronicle/Mycelia/other services via internal Docker networking
+ # No direct Chronicle WebSocket routing needed at Layer 1
- # NOTE: Chronicle REST APIs are now accessed via generic proxy pattern:
- # /api/services/chronicle-backend/proxy/* instead of direct /chronicle routing
- # This provides unified auth and centralized routing through ushadow backend
- # WebSockets go directly to Chronicle for low latency
+ # NOTE: Chronicle REST APIs are accessed via generic proxy pattern:
+ # /api/services/chronicle-backend/proxy/* - unified auth through ushadow backend
# Frontend catches everything else
if not add_serve_route("/", frontend_target):
diff --git a/ushadow/backend/tests/conftest.py b/ushadow/backend/tests/conftest.py
index b53f47c9..c21ed472 100644
--- a/ushadow/backend/tests/conftest.py
+++ b/ushadow/backend/tests/conftest.py
@@ -24,9 +24,9 @@
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
-# Add src to path for imports
+# Add backend root to path for src.* imports
backend_root = Path(__file__).parent.parent
-sys.path.insert(0, str(backend_root / "src"))
+sys.path.insert(0, str(backend_root))
# Find project root (contains config/ directory)
project_root = backend_root.parent.parent
@@ -74,8 +74,12 @@ def test_config_dir():
""")
# Reset settings store singleton to pick up new CONFIG_DIR
- import src.config.omegaconf_settings as settings_module
- settings_module._settings_store = None
+ try:
+ import src.config.omegaconf_settings as settings_module
+ settings_module._settings_store = None
+ except ModuleNotFoundError:
+ # Settings module not needed for all tests
+ pass
yield test_config_dir
diff --git a/ushadow/backend/tests/test_yaml_parser.py b/ushadow/backend/tests/test_yaml_parser.py
index a45924ab..d606e9b6 100644
--- a/ushadow/backend/tests/test_yaml_parser.py
+++ b/ushadow/backend/tests/test_yaml_parser.py
@@ -219,7 +219,7 @@ def test_parse_full_compose(self):
- mem0
networks:
- infra-network:
+ ushadow-network:
external: true
volumes:
@@ -255,7 +255,7 @@ def test_parse_full_compose(self):
assert mem0_ui.depends_on == ["mem0"]
# Check networks and volumes
- assert "infra-network" in result.networks
+ assert "ushadow-network" in result.networks
assert "mem0_data" in result.volumes
finally:
diff --git a/ushadow/backend/update_keycloak_csp.py b/ushadow/backend/update_keycloak_csp.py
new file mode 100644
index 00000000..b1688c53
--- /dev/null
+++ b/ushadow/backend/update_keycloak_csp.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+Update Keycloak realm browser security headers to allow embedding in Tauri/Tailscale.
+
+This script updates the CSP frame-ancestors to allow the frontend to embed Keycloak
+in iframes from any origin (needed for Tauri launcher and Tailscale domains).
+"""
+
+import os
+import sys
+from keycloak import KeycloakAdmin
+
+
+def update_csp():
+ """Update Keycloak realm CSP to allow embedding from any origin."""
+
+ # Get config from environment
+ keycloak_url = os.getenv("KEYCLOAK_URL", "http://keycloak:8080")
+ realm = os.getenv("KEYCLOAK_REALM", "ushadow")
+ admin_user = os.getenv("KEYCLOAK_ADMIN", "admin")
+ admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+
+ print(f"[UPDATE-CSP] Connecting to Keycloak at {keycloak_url}")
+ print(f"[UPDATE-CSP] Realm: {realm}")
+
+ # Connect to Keycloak
+ admin = KeycloakAdmin(
+ server_url=keycloak_url,
+ username=admin_user,
+ password=admin_password,
+ realm_name="master", # Authenticate against master realm
+ verify=True,
+ )
+
+ # Browser security headers with relaxed frame-ancestors for development
+ # Note: 'self' allows Keycloak to embed itself
+ # http: https: allows any HTTP/HTTPS origin (needed for Tailscale and launcher)
+ # tauri: allows Tauri apps
+ headers = {
+ "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self' http: https: tauri:; object-src 'none';",
+ "xContentTypeOptions": "nosniff",
+ "xRobotsTag": "none",
+ "xFrameOptions": "", # Remove X-Frame-Options (conflicts with CSP frame-ancestors)
+ "xXSSProtection": "1; mode=block",
+ "strictTransportSecurity": "max-age=31536000; includeSubDomains"
+ }
+
+ print("[UPDATE-CSP] Updating Keycloak realm browser security headers...")
+ print(f"[UPDATE-CSP] CSP: {headers['contentSecurityPolicy']}")
+
+ try:
+ # Get current realm configuration
+ realm_config = admin.get_realm(realm)
+
+ # Update browserSecurityHeaders
+ realm_config["browserSecurityHeaders"] = headers
+
+ # Update realm
+ admin.update_realm(realm, realm_config)
+
+ print("[UPDATE-CSP] ✓ Successfully updated Keycloak CSP")
+ print("[UPDATE-CSP] The frontend can now embed Keycloak from any origin")
+ return 0
+ except Exception as e:
+ print(f"[UPDATE-CSP] ❌ Failed to update CSP: {e}")
+ import traceback
+ traceback.print_exc()
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(update_csp())
diff --git a/ushadow/backend/uv.lock b/ushadow/backend/uv.lock
index 93060783..c90f84e9 100644
--- a/ushadow/backend/uv.lock
+++ b/ushadow/backend/uv.lock
@@ -6,6 +6,15 @@ resolution-markers = [
"python_full_version < '3.14'",
]
+[[package]]
+name = "aiofiles"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
+]
+
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
@@ -193,6 +202,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
+[[package]]
+name = "atproto"
+version = "0.0.65"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "cryptography" },
+ { name = "dnspython" },
+ { name = "httpx" },
+ { name = "libipld" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/0f/b6e26f99ef730f1e5779f5833ba794343df78ee1e02041d3b05bd5005066/atproto-0.0.65.tar.gz", hash = "sha256:027c6ed98746a9e6f1bb24bc18db84b80b386037709ff3af9ef927dce3dd4938", size = 210996, upload-time = "2025-12-08T15:53:44.585Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/d9/360149e7bd9bac580496ce9fddc0ef320b3813aadd72be6abc011600862d/atproto-0.0.65-py3-none-any.whl", hash = "sha256:ea53dea57454c9e56318b5d25ceb35854d60ba238b38b0e5ca79aa1a2df85846", size = 446650, upload-time = "2025-12-08T15:53:43.029Z" },
+]
+
[[package]]
name = "attrs"
version = "25.4.0"
@@ -284,6 +312,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/f2/adfea21c19d73ad2e90f5346c166523dadc33493a0b398d543eeb9b67e7a/beanie-1.30.0-py3-none-any.whl", hash = "sha256:385f1b850b36a19dd221aeb83e838c83ec6b47bbf6aeac4e5bf8b8d40bfcfe51", size = 87140, upload-time = "2025-06-10T19:47:59.066Z" },
]
+[[package]]
+name = "blurhash"
+version = "1.1.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/f3/9e636182d0e6b3f6b7879242f7f8add78238a159e8087ec39941f5d65af7/blurhash-1.1.5.tar.gz", hash = "sha256:181e1484b6a8ab5cff0ef37739150c566f4a72f2ab0dcb79660b6cee69c137a9", size = 50859, upload-time = "2025-08-17T10:36:12.519Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/dc/cadbf64b335a2ee0f31a84d05f34551c2199caa6f639a90c9157b564d0d6/blurhash-1.1.5-py2.py3-none-any.whl", hash = "sha256:96a8686e8b9fced1676550b814e59256214e2d4033202b16c91271ed4d317fec", size = 6632, upload-time = "2025-08-17T10:36:11.404Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -558,6 +595,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[package]]
+name = "deprecation"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -1151,6 +1209,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
+[[package]]
+name = "jwcrypto"
+version = "1.5.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" },
+]
+
[[package]]
name = "kubernetes"
version = "35.0.0"
@@ -1183,6 +1254,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/13/e37962a20f7051b2d6d286c3feb85754f9ea8c4cac302927971e910cc9f6/lazy_model-0.2.0-py3-none-any.whl", hash = "sha256:5a3241775c253e36d9069d236be8378288a93d4fc53805211fd152e04cc9c342", size = 13719, upload-time = "2023-09-10T02:29:59.067Z" },
]
+[[package]]
+name = "libipld"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/2b/4e84e033268d2717c692e5034e016b1d82501736cd297586fd1c7378ccd5/libipld-3.3.2.tar.gz", hash = "sha256:7e85ccd9136110e63943d95232b193c893e369c406273d235160e5cc4b39c9ce", size = 4401259, upload-time = "2025-12-05T13:00:20.34Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/0b/f65e7d56d0dec2804c1508aef4cf5d3a775273a090ae3047123f6f3e0f63/libipld-3.3.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3f033e98c9e95e8448c97bbc904271908076974d790a895abade2ae89433715e", size = 269020, upload-time = "2025-12-05T12:58:26.503Z" },
+ { url = "https://files.pythonhosted.org/packages/19/20/01a3be66e8945aaef9959ce80a07bf959e31b2bd2216bd199b24b463235a/libipld-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88ac549eb6c56287785ad20d0e7785d3e8b153b6a322fd5d7edf0e7fda2b182e", size = 260450, upload-time = "2025-12-05T12:58:27.735Z" },
+ { url = "https://files.pythonhosted.org/packages/af/06/a052e57bc99ec592d4b40c641d492f5fb225d25cc17f9edbf4f5918d7ff4/libipld-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:627035693460bae559d2e7f46bc577a27504d6e38e8715fcf9a8d905f6b1c72d", size = 280170, upload-time = "2025-12-05T12:58:28.977Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/34/f20ff8a1b28a76d28f20895b1cb7d88422946e6ff6d8bc3d26a0b444e990/libipld-3.3.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36be4ce9cb417eedec253eda9f55b92f29a35cbfcb24d108b496c72934fea7a2", size = 290219, upload-time = "2025-12-05T12:58:30.376Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/0c/253c1d433e01c95d70c1b146e065fd5a3e1284ed0072f082603b5daf9223/libipld-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:908630dc28b16a517cf323293f0843f481b0872649cba7d4cfdbc6eb258f5674", size = 315833, upload-time = "2025-12-05T12:58:31.61Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4a/2b8da906680e7379b31e1b31a4e49d90725a767e53510eb88f85f91e71c6/libipld-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ac45e3aef416fe2eccbe84e562d81714416790bfd0756a1aa49ba895d4c7010", size = 330068, upload-time = "2025-12-05T12:58:32.94Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/73/be4031e3e1f839c286a6d9277fcacd756160a18009aa649adee308531698/libipld-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3344f30d47dcab9cba41dd8f2243874af91939e38e3c31f20d586383ca74296e", size = 283716, upload-time = "2025-12-05T12:58:34.166Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/f2/35ebdb7b53cc4a97a2a8d580d5c302bf30a66d918273a0d01c3cd77b9336/libipld-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4443d047fd1a9679534a87a6ee35c3a10793d4453801281341bb1e8390087c69", size = 309913, upload-time = "2025-12-05T12:58:35.392Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/d7/a1ffdb1b2986e60dd59d094c86e5bb318739c6d709b9e8af255667b7c578/libipld-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37ea7cb7afb94277e4e095bcc0ae888ed4b6e0fe8082c41dccd6e9487ccfd729", size = 463850, upload-time = "2025-12-05T12:58:36.702Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/7d/440e372c3b8070cbf9200e1ddf3dff7409bcbc9243aade08e99c9e845e90/libipld-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:634d176664cf295360712157b5c5a83539da2f4416f3e0491340064d49e74fd8", size = 460370, upload-time = "2025-12-05T12:58:38.032Z" },
+ { url = "https://files.pythonhosted.org/packages/86/3e/cfcbbe21b30752482afa22fd528635a96901b39e517a10b73fc422f3d29b/libipld-3.3.2-cp312-cp312-win32.whl", hash = "sha256:071de5acf902e9a21d761572755afc8403cbaadd4b8199e7504ad52ee45b6b5e", size = 159380, upload-time = "2025-12-05T12:58:39.266Z" },
+ { url = "https://files.pythonhosted.org/packages/af/b5/b1cbc3347cf831c0599bb9b5579ed286939455d11d6f70110a3b8fb7d695/libipld-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:e35a8735b8a4bdd09b9edfbf1ae36e9ba9a804de50c99352c9a06aa3da109a62", size = 158896, upload-time = "2025-12-05T12:58:40.457Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/cd/4ac32a0297c1d91d7147178927144dcb4456c35076388efb7c7f76e90695/libipld-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:36fe9cd1b5a75a315cab30091579242d05df39692f773f7d8221250503753e3a", size = 149432, upload-time = "2025-12-05T12:58:41.691Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a6/2bf577bde352fdb81ebe2e271e542b85f1aeae630405cae1b9d07a97b5e9/libipld-3.3.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:63bc6858d73c324e29d74155bdb339e14330a88bb1a8cc8fdc295048337dca09", size = 269326, upload-time = "2025-12-05T12:58:42.967Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/83/850a0bb214c31c128447e29cdbea816225ee2c8fbb397a8c865f895198e4/libipld-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4140f030eb3cfff17d04b9481f13aaed0b2910d1371fe7489394120ed1d09ae5", size = 260709, upload-time = "2025-12-05T12:58:44.232Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f8/0c02a2acb246603f5351d0a71055d0c835bc0bc5332c5ca5d29a1d95b04c/libipld-3.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a48bc2f7845825143a36f6a305680823a2816488593024803064d0803e3cee35", size = 280309, upload-time = "2025-12-05T12:58:46.137Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/2e/ca50530aed1911d99a730f30ab73e7731da8299a933b909a96fcdbb1baf6/libipld-3.3.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7627f371682160cae818f817eb846bc8c267a5daa028748a7c73103d8df00eb", size = 290446, upload-time = "2025-12-05T12:58:47.49Z" },
+ { url = "https://files.pythonhosted.org/packages/68/09/dd0f39cf78dbc7f5f2ca1208fc9ff284b56c2b90edf3dbf98c4b36491b6c/libipld-3.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a7de390a3eb897d3194f6c96067c21338fbe6e0fc1145ab6b51af276aa7a08e", size = 316193, upload-time = "2025-12-05T12:58:49.057Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/75/ca6fe1673c80f7f4164edf9647dd2cb622455a73890e96648c44c361c918/libipld-3.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:196a8fcd86ae0c8096cea85ff308edf315d77fbb677ef3dd9eff0be9da526499", size = 330556, upload-time = "2025-12-05T12:58:50.471Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/41/aff762ccf5a80b66a911c576afcd850f0d64cb43d51cb63c29429dc68230/libipld-3.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b040dab7eb04b0ff730e68840f40eb225c9f14e73ad21238b76c7b8ded3ad99d", size = 283970, upload-time = "2025-12-05T12:58:52.131Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/56/3a19a6067bde8827146cd771583e8930cf952709f036328579747647f38f/libipld-3.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d7cd1e7e88b0fbc8f4aa267bdea2d10452c9dd0e1aafa82a5e0751427f222b0", size = 309885, upload-time = "2025-12-05T12:58:53.406Z" },
+ { url = "https://files.pythonhosted.org/packages/de/9b/0b4ee60ede82cdd301e2266a8172e8ee6f1b40c7dbd797510e632314ddf6/libipld-3.3.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:76731ebebd824fa45e51cc85506b108aa5da7322e43864909895f1779e9e4b41", size = 464028, upload-time = "2025-12-05T12:58:54.755Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/c2/8edf65cf2c98bfbf6535b65f4bcc461ecec65ae6b9e3fb5a4308b9a5fb7a/libipld-3.3.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7b8e7100bffbe579b7c92a3c6a8852ce333e0de171e696a2063e1e39ec9cc50a", size = 460526, upload-time = "2025-12-05T12:58:56.231Z" },
+ { url = "https://files.pythonhosted.org/packages/17/3f/d6d2aa42f07855be6b7e1fb43d76e39945469fc54fe9366bf8c9a81ca38e/libipld-3.3.2-cp313-cp313-win32.whl", hash = "sha256:06f766cec75f3d78339caa3ce3c6977e290e1a97f37e5f4ba358da2e77340196", size = 159501, upload-time = "2025-12-05T12:58:57.482Z" },
+ { url = "https://files.pythonhosted.org/packages/12/2a/83f634329f1d1912e5d37aec717396c76ef689fa8c8997b16cf0866a1985/libipld-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be484f1dc5525453e17f07f02202180c708213f2b6ea06d3b9247a5702e0229", size = 159090, upload-time = "2025-12-05T12:58:58.628Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f4/5b55acce9f3626f8cbd54163f22a0917430d7307bf56fd30d88df7a0a897/libipld-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:4446cae7584a446b58de66942f89f155d95c2cbfb9ad215af359086824d4e3b9", size = 149497, upload-time = "2025-12-05T12:59:00.191Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d6/9ab52adf13ee501b50624ef1265657aa30b3267998dfadcb44d77bbeef42/libipld-3.3.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5947e99b40e923170094a3313c9f3629c6ed475465ba95eadce6cdcf08f1f65a", size = 268909, upload-time = "2025-12-05T12:59:02.485Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/12/d6f04fb3d6911a276940c89b5ad3e6168d79fda9ae79a812d4da91c433d6/libipld-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f46179c722baf74c627c01c0bf85be7fcbde66bbf7c5f8c1bbb57bd3a17b861b", size = 261052, upload-time = "2025-12-05T12:59:03.829Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/23/6cade33d39f00eb71fde1c8fe6f73c5db5274ef8abeac3d2e6d989e65718/libipld-3.3.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e3e9be4bdeb90dbc537a53f8d06e8b2c703f4b7868f9316958e1bbde526a143", size = 280280, upload-time = "2025-12-05T12:59:05.13Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/42/50445b6c1c418a3514feb7d267d308e9fb9fe473fbbfaa205bc288ffe5ed/libipld-3.3.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b155c02626b194439f4b519a53985aedc8637ae56cf640ea6acf6172a37465de", size = 290306, upload-time = "2025-12-05T12:59:06.372Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b1/7c197e21f1635ba31b2f4e893d3368598a48d990cebc4308ba496bad1409/libipld-3.3.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a1d84c630961cff188deaa2129c86d69f5779c8d02046fbe0c629ef162bc3df", size = 315801, upload-time = "2025-12-05T12:59:07.918Z" },
+ { url = "https://files.pythonhosted.org/packages/83/df/51a549e3017cc496a80852063124793007cb9b4cf2cae2e8a99f5c3dd814/libipld-3.3.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5393886a7e387751904681ecfa7e5912471b46043f044baa041a2b4772e4f839", size = 330420, upload-time = "2025-12-05T12:59:09.185Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/f8/84107ad6431311283dadf697fd238ea271e0af1068a0d13e574be5027f32/libipld-3.3.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44ca1ba44cb801686557e9544d248e013a2d5d1ab9fed796f090bb0d51d8f4ef", size = 283791, upload-time = "2025-12-05T12:59:10.481Z" },
+ { url = "https://files.pythonhosted.org/packages/35/c5/e3c5116b66383f7e54b9d1feb6d6e254a383311a4cce2940942f07d45893/libipld-3.3.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd0877ef4a1bd6e42ba52659769b5b766583c67b3cfb4e7143f9d10b81fb7a74", size = 309401, upload-time = "2025-12-05T12:59:11.711Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/b5/b9345d47569806e6f0041d339c9a1ec0be765fd8a3588308a7a40c383dd9/libipld-3.3.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:91b02da059a6ae7f783efa826f640ab1ca5eb5dd370bfd3f41071693a363c4fb", size = 463929, upload-time = "2025-12-05T12:59:13.344Z" },
+ { url = "https://files.pythonhosted.org/packages/92/4b/ae985a308191771e5a9e8e3108a3a4ed7090147e21a7cda0c0e345adc22a/libipld-3.3.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:95a2c4f507c88c01a797ec97ce10603bea684c03208227703e007485dc631971", size = 460308, upload-time = "2025-12-05T12:59:14.702Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/d6/98aafc9721dd239e578e2826cbb1e9ef438d76c0ec125bce64346e439041/libipld-3.3.2-cp314-cp314-win32.whl", hash = "sha256:5a50cbf5b3b73164fbb88169573ed3e824024c12fbc5f9efd87fb5c8f236ccc1", size = 159315, upload-time = "2025-12-05T12:59:16.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9c/6b7b91a417162743d9ea109e142fe485b2f6dafadb276c6e5a393f772715/libipld-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:c1f3ed8f70b215a294b5c6830e91af48acde96b3c8a6cae13304291f8240b939", size = 159168, upload-time = "2025-12-05T12:59:17.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/19/bb42dc53bb8855c1f40b4a431ed3cb2df257bd5a6af61842626712c83073/libipld-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:08261503b7307c6d9acbd3b2a221da9294b457204dcefce446f627893abb077e", size = 149324, upload-time = "2025-12-05T12:59:18.815Z" },
+]
+
[[package]]
name = "litellm"
version = "1.81.1"
@@ -1300,6 +1418,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/b6/0a907f92c2158c9841da0227c7074ce1490f578f34d67cbba82ba8f9146e/marshmallow-4.2.0-py3-none-any.whl", hash = "sha256:1dc369bd13a8708a9566d6f73d1db07d50142a7580f04fd81e1c29a4d2e10af4", size = 48448, upload-time = "2026-01-04T16:07:34.269Z" },
]
+[[package]]
+name = "mastodon-py"
+version = "2.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blurhash" },
+ { name = "decorator" },
+ { name = "python-dateutil" },
+ { name = "python-magic", marker = "sys_platform != 'win32'" },
+ { name = "python-magic-bin", marker = "sys_platform == 'win32'" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/88/ec/1eccba4dda197e6993dd1b8a4fa5728f8ed64d3ba54d61ebfe2420a20f4e/mastodon_py-2.1.4.tar.gz", hash = "sha256:6602e9ca4db37c70b5adae5964d02e9a529f6cc8473947a314261008add208a5", size = 11636752, upload-time = "2025-09-23T09:39:04.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/eb/23afadb9a0aee04a52adfc010384da267b42b66be6cbb3ed2d3c3edc20f4/mastodon_py-2.1.4-py3-none-any.whl", hash = "sha256:447ce341cf9a67e70789abf6a2c1a54b52cd2cd021818ccb32c52f34804c7896", size = 123469, upload-time = "2025-09-23T09:39:02.515Z" },
+]
+
[[package]]
name = "mcp"
version = "1.25.0"
@@ -1445,6 +1580,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
]
+[[package]]
+name = "neo4j"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" },
+]
+
[[package]]
name = "oauthlib"
version = "3.3.1"
@@ -1965,6 +2112,41 @@ cryptography = [
{ name = "cryptography" },
]
+[[package]]
+name = "python-keycloak"
+version = "7.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "deprecation" },
+ { name = "httpx" },
+ { name = "jwcrypto" },
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/9e/096568fa9348d52911042924bfcb81193e2b750fc6f5aca07206e43ae74c/python_keycloak-7.0.3.tar.gz", hash = "sha256:13e5ac449acf5334d62550895bfcd2c08d60c8e22f61a6512a6ad9c844cf9f73", size = 78723, upload-time = "2026-01-28T11:29:47.648Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/ed/a4e937bb0008a848be97f46cf5bb4d4af7f390a929b6e817eba7de2f4f69/python_keycloak-7.0.3-py3-none-any.whl", hash = "sha256:08de2c53f742360ed228e17f812a49964c5c52dcaf2439c3a6a1ab28e287cdd6", size = 87526, upload-time = "2026-01-28T11:29:46.534Z" },
+]
+
+[[package]]
+name = "python-magic"
+version = "0.4.27"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" },
+]
+
+[[package]]
+name = "python-magic-bin"
+version = "0.4.14"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/5d/10b9ac745d9fd2f7151a2ab901e6bb6983dbd70e87c71111f54859d1ca2e/python_magic_bin-0.4.14-py2.py3-none-win32.whl", hash = "sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892", size = 397784, upload-time = "2017-10-02T16:30:15.806Z" },
+ { url = "https://files.pythonhosted.org/packages/07/c2/094e3d62b906d952537196603a23aec4bcd7c6126bf80eb14e6f9f4be3a2/python_magic_bin-0.4.14-py2.py3-none-win_amd64.whl", hash = "sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69", size = 409299, upload-time = "2017-10-02T16:30:18.545Z" },
+]
+
[[package]]
name = "python-multipart"
version = "0.0.21"
@@ -1974,6 +2156,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
]
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+]
+
[[package]]
name = "pywin32"
version = "311"
@@ -2192,6 +2383,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
[[package]]
name = "rich"
version = "14.2.0"
@@ -2570,6 +2773,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+ { name = "atproto" },
{ name = "bcrypt" },
{ name = "beanie" },
{ name = "docker" },
@@ -2580,8 +2784,10 @@ dependencies = [
{ name = "httpx" },
{ name = "kubernetes" },
{ name = "litellm" },
+ { name = "mastodon-py" },
{ name = "mcp" },
{ name = "motor" },
+ { name = "neo4j" },
{ name = "omegaconf" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "prompt-toolkit" },
@@ -2591,6 +2797,7 @@ dependencies = [
{ name = "pymongo" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
+ { name = "python-keycloak" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] },
@@ -2622,6 +2829,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.7" },
+ { name = "atproto", specifier = ">=0.0.60" },
{ name = "bcrypt", specifier = ">=4.2.1" },
{ name = "beanie", specifier = ">=1.27.0" },
{ name = "docker", specifier = ">=7.1.0" },
@@ -2632,8 +2840,10 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27.2" },
{ name = "kubernetes", specifier = ">=31.0.0" },
{ name = "litellm", specifier = ">=1.50.0" },
+ { name = "mastodon-py", specifier = ">=1.8.0" },
{ name = "mcp", specifier = ">=1.1.0" },
{ name = "motor", specifier = ">=3.6.0" },
+ { name = "neo4j", specifier = ">=5.26.0" },
{ name = "omegaconf", specifier = ">=2.3.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "prompt-toolkit", specifier = ">=3.0.48" },
@@ -2647,6 +2857,7 @@ requires-dist = [
{ name = "pytest-env", marker = "extra == 'dev'", specifier = ">=1.1.0" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
+ { name = "python-keycloak", specifier = ">=4.5.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
@@ -2814,47 +3025,33 @@ wheels = [
[[package]]
name = "websockets"
-version = "16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
- { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
- { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
- { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
- { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
- { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
- { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
- { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
- { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
- { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
- { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
- { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
- { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
- { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
- { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
- { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
- { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
- { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
- { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
- { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
- { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
- { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
- { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
- { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
- { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
- { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
- { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
- { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
- { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
- { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
- { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
diff --git a/ushadow/client/auth.py b/ushadow/client/auth.py
index 74fde3fa..437ce32e 100644
--- a/ushadow/client/auth.py
+++ b/ushadow/client/auth.py
@@ -104,22 +104,100 @@ def _ensure_authenticated(self) -> str:
if self.verbose:
print(f"🔐 Logging in as {self.email}...")
- login_data = urlencode({"username": self.email, "password": self.password})
- response = httpx.post(
- f"{self.base_url}/api/auth/jwt/login",
- content=login_data.encode(),
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- timeout=10.0,
- )
- response.raise_for_status()
- result = response.json()
-
- self._token = result["access_token"]
+ # Try Keycloak direct grant flow first
+ token = self._try_keycloak_direct_grant()
+ if token:
+ self._token = token
+ if self.verbose:
+ print("✅ Login successful (Keycloak)")
+ return self._token
- if self.verbose:
- print("✅ Login successful")
+ # Fallback to legacy JWT login
+ try:
+ login_data = urlencode({"username": self.email, "password": self.password})
+ response = httpx.post(
+ f"{self.base_url}/api/auth/jwt/login",
+ content=login_data.encode(),
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ timeout=10.0,
+ )
+ response.raise_for_status()
+ result = response.json()
+ self._token = result["access_token"]
+
+ if self.verbose:
+ print("✅ Login successful (legacy)")
- return self._token
+ return self._token
+ except Exception as e:
+ if self.verbose:
+ print(f"❌ Login failed: {e}")
+ raise
+
+ def _try_keycloak_direct_grant(self) -> Optional[str]:
+ """Try to authenticate using Keycloak direct grant (Resource Owner Password Credentials).
+
+ Returns:
+ Access token if successful, None if Keycloak is not available or auth fails
+ """
+ try:
+ # Get Keycloak configuration from backend
+ config_response = httpx.get(
+ f"{self.base_url}/api/keycloak/config",
+ timeout=5.0,
+ )
+
+ if config_response.status_code != 200:
+ if self.verbose:
+ print(f"⚠️ Keycloak config endpoint failed: {config_response.status_code}")
+ return None
+
+ kc_config = config_response.json()
+
+ # Check if Keycloak is enabled
+ if not kc_config.get("enabled"):
+ if self.verbose:
+ print("⚠️ Keycloak is disabled in backend configuration")
+ return None
+
+ # Build token endpoint URL
+ keycloak_url = kc_config.get("public_url", "http://localhost:8081")
+ realm = kc_config.get("realm", "ushadow")
+ token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
+
+ if self.verbose:
+ print(f"🔐 Attempting Keycloak authentication: {token_url}")
+ print(f" User: {self.email}, Realm: {realm}")
+
+ # Use direct grant flow (Resource Owner Password Credentials)
+ token_response = httpx.post(
+ token_url,
+ data={
+ "grant_type": "password",
+ "client_id": "ushadow-cli", # Dedicated CLI client with direct grant enabled
+ "username": self.email,
+ "password": self.password,
+ },
+ timeout=10.0,
+ )
+
+ if token_response.status_code != 200:
+ if self.verbose:
+ try:
+ error_detail = token_response.json()
+ error_msg = error_detail.get("error_description") or error_detail.get("error")
+ print(f"⚠️ Keycloak auth failed ({token_response.status_code}): {error_msg}")
+ except Exception:
+ print(f"⚠️ Keycloak auth failed: {token_response.status_code} - {token_response.text[:200]}")
+ return None
+
+ tokens = token_response.json()
+ return tokens.get("access_token")
+
+ except Exception as e:
+ if self.verbose:
+ print(f"⚠️ Keycloak not available: {e.__class__.__name__}: {e}")
+ return None
def _request(
self,
diff --git a/ushadow/frontend/keycloak-theme/login/resources/css/login.css b/ushadow/frontend/keycloak-theme/login/resources/css/login.css
new file mode 100644
index 00000000..8363ad3a
--- /dev/null
+++ b/ushadow/frontend/keycloak-theme/login/resources/css/login.css
@@ -0,0 +1,719 @@
+/**
+ * Ushadow Keycloak Login Theme
+ * Matches the frontend login design exactly
+ */
+
+/* ============================================
+ GLOBAL STYLES & PAGE BACKGROUND
+ ============================================ */
+
+body,
+html {
+ margin: 0 !important;
+ padding: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ overflow-x: hidden !important;
+}
+
+body,
+html,
+.login-pf-page {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif !important;
+ background-color: #18181b !important; /* Dark purple-black like reference */
+ color: #ffffff !important;
+}
+
+/* Make the page wrapper full height */
+.login-pf-page {
+ min-height: 100vh !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: flex-start !important;
+ padding-top: 4rem !important;
+ position: relative !important;
+}
+
+.login-pf,
+.login-pf-page .login-pf {
+ width: 100% !important;
+ max-width: none !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: 2rem 1rem !important;
+}
+
+/* Purple glow top-right */
+body::before {
+ content: '' !important;
+ position: fixed !important;
+ top: -200px !important;
+ right: -200px !important;
+ width: 600px !important;
+ height: 600px !important;
+ background: radial-gradient(circle, rgba(168, 85, 247, 0.15) 0%, transparent 70%) !important;
+ pointer-events: none !important;
+ z-index: 0 !important;
+}
+
+/* Green glow bottom-left */
+body::after {
+ content: '' !important;
+ position: fixed !important;
+ bottom: -200px !important;
+ left: -200px !important;
+ width: 600px !important;
+ height: 600px !important;
+ background: radial-gradient(circle, rgba(74, 222, 128, 0.12) 0%, transparent 70%) !important;
+ pointer-events: none !important;
+ z-index: 0 !important;
+}
+
+/* ============================================
+ LOGO & HEADER
+ ============================================ */
+
+#kc-header-wrapper {
+ width: 100% !important;
+ text-align: center !important;
+ margin: 0 auto 1rem auto !important;
+ position: relative !important;
+ z-index: 10 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+}
+
+/* Logo image - large 3D U */
+#kc-header-wrapper::before {
+ content: '';
+ display: block;
+ width: 120px !important;
+ height: 120px !important;
+ margin: 0 auto 0.75rem;
+ background: url('../img/logo.png') center no-repeat;
+ background-size: contain;
+ filter: drop-shadow(0 8px 24px rgba(74, 222, 128, 0.2)) drop-shadow(0 8px 24px rgba(168, 85, 247, 0.2));
+}
+
+/* Ushadow brand text - GRADIENT green to purple */
+#kc-header,
+#kc-header-wrapper h1 {
+ font-size: 2.25rem !important;
+ font-weight: 600 !important;
+ background: linear-gradient(90deg, #4ade80 0%, #a855f7 100%) !important;
+ -webkit-background-clip: text !important;
+ -webkit-text-fill-color: transparent !important;
+ background-clip: text !important;
+ margin: 0 auto 0.25rem auto !important;
+ letter-spacing: -0.03em !important;
+ display: inline-block !important;
+ text-align: center !important;
+ width: auto !important;
+}
+
+/* "AI Orchestration Platform" subtitle */
+#kc-header::after {
+ content: 'AI Orchestration Platform';
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 400;
+ color: #a1a1aa;
+ margin-top: 0.25rem;
+ margin-bottom: 0.5rem;
+ background: none !important;
+ -webkit-text-fill-color: #a1a1aa !important;
+ letter-spacing: normal !important;
+}
+
+/* ============================================
+ LOGIN CARD
+ ============================================ */
+
+#kc-content-wrapper,
+#kc-content {
+ position: relative !important;
+ z-index: 10 !important;
+ width: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+}
+
+#kc-form,
+.login-pf form {
+ width: 100% !important;
+ max-width: 420px !important;
+}
+
+.card-pf {
+ background-color: rgba(26, 26, 31, 0.8) !important; /* Semi-transparent dark */
+ backdrop-filter: blur(10px) !important;
+ border: 1px solid rgba(63, 63, 70, 0.5) !important;
+ border-radius: 16px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ padding: 2.5rem !important;
+ width: 100% !important;
+ max-width: 420px !important;
+ margin: 0 auto !important;
+}
+
+/* ============================================
+ PAGE TITLE - "Sign in to your account"
+ ============================================ */
+
+#kc-page-title,
+.instruction {
+ font-size: 0.9375rem !important;
+ font-weight: 400 !important;
+ color: #a1a1aa !important;
+ margin-bottom: 1.75rem !important;
+ text-align: center !important;
+}
+
+/* ============================================
+ FORM ELEMENTS
+ ============================================ */
+
+.form-group,
+.pf-c-form__group {
+ margin-bottom: 1.25rem !important;
+ display: block !important; /* Override grid layout */
+ grid-template-columns: none !important; /* Remove two-column layout */
+}
+
+/* Force single-column layout for registration form */
+.pf-c-form__group-label,
+.pf-c-form__group-control {
+ grid-column: auto !important;
+ max-width: 100% !important;
+}
+
+label,
+.pf-c-form__label {
+ display: block !important;
+ font-size: 0.875rem !important;
+ font-weight: 400 !important;
+ color: #d4d4d8 !important; /* Lighter gray for labels */
+ margin-bottom: 0.5rem !important;
+ width: 100% !important;
+}
+
+/* Label text wrapper - keep inline with asterisk */
+.pf-c-form__label-text {
+ display: inline !important;
+}
+
+/* Required field indicator - inline with label */
+.pf-c-form__label-required {
+ display: inline !important;
+ color: #f87171 !important; /* Red asterisk */
+ margin-left: 0.25rem !important;
+}
+
+/* "Required fields" text */
+.subtitle,
+#kc-content-wrapper > p {
+ font-size: 0.75rem !important;
+ color: #71717a !important;
+ margin-bottom: 1rem !important;
+}
+
+/* Input fields - ROUNDED like reference */
+input[type="text"],
+input[type="email"],
+input[type="password"],
+input.pf-c-form-control {
+ width: 100% !important;
+ padding: 0.75rem 1rem !important;
+ font-size: 0.9375rem !important;
+ border: 1px solid rgba(63, 63, 70, 0.6) !important; /* Subtle border */
+ border-radius: 10px !important; /* Nicely rounded like reference */
+ background-color: rgba(24, 24, 27, 0.8) !important; /* Darker, more opaque */
+ background-image: none !important; /* Remove any gradient overlays */
+ color: #ffffff !important;
+ transition: all 0.2s ease-in-out !important;
+ box-sizing: border-box !important;
+}
+
+/* Aggressive override for password field specifically */
+input[type="password"] {
+ background-color: rgba(24, 24, 27, 0.8) !important;
+ background-image: none !important;
+ background: rgba(24, 24, 27, 0.8) !important;
+}
+
+/* Remove white outline/border from password field wrapper */
+.pf-c-input-group,
+.pf-c-form-control__utilities,
+div[class*="input-group"] {
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+
+/* Password field parent containers */
+.pf-c-input-group::before,
+.pf-c-input-group::after {
+ display: none !important;
+}
+
+/* Make sure password input doesn't have extra borders from wrapper */
+.pf-c-input-group input[type="password"] {
+ border: 1px solid rgba(63, 63, 70, 0.6) !important;
+ box-shadow: none !important;
+}
+
+input[type="text"]:focus,
+input[type="email"]:focus,
+input[type="password"]:focus,
+input.pf-c-form-control:focus {
+ outline: none !important;
+ border-color: rgba(74, 222, 128, 0.5) !important;
+ background-color: rgba(24, 24, 27, 0.8) !important;
+ box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2) !important;
+}
+
+input::placeholder {
+ color: #71717a !important;
+}
+
+/* Password visibility toggle - no white background */
+.pf-c-button.pf-m-control,
+button[type="button"].pf-c-button {
+ background-color: transparent !important;
+ border: none !important;
+ color: #a1a1aa !important;
+ padding: 0.5rem !important;
+}
+
+.pf-c-button.pf-m-control:hover {
+ background-color: transparent !important;
+ color: #d4d4d8 !important;
+}
+
+/* Remove any default input borders/underlines */
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus {
+ -webkit-box-shadow: 0 0 0 1000px rgba(24, 24, 27, 0.6) inset !important;
+ -webkit-text-fill-color: #ffffff !important;
+ border-radius: 10px !important;
+}
+
+/* ============================================
+ CHECKBOX & LINKS
+ ============================================ */
+
+#kc-form-options {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ margin: 1rem 0 1.5rem 0 !important;
+}
+
+.checkbox,
+.pf-c-check {
+ display: flex !important;
+ align-items: center !important;
+}
+
+input[type="checkbox"] {
+ width: auto !important;
+ height: 1rem !important;
+ margin-right: 0.5rem !important;
+ accent-color: #4ade80 !important;
+ cursor: pointer !important;
+ border-radius: 4px !important;
+}
+
+.checkbox label,
+.pf-c-check__label {
+ margin-bottom: 0 !important;
+ font-size: 0.875rem !important;
+ color: #d4d4d8 !important;
+ cursor: pointer !important;
+}
+
+/* Links - blue like reference */
+a {
+ color: #60a5fa !important;
+ text-decoration: none !important;
+ font-size: 0.875rem !important;
+ transition: color 0.2s ease !important;
+}
+
+a:hover {
+ color: #93c5fd !important;
+ text-decoration: underline !important;
+}
+
+/* ============================================
+ BUTTONS
+ ============================================ */
+
+/* Primary button - GREEN like reference */
+.btn-primary,
+button[type="submit"],
+input[type="submit"],
+.pf-c-button.pf-m-primary {
+ width: 100% !important;
+ padding: 0.75rem 1.5rem !important;
+ font-size: 1rem !important;
+ font-weight: 500 !important;
+ color: #09090b !important; /* Very dark text on green */
+ background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%) !important;
+ background-image: linear-gradient(135deg, #4ade80 0%, #22c55e 100%) !important;
+ border: none !important;
+ border-radius: 10px !important; /* Match input rounding */
+ cursor: pointer !important;
+ transition: all 0.2s ease-in-out !important;
+ box-shadow: 0 0 24px rgba(74, 222, 128, 0.25) !important;
+ text-transform: none !important;
+}
+
+.btn-primary:hover,
+button[type="submit"]:hover,
+.pf-c-button.pf-m-primary:hover {
+ background: linear-gradient(135deg, #86efac 0%, #4ade80 100%) !important;
+ background-image: linear-gradient(135deg, #86efac 0%, #4ade80 100%) !important;
+ box-shadow: 0 0 32px rgba(74, 222, 128, 0.35) !important;
+ transform: translateY(-1px) !important;
+}
+
+.btn-primary:active,
+button[type="submit"]:active {
+ transform: translateY(0) !important;
+}
+
+/* ============================================
+ REGISTRATION LINK
+ ============================================ */
+
+#kc-registration {
+ text-align: center !important;
+ margin-top: 1.5rem !important;
+ padding-top: 1.5rem !important;
+ border-top: 1px solid rgba(63, 63, 70, 0.4) !important;
+}
+
+#kc-registration span {
+ color: #71717a !important;
+ font-size: 0.875rem !important;
+}
+
+#kc-registration a {
+ color: #4ade80 !important;
+ font-weight: 500 !important;
+ margin-left: 0.25rem !important;
+}
+
+#kc-registration a:hover {
+ color: #86efac !important;
+}
+
+/* ============================================
+ ALERTS & MESSAGES
+ ============================================ */
+
+.alert {
+ padding: 0.875rem 1rem !important;
+ border-radius: 10px !important;
+ margin-bottom: 1.25rem !important;
+ font-size: 0.875rem !important;
+ border: 1px solid transparent !important;
+}
+
+.alert-error,
+.pf-c-alert.pf-m-danger {
+ background-color: rgba(239, 68, 68, 0.1) !important;
+ border-color: rgba(239, 68, 68, 0.3) !important;
+ color: #fca5a5 !important;
+}
+
+.alert-success,
+.pf-c-alert.pf-m-success {
+ background-color: rgba(74, 222, 128, 0.1) !important;
+ border-color: rgba(74, 222, 128, 0.3) !important;
+ color: #86efac !important;
+}
+
+.alert-warning,
+.pf-c-alert.pf-m-warning {
+ background-color: rgba(251, 191, 36, 0.1) !important;
+ border-color: rgba(251, 191, 36, 0.3) !important;
+ color: #fcd34d !important;
+}
+
+.alert-info,
+.pf-c-alert.pf-m-info {
+ background-color: rgba(96, 165, 250, 0.1) !important;
+ border-color: rgba(96, 165, 250, 0.3) !important;
+ color: #93c5fd !important;
+}
+
+/* ============================================
+ SOCIAL LOGIN (if enabled)
+ ============================================ */
+
+.kc-social-links {
+ margin-top: 1.5rem !important;
+ border-top: 1px solid rgba(63, 63, 70, 0.4) !important;
+ padding-top: 1.5rem !important;
+}
+
+.kc-social-link {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: 0.75rem 1rem !important;
+ margin-bottom: 0.75rem !important;
+ background-color: rgba(24, 24, 27, 0.6) !important;
+ border: 1px solid rgba(63, 63, 70, 0.5) !important;
+ border-radius: 10px !important;
+ color: #d4d4d8 !important;
+ text-decoration: none !important;
+ transition: all 0.2s ease-in-out !important;
+ font-size: 0.9375rem !important;
+}
+
+.kc-social-link:hover {
+ background-color: rgba(39, 39, 42, 0.8) !important;
+ border-color: rgba(82, 82, 91, 0.6) !important;
+ transform: translateY(-1px) !important;
+}
+
+/* ============================================
+ FOOTER TEXT
+ ============================================ */
+
+#kc-info,
+#kc-info-wrapper {
+ text-align: center !important;
+ color: #71717a !important;
+ font-size: 0.75rem !important;
+ margin-top: 1rem !important;
+}
+
+/* ============================================
+ RESPONSIVE - MOBILE & KEYBOARD AWARE
+ ============================================ */
+
+/* Mobile portrait - general adjustments */
+@media (max-width: 768px) {
+ /* Use dynamic viewport height for better keyboard handling */
+ html,
+ body {
+ height: 100dvh !important;
+ min-height: 100dvh !important;
+ }
+
+ .login-pf-page {
+ min-height: 100dvh !important;
+ padding: 1.5rem 1rem !important;
+ justify-content: flex-start !important;
+ }
+
+ /* Smaller logo on mobile */
+ #kc-header-wrapper::before {
+ width: 80px !important;
+ height: 80px !important;
+ margin-bottom: 0.5rem !important;
+ }
+
+ /* Smaller title */
+ #kc-header,
+ #kc-header-wrapper h1 {
+ font-size: 1.75rem !important;
+ }
+
+ /* Smaller subtitle */
+ #kc-header::after {
+ font-size: 0.8rem !important;
+ }
+
+ /* Compact card with less padding */
+ .card-pf {
+ padding: 1.5rem !important;
+ margin: 0 !important;
+ max-width: 100% !important;
+ }
+
+ /* Reduce spacing between form elements */
+ .form-group,
+ .pf-c-form__group {
+ margin-bottom: 1rem !important;
+ }
+
+ /* Slightly smaller inputs */
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input.pf-c-form-control {
+ padding: 0.65rem 0.875rem !important;
+ font-size: 16px !important; /* Prevents iOS zoom on focus */
+ }
+
+ /* Compact button */
+ .btn-primary,
+ button[type="submit"],
+ input[type="submit"],
+ .pf-c-button.pf-m-primary {
+ padding: 0.65rem 1.25rem !important;
+ font-size: 0.9375rem !important;
+ }
+
+ /* Smaller page title */
+ #kc-page-title,
+ .instruction {
+ font-size: 0.875rem !important;
+ margin-bottom: 1.25rem !important;
+ }
+
+ /* Compact form options */
+ #kc-form-options {
+ margin: 0.75rem 0 1rem 0 !important;
+ }
+
+ /* Reduce glow effects on mobile (performance) */
+ body::before,
+ body::after {
+ display: none !important;
+ }
+}
+
+/* Small mobile devices - ultra compact */
+@media (max-width: 480px) {
+ .login-pf-page {
+ padding: 1rem 0.75rem !important;
+ }
+
+ /* Extra small logo */
+ #kc-header-wrapper::before {
+ width: 64px !important;
+ height: 64px !important;
+ }
+
+ #kc-header,
+ #kc-header-wrapper h1 {
+ font-size: 1.5rem !important;
+ }
+
+ .card-pf {
+ padding: 1.25rem !important;
+ border-radius: 12px !important;
+ }
+}
+
+/* Landscape mobile - keyboard takes up most of screen */
+@media (max-width: 768px) and (max-height: 500px) {
+ /* Critical: Minimal padding when in landscape with keyboard */
+ .login-pf-page {
+ padding: 0.5rem !important;
+ min-height: auto !important;
+ }
+
+ /* Hide logo in landscape to save space */
+ #kc-header-wrapper::before {
+ display: none !important;
+ }
+
+ /* Minimal header */
+ #kc-header,
+ #kc-header-wrapper h1 {
+ font-size: 1.25rem !important;
+ margin-bottom: 0 !important;
+ }
+
+ /* Hide subtitle in landscape */
+ #kc-header::after {
+ display: none !important;
+ }
+
+ /* Minimal card padding */
+ .card-pf {
+ padding: 1rem !important;
+ }
+
+ /* Compact form elements */
+ .form-group,
+ .pf-c-form__group {
+ margin-bottom: 0.75rem !important;
+ }
+
+ label,
+ .pf-c-form__label {
+ margin-bottom: 0.25rem !important;
+ }
+
+ /* Minimal input padding */
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input.pf-c-form-control {
+ padding: 0.5rem 0.75rem !important;
+ }
+
+ /* Compact button */
+ .btn-primary,
+ button[type="submit"],
+ input[type="submit"],
+ .pf-c-button.pf-m-primary {
+ padding: 0.5rem 1rem !important;
+ }
+
+ /* Hide registration link in landscape to save space */
+ #kc-registration {
+ display: none !important;
+ }
+
+ /* Minimal form options */
+ #kc-form-options {
+ margin: 0.5rem 0 0.75rem 0 !important;
+ }
+}
+
+/* Touch-specific improvements */
+@media (hover: none) and (pointer: coarse) {
+ /* Larger touch targets */
+ input[type="checkbox"] {
+ width: 1.125rem !important;
+ height: 1.125rem !important;
+ }
+
+ /* Easier to tap links */
+ a {
+ padding: 0.25rem 0 !important;
+ display: inline-block !important;
+ }
+
+ /* Better button touch target */
+ .btn-primary,
+ button[type="submit"],
+ input[type="submit"] {
+ min-height: 44px !important; /* Apple's recommended touch target */
+ }
+}
+
+/* ============================================
+ UTILITY OVERRIDES
+ ============================================ */
+
+/* Remove any PatternFly default styles that conflict */
+.pf-c-form-control {
+ background-image: none !important;
+}
+
+.pf-c-button {
+ text-transform: none !important;
+ letter-spacing: normal !important;
+}
+
+/* Ensure z-index stacking works */
+#kc-container {
+ position: relative;
+ z-index: 1;
+}
diff --git a/ushadow/frontend/keycloak-theme/login/resources/img/logo.png b/ushadow/frontend/keycloak-theme/login/resources/img/logo.png
new file mode 100644
index 00000000..642149a1
Binary files /dev/null and b/ushadow/frontend/keycloak-theme/login/resources/img/logo.png differ
diff --git a/ushadow/frontend/keycloak-theme/login/theme.properties b/ushadow/frontend/keycloak-theme/login/theme.properties
new file mode 100644
index 00000000..5a9284d9
--- /dev/null
+++ b/ushadow/frontend/keycloak-theme/login/theme.properties
@@ -0,0 +1,12 @@
+# Login Theme Configuration
+parent=keycloak
+
+# Import base styles
+import=common/keycloak
+
+# Custom styles
+styles=css/login.css
+
+# Custom logo
+# Place your logo in: login/resources/img/logo.png
+# Recommended size: 200x60px (transparent background)
diff --git a/ushadow/frontend/package-lock.json b/ushadow/frontend/package-lock.json
index f936a1ea..54c12865 100644
--- a/ushadow/frontend/package-lock.json
+++ b/ushadow/frontend/package-lock.json
@@ -19,17 +19,21 @@
"axios": "^1.7.7",
"d3": "^7.9.0",
"frappe-gantt": "^1.0.4",
+ "jwt-decode": "^4.0.0",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.69.0",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
+ "remark-gfm": "^4.0.1",
"vibe-kanban-web-companion": "^0.0.5",
"zod": "^4.2.1",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
+ "@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.7.0",
@@ -2522,6 +2526,33 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.19",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
+ "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
@@ -2846,19 +2877,45 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2866,18 +2923,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2894,6 +2964,12 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
@@ -3127,6 +3203,12 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "license": "ISC"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3357,6 +3439,16 @@
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3550,6 +3642,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3567,6 +3669,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -3646,6 +3788,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -3729,7 +3881,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/cytoscape": {
@@ -4167,7 +4318,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4181,6 +4331,19 @@
}
}
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4212,12 +4375,34 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -4596,6 +4781,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4606,6 +4801,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5019,12 +5220,62 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
"license": "Apache-2.0"
},
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5101,6 +5352,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -5110,6 +5367,30 @@
"node": ">=12"
}
},
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
@@ -5145,6 +5426,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5168,6 +5459,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5178,6 +5479,18 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
@@ -5266,6 +5579,15 @@
"node": ">=6"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5355,6 +5677,16 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5386,6 +5718,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5395,70 +5737,915 @@
"node": ">= 0.4"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
},
- "engines": {
- "node": ">=8.6"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
}
},
- "node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
- "node": ">=8.6"
+ "node": ">=12"
},
"funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
+ "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
}
},
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
- "mime-db": "1.52.0"
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
},
- "engines": {
- "node": ">= 0.6"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
}
},
- "node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -5479,7 +6666,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -5718,6 +6904,31 @@
"node": ">=6"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5992,6 +7203,16 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -6098,6 +7319,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-merge-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz",
@@ -6284,6 +7532,72 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/resizelistener": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/resizelistener/-/resizelistener-1.1.0.tgz",
@@ -6527,12 +7841,36 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -6546,6 +7884,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -6729,6 +8085,26 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -6788,6 +8164,93 @@
"integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
"license": "MIT"
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -6933,6 +8396,34 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vibe-kanban-web-companion": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/vibe-kanban-web-companion/-/vibe-kanban-web-companion-0.0.5.tgz",
@@ -7154,6 +8645,16 @@
"optional": true
}
}
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/ushadow/frontend/package.json b/ushadow/frontend/package.json
index ec0fb65a..260d0475 100644
--- a/ushadow/frontend/package.json
+++ b/ushadow/frontend/package.json
@@ -27,17 +27,21 @@
"axios": "^1.7.7",
"d3": "^7.9.0",
"frappe-gantt": "^1.0.4",
+ "jwt-decode": "^4.0.0",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.69.0",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
+ "remark-gfm": "^4.0.1",
"vibe-kanban-web-companion": "^0.0.5",
"zod": "^4.2.1",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
+ "@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.7.0",
diff --git a/ushadow/frontend/src/App.tsx b/ushadow/frontend/src/App.tsx
index 715bce6a..2fe1741d 100644
--- a/ushadow/frontend/src/App.tsx
+++ b/ushadow/frontend/src/App.tsx
@@ -1,11 +1,14 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { useEffect } from 'react'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
+import { KeycloakAuthProvider } from './contexts/KeycloakAuthContext'
import { FeatureFlagsProvider } from './contexts/FeatureFlagsContext'
import { WizardProvider } from './contexts/WizardContext'
import { ChronicleProvider } from './contexts/ChronicleContext'
import { ToastProvider } from './contexts/ToastContext'
+import { SettingsProvider } from './contexts/SettingsContext'
import EnvironmentFooter from './components/layout/EnvironmentFooter'
import BugReportButton from './components/BugReportButton'
import { useEnvironmentFavicon } from './hooks/useEnvironmentFavicon'
@@ -28,9 +31,12 @@ import Layout from './components/layout/Layout'
import RegistrationPage from './pages/RegistrationPage'
import LoginPage from './pages/LoginPage'
import ErrorPage from './pages/ErrorPage'
+import OAuthCallback from './auth/OAuthCallback'
import Dashboard from './pages/Dashboard'
import WizardStartPage from './pages/WizardStartPage'
import ChroniclePage from './pages/ChroniclePage'
+import ConversationsPage from './pages/ConversationsPage'
+import ConversationDetailPage from './pages/ConversationDetailPage'
import RecordingPage from './pages/RecordingPage'
import MCPPage from './pages/MCPPage'
import AgentZeroPage from './pages/AgentZeroPage'
@@ -40,10 +46,12 @@ import SettingsPage from './pages/SettingsPage'
import ServiceConfigsPage from './pages/ServiceConfigsPage'
import InterfacesPage from './pages/InterfacesPage'
import MemoriesPage from './pages/MemoriesPage'
+import MemoryDetailPage from './pages/MemoryDetailPage'
import ClusterPage from './pages/ClusterPage'
import SpeakerRecognitionPage from './pages/SpeakerRecognitionPage'
import ChatPage from './pages/ChatPage'
import TimelinePage from './pages/TimelinePage'
+import FeedPage from './pages/FeedPage'
// Wizards (all use WizardShell pattern)
import {
@@ -58,6 +66,7 @@ import {
} from './wizards'
import KubernetesClustersPage from './pages/KubernetesClustersPage'
import ColorSystemPreview from './components/ColorSystemPreview'
+import ShareViewPage from './pages/ShareViewPage'
function AppContent() {
// Set dynamic favicon based on environment
@@ -65,15 +74,19 @@ function AppContent() {
const { backendError, checkSetupStatus, isLoading, token } = useAuth()
+ // Note: Redirect URI registration moved to login flow (KeycloakAuthContext)
+ // to avoid unnecessary calls on every app mount
+
// Show error page if backend has configuration errors
if (backendError) {
return
+ Authentication Error
+
+
+ {ingressHostname}
+
+
+ + Accessible at: http://{ingressHostname} +
+- You don't have permission to access this page. + Admin-only feature (Keycloak role check not yet implemented)
- You'll be prompted to select a browser tab or screen to capture audio from. -
+⚠️ Important: Select "Chrome Tab" (not "Your Entire Screen")
++ {summary} +
+ )} + + {/* Footer metadata */} ++ {label?.guidance ?? 'Add a content source to get started.'} +
+ ++ Hit refresh to fetch posts from your sources and score them against your interests. +
+ ++ {plainText} +
+ + {/* Matched interests */} + {post.matched_interests.length > 0 && ( ++ {description} +
+ )} + + {/* Matched interests */} + {post.matched_interests.length > 0 && ( ++ DNS entry and Ingress created successfully. + {enableTls && ' TLS certificate will be issued automatically.'} +
++ First name will be used as FQDN: {shortnames[0] || 'name'}.{domain} +
+{error}
+This will create:
++ Setup custom DNS to access services via short, memorable names with automatic TLS certificates. +
+ + ++ Domain: {dnsStatus.domain} +
+No services added yet. Click "Add Service" to get started.
++ You can now add services to DNS with custom domain names and TLS certificates. +
++ Setup custom DNS for your Kubernetes services with automatic TLS certificates via Let's Encrypt. +
+ ++ Services will be accessible as: servicename.{domain || 'domain'} +
++ Used for certificate expiration notifications +
+{error}
+What this does:
+ {
setUserMenuOpen(false)
- logout()
+ handleLogout()
}}
className="w-full flex items-center space-x-3 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--error-400)' }}
diff --git a/ushadow/frontend/src/components/memories/MemoryCard.tsx b/ushadow/frontend/src/components/memories/MemoryCard.tsx
new file mode 100644
index 00000000..97edf518
--- /dev/null
+++ b/ushadow/frontend/src/components/memories/MemoryCard.tsx
@@ -0,0 +1,138 @@
+/**
+ * MemoryCard Component
+ *
+ * Displays a memory item in a card format with category badges, source attribution,
+ * and click interaction. Used in conversation detail page and memory list views.
+ */
+
+import { Brain, ExternalLink } from 'lucide-react'
+import type { ConversationMemory } from '../../services/api'
+
+interface MemoryCardProps {
+ memory: ConversationMemory
+ onClick?: () => void
+ showSource?: boolean
+ testId?: string
+}
+
+// Category color mapping (matching MemoryTable)
+const categoryColors: Record
+ {memory.content}
+
+ This service is not publicly accessible. Only accessible within your Tailnet.
+
+ Service will be accessible at:
+ {configureMutation.error instanceof Error ? configureMutation.error.message : 'Failed to configure route'}
+
Create and manage service instances from templates
diff --git a/ushadow/frontend/src/components/services/ProviderCard.tsx b/ushadow/frontend/src/components/services/ProviderCard.tsx
index 6107a368..aa9e1c01 100644
--- a/ushadow/frontend/src/components/services/ProviderCard.tsx
+++ b/ushadow/frontend/src/components/services/ProviderCard.tsx
@@ -2,7 +2,7 @@
* ProviderCard - Individual provider card with expand/collapse and env var editing
*/
-import { CheckCircle, ChevronUp, Cloud, HardDrive, Loader2, Pencil, Save } from 'lucide-react'
+import { AlertCircle, CheckCircle, ChevronUp, Cloud, HardDrive, Loader2, Pencil, Save } from 'lucide-react'
import EnvVarEditor from '../EnvVarEditor'
import { EnvVarInfo, EnvVarConfig } from '../../services/api'
@@ -31,12 +31,16 @@ export default function ProviderCard({
isLoading,
isSaving,
}: ProviderCardProps) {
+ const isConfigured = provider.configured !== false
+
return (
+ Select providers for each service capability
+
- Select providers for each service capability
+ {activeSubTab === 'api'
+ ? 'API services and their workers'
+ : 'User interface services'}
+ Expose ushadow to the public internet for remote access
+
+ Public URL
+
+ This URL is accessible from anywhere on the internet
+
+ Tailnet Only (Default): Only accessible via Tailscale VPN.
+ Most secure, recommended for personal use.
+
+ Funnel Enabled: Publicly accessible on the internet.
+ Anyone with the URL can access (requires authentication). Use for sharing with people outside your Tailnet.
+
+ Enabling Funnel will make your ushadow instance accessible to anyone on the internet.
+ Ensure authentication is properly configured before enabling.
+
@@ -793,147 +743,489 @@ export function FlatServiceCard({
+ {worker.template.description}
+
+ This will create a virtual public worker unode on the same physical machine with Tailscale Funnel enabled.
+ It can host services that need public internet access (like share-dmz).
+
+ Generate a key at{' '}
+
+ Tailscale Settings
+
+ {' '}with tags: Important: {publicUnodeResult.message}
+ Hostname: {publicUnodeResult.hostname}
+
+ Public URL: {publicUnodeResult.public_url}
+ Loading conversation...
+ {error ? String(error) : 'Conversation not found'}
+
+ {source === 'mycelia' ? 'Started' : 'Created'}
+
+ {new Date(startTime).toLocaleString()}
+
+ {source === 'mycelia' ? 'Ended' : 'Completed'}
+
+ {new Date(endTime).toLocaleString()}
+ Duration
+ {formatDuration(conversation.duration_seconds)}
+ Segments
+ {hasValidSegments ? (conversation.segments?.length || 0) : 0}
+ Memories
+ {conversation.memory_count || 0}
+ No memories extracted from this conversation No transcript available
+ View conversations from multiple sources
+
+
+ Failed to load Chronicle conversations. Service may be unavailable.
+ No conversations found
+ Failed to load Mycelia conversations. Service may be unavailable.
+ No conversations found
- {stat.label}
-
- {stat.value}
-
+ Conversations
+
+ {isLoading ? '...' : data?.stats.conversation_count || '0'}
+
+ Memories
+
+ {isLoading ? '...' : data?.stats.memory_count || '0'}
+
- No activity yet. Start by configuring your services in Services
-
+ Loading activities...
+
+ Failed to load activities. Please try again.
+
+ No activity yet. Start a conversation or create memories to see activity here.
+
+ {activity.title}
+
+ {activity.description}
+
+ Content ranked by your knowledge graph interests
+
+ 💡 Run this command in your terminal, then click "Paste from Clipboard" below
+
+ This is your first time launching Ushadow. Please create an admin account below to access the dashboard and configure your AI assistant.
+ No environment variables to configure
+ Add custom environment variables like MYCELIA_FRONTEND_PORT to override service ports
+
Configure providers and compose services
@@ -730,6 +733,29 @@ export default function ServicesPage() {
+ This is the legacy service management interface. For advanced features like service wiring,
+ custom configurations, and deployment management, please use the{' '}
+ Loading shared content...
+ {errorMessage}
+
+ {share_token.require_auth ? 'Private share' : 'Public share'}
+ Resource type: {resource.type} {data.error} Started
+ {new Date(startTime).toLocaleString()}
+ Ended
+ {new Date(endTime).toLocaleString()}
+ Duration
+ {duration}
+ Segments
+ {segments.length}
+ No transcript available {data.error}
+ Created: {new Date(createdAt).toLocaleString()}
+ Metadata:
- Don't see your model? Pull it with: No models installed
+ Pull a model to get started:
+
+ Downloading model — this may take a few minutes depending on size.
+
+ Don't see your model? Pull it with:
+ Spin up faster-whisper-server locally
+
- OpenAI-compatible Whisper endpoint (e.g., faster-whisper-server)
+ OpenAI-compatible Whisper endpoint
- Distribute to up to 10,000 testers via Apple's TestFlight. Requires Apple Developer account ($99/year).
-
- After submission, add testers by email in App Store Connect. They'll receive TestFlight invites.
+ Install the beta directly on your iPhone — no developer account needed.
- Start the OpenMemory and Chronicle containers to enable the web client.
+ Choose a service profile, then start the containers to enable the web client.
Service Profile
+ {profile.services.join(', ')}
+
- HTTPS Access Configured!
-
- Certificates provisioned and routing configured for {config.hostname}
-
+ HTTPS Access Configured!
+
+ Certificates provisioned and routing configured for {config.hostname}
+
+ {keycloakStatus.registered ? 'Keycloak OAuth Configured' : 'Keycloak Configuration Skipped'}
+
+ {keycloakStatus.message || (keycloakStatus.registered
+ ? 'OAuth login enabled for Tailscale domain'
+ : 'OAuth login may require backend restart or manual Keycloak configuration')}
+
+ Checking Keycloak Configuration...
+
+ Registering OAuth callback URLs for Tailscale domain
+
- This will automatically provision SSL certificates and configure routing via Tailscale Serve.
+ This will automatically:
- Automatically install prerequisites and start Ushadow
-
+ Manage multiple projects with independent configurations
+
+ Automatically install prerequisites and start Ushadow
+
- Install prerequisites and configure your single environment
+ Install prerequisites and configure shared infrastructure
No environment created yet
+ You need to log in to access Ushadow environments.
+
+ Click below to open the login page in your browser.
+
+ Environment: {environment.name} •
+ Port: {environment.webui_port}
+ {children} {children} {children} {children} {msg.text} {ticket.title} Waiting for your approval
+ {session.current_tool}
+ {session.current_tool_description && (
+ {session.current_tool_description}
+ )}
+ {!session.current_tool_description && session.current_tool_path && (
+ {session.current_tool_path.split('/').slice(-2).join('/')}
+ )}
+ {approvalError} Tickets Conversation
+ {session.user_message}
+
+ {session.current_tool}
+ {session.current_tool_description && (
+
+ {session.current_tool_description.slice(0, 40)}
+
+ )}
+
+ ⚠ Approve{session.current_tool ? `: ${session.current_tool}` : ''}
+ {session.current_tool_description && (
+ {session.current_tool_description.slice(0, 40)}
+ )}
+
+ Install lightweight hooks that capture Claude Code session activity into the launcher.
+ Hooks run asynchronously and never block Claude.
+ {error}
+ Adds entries to ~/.claude/settings.json · Writes ~/.claude/hooks/ushadow_launcher_hook.py
+ No sessions yet.
+ Run Active Ended Select a session Loading {envName}... {displayUrl} Failed to load {envName} {displayUrl} Starting containers...
+ {loadingAction === 'starting' && 'Starting containers...'}
+ {loadingAction === 'stopping' && 'Stopping containers...'}
+ {loadingAction === 'deleting' && 'Deleting environment...'}
+ {loadingAction === 'merging' && 'Merging worktree...'}
+ {!loadingAction && 'Processing...'}
+ This may take a moment Loading {environment.name}... {displayUrl} Failed to load {environment.name} {displayUrl} Starting containers...
+ {loadingAction === 'starting' && 'Starting containers...'}
+ {loadingAction === 'stopping' && 'Stopping containers...'}
+ {loadingAction === 'deleting' && 'Deleting environment...'}
+ {loadingAction === 'merging' && 'Merging worktree...'}
+ {!loadingAction && 'Processing...'}
+ This may take a moment
+ Drag ports between columns to categorize
+ Drop ports here Drop ports here
+ Check vars that should have environment name appended
+ No other variables found No variables match "{searchFilter}" No services detected
+ Select services for group actions (start/stop/restart):
+
+ {service.displayName}
+
+ {session.user_message.slice(0, 150)}
+
+ {session.current_tool}
+ {session.current_tool_description && (
+ {session.current_tool_description.slice(0, 80)}
+ )}
+ {session.cwd.split('/').slice(-3).join('/')}
- {branch.trim() && name.trim()
- ? `Will create: ${name.trim()}/${branch.trim()}-${baseBranch}`
- : name.trim()
- ? `Will create: ${name.trim()}/base-${baseBranch}`
- : `Enter environment name first`}
+ {(() => {
+ const envName = name.trim()
+ const branchSuffix = branch.trim() || 'base'
+ if (!envName) return 'Enter environment name first'
+
+ if (baseType === 'worktree') {
+ return `Will create: ${envName}/${branchSuffix} from ${selectedWorktree || '(select worktree)'}`
+ } else {
+ return `Will create: ${envName}/${branchSuffix}-${baseType} from origin/${baseType}`
+ }
+ })()}
- Creates worktree from origin/{baseBranch}
+
+ {/* Worktree selection dropdown */}
+ {baseType === 'worktree' && (
+
+ {baseType === 'worktree'
+ ? `Creates worktree branching from selected worktree`
+ : `Creates worktree from origin/${baseType}`}
- Creates a git worktree with branch name: envname/branchname-{baseBranch} from origin/{baseBranch}
+ {baseType === 'worktree'
+ ? 'Creates a git worktree branching from the selected worktree'
+ : `Creates a git worktree with branch name: envname/branchname-${baseType} from origin/${baseType}`}
{projectRoot}
+ Select which infrastructure services are shared across environments (won't be offset)
+ {service.name} :{service.defaultPort}
+ Drag ports between columns or click arrows. Managed ports will be offset for each environment.
+ No managed ports No shared infrastructure ports
+ Check variables that should have the environment name appended (e.g., DB_NAME becomes DB_NAME_envname)
+ Parent folder:
+ {isMultiProjectMode ? 'Project folder:' : 'Parent folder:'}
+ {parentPath} Click to choose parent folder...
+ {isMultiProjectMode ? 'Click to choose project folder...' : 'Click to choose parent folder...'}
+ Existing Ushadow repository found:
+ {isMultiProjectMode ? 'Existing git repository found:' : `Existing ${projectName} repository found:`}
+ {fullInstallPath} Will link to this existing installation Project folder:
+ {isMultiProjectMode ? 'Project root:' : 'Project folder:'}
+ {fullInstallPath} Folder exists but is not a valid Ushadow repository
+ {isMultiProjectMode
+ ? 'Folder exists but is not a valid git repository'
+ : `Folder exists but is not a valid ${projectName} repository`
+ }
+ No projects configured Click "Add Project" to get started
+ {project.displayName}
+
+ {project.rootPath}
+
- Configure default admin credentials that will be used for all new environments.
- These credentials are stored locally and used to auto-create users.
-
+ Configure which coding agent CLI to use when working on tickets
+
+ Manage multiple codebases beyond ushadow. Each project can have its own configuration and worktrees.
+
+ Enable ticket tracking with a Kanban board. Create tickets, link them to worktrees, and manage development workflows.
+
+ Monitor Claude Code sessions across all worktrees. View live transcripts, approve tool requests, and track progress.
+
- 💡 These credentials will be used to automatically create an admin user when you create a new environment.
+ 💡 The coding agent will be automatically started in the tmux window when you assign a ticket to an environment.
+ {ticket.description}
+
+ {successMessage}
+
+ {error}
+
+ Will use epic's shared branch
+
+ The tmux window no longer exists (likely due to system reboot)
+
+ The worktree still exists at {ticket.worktree_path}, but the tmux window {ticket.tmux_window_name} is gone.
+
+ What would you like to do?
+ handleRowClick(memory.id, e)}
className={`
- hover:bg-zinc-800/50 transition-colors
+ hover:bg-zinc-800/50 transition-colors cursor-pointer
${memory.state === 'paused' || memory.state === 'archived' ? 'opacity-60' : ''}
${isDeleting ? 'animate-pulse opacity-50' : ''}
`}
diff --git a/ushadow/frontend/src/components/services/DeploymentListItem.tsx b/ushadow/frontend/src/components/services/DeploymentListItem.tsx
index 25b4473c..e5ca7d9f 100644
--- a/ushadow/frontend/src/components/services/DeploymentListItem.tsx
+++ b/ushadow/frontend/src/components/services/DeploymentListItem.tsx
@@ -2,11 +2,15 @@
* DeploymentListItem - Individual deployment card with controls
*/
-import { HardDrive, Pencil, PlayCircle, StopCircle, Trash2 } from 'lucide-react'
+import { useState } from 'react'
+import { HardDrive, Pencil, PlayCircle, StopCircle, Trash2, Globe, ExternalLink, Loader2 } from 'lucide-react'
+import Modal from '../Modal'
+import FunnelRouteManager from './FunnelRouteManager'
interface DeploymentListItemProps {
deployment: any
serviceName: string
+ unodeFunnelEnabled?: boolean
onStop: (id: string) => void
onRestart: (id: string) => void
onEdit: (deployment: any) => void
@@ -16,16 +20,21 @@ interface DeploymentListItemProps {
export default function DeploymentListItem({
deployment,
serviceName,
+ unodeFunnelEnabled = false,
onStop,
onRestart,
onEdit,
onRemove,
}: DeploymentListItemProps) {
+ const [showFunnelManager, setShowFunnelManager] = useState(false)
const isRunning = deployment.status === 'running' || deployment.status === 'deploying'
+ const isTransitioning = deployment.status === 'starting' || deployment.status === 'stopping'
const statusColor = {
running: 'bg-success-100 dark:bg-success-900/30 text-success-700 dark:text-success-400',
deploying: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400',
+ starting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400',
+ stopping: 'bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400',
stopped: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400',
failed: 'bg-error-100 dark:bg-error-900/30 text-error-700 dark:text-error-400',
}[deployment.status] || 'bg-error-100 dark:bg-error-900/30 text-error-700 dark:text-error-400'
@@ -37,9 +46,10 @@ export default function DeploymentListItem({
{serviceName}
+ {isTransitioning && Public Access
+
+ {funnelConfig.route}
+
+
+ {publicUrl}
+
+ https://<hostname>{route || '/path'}
+ Services
-
- BETA
-
{capability}
-
+ Tailscale Funnel
+
+
@@ -621,6 +689,20 @@ export default function ClusterPage() {
{node.platform}
|
{node.role}
+ {node.labels?.zone === 'public' && (
+ <>
+ |
+
+
+ Create Public UNode
+
+ tag:dmz,tag:public
+
+
+
+ Failed to load conversation
+
+
+ {title}
+
+ {summary && (
+
+ Memories
+
+ {memoriesData && (
+
+ {memoriesData.count}
+
+ )}
+
+ Transcript
+
+
+ {/* Segmented transcript (only if segments have actual text) */}
+ {hasValidSegments ? (
+ Conversations
+
+ Chronicle
+ {chronicle.isLoading && (
+
+ )}
+
+
+ {chronicle.data.length} conversations
+
+
+ Mycelia
+ {mycelia.isLoading && (
+
+ )}
+
+
+ {mycelia.data.length} conversations
+
+ Feed
+
+ {command}
+
+ {namespace}
+ Ingress Configuration
+
+ {editingCluster !== cluster.cluster_id && (
+ .{cluster.ingress_domain}
+
+
+ Copy kubeconfig to clipboard
+
+
+ First-Time Setup Required
+
+ Services
+
+ LEGACY
+
+ Legacy Services Page
+
+
+ {statusCode === 403 ? 'Access Denied' : 'Share Not Found'}
+
+
+ Shared {resource.type}
+
+
+ {JSON.stringify(resource.data, null, 2)}
+
+
+ {title}
+
+ {summary && (
+
+ Transcript
+
+
+ {hasValidSegments ? (
+
+ {JSON.stringify(metadata, null, 2)}
+
+ ollama pull model-name
- ollama pull model-name
+
@@ -652,10 +785,10 @@ function TranscriptionStep({ parakeetStatus, onStartParakeet }: TranscriptionSte
+
One-Click Launch
- Project Management
+ One-Click Launch
+ Setup & Installation
Your Environment
- {!discovery || discovery.environments.length === 0 ? (
- Authentication Required
+ {children}
,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children}
,
+ hr: () =>
,
+ a: ({ href, children }) => {children},
+ code: ({ children, className }) => {
+ const isBlock = className?.startsWith('language-')
+ return isBlock
+ ? {children}
+ : {children}
+ },
+ pre: ({ children }) => (
+
+ {children}
+
+ ),
+}
+
+function TranscriptRow({ msg }: { msg: TranscriptMessage }) {
+ if (msg.role === 'user') {
+ return (
+
+ VS Code
+ Connect Claude Code Sessions
+ Claude Sessions
+ {active.length > 0 && (
+
+ {active.length} active
+
+ )}
+ claude in any worktree.
+ Create New Epic
+
+
+ Create New Ticket
+
+
+
+
+ Port Management
+ Managed (Offset)
+ {port.name}
+ {port.default_value}
+ Shared (No Offset)
+ {port.name}
+ {port.default_value}
+ Environment Variables
+ {envVar.name}
+ {envVar.default_value}
+ Kanban Board
+
+ {/* Epic Filter */}
+ {column.title}
+
+ {filteredTickets[column.status]?.length || 0}
+
+
+ VS Code
+
@@ -103,25 +148,31 @@ export function NewEnvironmentDialog({
data-testid="branch-name-input"
/>
Project Configuration
+ Project Information
+ Environment Startup
+ Shared Infrastructure
+ Port Management
+ Managed Ports (Offset)
+ {port.name}
+ {port.default_value}
+ Shared Infrastructure (No Offset)
+ {port.name}
+ {port.default_value}
+ Other Environment Variables
+ {envVar.name}
+ {envVar.default_value}
+ {appendEnvName[envVar.name] && (
+
+ Append env name
+
+ )}
+ Projects
+ Coding Agent
+ Environment Startup
+
+ {ticket.title}
+
+
+ {PRIORITY_LABELS[ticket.priority]}
+
+
+ Ticket Details
+
+
+ Workstream
+
+
+ {/* Current Assignment Display */}
+ {hasAssignment && ticket.environment_name && (
+
+ Actions
+
+
+ {/* Terminal Button */}
+
+ Tmux Window Missing
+
+
/gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Post Card
+// ─────────────────────────────────────────────────────────────────────────────
+
+interface PostCardProps {
+ post: FeedPost;
+ onBookmark: (postId: string) => void;
+ onMarkSeen: (postId: string) => void;
+}
+
+function PostCard({ post, onBookmark, onMarkSeen }: PostCardProps) {
+ const plainText = stripHtml(post.content);
+
+ const handleOpenLink = () => {
+ if (post.url) Linking.openURL(post.url);
+ };
+
+ return (
+
/gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+
+ const formatCount = (n: number | null | undefined): string => {
+ if (n == null) return '—';
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return String(n);
+ };
+
+ // ─── Renderers ───────────────────────────────────────────────────────
+
+ const renderMastodonPost = (post: FeedPost) => (
+