Compare commits

...

44 Commits

Author SHA1 Message Date
d218902cb0 docs: design system reference and task completion (MS15-DOC-001) (#454)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:20:28 +00:00
b43e860c40 feat(web): Phase 3 — Dashboard Page (#450) (#453)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:18:50 +00:00
716f230f72 feat(ui,web): Phase 2 — Shared Components & Terminal Panel (#449) (#452)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:12:13 +00:00
a5ed260fbd feat(web): MS15 Phase 1 — Design System & App Shell (#451)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 20:57:06 +00:00
9b5c15ca56 style(ui): use padding for AuthDivider vertical spacing (#446) (#447)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 18:02:45 +00:00
74c8c376b7 docs(coolify): update deployment docs with operations guide (#445)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 08:05:47 +00:00
9901fba61e docs: add Coolify deployment guide and compose file (#444)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 07:40:24 +00:00
17144b1c42 style(ui): refine login card shape and divider spacing (#439)
Some checks are pending
ci/woodpecker/push/orchestrator Pipeline is running
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 06:19:23 +00:00
a6f75cd587 fix(ui): use arbitrary opacity for AuthCard dark background (#438)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 05:33:14 +00:00
06e54328d5 fix(web): force dynamic rendering for runtime env injection (#437)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 03:54:12 +00:00
7480deff10 fix(web): add Tailwind CSS setup for design system rendering (#436)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 23:36:16 +00:00
1b66417be5 fix(web): restore login page design and add runtime config injection (#435)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 23:16:02 +00:00
23d610ba5b chore: switch from develop/dev to main/latest image tags (#434)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 22:05:07 +00:00
25ae14aba1 fix(web): resolve flaky CI test failures (#433)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 21:12:00 +00:00
1425893318 Merge pull request 'Merge develop into main — branch consolidation' (#432) from merge/develop-to-main into main
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-21 20:56:40 +00:00
bc4c1f9c70 Merge develop into main
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Consolidate all feature and fix branches into main:
- feat: orchestrator observability + mosaic rails integration (#422)
- fix: post-422 CI and compose env follow-up (#423)
- fix: orchestrator startup provider-key requirements (#425)
- fix: BetterAuth OAuth2 flow and compose wiring (#426)
- fix: BetterAuth UUID ID generation (#427)
- test: web vitest localStorage/file warnings (#428)
- fix: auth frontend remediation + review hardening (#421)
- Plus numerous Docker, deploy, and auth fixes from develop

Lockfile conflict resolved by regenerating from merged package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:52:43 -06:00
d66451cf48 fix(ci): suppress Next.js bundled tar/minimatch CVEs in trivy (#431)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 20:40:17 +00:00
c23ebca648 fix(ci): resolve pipeline #516 audit and test failures (#429)
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 20:11:58 +00:00
Jason Woltje
eae55bc4a3 chore: mosaic upgrade — centralize AGENTS.md, update CLAUDE.md pointer
CLAUDE.md replaced with thin pointer to ~/.config/mosaic/AGENTS.md.
SOUL.md and AGENTS.md now managed globally by the Mosaic framework.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:08:25 -06:00
b5ac2630c1 docs(auth): record digest-based deploy fix verification 2026-02-18 23:39:06 -06:00
8424a28faa fix(auth): use set_config for transaction-scoped RLS context
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 23:23:15 -06:00
d2cec04cba fix(auth): preserve raw BetterAuth cookie token for session lookup
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 23:06:37 -06:00
9ac971e857 chore(deploy): align swarm auth env with deployed stack
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 22:40:22 -06:00
0c2a6b14cf fix(auth): verify BetterAuth sessions via cookie headers 2026-02-18 22:39:54 -06:00
af299abdaf debug(auth): log session cookie source
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 21:36:01 -06:00
fa9f173f8e chore(web): use prod-only deps in runtime image
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 21:13:12 -06:00
7935d86015 chore(web): avoid pnpm in runtime image to reduce CVE noise
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 20:24:22 -06:00
f43631671f chore(deps): override tar to 7.5.8 for trivy
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
2026-02-18 20:01:10 -06:00
8328f9509b Merge pull request 'test(web): silence localStorage-file warnings in vitest' (#428) from fix/web-test-warnings-2 into develop
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #428
2026-02-19 01:45:06 +00:00
f72e8c2da9 chore(deps): override minimatch to 10.2.1 for audit fix
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 19:41:38 -06:00
1a668627a3 test(web): silence localStorage-file warnings in vitest setup
Some checks failed
ci/woodpecker/push/web Pipeline failed
2026-02-18 19:38:23 -06:00
bd3625ae1b Merge pull request 'fix(auth): generate UUID ids for BetterAuth Prisma writes' (#427) from fix/authentik-betterauth-interop into develop
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #427
2026-02-19 01:07:32 +00:00
aeac188d40 chore(deps): override minimatch to 10.2.1 for audit fix
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 18:53:25 -06:00
f219dd71a0 fix(auth): use UUID id generation for BetterAuth DB models
Some checks failed
ci/woodpecker/push/api Pipeline failed
2026-02-18 18:49:16 -06:00
2c3c1f67ac Merge pull request 'fix(auth): restore BetterAuth OAuth2 flow and compose wiring' (#426) from fix/authentik-betterauth-interop into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #426
2026-02-18 05:44:19 +00:00
dedc1af080 fix(auth): restore BetterAuth OIDC flow across api/web/compose
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-17 23:37:49 -06:00
3b16b2c743 Merge pull request 'Fix orchestrator startup provider-key requirements for Issue 424' (#425) from fix/post-422-runtime into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
Reviewed-on: #425
2026-02-17 23:17:39 +00:00
Jason Woltje
6fd8e85266 fix(orchestrator): make provider-aware Claude key startup requirements
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
2026-02-17 17:15:42 -06:00
Jason Woltje
d3474cdd74 chore(orchestrator): bootstrap issue 424 2026-02-17 17:05:09 -06:00
157b702331 Merge pull request 'fix(runtime): post-422 CI and compose env follow-up' (#423) from fix/post-422-runtime into develop
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Reviewed-on: #423
2026-02-17 22:47:50 +00:00
Jason Woltje
63c6a129bd fix(runtime): stabilize LinkAutocomplete nav test and wire required compose env
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 16:42:34 -06:00
0a780a5062 Merge pull request 'bootstrap mosaic-stack to Mosaic standards layer' (#420) from fix/auth-frontend-remediation into main
Some checks failed
ci/woodpecker/manual/api Pipeline failed
ci/woodpecker/manual/web Pipeline failed
ci/woodpecker/manual/orchestrator Pipeline failed
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
Reviewed-on: #420
2026-02-17 18:51:54 +00:00
a1515676db Merge branch 'main' into fix/auth-frontend-remediation
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-17 18:46:50 +00:00
b719fa0444 Merge pull request 'chore: upgrade Node.js runtime to v24 across codebase' (#419) from fix/auth-frontend-remediation into main
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline was successful
Reviewed-on: #419
2026-02-17 01:04:46 +00:00
124 changed files with 8523 additions and 1108 deletions

View File

@@ -15,6 +15,14 @@ WEB_PORT=3000
# ======================
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001
# Frontend auth mode:
# - real: Normal auth/session flow
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
# Use `mock` locally to continue FE work when auth flow is unstable.
# If omitted, web runtime defaults:
# - development -> mock
# - production -> real
NEXT_PUBLIC_AUTH_MODE=real
# ======================
# PostgreSQL Database
@@ -70,9 +78,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/callback/authentik
# Production: https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik
@@ -116,6 +124,9 @@ JWT_EXPIRATION=24h
# This is used by BetterAuth for session management and CSRF protection
# Example: openssl rand -base64 32
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
# Optional explicit BetterAuth origin for callback/error URL generation.
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
BETTER_AUTH_URL=
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
@@ -204,11 +215,9 @@ NODE_ENV=development
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
# For local builds, use docker-compose.build.yml instead
# Options:
# - dev: Pull development images from registry (default, built from develop branch)
# - latest: Pull latest stable images from registry (built from main branch)
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
# - latest: Pull latest images from registry (default, built from main branch)
# - <version>: Use specific version tag (e.g., v1.0.0)
IMAGE_TAG=dev
IMAGE_TAG=latest
# ======================
# Docker Compose Profiles
@@ -406,8 +415,7 @@ AI_PROVIDER=ollama
OLLAMA_MODEL=llama3.1:latest
# Claude API Key
# Required by the orchestrator service in swarm deployment.
# Also used when AI_PROVIDER=claude for other services.
# Required only when AI_PROVIDER=claude.
# Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY

View File

@@ -6,7 +6,7 @@
# - npm bundled CVEs (5): npm removed from production Node.js images
# - Node.js 20 → 24 LTS migration (#367): base images updated
#
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar (3 CVEs)
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs)
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
# === OpenBao false positives ===
@@ -17,15 +17,18 @@ CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
# === Next.js bundled tar CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles tar@7.5.2 in next/dist/compiled/tar/ (pre-compiled).
# This is NOT a pnpm dependency — it's embedded in the Next.js package itself.
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles tar@7.5.2 and minimatch@9.0.5 in next/dist/compiled/ (pre-compiled).
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself.
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
# Affects web image only (orchestrator and API are clean).
# npm was also removed from all production images, eliminating the npm-bundled copy.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.7.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1.
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.

View File

@@ -85,12 +85,11 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
## Image Tagging
| Condition | Tag | Purpose |
| ---------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current production release |
| `develop` branch | `dev` | Current development build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
| Condition | Tag | Purpose |
| ------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current latest build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
## Required Secrets
@@ -138,5 +137,5 @@ Fails on blockers or critical/high severity security findings.
### Pipeline runs Docker builds on pull requests
- Docker build steps have `when: branch: [main, develop]` guards
- Docker build steps have `when: branch: [main]` guards
- PRs only run quality gates, not Docker builds

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/api.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -151,12 +152,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -179,7 +178,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -187,7 +186,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-api
@@ -229,7 +228,7 @@ steps:
}
link_package "stack-api"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-api

View File

@@ -92,12 +92,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
fi
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- ruff-check
@@ -124,7 +122,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -132,7 +130,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-coordinator
@@ -174,7 +172,7 @@ steps:
}
link_package "stack-coordinator"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-coordinator

View File

@@ -36,12 +36,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
fi
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
docker-build-openbao:
@@ -61,12 +59,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
fi
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
# === Container Security Scans ===
@@ -87,7 +83,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -95,7 +91,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-postgres
@@ -116,7 +112,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -124,7 +120,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-openbao
@@ -167,7 +163,7 @@ steps:
link_package "stack-postgres"
link_package "stack-openbao"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-postgres

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/orchestrator.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -108,12 +109,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -136,7 +135,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -144,7 +143,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-orchestrator
@@ -186,7 +185,7 @@ steps:
}
link_package "stack-orchestrator"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-orchestrator

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/web.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -119,12 +120,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -147,7 +146,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -155,7 +154,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-web
@@ -197,7 +196,7 @@ steps:
}
link_package "stack-web"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-web

View File

@@ -1,14 +1,10 @@
# Compatibility Pointer
# CLAUDE Compatibility Pointer
This repository uses an agent-neutral Mosaic standards model.
This file exists so Claude Code sessions load Mosaic standards.
Authoritative repo guidance is in `AGENTS.md`.
## MANDATORY — Read Before Any Response
Load order for Claude sessions:
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
1. `SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `AGENTS.md`
4. `.mosaic/repo-hooks.sh`
If you were started from `CLAUDE.md`, continue by reading `AGENTS.md` now.
That file is the universal agent configuration. Do NOT respond until you have loaded it.
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.

View File

@@ -232,7 +232,7 @@ docker compose -f docker-compose.openbao.yml up -d
sleep 30 # Wait for auto-initialization
# 5. Deploy swarm stack
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 6. Check deployment status
docker stack services mosaic
@@ -526,10 +526,9 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
### Branch Strategy
- `main`Stable releases only
- `develop` — Active development (default working branch)
- `feature/*`Feature branches from develop
- `fix/*` — Bug fix branches
- `main`Trunk branch (all development merges here)
- `feature/*` — Feature branches from main
- `fix/*`Bug fix branches from main
### Running Locally
@@ -739,7 +738,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
4. Run tests: `pnpm test`
5. Build: `pnpm build`
6. Commit with conventional format: `feat(#issue): Description`
7. Push and create a pull request to `develop`
7. Push and create a pull request to `main`
### Commit Format

View File

@@ -18,7 +18,13 @@ vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
}));
import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
import {
isOidcEnabled,
validateOidcConfig,
createAuth,
getTrustedOrigins,
getBetterAuthBaseUrl,
} from "./auth.config";
describe("auth.config", () => {
// Store original env vars to restore after each test
@@ -32,6 +38,7 @@ describe("auth.config", () => {
delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI;
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.TRUSTED_ORIGINS;
@@ -95,7 +102,7 @@ describe("auth.config", () => {
it("should throw when OIDC_ISSUER is missing", () => {
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
@@ -104,7 +111,7 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_ID is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
});
@@ -112,7 +119,7 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
});
@@ -146,7 +153,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = " ";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
});
@@ -155,7 +162,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
@@ -165,7 +172,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow();
});
@@ -189,30 +196,30 @@ describe("auth.config", () => {
expect(() => validateOidcConfig()).toThrow("Parse error:");
});
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
expect(() => validateOidcConfig()).toThrow(
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"'
);
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
});
it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should warn but not throw when using localhost in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -226,7 +233,7 @@ describe("auth.config", () => {
it("should warn but not throw when using 127.0.0.1 in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -240,7 +247,7 @@ describe("auth.config", () => {
it("should not warn about localhost when not in production", () => {
process.env.NODE_ENV = "development";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -265,16 +272,19 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockGenericOAuth).toHaveBeenCalledOnce();
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
config: Array<{ pkce?: boolean }>;
config: Array<{ pkce?: boolean; redirectURI?: string }>;
};
expect(callArgs.config[0].pkce).toBe(true);
expect(callArgs.config[0].redirectURI).toBe(
"https://app.example.com/auth/oauth2/callback/authentik"
);
});
it("should not call genericOAuth when OIDC is disabled", () => {
@@ -290,7 +300,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_ID deliberately not set
// validateOidcConfig will throw first, so we need to bypass it
@@ -307,7 +317,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_SECRET deliberately not set
const mockPrisma = {} as PrismaClient;
@@ -318,7 +328,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_ISSUER deliberately not set
const mockPrisma = {} as PrismaClient;
@@ -354,8 +364,7 @@ describe("auth.config", () => {
});
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
process.env.TRUSTED_ORIGINS =
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
const origins = getTrustedOrigins();
@@ -364,8 +373,7 @@ describe("auth.config", () => {
});
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
process.env.TRUSTED_ORIGINS =
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
const origins = getTrustedOrigins();
@@ -516,6 +524,21 @@ describe("auth.config", () => {
expect(config.session.updateAge).toBe(7200);
});
it("should configure BetterAuth database ID generation as UUID", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
database: {
generateId: string;
};
};
};
expect(config.advanced.database.generateId).toBe("uuid");
});
it("should set httpOnly cookie attribute to true", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
@@ -552,6 +575,7 @@ describe("auth.config", () => {
it("should set secure cookie attribute to true in production", () => {
process.env.NODE_ENV = "production";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
@@ -624,4 +648,69 @@ describe("auth.config", () => {
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
});
});
describe("getBetterAuthBaseUrl", () => {
it("should prefer BETTER_AUTH_URL when set", () => {
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
});
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
});
it("should throw when base URL is invalid", () => {
process.env.BETTER_AUTH_URL = "not-a-url";
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
});
it("should throw when base URL is missing in production", () => {
process.env.NODE_ENV = "production";
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
});
it("should throw when base URL is not https in production", () => {
process.env.NODE_ENV = "production";
process.env.BETTER_AUTH_URL = "http://api.example.com";
expect(() => getBetterAuthBaseUrl()).toThrow(
"BetterAuth base URL must use https in production"
);
});
});
describe("createAuth - baseURL wiring", () => {
beforeEach(() => {
mockBetterAuth.mockClear();
mockPrismaAdapter.mockClear();
});
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
});
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.fallback.dev");
});
});
});

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { genericOAuth } from "better-auth/plugins";
@@ -14,6 +13,41 @@ const REQUIRED_OIDC_ENV_VARS = [
"OIDC_REDIRECT_URI",
] as const;
/**
* Resolve BetterAuth base URL from explicit auth URL or API URL.
* BetterAuth uses this to generate absolute callback/error URLs.
*/
export function getBetterAuthBaseUrl(): string | undefined {
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
if (!configured || configured.trim() === "") {
if (process.env.NODE_ENV === "production") {
throw new Error(
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
);
}
return undefined;
}
let parsed: URL;
try {
parsed = new URL(configured);
} catch (urlError: unknown) {
const detail = urlError instanceof Error ? urlError.message : String(urlError);
throw new Error(
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
);
}
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
throw new Error(
`BetterAuth base URL must use https in production. Current value: "${configured}".`
);
}
return parsed.origin;
}
/**
* Check if OIDC authentication is enabled via environment variable
*/
@@ -59,17 +93,17 @@ export function validateOidcConfig(): void {
);
}
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
validateRedirectUri();
}
/**
* Validates the OIDC_REDIRECT_URI environment variable.
* - Must be a parseable URL
* - Path must start with /auth/callback
* - Path must start with /auth/oauth2/callback
* - Warns (but does not throw) if using localhost in production
*
* @throws Error if URL is invalid or path does not start with /auth/callback
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
*/
function validateRedirectUri(): void {
const redirectUri = process.env.OIDC_REDIRECT_URI;
@@ -86,14 +120,14 @@ function validateRedirectUri(): void {
throw new Error(
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
`Parse error: ${detail}. ` +
`Example: "https://app.example.com/auth/callback/authentik".`
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
if (!parsed.pathname.startsWith("/auth/callback")) {
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
throw new Error(
`OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://app.example.com/auth/callback/authentik".`
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
@@ -120,6 +154,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
const issuer = process.env.OIDC_ISSUER;
const redirectUri = process.env.OIDC_REDIRECT_URI;
if (!clientId) {
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
@@ -130,6 +165,9 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
if (!issuer) {
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
}
if (!redirectUri) {
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
}
return [
genericOAuth({
@@ -139,6 +177,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
clientId,
clientSecret,
discoveryUrl: `${issuer}.well-known/openid-configuration`,
redirectURI: redirectUri,
pkce: true,
scopes: ["openid", "profile", "email"],
},
@@ -203,7 +242,10 @@ export function createAuth(prisma: PrismaClient) {
// Validate OIDC configuration at startup - fail fast if misconfigured
validateOidcConfig();
const baseURL = getBetterAuthBaseUrl();
return betterAuth({
baseURL,
basePath: "/auth",
database: prismaAdapter(prisma, {
provider: "postgresql",
@@ -217,7 +259,10 @@ export function createAuth(prisma: PrismaClient) {
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
},
advanced: {
generateId: () => randomUUID(),
database: {
// BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs.
generateId: "uuid",
},
defaultCookieAttributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",

View File

@@ -102,11 +102,46 @@ describe("AuthController", () => {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect((err as HttpException).getResponse()).toBe(
"Unable to complete authentication. Please try again in a moment.",
"Unable to complete authentication. Please try again in a moment."
);
}
});
it("should preserve better-call status and body for handler APIError", async () => {
const apiError = {
statusCode: HttpStatus.BAD_REQUEST,
message: "Invalid OAuth configuration",
body: {
message: "Invalid OAuth configuration",
code: "INVALID_OAUTH_CONFIGURATION",
},
};
mockNodeHandler.mockRejectedValueOnce(apiError);
const mockRequest = {
method: "POST",
url: "/auth/sign-in/oauth2",
headers: {},
ip: "192.168.1.10",
socket: { remoteAddress: "192.168.1.10" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
try {
await controller.handleAuth(mockRequest, mockResponse);
expect.unreachable("Expected HttpException to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
expect((err as HttpException).getResponse()).toMatchObject({
message: "Invalid OAuth configuration",
});
}
});
it("should log warning and not throw when handler throws after headers sent", async () => {
const handlerError = new Error("Stream interrupted");
mockNodeHandler.mockRejectedValueOnce(handlerError);
@@ -142,9 +177,7 @@ describe("AuthController", () => {
headersSent: false,
} as unknown as ExpressResponse;
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
HttpException,
);
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
});
});
@@ -187,7 +220,7 @@ describe("AuthController", () => {
OIDC_CLIENT_SECRET: "test-client-secret",
OIDC_CLIENT_ID: "test-client-id",
OIDC_ISSUER: "https://auth.test.com/",
OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
BETTER_AUTH_SECRET: "test-better-auth-secret",
JWT_SECRET: "test-jwt-secret",
CSRF_SECRET: "test-csrf-secret",
@@ -296,11 +329,9 @@ describe("AuthController", () => {
},
};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
@@ -313,22 +344,18 @@ describe("AuthController", () => {
},
};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
const mockRequest = {};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
});
@@ -401,9 +428,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
@@ -423,13 +448,9 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
// Ensure it does NOT contain the second IP in the extracted position
expect(debugSpy).toHaveBeenCalledWith(
expect.not.stringContaining("70.41.3.18"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
});
it("should extract first IP from X-Forwarded-For as array", async () => {
@@ -449,9 +470,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
@@ -471,9 +490,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("192.168.1.100"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
});
});
});

View File

@@ -133,6 +133,11 @@ export class AuthController {
);
if (!res.headersSent) {
const mappedError = this.mapToHttpException(error);
if (mappedError) {
throw mappedError;
}
throw new HttpException(
"Unable to complete authentication. Please try again in a moment.",
HttpStatus.INTERNAL_SERVER_ERROR
@@ -159,4 +164,45 @@ export class AuthController {
// Fall back to direct IP
return req.ip ?? req.socket.remoteAddress ?? "unknown";
}
/**
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
* every failure into a generic 500.
*/
private mapToHttpException(error: unknown): HttpException | null {
if (error instanceof HttpException) {
return error;
}
if (!error || typeof error !== "object") {
return null;
}
const statusCode = "statusCode" in error ? error.statusCode : undefined;
if (!this.isHttpStatus(statusCode)) {
return null;
}
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
if (
responseBody !== undefined &&
responseBody !== null &&
(typeof responseBody === "string" || typeof responseBody === "object")
) {
return new HttpException(responseBody, statusCode);
}
const message =
"message" in error && typeof error.message === "string" && error.message.length > 0
? error.message
: "Authentication request failed";
return new HttpException(message, statusCode);
}
private isHttpStatus(value: unknown): value is number {
if (typeof value !== "number" || !Number.isInteger(value)) {
return false;
}
return value >= 400 && value <= 599;
}
}

View File

@@ -410,7 +410,7 @@ describe("AuthService", () => {
},
};
it("should return session data for valid token", async () => {
it("should validate session token using secure BetterAuth cookie header", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
@@ -418,7 +418,58 @@ describe("AuthService", () => {
const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledTimes(1);
expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
});
it("should preserve raw cookie token value without URL re-encoding", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("tok/with+=chars=");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=tok/with+=chars=",
},
});
});
it("should fall back to Authorization header when cookie-based lookups miss", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenNthCalledWith(1, {
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(2, {
headers: {
cookie: "better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(3, {
headers: {
cookie: "__Host-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(4, {
headers: {
authorization: "Bearer valid-token",
},
@@ -517,14 +568,10 @@ describe("AuthService", () => {
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockRejectedValue(new Error("certificate has expired"));
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow(
"certificate has expired"
);
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
});
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {

View File

@@ -21,6 +21,10 @@ interface VerifiedSession {
session: Record<string, unknown>;
}
interface SessionHeaderCandidate {
headers: Record<string, string>;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -103,36 +107,27 @@ export class AuthService {
* Only known-safe auth errors return null; everything else propagates as 500.
*/
async verifySession(token: string): Promise<VerifiedSession | null> {
try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession({
headers: {
authorization: `Bearer ${token}`,
},
});
let sawNonError = false;
if (!session) {
return null;
}
for (const candidate of this.buildSessionHeaderCandidates(token)) {
try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession(candidate);
return {
user: session.user as Record<string, unknown>,
session: session.session as Record<string, unknown>,
};
} catch (error: unknown) {
// Only known-safe auth errors return null
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const isExpectedAuthError =
msg.includes("invalid token") ||
msg.includes("token expired") ||
msg.includes("session expired") ||
msg.includes("session not found") ||
msg.includes("invalid session") ||
msg === "unauthorized" ||
msg === "expired";
if (!session) {
continue;
}
return {
user: session.user as Record<string, unknown>,
session: session.session as Record<string, unknown>,
};
} catch (error: unknown) {
if (error instanceof Error) {
if (this.isExpectedAuthError(error.message)) {
continue;
}
if (!isExpectedAuthError) {
// Infrastructure or unexpected — propagate as 500
const safeMessage = (error.stack ?? error.message).replace(
/Bearer\s+\S+/gi,
@@ -141,14 +136,55 @@ export class AuthService {
this.logger.error("Session verification failed due to unexpected error", safeMessage);
throw error;
}
// Non-Error thrown values — log once for observability, treat as auth failure
if (!sawNonError) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
sawNonError = true;
}
}
// Non-Error thrown values — log for observability, treat as auth failure
if (!(error instanceof Error)) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
}
return null;
}
return null;
}
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
return [
{
headers: {
cookie: `__Secure-better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `__Host-better-auth.session_token=${token}`,
},
},
{
headers: {
authorization: `Bearer ${token}`,
},
},
];
}
private isExpectedAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("invalid token") ||
normalized.includes("token expired") ||
normalized.includes("session expired") ||
normalized.includes("session not found") ||
normalized.includes("invalid session") ||
normalized === "unauthorized" ||
normalized === "expired"
);
}
/**

View File

@@ -1,10 +1,18 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from "@nestjs/common";
import { AuthService } from "../auth.service";
import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -59,7 +67,8 @@ export class AuthGuard implements CanActivate {
}
/**
* Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
* Extract token from cookie.
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
*/
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
// Express types `cookies` as `any`; cast to a known shape for type safety.
@@ -68,8 +77,23 @@ export class AuthGuard implements CanActivate {
return undefined;
}
// BetterAuth uses 'better-auth.session_token' as the cookie name by default
return cookies["better-auth.session_token"];
// BetterAuth default cookie name is "better-auth.session_token"
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-".
const candidates = [
"__Secure-better-auth.session_token",
"better-auth.session_token",
"__Host-better-auth.session_token",
] as const;
for (const name of candidates) {
const token = cookies[name];
if (token) {
this.logger.debug(`Session cookie found: ${name}`);
return token;
}
}
return undefined;
}
/**

View File

@@ -137,13 +137,13 @@ describe("RLS Context Integration", () => {
queries: ["findMany"],
});
// Verify SET LOCAL was called
// Verify transaction-local set_config calls were made
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
workspaceId
);
});

View File

@@ -80,7 +80,7 @@ describe("RlsContextInterceptor", () => {
expect(result).toEqual({ data: "test response" });
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
});
@@ -111,13 +111,13 @@ describe("RlsContextInterceptor", () => {
// Check that user context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
1,
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
// Check that workspace context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
workspaceId
);
});

View File

@@ -100,12 +100,12 @@ export class RlsContextInterceptor implements NestInterceptor {
this.prisma
.$transaction(
async (tx) => {
// Set user context (always present for authenticated requests)
await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
// Use set_config(..., true) so values are transaction-local and parameterized safely.
// Direct SET LOCAL with bind parameters produces invalid SQL on PostgreSQL.
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
// Set workspace context (if present)
if (workspaceId) {
await tx.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
await tx.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
}
// Propagate the transaction client via AsyncLocalStorage

View File

@@ -50,6 +50,8 @@ describe("TelemetryInterceptor", () => {
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: false,
writableEnded: false,
}),
}),
getClass: vi.fn().mockReturnValue({ name: "TestController" }),
@@ -101,6 +103,35 @@ describe("TelemetryInterceptor", () => {
expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id");
});
it("should not set trace header when response is already committed", async () => {
const committedResponseContext = {
...mockContext,
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue({
method: "GET",
url: "/api/test",
path: "/api/test",
}),
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: true,
writableEnded: true,
}),
}),
} as unknown as ExecutionContext;
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
const committedResponse = committedResponseContext.switchToHttp().getResponse();
await lastValueFrom(interceptor.intercept(committedResponseContext, mockHandler));
expect(committedResponse.setHeader).not.toHaveBeenCalled();
});
it("should record exception on error", async () => {
const error = new Error("Test error");
mockHandler = {

View File

@@ -88,7 +88,7 @@ export class TelemetryInterceptor implements NestInterceptor {
// Add trace context to response headers for distributed tracing
const spanContext = span.spanContext();
if (spanContext.traceId) {
if (spanContext.traceId && !response.headersSent && !response.writableEnded) {
response.setHeader("x-trace-id", spanContext.traceId);
}
} catch (error) {

View File

@@ -1,6 +1,8 @@
# Orchestrator Configuration
ORCHESTRATOR_PORT=3001
NODE_ENV=development
# AI provider for orchestrator agents: ollama, claude, openai
AI_PROVIDER=ollama
# Valkey
VALKEY_HOST=localhost
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
VALKEY_URL=redis://localhost:6379
# Claude API
# Required only when AI_PROVIDER=claude.
CLAUDE_API_KEY=your-api-key-here
# Docker

View File

@@ -186,17 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
Environment variables loaded via `@nestjs/config`. Key variables:
| Variable | Description |
| -------------------------------- | -------------------------------------------------- |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `CLAUDE_API_KEY` | Claude API key for agents |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
| Variable | Description |
| -------------------------------- | ------------------------------------------------------------ |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
| `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
## Related Documentation

View File

@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
Sensitive configuration is passed via environment variables:
- `CLAUDE_API_KEY`: Claude API credentials
- `AI_PROVIDER`: Orchestrator LLM provider
- `CLAUDE_API_KEY`: Claude credentials (required only for `AI_PROVIDER=claude`)
- `VALKEY_URL`: Cache connection string
**Best Practices:**

View File

@@ -1,5 +1,5 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { BullModule } from "@nestjs/bullmq";
import { ThrottlerModule } from "@nestjs/throttler";
import { HealthModule } from "./api/health/health.module";
@@ -22,11 +22,15 @@ import { orchestratorConfig } from "./config/orchestrator.config";
isGlobal: true,
load: [orchestratorConfig],
}),
BullModule.forRoot({
connection: {
host: process.env.VALKEY_HOST ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? "6379"),
},
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>("orchestrator.valkey.host", "localhost"),
port: configService.get<number>("orchestrator.valkey.port", 6379),
password: configService.get<string>("orchestrator.valkey.password"),
},
}),
}),
ThrottlerModule.forRoot([
{

View File

@@ -120,6 +120,42 @@ describe("orchestratorConfig", () => {
expect(config.valkey.port).toBe(6379);
expect(config.valkey.url).toBe("redis://localhost:6379");
});
it("should derive valkey host and port from VALKEY_URL when VALKEY_HOST/VALKEY_PORT are not set", () => {
delete process.env.VALKEY_HOST;
delete process.env.VALKEY_PORT;
process.env.VALKEY_URL = "redis://valkey:6380";
const config = orchestratorConfig();
expect(config.valkey.host).toBe("valkey");
expect(config.valkey.port).toBe(6380);
expect(config.valkey.url).toBe("redis://valkey:6380");
});
it("should derive valkey password from VALKEY_URL when VALKEY_PASSWORD is not set", () => {
delete process.env.VALKEY_PASSWORD;
delete process.env.VALKEY_HOST;
delete process.env.VALKEY_PORT;
process.env.VALKEY_URL = "redis://:url-secret@valkey:6379";
const config = orchestratorConfig();
expect(config.valkey.password).toBe("url-secret");
});
it("should prefer explicit valkey env vars over VALKEY_URL values", () => {
process.env.VALKEY_HOST = "explicit-host";
process.env.VALKEY_PORT = "6390";
process.env.VALKEY_PASSWORD = "explicit-password";
process.env.VALKEY_URL = "redis://:url-secret@valkey:6380";
const config = orchestratorConfig();
expect(config.valkey.host).toBe("explicit-host");
expect(config.valkey.port).toBe(6390);
expect(config.valkey.password).toBe("explicit-password");
});
});
describe("valkey timeout config (SEC-ORCH-28)", () => {
@@ -181,4 +217,30 @@ describe("orchestratorConfig", () => {
expect(config.spawner.maxConcurrentAgents).toBe(10);
});
});
describe("AI provider config", () => {
it("should default aiProvider to ollama when unset", () => {
delete process.env.AI_PROVIDER;
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
it("should normalize AI provider to lowercase", () => {
process.env.AI_PROVIDER = " cLaUdE ";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("claude");
});
it("should fallback unsupported AI provider to ollama", () => {
process.env.AI_PROVIDER = "bad-provider";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
});
});

View File

@@ -1,61 +1,96 @@
import { registerAs } from "@nestjs/config";
export const orchestratorConfig = registerAs("orchestrator", () => ({
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
valkey: {
host: process.env.VALKEY_HOST ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
password: process.env.VALKEY_PASSWORD,
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
},
claude: {
apiKey: process.env.CLAUDE_API_KEY,
},
docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
},
git: {
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
},
killswitch: {
enabled: process.env.KILLSWITCH_ENABLED === "true",
},
sandbox: {
enabled: process.env.SANDBOX_ENABLED !== "false",
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
},
coordinator: {
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
apiKey: process.env.COORDINATOR_API_KEY,
},
yolo: {
enabled: process.env.YOLO_MODE === "true",
},
spawner: {
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
},
queue: {
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
completedRetentionAgeSeconds: parseInt(
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
10
),
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
},
}));
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
if (!provider) {
return "ollama";
}
if (provider !== "ollama" && provider !== "claude" && provider !== "openai") {
return "ollama";
}
return provider;
};
const parseValkeyUrl = (url: string): { host?: string; port?: number; password?: string } => {
try {
const parsed = new URL(url);
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
return {
host: parsed.hostname || undefined,
port: Number.isNaN(port) ? undefined : port,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
};
} catch {
return {};
}
};
export const orchestratorConfig = registerAs("orchestrator", () => {
const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379";
const parsedValkeyUrl = parseValkeyUrl(valkeyUrl);
return {
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
valkey: {
host: process.env.VALKEY_HOST ?? parsedValkeyUrl.host ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? String(parsedValkeyUrl.port ?? 6379), 10),
password: process.env.VALKEY_PASSWORD ?? parsedValkeyUrl.password,
url: valkeyUrl,
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
},
claude: {
apiKey: process.env.CLAUDE_API_KEY,
},
aiProvider: normalizeAiProvider(),
docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
},
git: {
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
},
killswitch: {
enabled: process.env.KILLSWITCH_ENABLED === "true",
},
sandbox: {
enabled: process.env.SANDBOX_ENABLED !== "false",
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
},
coordinator: {
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
apiKey: process.env.COORDINATOR_API_KEY,
},
yolo: {
enabled: process.env.YOLO_MODE === "true",
},
spawner: {
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
},
queue: {
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
completedRetentionAgeSeconds: parseInt(
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
10
),
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
},
};
});

View File

@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
// Create mock ConfigService
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
expect(service).toBeDefined();
});
it("should initialize with Claude API key from config", () => {
it("should initialize with default AI provider when API key is omitted", () => {
const noClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
if (key === "orchestrator.spawner.sessionCleanupDelayMs") {
return 30000;
}
return undefined;
}),
} as unknown as ConfigService;
const serviceNoKey = new AgentSpawnerService(noClaudeConfigService);
expect(serviceNoKey).toBeDefined();
});
it("should initialize with Claude provider when key is present", () => {
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
});
it("should throw error if Claude API key is missing", () => {
it("should initialize with CLAUDE provider when API key is present", () => {
const claudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
const claudeService = new AgentSpawnerService(claudeConfigService);
expect(claudeService).toBeDefined();
});
it("should throw error if Claude API key is missing when provider is claude", () => {
const badConfigService = {
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
"CLAUDE_API_KEY is not configured"
"CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'"
);
});
it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => {
const nonClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow();
});
});
describe("spawnAgent", () => {

View File

@@ -14,6 +14,8 @@ import {
* This allows time for status queries before the session is removed
*/
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
const SUPPORTED_AI_PROVIDERS = ["ollama", "claude", "openai"] as const;
type SupportedAiProvider = (typeof SUPPORTED_AI_PROVIDERS)[number];
/**
* Service responsible for spawning Claude agents using Anthropic SDK
@@ -21,22 +23,38 @@ const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
@Injectable()
export class AgentSpawnerService implements OnModuleDestroy {
private readonly logger = new Logger(AgentSpawnerService.name);
private readonly anthropic: Anthropic;
private readonly anthropic: Anthropic | undefined;
private readonly aiProvider: SupportedAiProvider;
private readonly sessions = new Map<string, AgentSession>();
private readonly maxConcurrentAgents: number;
private readonly sessionCleanupDelayMs: number;
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
constructor(private readonly configService: ConfigService) {
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
this.aiProvider = this.normalizeAiProvider(configuredProvider);
this.logger.log(`AgentSpawnerService resolved AI provider: ${this.aiProvider}`);
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
if (!apiKey) {
throw new Error("CLAUDE_API_KEY is not configured");
}
if (this.aiProvider === "claude") {
if (!apiKey) {
throw new Error("CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'");
}
this.anthropic = new Anthropic({
apiKey,
});
this.logger.log("CLAUDE_API_KEY is configured. Initializing Anthropic client.");
this.anthropic = new Anthropic({ apiKey });
} else {
if (apiKey) {
this.logger.debug(
`CLAUDE_API_KEY is set but ignored because AI provider is '${this.aiProvider}'`
);
} else {
this.logger.log(`CLAUDE_API_KEY not required for AI provider '${this.aiProvider}'.`);
}
this.anthropic = undefined;
}
// Default to 20 if not configured
this.maxConcurrentAgents =
@@ -48,10 +66,27 @@ export class AgentSpawnerService implements OnModuleDestroy {
DEFAULT_SESSION_CLEANUP_DELAY_MS;
this.logger.log(
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
`AgentSpawnerService initialized with ${this.aiProvider} AI provider (max concurrent agents: ${String(
this.maxConcurrentAgents
)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
);
}
private normalizeAiProvider(provider?: string): SupportedAiProvider {
const normalizedProvider = provider?.trim().toLowerCase();
if (!normalizedProvider) {
return "ollama";
}
if (!SUPPORTED_AI_PROVIDERS.includes(normalizedProvider as SupportedAiProvider)) {
this.logger.warn(`Unsupported AI provider '${normalizedProvider}'. Defaulting to 'ollama'.`);
return "ollama";
}
return normalizedProvider as SupportedAiProvider;
}
/**
* Clean up all pending cleanup timers on module destroy
*/

View File

@@ -27,6 +27,20 @@ COPY apps/web/package.json ./apps/web/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
RUN pnpm install --frozen-lockfile
# ======================
# Production dependencies stage
# ======================
FROM base AS prod-deps
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
COPY apps/web/package.json ./apps/web/
# Install production dependencies only
RUN pnpm install --frozen-lockfile --prod
# ======================
# Builder stage
# ======================
@@ -81,14 +95,13 @@ ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& corepack enable && corepack prepare pnpm@10.27.0 --activate \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
WORKDIR /app
# Copy node_modules from builder (includes all dependencies in pnpm store)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Copy built packages (includes dist/ directories)
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
@@ -99,7 +112,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/next.config.ts ./apps/web/
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
# Copy app's node_modules which contains symlinks to root node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=prod-deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Set working directory to web app
WORKDIR /app/apps/web
@@ -113,6 +126,7 @@ EXPOSE ${PORT:-3000}
# Environment variables
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"
ENV PATH="/app/apps/web/node_modules/.bin:${PATH}"
# Health check uses PORT env var (set by docker-compose or defaults to 3000)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
@@ -122,4 +136,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["pnpm", "start"]
CMD ["next", "start"]

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next";
const defaultAuthMode = process.env.NODE_ENV === "development" ? "mock" : "real";
const authMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? defaultAuthMode).toLowerCase();
if (!["real", "mock"].includes(authMode)) {
throw new Error(`Invalid NEXT_PUBLIC_AUTH_MODE "${authMode}". Expected one of: real, mock.`);
}
if (authMode === "mock" && process.env.NODE_ENV !== "development") {
throw new Error("NEXT_PUBLIC_AUTH_MODE=mock is only allowed for local development.");
}
const nextConfig: NextConfig = {
transpilePackages: ["@mosaic/ui", "@mosaic/shared"],
};

View File

@@ -47,7 +47,10 @@
"@types/react-grid-layout": "^2.1.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.24",
"jsdom": "^26.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "^5.8.2",
"vitest": "^3.0.8"
}

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginPage from "./page";
const { mockPush, mockReplace, mockSearchParams, authState } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockReplace: vi.fn(),
mockSearchParams: new URLSearchParams(),
authState: {
isAuthenticated: false,
refreshSession: vi.fn(),
},
}));
const { mockFetchWithRetry } = vi.hoisted(() => ({
mockFetchWithRetry: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: (): { push: Mock; replace: Mock } => ({
push: mockPush,
replace: mockReplace,
}),
useSearchParams: (): URLSearchParams => mockSearchParams,
}));
vi.mock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3001",
IS_MOCK_AUTH_MODE: true,
}));
vi.mock("@/lib/auth-client", () => ({
signIn: {
oauth2: vi.fn(),
email: vi.fn(),
},
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
isAuthenticated: authState.isAuthenticated,
refreshSession: authState.refreshSession,
}),
}));
vi.mock("@/lib/auth/fetch-with-retry", () => ({
fetchWithRetry: mockFetchWithRetry,
}));
describe("LoginPage (mock auth mode)", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockSearchParams.delete("error");
authState.isAuthenticated = false;
authState.refreshSession.mockResolvedValue(undefined);
});
it("should render mock auth controls", (): void => {
render(<LoginPage />);
expect(screen.getByText(/local mock auth mode is active/i)).toBeInTheDocument();
expect(screen.getByTestId("mock-auth-login")).toBeInTheDocument();
expect(mockFetchWithRetry).not.toHaveBeenCalled();
});
it("should continue with mock session and navigate to tasks", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByTestId("mock-auth-login"));
await waitFor(() => {
expect(authState.refreshSession).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith("/tasks");
});
});
it("should auto-redirect authenticated mock users to tasks", async (): Promise<void> => {
authState.isAuthenticated = true;
render(<LoginPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith("/tasks");
});
});
});

View File

@@ -16,6 +16,11 @@ const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } =
mockSearchParams: new URLSearchParams(),
}));
const { mockRefreshSession, mockIsAuthenticated } = vi.hoisted(() => ({
mockRefreshSession: vi.fn(),
mockIsAuthenticated: false,
}));
vi.mock("next/navigation", () => ({
useRouter: (): { push: Mock; replace: Mock } => ({
push: mockPush,
@@ -33,6 +38,14 @@ vi.mock("@/lib/auth-client", () => ({
vi.mock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3001",
IS_MOCK_AUTH_MODE: false,
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
isAuthenticated: mockIsAuthenticated,
refreshSession: mockRefreshSession,
}),
}));
// Mock fetchWithRetry to behave like fetch for test purposes
@@ -91,6 +104,7 @@ describe("LoginPage", (): void => {
mockSearchParams.delete("error");
// Default: OAuth2 returns a resolved promise (fire-and-forget redirect)
mockOAuth2.mockResolvedValue(undefined);
mockRefreshSession.mockResolvedValue(undefined);
});
it("renders loading state initially", (): void => {
@@ -113,8 +127,8 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center");
expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument();
});
it("has proper layout styling", async (): Promise<void> => {
@@ -172,7 +186,7 @@ describe("LoginPage", (): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
});
expect(screen.getByText(/or continue with email/i)).toBeInTheDocument();
expect(screen.getByText(/or continue with/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
@@ -186,7 +200,11 @@ describe("LoginPage", (): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
});
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
// The divider element should not appear (no credentials provider)
const dividerTexts = screen.queryAllByText(/or continue with/i);
// OAuthButton text contains "Continue with" so filter for the divider specifically
const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with");
expect(dividerOnly).toHaveLength(0);
});
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
@@ -201,7 +219,6 @@ describe("LoginPage", (): void => {
// Should NOT silently fall back to email form
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
// Should show the error banner with helpful message
expect(
@@ -276,7 +293,7 @@ describe("LoginPage", (): void => {
expect(mockOAuth2).toHaveBeenCalledWith({
providerId: "authentik",
callbackURL: "/",
callbackURL: "http://localhost:3000/",
});
});
@@ -439,7 +456,7 @@ describe("LoginPage", (): void => {
/* ------------------------------------------------------------------ */
describe("responsive layout", (): void => {
it("applies mobile-first padding to main element", async (): Promise<void> => {
it("applies AuthShell layout classes to main element", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
@@ -449,8 +466,7 @@ describe("LoginPage", (): void => {
});
const main = container.querySelector("main");
expect(main).toHaveClass("p-4", "sm:p-8");
expect(main).toHaveClass("min-h-screen", "items-center", "justify-center");
});
it("applies responsive text size to heading", async (): Promise<void> => {
@@ -463,10 +479,10 @@ describe("LoginPage", (): void => {
});
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
expect(heading).toHaveClass("text-xl", "sm:text-2xl");
});
it("applies responsive padding to card container", async (): Promise<void> => {
it("AuthCard applies card styling with padding", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
@@ -475,12 +491,12 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const card = container.querySelector(".bg-white");
expect(card).toHaveClass("p-4", "sm:p-8");
// AuthCard uses rounded-b-2xl and p-6 sm:p-10
const card = container.querySelector(".rounded-b-2xl");
expect(card).toHaveClass("p-6", "sm:p-10");
});
it("card container has full width with max-width constraint", async (): Promise<void> => {
it("AuthShell constrains card width", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
@@ -489,9 +505,9 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const wrapper = container.querySelector(".max-w-md");
expect(wrapper).toHaveClass("w-full", "max-w-md");
// AuthShell wraps children in max-w-[27rem]
const wrapper = container.querySelector(".max-w-\\[27rem\\]");
expect(wrapper).toHaveClass("w-full");
});
});

View File

@@ -5,10 +5,12 @@ import type { ReactElement } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { API_BASE_URL } from "@/lib/config";
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
import { signIn } from "@/lib/auth-client";
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
import { parseAuthError } from "@/lib/auth/auth-errors";
import { useAuth } from "@/lib/auth/auth-context";
import { OAuthButton } from "@/components/auth/OAuthButton";
import { LoginForm } from "@/components/auth/LoginForm";
import { AuthDivider } from "@/components/auth/AuthDivider";
@@ -18,23 +20,21 @@ export default function LoginPage(): ReactElement {
return (
<Suspense
fallback={
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
</div>
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
<AuthShell>
<AuthCard>
<div className="flex flex-col items-center gap-6">
<AuthBrand />
<div
className="flex items-center justify-center py-8"
role="status"
aria-label="Loading authentication options"
>
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
<span className="sr-only">Loading authentication options</span>
</div>
</div>
</div>
</main>
</AuthCard>
</AuthShell>
}
>
<LoginPageContent />
@@ -45,6 +45,7 @@ export default function LoginPage(): ReactElement {
function LoginPageContent(): ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, refreshSession } = useAuth();
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
const [loadingConfig, setLoadingConfig] = useState(true);
const [retryCount, setRetryCount] = useState(0);
@@ -68,6 +69,18 @@ function LoginPageContent(): ReactElement {
}, [searchParams, router]);
useEffect(() => {
if (IS_MOCK_AUTH_MODE && isAuthenticated) {
router.replace("/tasks");
}
}, [isAuthenticated, router]);
useEffect(() => {
if (IS_MOCK_AUTH_MODE) {
setConfig({ providers: [] });
setLoadingConfig(false);
return;
}
let cancelled = false;
async function fetchConfig(): Promise<void> {
@@ -113,7 +126,9 @@ function LoginPageContent(): ReactElement {
const handleOAuthLogin = useCallback((providerId: string): void => {
setOauthLoading(providerId);
setError(null);
signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => {
const callbackURL =
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
@@ -156,18 +171,64 @@ function LoginPageContent(): ReactElement {
setRetryCount((c) => c + 1);
}, []);
const handleMockLogin = useCallback(async (): Promise<void> => {
setError(null);
try {
await refreshSession();
router.push("/tasks");
} catch (err: unknown) {
const parsed = parseAuthError(err);
setError(parsed.message);
}
}, [refreshSession, router]);
if (IS_MOCK_AUTH_MODE) {
return (
<AuthShell>
<AuthCard>
<div className="flex flex-col items-center gap-6">
<AuthBrand />
<div className="text-center">
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
Local mock auth mode is active
</p>
</div>
</div>
<div className="mt-6 space-y-4">
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
{error && <AuthErrorBanner message={error} />}
<button
type="button"
onClick={() => {
void handleMockLogin();
}}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
data-testid="mock-auth-login"
>
Continue with Mock Session
</button>
</div>
</AuthCard>
</AuthShell>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
<p className="text-base sm:text-lg text-gray-600">
Your personal assistant platform. Organize tasks, events, and projects with a
PDA-friendly approach.
</p>
<AuthShell>
<AuthCard>
<div className="flex flex-col items-center gap-6">
<AuthBrand />
<div className="text-center">
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
Sign in to your orchestration platform
</p>
</div>
</div>
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
<div className="mt-6">
{loadingConfig ? (
<div
className="flex items-center justify-center py-8"
@@ -175,7 +236,7 @@ function LoginPageContent(): ReactElement {
role="status"
aria-label="Loading authentication options"
>
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
<span className="sr-only">Loading authentication options</span>
</div>
) : config === null ? (
@@ -185,47 +246,35 @@ function LoginPageContent(): ReactElement {
<button
type="button"
onClick={handleRetry}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
>
Try again
</button>
</div>
</div>
) : (
<>
<div className="space-y-0">
{urlError && (
<AuthErrorBanner
message={urlError}
onDismiss={(): void => {
setUrlError(null);
}}
/>
<div className="mb-4">
<AuthErrorBanner
message={urlError}
onDismiss={(): void => {
setUrlError(null);
}}
/>
</div>
)}
{error && !hasCredentials && (
<AuthErrorBanner
message={error}
onDismiss={(): void => {
setError(null);
}}
/>
)}
{hasOAuth &&
oauthProviders.map((provider) => (
<OAuthButton
key={provider.id}
providerName={provider.name}
providerId={provider.id}
onClick={(): void => {
handleOAuthLogin(provider.id);
<div className="mb-4">
<AuthErrorBanner
message={error}
onDismiss={(): void => {
setError(null);
}}
isLoading={oauthLoading === provider.id}
disabled={oauthLoading !== null && oauthLoading !== provider.id}
/>
))}
{hasOAuth && hasCredentials && <AuthDivider />}
</div>
)}
{hasCredentials && (
<LoginForm
@@ -234,10 +283,33 @@ function LoginPageContent(): ReactElement {
error={error}
/>
)}
</>
{hasOAuth && hasCredentials && <AuthDivider />}
{hasOAuth && (
<div className="space-y-2">
{oauthProviders.map((provider) => (
<OAuthButton
key={provider.id}
providerName={provider.name}
providerId={provider.id}
onClick={(): void => {
handleOAuthLogin(provider.id);
}}
isLoading={oauthLoading === provider.id}
disabled={oauthLoading !== null && oauthLoading !== provider.id}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
</main>
<div className="mt-6 flex justify-center">
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
</div>
</AuthCard>
</AuthShell>
);
}

View File

@@ -3,10 +3,80 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context";
import { Navigation } from "@/components/layout/Navigation";
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
import { AppHeader } from "@/components/layout/AppHeader";
import { AppSidebar } from "@/components/layout/AppSidebar";
import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext";
import { ChatOverlay } from "@/components/chat";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SIDEBAR_EXPANDED_WIDTH = "240px";
const SIDEBAR_COLLAPSED_WIDTH = "60px";
// ---------------------------------------------------------------------------
// Inner shell — must be a child of SidebarProvider to use useSidebar
// ---------------------------------------------------------------------------
interface AppShellProps {
children: ReactNode;
}
function AppShell({ children }: AppShellProps): React.JSX.Element {
const { collapsed, isMobile } = useSidebar();
// On tablet (mdlg), hide sidebar from the grid when the sidebar is collapsed.
// On mobile, the sidebar is fixed-position so the grid is always single-column.
const sidebarHidden = !isMobile && collapsed;
return (
<div
className="app-shell"
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
style={
{
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
transition: "grid-template-columns 0.2s var(--ease, ease)",
} as React.CSSProperties
}
>
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
<AppHeader />
{/* Sidebar — left column, row 2, via .app-sidebar CSS class */}
<AppSidebar />
{/* Main content — right column, row 2, via .app-main CSS class */}
<main className="app-main" id="main-content">
{IS_MOCK_AUTH_MODE && (
<div
className="border-b px-4 py-2 text-xs font-medium flex-shrink-0"
style={{
borderColor: "var(--ms-amber-500)",
background: "rgba(245, 158, 11, 0.08)",
color: "var(--ms-amber-400)",
}}
data-testid="mock-auth-banner"
>
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
</div>
)}
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</main>
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
</div>
);
}
// ---------------------------------------------------------------------------
// Authenticated layout — handles auth guard + provides sidebar context
// ---------------------------------------------------------------------------
export default function AuthenticatedLayout({
children,
}: {
@@ -22,11 +92,7 @@ export default function AuthenticatedLayout({
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</div>
);
return <MosaicSpinner size={48} fullPage />;
}
if (!isAuthenticated) {
@@ -34,10 +100,8 @@ export default function AuthenticatedLayout({
}
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="pt-16">{children}</div>
<ChatOverlay />
</div>
<SidebarProvider>
<AppShell>{children}</AppShell>
</SidebarProvider>
);
}

View File

@@ -1,78 +1,32 @@
"use client";
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
import { mockTasks } from "@/lib/api/tasks";
import { mockEvents } from "@/lib/api/events";
import type { Task, Event } from "@mosaic/shared";
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
import { QuickActions } from "@/components/dashboard/QuickActions";
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
import { TokenBudget } from "@/components/dashboard/TokenBudget";
export default function DashboardPage(): ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadDashboardData();
}, []);
async function loadDashboardData(): Promise<void> {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with real API calls when backend is ready
// const [tasksData, eventsData] = await Promise.all([fetchTasks(), fetchEvents()]);
await new Promise((resolve) => setTimeout(resolve, 300));
setTasks(mockTasks);
setEvents(mockEvents);
} catch (err) {
setError(
err instanceof Error
? err.message
: "We had trouble loading your dashboard. Please try again when you're ready."
);
} finally {
setIsLoading(false);
}
}
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-2">Welcome back! Here&apos;s your overview</p>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DashboardMetrics />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 320px",
gap: 16,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions />
<QuickActions />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<ActivityFeed />
<TokenBudget />
</div>
</div>
{error !== null ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<p className="text-amber-800">{error}</p>
<button
onClick={() => void loadDashboardData()}
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
>
Try again
</button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top row: Domain Overview and Quick Capture */}
<div className="lg:col-span-2">
<DomainOverviewWidget tasks={tasks} isLoading={isLoading} />
</div>
<RecentTasksWidget tasks={tasks} isLoading={isLoading} />
<UpcomingEventsWidget events={events} isLoading={isLoading} />
<div className="lg:col-span-2">
<QuickCaptureWidget />
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -3,147 +3,303 @@
@tailwind utilities;
/* =============================================================================
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
Philosophy: "Good design is as little design as possible." - Dieter Rams
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
============================================================================= */
/* -----------------------------------------------------------------------------
CSS Custom Properties - Light Theme (Default)
Primitive Tokens (Dark-first — dark is the default theme)
----------------------------------------------------------------------------- */
:root {
/* Base colors - increased contrast from surfaces */
--color-background: 245 247 250;
--color-foreground: 15 23 42;
/* Mosaic design tokens — dark palette (default) */
--ms-bg-950: #080b12;
--ms-bg-900: #0f141d;
--ms-bg-850: #151b26;
--ms-surface-800: #1b2331;
--ms-surface-750: #232d3f;
--ms-border-700: #2f3b52;
--ms-text-100: #eef3ff;
--ms-text-300: #c5d0e6;
--ms-text-500: #8f9db7;
--ms-blue-500: #2f80ff;
--ms-blue-400: #56a0ff;
--ms-red-500: #e5484d;
--ms-red-400: #f06a6f;
--ms-purple-500: #8b5cf6;
--ms-purple-400: #a78bfa;
--ms-teal-500: #14b8a6;
--ms-teal-400: #2dd4bf;
--ms-amber-500: #f59e0b;
--ms-amber-400: #fbbf24;
--ms-pink-500: #ec4899;
--ms-emerald-500: #10b981;
--ms-orange-500: #f97316;
--ms-cyan-500: #06b6d4;
--ms-indigo-500: #6366f1;
/* Surface hierarchy (elevation levels) - improved contrast */
--surface-0: 255 255 255;
--surface-1: 250 251 252;
--surface-2: 241 245 249;
--surface-3: 226 232 240;
/* Semantic aliases — dark theme is default */
--bg: var(--ms-bg-900);
--bg-deep: var(--ms-bg-950);
--bg-mid: var(--ms-bg-850);
--surface: var(--ms-surface-800);
--surface-2: var(--ms-surface-750);
--border: var(--ms-border-700);
--text: var(--ms-text-100);
--text-2: var(--ms-text-300);
--muted: var(--ms-text-500);
--primary: var(--ms-blue-500);
--primary-l: var(--ms-blue-400);
--danger: var(--ms-red-500);
--success: var(--ms-teal-500);
--warn: var(--ms-amber-500);
--purple: var(--ms-purple-500);
/* Text hierarchy */
--text-primary: 15 23 42;
--text-secondary: 51 65 85;
--text-tertiary: 71 85 105;
--text-muted: 100 116 139;
/* Typography */
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
/* Border colors - stronger borders for light mode */
--border-default: 203 213 225;
--border-subtle: 226 232 240;
--border-strong: 148 163 184;
/* Radius scale */
--r: 8px;
--r-sm: 5px;
--r-lg: 12px;
--r-xl: 16px;
/* Brand accent - Indigo (professional, trustworthy) */
--accent-primary: 79 70 229;
--accent-primary-hover: 67 56 202;
--accent-primary-light: 238 242 255;
--accent-primary-muted: 199 210 254;
/* Layout dimensions */
--sidebar-w: 260px;
--topbar-h: 56px;
--terminal-h: 220px;
/* Semantic colors - Success (Emerald) */
--semantic-success: 16 185 129;
--semantic-success-light: 209 250 229;
--semantic-success-dark: 6 95 70;
/* Easing */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
/* Semantic colors - Warning (Amber) */
--semantic-warning: 245 158 11;
--semantic-warning-light: 254 243 199;
--semantic-warning-dark: 146 64 14;
/* Semantic colors - Error (Rose) */
--semantic-error: 244 63 94;
--semantic-error-light: 255 228 230;
--semantic-error-dark: 159 18 57;
/* Semantic colors - Info (Sky) */
--semantic-info: 14 165 233;
--semantic-info-light: 224 242 254;
--semantic-info-dark: 3 105 161;
/* Focus ring */
--focus-ring: 99 102 241;
--focus-ring-offset: 255 255 255;
/* Shadows - visible but subtle */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
}
/* -----------------------------------------------------------------------------
CSS Custom Properties - Dark Theme
----------------------------------------------------------------------------- */
.dark {
--color-background: 3 7 18;
--color-foreground: 248 250 252;
/* Surface hierarchy (elevation levels) */
--surface-0: 15 23 42;
--surface-1: 30 41 59;
--surface-2: 51 65 85;
--surface-3: 71 85 105;
/* Text hierarchy */
--text-primary: 248 250 252;
--text-secondary: 203 213 225;
--text-tertiary: 148 163 184;
--text-muted: 100 116 139;
/* Border colors */
--border-default: 51 65 85;
--border-subtle: 30 41 59;
--border-strong: 71 85 105;
/* Brand accent adjustments for dark mode */
--accent-primary: 129 140 248;
--accent-primary-hover: 165 180 252;
--accent-primary-light: 30 27 75;
--accent-primary-muted: 55 48 163;
/* Semantic colors adjustments */
--semantic-success: 52 211 153;
--semantic-success-light: 6 78 59;
--semantic-success-dark: 167 243 208;
--semantic-warning: 251 191 36;
--semantic-warning-light: 120 53 15;
--semantic-warning-dark: 253 230 138;
--semantic-error: 251 113 133;
--semantic-error-light: 136 19 55;
--semantic-error-dark: 253 164 175;
--semantic-info: 56 189 248;
--semantic-info-light: 12 74 110;
--semantic-info-dark: 186 230 253;
/* Focus ring */
--focus-ring: 129 140 248;
--focus-ring-offset: 15 23 42;
/* Shadows - subtle glow in dark mode */
/* Legacy shadow tokens (retained for component compat) */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
}
/* -----------------------------------------------------------------------------
Light Theme Override — applied via data-theme attribute on <html>
----------------------------------------------------------------------------- */
[data-theme="light"] {
--ms-bg-950: #f8faff;
--ms-bg-900: #f0f4fc;
--ms-bg-850: #e8edf8;
--ms-surface-800: #dde4f2;
--ms-surface-750: #d0d9ec;
--ms-border-700: #b8c4de;
--ms-text-100: #0f141d;
--ms-text-300: #2f3b52;
--ms-text-500: #5a6a87;
/* Re-alias semantics for light — identical structure, primitive tokens differ */
--bg: var(--ms-bg-900);
--bg-deep: var(--ms-bg-950);
--bg-mid: var(--ms-bg-850);
--surface: var(--ms-surface-800);
--surface-2: var(--ms-surface-750);
--border: var(--ms-border-700);
--text: var(--ms-text-100);
--text-2: var(--ms-text-300);
--muted: var(--ms-text-500);
/* Lighter shadows for light mode */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
}
/* -----------------------------------------------------------------------------
Base Styles
----------------------------------------------------------------------------- */
* {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 15px;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
color: rgb(var(--text-primary));
background: rgb(var(--color-background));
font-size: 14px;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.5;
transition: background-color 0.15s ease, color 0.15s ease;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input,
select,
textarea {
font-family: inherit;
}
ul {
list-style: none;
}
/* Subtle grain/noise overlay for texture */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.025;
}
/* -----------------------------------------------------------------------------
Focus States - Accessible & Visible
----------------------------------------------------------------------------- */
@layer base {
:focus-visible {
outline: 2px solid var(--ms-blue-400);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
}
/* -----------------------------------------------------------------------------
Scrollbar Styling - Minimal & Professional
----------------------------------------------------------------------------- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
/* -----------------------------------------------------------------------------
App Shell Grid Layout
----------------------------------------------------------------------------- */
.app-shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
height: 100vh;
overflow: hidden;
}
.app-header {
grid-column: 1 / -1;
grid-row: 1;
background: var(--bg-deep);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
z-index: 100;
}
.app-sidebar {
grid-column: 1;
grid-row: 2;
background: var(--bg-deep);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-main {
grid-column: 2;
grid-row: 2;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* -----------------------------------------------------------------------------
Responsive App Shell — Mobile (< 768px): single-column, sidebar as overlay
----------------------------------------------------------------------------- */
@media (max-width: 767px) {
.app-shell {
grid-template-columns: 1fr;
}
.app-sidebar {
position: fixed;
left: 0;
top: var(--topbar-h);
bottom: 0;
width: 240px;
z-index: 150;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.app-sidebar[data-mobile-open="true"] {
transform: translateX(0);
}
.app-main {
grid-column: 1;
}
.app-header {
grid-column: 1;
}
}
/* -----------------------------------------------------------------------------
Responsive App Shell — Tablet (768px1023px): sidebar toggleable, pushes content
----------------------------------------------------------------------------- */
@media (min-width: 768px) and (max-width: 1023px) {
.app-shell[data-sidebar-hidden="true"] {
grid-template-columns: 1fr;
}
.app-shell[data-sidebar-hidden="true"] .app-sidebar {
display: none;
}
.app-shell[data-sidebar-hidden="true"] .app-main {
grid-column: 1;
}
.app-shell[data-sidebar-hidden="true"] .app-header {
grid-column: 1;
}
}
/* -----------------------------------------------------------------------------
@@ -182,102 +338,10 @@ body {
}
.text-mono {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-family: var(--mono);
font-size: 0.8125rem;
line-height: 1.25rem;
}
/* Text color utilities */
.text-primary {
color: rgb(var(--text-primary));
}
.text-secondary {
color: rgb(var(--text-secondary));
}
.text-tertiary {
color: rgb(var(--text-tertiary));
}
.text-muted {
color: rgb(var(--text-muted));
}
}
/* -----------------------------------------------------------------------------
Surface & Card Utilities
----------------------------------------------------------------------------- */
@layer utilities {
.surface-0 {
background-color: rgb(var(--surface-0));
}
.surface-1 {
background-color: rgb(var(--surface-1));
}
.surface-2 {
background-color: rgb(var(--surface-2));
}
.surface-3 {
background-color: rgb(var(--surface-3));
}
.border-default {
border-color: rgb(var(--border-default));
}
.border-subtle {
border-color: rgb(var(--border-subtle));
}
.border-strong {
border-color: rgb(var(--border-strong));
}
}
/* -----------------------------------------------------------------------------
Focus States - Accessible & Visible
----------------------------------------------------------------------------- */
@layer base {
:focus-visible {
outline: 2px solid rgb(var(--focus-ring));
outline-offset: 2px;
}
/* Remove default focus for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
}
/* -----------------------------------------------------------------------------
Scrollbar Styling - Minimal & Professional
----------------------------------------------------------------------------- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--text-muted) / 0.4);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--text-muted) / 0.6);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--text-muted) / 0.4) transparent;
}
/* -----------------------------------------------------------------------------
@@ -292,40 +356,46 @@ body {
.btn-primary {
@apply btn px-4 py-2;
background-color: rgb(var(--accent-primary));
background: linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500));
color: white;
border-radius: var(--r);
}
.btn-primary:hover:not(:disabled) {
background-color: rgb(var(--accent-primary-hover));
box-shadow: 0 8px 28px rgba(47, 128, 255, 0.38);
transform: translateY(-1px);
}
.btn-secondary {
@apply btn px-4 py-2;
background-color: rgb(var(--surface-2));
color: rgb(var(--text-primary));
border: 1px solid rgb(var(--border-default));
background-color: var(--surface);
color: var(--text-2);
border: 1px solid var(--border);
border-radius: var(--r);
}
.btn-secondary:hover:not(:disabled) {
background-color: rgb(var(--surface-3));
background-color: var(--surface-2);
color: var(--text);
}
.btn-ghost {
@apply btn px-3 py-2;
background-color: transparent;
color: rgb(var(--text-secondary));
color: var(--muted);
border-radius: var(--r);
}
.btn-ghost:hover:not(:disabled) {
background-color: rgb(var(--surface-2));
color: rgb(var(--text-primary));
background-color: var(--surface);
color: var(--text);
}
.btn-danger {
@apply btn px-4 py-2;
background-color: rgb(var(--semantic-error));
background-color: var(--danger);
color: white;
border-radius: var(--r);
}
.btn-danger:hover:not(:disabled) {
@@ -346,34 +416,36 @@ body {
----------------------------------------------------------------------------- */
@layer components {
.input {
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
background-color: rgb(var(--surface-0));
border: 1px solid rgb(var(--border-default));
color: rgb(var(--text-primary));
@apply w-full text-sm transition-all duration-150;
@apply focus:outline-none;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text);
padding: 11px 14px;
}
.input::placeholder {
color: rgb(var(--text-muted));
color: var(--muted);
}
.input:focus {
border-color: rgb(var(--accent-primary));
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(47, 128, 255, 0.12);
}
.input:disabled {
@apply opacity-50 cursor-not-allowed;
background-color: rgb(var(--surface-1));
background-color: var(--surface);
}
.input-error {
border-color: rgb(var(--semantic-error));
border-color: var(--danger);
}
.input-error:focus {
border-color: rgb(var(--semantic-error));
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.12);
}
}
@@ -383,8 +455,8 @@ body {
@layer components {
.card {
@apply rounded-lg p-4;
background-color: rgb(var(--surface-0));
border: 1px solid rgb(var(--border-default));
background-color: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
@@ -398,7 +470,7 @@ body {
}
.card-interactive:hover {
border-color: rgb(var(--border-strong));
border-color: var(--muted);
box-shadow: var(--shadow-md);
}
}
@@ -412,33 +484,33 @@ body {
}
.badge-success {
background-color: rgb(var(--semantic-success-light));
color: rgb(var(--semantic-success-dark));
background-color: rgba(20, 184, 166, 0.15);
color: var(--ms-teal-400);
}
.badge-warning {
background-color: rgb(var(--semantic-warning-light));
color: rgb(var(--semantic-warning-dark));
background-color: rgba(245, 158, 11, 0.15);
color: var(--ms-amber-400);
}
.badge-error {
background-color: rgb(var(--semantic-error-light));
color: rgb(var(--semantic-error-dark));
background-color: rgba(229, 72, 77, 0.15);
color: var(--ms-red-400);
}
.badge-info {
background-color: rgb(var(--semantic-info-light));
color: rgb(var(--semantic-info-dark));
background-color: rgba(47, 128, 255, 0.15);
color: var(--ms-blue-400);
}
.badge-neutral {
background-color: rgb(var(--surface-2));
color: rgb(var(--text-secondary));
background-color: var(--surface-2);
color: var(--text-2);
}
.badge-primary {
background-color: rgb(var(--accent-primary-light));
color: rgb(var(--accent-primary));
background-color: rgba(47, 128, 255, 0.15);
color: var(--primary-l);
}
}
@@ -451,26 +523,29 @@ body {
}
.status-dot-success {
background-color: rgb(var(--semantic-success));
background-color: var(--success);
box-shadow: 0 0 5px var(--success);
}
.status-dot-warning {
background-color: rgb(var(--semantic-warning));
background-color: var(--warn);
box-shadow: 0 0 5px var(--warn);
}
.status-dot-error {
background-color: rgb(var(--semantic-error));
background-color: var(--danger);
box-shadow: 0 0 5px var(--danger);
}
.status-dot-info {
background-color: rgb(var(--semantic-info));
background-color: var(--primary);
box-shadow: 0 0 5px var(--primary);
}
.status-dot-neutral {
background-color: rgb(var(--text-muted));
background-color: var(--muted);
}
/* Pulsing indicator for live/active status */
.status-dot-pulse {
@apply relative;
}
@@ -489,12 +564,12 @@ body {
@layer components {
.kbd {
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
background-color: rgb(var(--surface-2));
border: 1px solid rgb(var(--border-default));
color: rgb(var(--text-tertiary));
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
background-color: var(--surface-2);
border: 1px solid var(--border);
color: var(--muted);
font-family: var(--mono);
min-width: 1.5rem;
box-shadow: 0 1px 0 rgb(var(--border-strong));
box-shadow: 0 1px 0 var(--border);
}
.kbd-group {
@@ -512,13 +587,13 @@ body {
.table-pro thead {
@apply sticky top-0;
background-color: rgb(var(--surface-1));
border-bottom: 1px solid rgb(var(--border-default));
background-color: var(--surface);
border-bottom: 1px solid var(--border);
}
.table-pro th {
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
color: rgb(var(--text-tertiary));
color: var(--muted);
}
.table-pro th.sortable {
@@ -526,16 +601,16 @@ body {
}
.table-pro th.sortable:hover {
color: rgb(var(--text-primary));
color: var(--text);
}
.table-pro tbody tr {
border-bottom: 1px solid rgb(var(--border-subtle));
border-bottom: 1px solid var(--border);
transition: background-color 0.1s ease;
}
.table-pro tbody tr:hover {
background-color: rgb(var(--surface-1));
background-color: var(--surface);
}
.table-pro td {
@@ -555,9 +630,9 @@ body {
@apply animate-pulse rounded;
background: linear-gradient(
90deg,
rgb(var(--surface-2)) 0%,
rgb(var(--surface-1)) 50%,
rgb(var(--surface-2)) 100%
var(--surface) 0%,
var(--surface-2) 50%,
var(--surface) 100%
);
background-size: 200% 100%;
}
@@ -590,15 +665,16 @@ body {
}
.modal-content {
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
background-color: rgb(var(--surface-0));
border: 1px solid rgb(var(--border-default));
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
}
.modal-header {
@apply flex items-center justify-between p-4 border-b;
border-color: rgb(var(--border-default));
border-color: var(--border);
}
.modal-body {
@@ -607,7 +683,7 @@ body {
.modal-footer {
@apply flex items-center justify-end gap-3 p-4 border-t;
border-color: rgb(var(--border-default));
border-color: var(--border);
}
}
@@ -617,9 +693,10 @@ body {
@layer components {
.tooltip {
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
background-color: rgb(var(--text-primary));
color: rgb(var(--color-background));
background-color: var(--text);
color: var(--bg);
box-shadow: var(--shadow-md);
border-radius: var(--r-sm);
}
.tooltip::before {
@@ -630,7 +707,7 @@ body {
.tooltip-top::before {
@apply left-1/2 top-full -translate-x-1/2;
border-top-color: rgb(var(--text-primary));
border-top-color: var(--text);
}
}
@@ -680,12 +757,10 @@ body {
animation: scaleIn 0.15s ease-out;
}
/* Message animation - subtle for chat */
.message-animate {
animation: slideIn 0.2s ease-out;
}
/* Menu dropdown animation */
.animate-menu-enter {
animation: scaleIn 0.1s ease-out;
}
@@ -710,13 +785,8 @@ body {
----------------------------------------------------------------------------- */
@media (prefers-contrast: high) {
:root {
--border-default: 100 116 139;
--border-strong: 71 85 105;
}
.dark {
--border-default: 148 163 184;
--border-strong: 203 213 225;
--border: #4a5a78;
--muted: #a0b0cc;
}
}

View File

@@ -1,18 +1,56 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Outfit, Fira_Code } from "next/font/google";
import { AuthProvider } from "@/lib/auth/auth-context";
import { ErrorBoundary } from "@/components/error-boundary";
import { ThemeProvider } from "@/providers/ThemeProvider";
import "./globals.css";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Mosaic Stack",
description: "Mosaic Stack Web Application",
};
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
display: "swap",
});
const firaCode = Fira_Code({
subsets: ["latin"],
variable: "--font-fira-code",
display: "swap",
});
/**
* Runtime env vars injected as a synchronous script so client-side modules
* can read them before React hydration. This allows Docker env vars to
* override the build-time baked NEXT_PUBLIC_* values.
*/
function runtimeEnvScript(): string {
const env: Record<string, string> = {};
for (const key of [
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_ORCHESTRATOR_URL",
"NEXT_PUBLIC_AUTH_MODE",
]) {
const value = process.env[key];
if (value) {
env[key] = value;
}
}
return `window.__MOSAIC_ENV__=${JSON.stringify(env)};`;
}
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
return (
<html lang="en">
<html lang="en" className={`${outfit.variable} ${firaCode.variable}`}>
<head>
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
</head>
<body>
<ThemeProvider>
<ErrorBoundary>

View File

@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
describe("AuthDivider", (): void => {
it("should render with default text", (): void => {
render(<AuthDivider />);
expect(screen.getByText("or continue with email")).toBeInTheDocument();
expect(screen.getByText("or continue with")).toBeInTheDocument();
});
it("should render with custom text", (): void => {
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
expect(screen.getByText("or sign up")).toBeInTheDocument();
});
it("should render a horizontal divider line", (): void => {
it("should render horizontal divider lines", (): void => {
const { container } = render(<AuthDivider />);
const line = container.querySelector("span.border-t");
expect(line).toBeInTheDocument();
const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
expect(lines.length).toBe(2);
});
it("should apply uppercase styling to text", (): void => {

View File

@@ -1,18 +1,2 @@
interface AuthDividerProps {
text?: string;
}
export function AuthDivider({
text = "or continue with email",
}: AuthDividerProps): React.ReactElement {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500">{text}</span>
</div>
</div>
);
}
export { AuthDivider } from "@mosaic/ui";
export type { AuthDividerProps } from "@mosaic/ui";

View File

@@ -18,17 +18,10 @@ describe("AuthErrorBanner", (): void => {
expect(alert).toHaveAttribute("aria-live", "polite");
});
it("should render the info icon, not a warning icon", (): void => {
it("should render an icon", (): void => {
const { container } = render(<AuthErrorBanner message="Test message" />);
// Info icon from lucide-react renders as an SVG
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThanOrEqual(1);
// The container should use blue styling, not red/yellow
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("text-blue-700");
expect(alert.className).not.toContain("red");
expect(alert.className).not.toContain("yellow");
});
it("should render dismiss button when onDismiss is provided", (): void => {
@@ -54,14 +47,6 @@ describe("AuthErrorBanner", (): void => {
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("should use blue info styling, not red or alarming colors", (): void => {
render(<AuthErrorBanner message="Test" />);
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("border-blue-200");
expect(alert.className).toContain("text-blue-700");
});
it("should render all PDA-friendly error messages", (): void => {
const messages = [
"Authentication paused. Please try again when ready.",

View File

@@ -13,7 +13,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<div
role="alert"
aria-live="polite"
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
className="flex items-start gap-3 rounded-lg border border-[#f06a6f]/55 bg-[#fff1f2] p-4 text-[#9f1239] dark:border-[#e5484d]/55 dark:bg-[#3a111b]/70 dark:text-[#fecdd3]"
>
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="flex-1 text-sm">{message}</span>
@@ -21,7 +21,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<button
type="button"
onClick={onDismiss}
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
className="flex-shrink-0 text-[#be123c] transition-colors hover:text-[#881337] dark:text-[#fda4af] dark:hover:text-[#ffe4e6]"
aria-label="Dismiss"
>
<X className="h-4 w-4" aria-hidden="true" />

View File

@@ -9,12 +9,14 @@ export interface LoginFormProps {
onSubmit: (email: string, password: string) => void | Promise<void>;
isLoading?: boolean;
error?: string | null;
disabled?: boolean;
}
export function LoginForm({
onSubmit,
isLoading = false,
error = null,
disabled = false,
}: LoginFormProps): ReactElement {
const emailRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState("");
@@ -77,7 +79,10 @@ export function LoginForm({
)}
<div>
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="login-email"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Email
</label>
<input
@@ -91,13 +96,17 @@ export function LoginForm({
validateEmail(e.target.value);
}
}}
disabled={isLoading}
disabled={isLoading || disabled}
autoComplete="email"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
emailError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
emailError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
@@ -105,14 +114,21 @@ export function LoginForm({
aria-describedby={emailError ? "login-email-error" : undefined}
/>
{emailError && (
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
<p
id="login-email-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{emailError}
</p>
)}
</div>
<div>
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="login-password"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Password
</label>
<input
@@ -125,13 +141,17 @@ export function LoginForm({
validatePassword(e.target.value);
}
}}
disabled={isLoading}
disabled={isLoading || disabled}
autoComplete="current-password"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
passwordError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
passwordError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
@@ -139,7 +159,11 @@ export function LoginForm({
aria-describedby={passwordError ? "login-password-error" : undefined}
/>
{passwordError && (
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
<p
id="login-password-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{passwordError}
</p>
)}
@@ -147,13 +171,13 @@ export function LoginForm({
<button
type="submit"
disabled={isLoading}
disabled={isLoading || disabled}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
isLoading ? "opacity-50 pointer-events-none" : "",
"w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
"bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
isLoading || disabled ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
.join(" ")}

View File

@@ -13,10 +13,12 @@ export interface OAuthButtonProps {
export function OAuthButton({
providerName,
providerId,
onClick,
isLoading = false,
disabled = false,
}: OAuthButtonProps): ReactElement {
const accentColor = resolveProviderAccent(providerId);
const isDisabled = disabled || isLoading;
return (
@@ -27,10 +29,12 @@ export function OAuthButton({
disabled={isDisabled}
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
"w-full inline-flex items-center justify-center gap-2 rounded-lg",
"border border-[#b8c4de] bg-[#f8faff]/90 px-4 py-3 text-sm font-semibold text-[#2f3b52]",
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"hover:border-[#2f80ff] hover:bg-[#dde4f2] hover:text-[#0f141d]",
"dark:border-[#2f3b52] dark:bg-[#0f141d]/75 dark:text-[#c5d0e6]",
"dark:hover:border-[#2f80ff] dark:hover:bg-[#232d3f] dark:hover:text-[#eef3ff]",
isDisabled ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
@@ -42,8 +46,33 @@ export function OAuthButton({
<span>Connecting...</span>
</>
) : (
<span>Continue with {providerName}</span>
<>
<span
aria-hidden="true"
className="h-2 w-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span>Continue with {providerName}</span>
</>
)}
</button>
);
}
function resolveProviderAccent(providerId: string): string {
const normalized = providerId.toLowerCase();
if (normalized.includes("github")) {
return "#8b5cf6";
}
if (normalized.includes("google")) {
return "#e5484d";
}
if (normalized.includes("ldap")) {
return "#14b8a6";
}
return "#2f80ff";
}

View File

@@ -0,0 +1,169 @@
import type { ReactElement } from "react";
import { Card, SectionHeader, Badge } from "@mosaic/ui";
type BadgeVariantType =
| "badge-amber"
| "badge-red"
| "badge-teal"
| "badge-blue"
| "badge-muted"
| "badge-purple"
| "badge-pulse";
interface ActivityItem {
id: string;
icon: string;
iconBg: string;
title: string;
highlight: string;
rest: string;
timestamp: string;
badge?: {
text: string;
variant: BadgeVariantType;
};
}
const activityItems: ActivityItem[] = [
{
id: "act-1",
icon: "✓",
iconBg: "rgba(20,184,166,0.15)",
title: "",
highlight: "planner-agent",
rest: " completed task analysis for infra-refactor",
timestamp: "2m ago",
},
{
id: "act-2",
icon: "⚠",
iconBg: "rgba(245,158,11,0.15)",
title: "",
highlight: "executor-agent",
rest: " hit rate limit on Terraform API",
timestamp: "5m ago",
badge: { text: "warn", variant: "badge-amber" },
},
{
id: "act-3",
icon: "↑",
iconBg: "rgba(47,128,255,0.15)",
title: "",
highlight: "ORCH-002",
rest: " session started for api-v3-migration",
timestamp: "12m ago",
},
{
id: "act-4",
icon: "✗",
iconBg: "rgba(229,72,77,0.15)",
title: "",
highlight: "migrator-agent",
rest: " failed to connect to staging database",
timestamp: "18m ago",
badge: { text: "error", variant: "badge-red" },
},
{
id: "act-5",
icon: "✓",
iconBg: "rgba(20,184,166,0.15)",
title: "",
highlight: "reviewer-agent",
rest: " approved PR #214 in infra-refactor",
timestamp: "34m ago",
},
{
id: "act-6",
icon: "⟳",
iconBg: "rgba(139,92,246,0.15)",
title: "Token budget reset for ",
highlight: "gpt-4o",
rest: " model",
timestamp: "1h ago",
},
{
id: "act-7",
icon: "★",
iconBg: "rgba(20,184,166,0.15)",
title: "Project ",
highlight: "data-pipeline",
rest: " marked as completed",
timestamp: "2h ago",
},
];
interface ActivityItemRowProps {
item: ActivityItem;
}
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "8px 0",
borderBottom: "1px solid var(--border)",
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 6,
flexShrink: 0,
background: item.iconBg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8rem",
color: "var(--text)",
}}
>
{item.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-2)",
lineHeight: 1.4,
}}
>
{item.title}
<strong style={{ color: "var(--text)" }}>{item.highlight}</strong>
{item.rest}
{item.badge !== undefined && (
<Badge variant={item.badge.variant} style={{ marginLeft: 6 }}>
{item.badge.text}
</Badge>
)}
</div>
<div
style={{
fontSize: "0.7rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
marginTop: 2,
}}
>
{item.timestamp}
</div>
</div>
</div>
);
}
export function ActivityFeed(): ReactElement {
return (
<Card>
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
<div>
{activityItems.map((item) => (
<ActivityItemRow key={item.id} item={item} />
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import type { ReactElement } from "react";
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
const cells: MetricCell[] = [
{
label: "Active Agents",
value: "47",
color: "var(--ms-blue-400)",
trend: { direction: "up", text: "↑ +3 from yesterday" },
},
{
label: "Tasks Completed",
value: "1,284",
color: "var(--ms-teal-400)",
trend: { direction: "up", text: "↑ +128 today" },
},
{
label: "Avg Response Time",
value: "2.4s",
color: "var(--ms-purple-400)",
trend: { direction: "down", text: "↓ -0.3s improved" },
},
{
label: "Token Usage",
value: "3.2M",
color: "var(--ms-amber-400)",
trend: { direction: "neutral", text: "78% of budget" },
},
{
label: "Error Rate",
value: "0.4%",
color: "var(--ms-red-400)",
trend: { direction: "down", text: "↓ -0.1% improved" },
},
{
label: "Active Projects",
value: "8",
color: "var(--ms-cyan-500)",
trend: { direction: "neutral", text: "2 deploying" },
},
];
export function DashboardMetrics(): ReactElement {
return <MetricsStrip cells={cells} />;
}

View File

@@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import type { ReactElement } from "react";
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
interface AgentNode {
id: string;
initials: string;
avatarColor: string;
name: string;
task: string;
status: "teal" | "blue" | "amber" | "red" | "muted";
}
interface OrchestratorSession {
id: string;
orchId: string;
name: string;
badge: string;
badgeVariant:
| "badge-teal"
| "badge-amber"
| "badge-red"
| "badge-blue"
| "badge-muted"
| "badge-purple"
| "badge-pulse";
duration: string;
agents: AgentNode[];
}
const sessions: OrchestratorSession[] = [
{
id: "s1",
orchId: "ORCH-001",
name: "infra-refactor",
badge: "running",
badgeVariant: "badge-teal",
duration: "2h 14m",
agents: [
{
id: "a1",
initials: "PL",
avatarColor: "rgba(47,128,255,0.15)",
name: "planner-agent",
task: "Analyzing network topology",
status: "blue",
},
{
id: "a2",
initials: "EX",
avatarColor: "rgba(20,184,166,0.15)",
name: "executor-agent",
task: "Applying Terraform modules",
status: "teal",
},
{
id: "a3",
initials: "QA",
avatarColor: "rgba(245,158,11,0.15)",
name: "reviewer-agent",
task: "Waiting for executor output",
status: "amber",
},
],
},
{
id: "s2",
orchId: "ORCH-002",
name: "api-v3-migration",
badge: "running",
badgeVariant: "badge-teal",
duration: "45m",
agents: [
{
id: "a4",
initials: "MG",
avatarColor: "rgba(139,92,246,0.15)",
name: "migrator-agent",
task: "Rewriting endpoint handlers",
status: "blue",
},
],
},
];
interface AgentNodeItemProps {
agent: AgentNode;
}
function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={(): void => {
setHovered(true);
}}
onMouseLeave={(): void => {
setHovered(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "7px 10px",
borderRadius: "var(--r-sm)",
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
background: hovered ? "var(--surface)" : "var(--bg-mid)",
transition: "border-color 0.15s, background 0.15s",
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: 6,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontFamily: "var(--mono)",
fontSize: "0.7rem",
background: agent.avatarColor,
color: "var(--text)",
}}
>
{agent.initials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{agent.name}
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--muted)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{agent.task}
</div>
</div>
<Dot variant={agent.status} />
</div>
);
}
interface OrchCardProps {
session: OrchestratorSession;
}
function OrchCard({ session }: OrchCardProps): ReactElement {
return (
<div
style={{
background: "var(--bg-mid)",
border: "1px solid var(--border)",
borderRadius: "var(--r-md)",
padding: "12px 14px",
marginBottom: 10,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
}}
>
<Dot variant="teal" />
<span
style={{
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: "var(--ms-purple-400)",
fontWeight: 700,
}}
>
{session.orchId}
</span>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{session.name}
</span>
<Badge variant={session.badgeVariant}>{session.badge}</Badge>
<span
style={{
marginLeft: "auto",
fontSize: "0.72rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
}}
>
{session.duration}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{session.agents.map((agent) => (
<AgentNodeItem key={agent.id} agent={agent} />
))}
</div>
</div>
);
}
export function OrchestratorSessions(): ReactElement {
return (
<Card>
<SectionHeader
title="Active Orchestrator Sessions"
subtitle="3 of 8 projects running"
actions={<Badge variant="badge-teal">3 active</Badge>}
/>
<div>
{sessions.map((session) => (
<OrchCard key={session.id} session={session} />
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import type { ReactElement } from "react";
import { Card, SectionHeader } from "@mosaic/ui";
interface QuickAction {
id: string;
label: string;
icon: string;
iconBg: string;
}
const actions: QuickAction[] = [
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
];
interface ActionButtonProps {
action: QuickAction;
}
function ActionButton({ action }: ActionButtonProps): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
type="button"
onMouseEnter={(): void => {
setHovered(true);
}}
onMouseLeave={(): void => {
setHovered(false);
}}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "16px 12px",
borderRadius: "var(--r-md)",
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
background: hovered ? "var(--surface)" : "var(--bg-mid)",
cursor: "pointer",
transition: "border-color 0.15s, background 0.15s",
width: "100%",
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
background: action.iconBg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.875rem",
}}
>
{action.icon}
</div>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{action.label}
</span>
</button>
);
}
export function QuickActions(): ReactElement {
return (
<Card>
<SectionHeader title="Quick Actions" />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
}}
>
{actions.map((action) => (
<ActionButton key={action.id} action={action} />
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,98 @@
import type { ReactElement } from "react";
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
interface ModelBudget {
id: string;
label: string;
usage: string;
value: number;
variant: ProgressBarVariant;
}
const models: ModelBudget[] = [
{
id: "sonnet",
label: "claude-3-5-sonnet",
usage: "2.1M / 3M",
value: 70,
variant: "blue",
},
{
id: "haiku",
label: "claude-3-haiku",
usage: "890K / 5M",
value: 18,
variant: "teal",
},
{
id: "gpt4o",
label: "gpt-4o",
usage: "320K / 1M",
value: 32,
variant: "purple",
},
{
id: "llama",
label: "local/llama-3.3",
usage: "unlimited",
value: 55,
variant: "amber",
},
];
interface ModelRowProps {
model: ModelBudget;
}
function ModelRow({ model }: ModelRowProps): ReactElement {
return (
<div style={{ marginBottom: 14 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 5,
}}
>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text-2)",
fontFamily: "var(--mono)",
}}
>
{model.label}
</span>
<span
style={{
fontSize: "0.72rem",
color: "var(--muted)",
fontFamily: "var(--mono)",
}}
>
{model.usage}
</span>
</div>
<ProgressBar
value={model.value}
variant={model.variant}
label={`${model.label} token usage`}
/>
</div>
);
}
export function TokenBudget(): ReactElement {
return (
<Card>
<SectionHeader title="Token Budget" subtitle="Usage by model" />
<div>
{models.map((model) => (
<ModelRow key={model.id} model={model} />
))}
</div>
</Card>
);
}

View File

@@ -46,8 +46,8 @@ describe("FilterBar", (): void => {
it("should debounce search input", async (): Promise<void> => {
const user = userEvent.setup();
// Use a very short debounce to test the behavior without flaky timing
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={100} />);
// Use a debounce long enough that CI environments don't fire it between keystrokes
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={500} />);
const searchInput = screen.getByPlaceholderText(/search/i);
mockOnFilterChange.mockClear();
@@ -71,7 +71,7 @@ describe("FilterBar", (): void => {
expect.objectContaining({ search: "test" })
);
},
{ timeout: 200 }
{ timeout: 1000 }
);
// Verify it was only called once (debounced)

View File

@@ -56,6 +56,15 @@ export function LinkAutocomplete({
const mirrorRef = useRef<HTMLDivElement | null>(null);
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
// Refs for event handler to avoid stale closures when effects re-attach listeners
const stateRef = useRef(state);
const resultsRef = useRef(results);
const selectedIndexRef = useRef(selectedIndex);
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
stateRef.current = state;
resultsRef.current = results;
selectedIndexRef.current = selectedIndex;
/**
* Search for knowledge entries matching the query.
* Accepts an AbortSignal to allow cancellation of in-flight requests,
@@ -254,47 +263,48 @@ export function LinkAutocomplete({
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
/**
* Handle keyboard navigation in the dropdown
* Handle keyboard navigation in the dropdown.
* Reads from refs to avoid stale closures when the effect
* that attaches this listener hasn't re-run yet.
*/
const handleKeyDown = useCallback(
(e: KeyboardEvent): void => {
if (!state.isOpen) return;
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
if (!stateRef.current.isOpen) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
break;
const currentResults = resultsRef.current;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
break;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
break;
case "Enter":
e.preventDefault();
if (results.length > 0 && selectedIndex >= 0) {
const selected = results[selectedIndex];
if (selected) {
insertLink(selected);
}
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
break;
case "Enter":
e.preventDefault();
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
const selected = currentResults[selectedIndexRef.current];
if (selected) {
insertLinkRef.current?.(selected);
}
break;
}
break;
case "Escape":
e.preventDefault();
setState({
isOpen: false,
query: "",
position: { top: 0, left: 0 },
triggerIndex: -1,
});
setResults([]);
break;
}
},
[state.isOpen, results, selectedIndex]
);
case "Escape":
e.preventDefault();
setState({
isOpen: false,
query: "",
position: { top: 0, left: 0 },
triggerIndex: -1,
});
setResults([]);
break;
}
}, []);
/**
* Insert the selected link into the textarea
@@ -330,6 +340,7 @@ export function LinkAutocomplete({
},
[textareaRef, state.triggerIndex, onInsert]
);
insertLinkRef.current = insertLink;
/**
* Handle click on a result

View File

@@ -466,7 +466,9 @@ describe("LinkAutocomplete", (): void => {
expect(firstItem).toHaveClass("bg-blue-50");
// Press ArrowDown
fireEvent.keyDown(textarea, { key: "ArrowDown" });
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowDown" });
});
// Second item should now be selected
await waitFor(() => {
@@ -475,7 +477,9 @@ describe("LinkAutocomplete", (): void => {
});
// Press ArrowUp
fireEvent.keyDown(textarea, { key: "ArrowUp" });
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowUp" });
});
// First item should be selected again
await waitFor(() => {

View File

@@ -0,0 +1,647 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context";
import { ThemeToggle } from "./ThemeToggle";
import { useSidebar } from "./SidebarContext";
/**
* Full-width application header (topbar).
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
* Spans grid-column 1 / -1 in the app shell grid layout.
*
* Layout (left → right):
* [Logo/Brand] [Breadcrumb] [Search] [spacer]
* [System Status] [Terminal Toggle] [Notifications] [Theme Toggle] [User Avatar+Dropdown]
*/
export function AppHeader(): React.JSX.Element {
const { user, signOut } = useAuth();
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
const pathname = usePathname();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
const handleOutsideClick = useCallback((event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}, []);
useEffect(() => {
if (dropdownOpen) {
document.addEventListener("mousedown", handleOutsideClick);
} else {
document.removeEventListener("mousedown", handleOutsideClick);
}
return (): void => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [dropdownOpen, handleOutsideClick]);
// Derive breadcrumb segments from pathname
const breadcrumbSegments = pathname
.split("/")
.filter(Boolean)
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " "));
// User initials for avatar fallback
const initials = user?.name
? user.name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase()
: user?.email
? (user.email[0] ?? "?").toUpperCase()
: "?";
const handleHamburgerClick = useCallback((): void => {
if (isMobile) {
setMobileOpen(!mobileOpen);
} else {
toggleCollapsed();
}
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
return (
<header className="app-header">
{/* ── Hamburger — visible below lg ── */}
<button
type="button"
className="lg:hidden"
onClick={handleHamburgerClick}
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
style={{
width: 34,
height: 34,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
background: "none",
border: "none",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 4h12M2 8h12M2 12h12" />
</svg>
</button>
{/* ── Brand / Logo ── */}
<Link
href="/"
className="flex items-center gap-2 flex-shrink-0"
aria-label="Mosaic Stack home"
>
{/* Mosaic logo mark: four colored squares + center dot */}
<div
className="relative flex-shrink-0"
style={{ width: 28, height: 28 }}
aria-hidden="true"
>
<span
className="absolute rounded-sm"
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
/>
<span
className="absolute rounded-sm"
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
/>
<span
className="absolute rounded-sm"
style={{
bottom: 0,
right: 0,
width: 11,
height: 11,
background: "var(--ms-teal-500)",
}}
/>
<span
className="absolute rounded-sm"
style={{
bottom: 0,
left: 0,
width: 11,
height: 11,
background: "var(--ms-amber-500)",
}}
/>
<span
className="absolute rounded-full"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 8,
height: 8,
background: "var(--ms-pink-500)",
}}
/>
</div>
<span
className="text-sm font-bold"
style={{
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "-0.02em",
}}
>
Mosaic Stack
</span>
</Link>
{/* ── Breadcrumb ── */}
<nav
aria-label="Breadcrumb"
className="hidden sm:flex items-center"
style={{ fontSize: "0.8rem", color: "var(--text-2)", marginLeft: 4 }}
>
{breadcrumbSegments.length === 0 ? (
<span>Dashboard</span>
) : (
breadcrumbSegments.map((seg, idx) => (
<span key={idx} className="flex items-center gap-1">
{idx > 0 && <span style={{ color: "var(--muted)", margin: "0 2px" }}>/</span>}
<span
style={{
color: idx === breadcrumbSegments.length - 1 ? "var(--text-2)" : "var(--muted)",
}}
>
{seg}
</span>
</span>
))
)}
</nav>
{/* ── Search Bar ── */}
<div
className="hidden md:flex items-center"
style={{
flex: 1,
maxWidth: 340,
marginLeft: 16,
gap: 8,
background: "var(--surface)",
border: `1px solid ${searchFocused ? "var(--primary)" : "var(--border)"}`,
borderRadius: 6,
padding: "7px 12px",
transition: "border-color 0.15s",
}}
>
{/* Search icon */}
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ color: "var(--muted)", flexShrink: 0 }}
aria-hidden="true"
>
<circle cx="7" cy="7" r="5" />
<path d="M11 11l3 3" />
</svg>
<input
type="text"
placeholder="Search projects, agents, tasks… (⌘K)"
onFocus={() => {
setSearchFocused(true);
}}
onBlur={() => {
setSearchFocused(false);
}}
style={{
flex: 1,
background: "none",
border: "none",
outline: "none",
color: "var(--text)",
fontSize: "0.83rem",
fontFamily: "inherit",
}}
aria-label="Search projects, agents, tasks"
/>
</div>
{/* ── Spacer ── */}
<div style={{ flex: 1 }} />
{/* ── Right side controls ── */}
<div className="flex items-center" style={{ gap: 8 }}>
{/* System Status */}
<div
className="hidden lg:flex items-center"
style={{
gap: 7,
padding: "5px 10px",
borderRadius: 6,
background: "var(--surface)",
border: "1px solid var(--border)",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
}}
aria-label="System status: All Systems Operational"
>
<div
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "var(--success)",
boxShadow: "0 0 5px var(--success)",
flexShrink: 0,
}}
aria-hidden="true"
/>
<span style={{ color: "var(--muted)" }}>All Systems</span>
<span style={{ color: "var(--success)" }}>Operational</span>
</div>
{/* Terminal Toggle */}
<TerminalToggleButton />
{/* Notifications */}
<button
title="Notifications"
aria-label="Notifications (1 unread)"
style={{
width: 34,
height: 34,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
position: "relative",
background: "none",
border: "none",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M8 1a5 5 0 0 1 5 5v2l1 2H2l1-2V6a5 5 0 0 1 5-5z" />
<path d="M6 13a2 2 0 0 0 4 0" />
</svg>
{/* Notification badge */}
<div
aria-hidden="true"
style={{
position: "absolute",
top: 4,
right: 4,
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--danger)",
border: "2px solid var(--bg-deep)",
}}
/>
</button>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Avatar + Dropdown */}
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
<button
onClick={() => {
setDropdownOpen((prev) => !prev);
}}
aria-label="Open user menu"
aria-expanded={dropdownOpen}
aria-haspopup="menu"
style={{
width: 30,
height: 30,
borderRadius: "50%",
background: user?.image
? "none"
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
flexShrink: 0,
overflow: "hidden",
}}
>
{user?.image ? (
<img
src={user.image}
alt={user.name || user.email || "User avatar"}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
color: "#fff",
letterSpacing: "0.02em",
lineHeight: 1,
}}
>
{initials}
</span>
)}
</button>
{/* Dropdown Menu */}
{dropdownOpen && (
<div
role="menu"
aria-label="User menu"
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: 6,
minWidth: 200,
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
zIndex: 200,
}}
>
{/* User info header */}
<div
style={{
padding: "8px 12px",
borderRadius: 6,
marginBottom: 2,
}}
>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
color: "var(--text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user?.name ?? "User"}
</div>
{user?.email && (
<div
style={{
fontSize: "0.75rem",
color: "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
>
{user.email}
</div>
)}
</div>
{/* Divider */}
<div
aria-hidden="true"
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
/>
{/* Profile link */}
<DropdownItem
href="/profile"
onClick={() => {
setDropdownOpen(false);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
</svg>
Profile
</DropdownItem>
{/* Account Settings link */}
<DropdownItem
href="/settings"
onClick={() => {
setDropdownOpen(false);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
</svg>
Account Settings
</DropdownItem>
{/* Divider */}
<div
aria-hidden="true"
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
/>
{/* Sign Out */}
<button
role="menuitem"
onClick={() => {
setDropdownOpen(false);
void signOut();
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 6,
fontSize: "0.83rem",
cursor: "pointer",
background: "none",
border: "none",
color: "var(--danger)",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface-2)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
</svg>
Sign Out
</button>
</div>
)}
</div>
</div>
</header>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
/** Terminal toggle button — visual only; no panel wired yet. */
function TerminalToggleButton(): React.JSX.Element {
const [hovered, setHovered] = useState(false);
return (
<button
title="Toggle terminal"
aria-label="Toggle terminal panel"
className="hidden lg:flex items-center"
style={{
gap: 6,
padding: "5px 10px",
borderRadius: 6,
background: "var(--surface)",
border: `1px solid ${hovered ? "var(--success)" : "var(--border)"}`,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: hovered ? "var(--success)" : "var(--text-2)",
cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
flexShrink: 0,
}}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l3 3-3 3M9 12h3" />
</svg>
Terminal
</button>
);
}
interface DropdownItemProps {
href: string;
onClick: () => void;
children: React.ReactNode;
}
/** A navigation link styled as a dropdown menu item. */
function DropdownItem({ href, onClick, children }: DropdownItemProps): React.JSX.Element {
const [hovered, setHovered] = useState(false);
return (
<Link
href={href}
role="menuitem"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 6,
fontSize: "0.83rem",
cursor: "pointer",
color: "var(--text-2)",
textDecoration: "none",
background: hovered ? "var(--surface-2)" : "none",
transition: "background 0.1s",
}}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,731 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { useAuth } from "@/lib/auth/auth-context";
import { useSidebar } from "./SidebarContext";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NavBadge {
label: string;
pulse?: boolean;
}
interface NavItemConfig {
href: string;
label: string;
icon: React.JSX.Element;
badge?: NavBadge;
}
interface NavGroup {
label: string;
items: NavItemConfig[];
}
// ---------------------------------------------------------------------------
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
// ---------------------------------------------------------------------------
function IconDashboard(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
);
}
function IconProjects(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="4" width="14" height="10" rx="1.5" />
<path d="M1 7h14" />
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
</svg>
);
}
function IconProjectWorkspace(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="4" r="2" />
<circle cx="3" cy="12" r="2" />
<circle cx="13" cy="12" r="2" />
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
</svg>
);
}
function IconKanban(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="4" height="12" rx="1" />
<rect x="6" y="2" width="4" height="12" rx="1" />
<rect x="11" y="2" width="4" height="12" rx="1" />
</svg>
);
}
function IconFileManager(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
</svg>
);
}
function IconLogs(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 4h12M2 8h8M2 12h10" />
</svg>
);
}
function IconTerminal(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l3 3-3 3M9 12h3" />
</svg>
);
}
function IconSettings(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
</svg>
);
}
function IconChevronLeft(): React.JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="M10 3L5 8l5 5" />
</svg>
);
}
function IconChevronRight(): React.JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="M6 3l5 5-5 5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Nav groups definition
// ---------------------------------------------------------------------------
const NAV_GROUPS: NavGroup[] = [
{
label: "Overview",
items: [
{
href: "/",
label: "Dashboard",
icon: <IconDashboard />,
badge: { label: "live", pulse: true },
},
],
},
{
label: "Workspace",
items: [
{
href: "/projects",
label: "Projects",
icon: <IconProjects />,
},
{
href: "/workspace",
label: "Project Workspace",
icon: <IconProjectWorkspace />,
},
{
href: "/kanban",
label: "Kanban",
icon: <IconKanban />,
},
{
href: "/files",
label: "File Manager",
icon: <IconFileManager />,
},
],
},
{
label: "Operations",
items: [
{
href: "/logs",
label: "Logs & Telemetry",
icon: <IconLogs />,
badge: { label: "live", pulse: true },
},
{
href: "#terminal",
label: "Terminal",
icon: <IconTerminal />,
},
],
},
{
label: "System",
items: [
{
href: "/settings",
label: "Settings",
icon: <IconSettings />,
},
],
},
];
// ---------------------------------------------------------------------------
// Helper: derive initials from display name
// ---------------------------------------------------------------------------
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
const first = parts[0] ?? "";
if (parts.length === 1) {
return first.slice(0, 2).toUpperCase();
}
const last = parts[parts.length - 1] ?? "";
return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase();
}
// ---------------------------------------------------------------------------
// NavBadge component
// ---------------------------------------------------------------------------
interface NavBadgeProps {
badge: NavBadge;
}
function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element {
const pulseStyle: React.CSSProperties = badge.pulse
? {
background: "rgba(47,128,255,0.15)",
color: "var(--primary-l)",
}
: {
background: "var(--surface-2)",
color: "var(--muted)",
};
return (
<span
style={{
marginLeft: "auto",
fontSize: "0.68rem",
fontFamily: "var(--mono)",
padding: "1px 6px",
borderRadius: "10px",
display: "inline-flex",
alignItems: "center",
gap: "4px",
flexShrink: 0,
...pulseStyle,
}}
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
>
{badge.pulse && (
<span
style={{
display: "inline-block",
width: "5px",
height: "5px",
borderRadius: "50%",
background: "var(--primary-l)",
boxShadow: "0 0 4px var(--primary)",
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
}}
aria-hidden="true"
/>
)}
{badge.label}
</span>
);
}
// ---------------------------------------------------------------------------
// NavItem component
// ---------------------------------------------------------------------------
interface NavItemProps {
item: NavItemConfig;
isActive: boolean;
collapsed: boolean;
}
function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element {
const baseStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "11px",
padding: "9px 10px",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: 500,
color: isActive ? "var(--text)" : "var(--muted)",
background: isActive ? "var(--surface)" : "transparent",
position: "relative",
transition: "background 0.12s ease, color 0.12s ease",
textDecoration: "none",
justifyContent: collapsed ? "center" : undefined,
whiteSpace: "nowrap",
overflow: "hidden",
};
const iconStyle: React.CSSProperties = {
width: "16px",
height: "16px",
flexShrink: 0,
opacity: isActive ? 1 : 0.7,
transition: "opacity 0.12s ease",
};
const content = (
<>
{/* Active left accent bar */}
{isActive && (
<span
style={{
position: "absolute",
left: 0,
top: "6px",
bottom: "6px",
width: "3px",
background: "var(--primary)",
borderRadius: "0 2px 2px 0",
}}
aria-hidden="true"
/>
)}
{/* Icon */}
<span style={iconStyle}>{item.icon}</span>
{/* Label and badge — hidden when collapsed */}
{!collapsed && (
<>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
{item.label}
</span>
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
</>
)}
</>
);
const sharedProps = {
style: baseStyle,
"aria-current": isActive ? ("page" as const) : undefined,
title: collapsed ? item.label : undefined,
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
"[data-nav-icon]"
);
if (iconEl) iconEl.style.opacity = "1";
}
},
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "transparent";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
"[data-nav-icon]"
);
if (iconEl) iconEl.style.opacity = "0.7";
}
},
};
if (item.href.startsWith("#")) {
return (
<a href={item.href} {...sharedProps}>
{content}
</a>
);
}
return (
<Link href={item.href} {...sharedProps}>
{content}
</Link>
);
}
// ---------------------------------------------------------------------------
// UserCard component
// ---------------------------------------------------------------------------
interface UserCardProps {
collapsed: boolean;
}
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
const { user } = useAuth();
const displayName = user?.name ?? "User";
const initials = getInitials(displayName);
const role = user?.workspaceRole ?? "Member";
return (
<footer
style={{
padding: "12px 10px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "8px 10px",
borderRadius: "6px",
cursor: "pointer",
transition: "background 0.12s ease",
justifyContent: collapsed ? "center" : undefined,
}}
role="button"
tabIndex={0}
title={collapsed ? `${displayName}${role}` : undefined}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
onKeyDown={(e): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
}
}}
aria-label={`User: ${displayName}, Role: ${role}`}
>
{/* Avatar */}
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.75rem",
fontWeight: 700,
color: "#fff",
flexShrink: 0,
overflow: "hidden",
}}
>
{user?.image ? (
<Image
src={user.image}
alt={`${displayName} avatar`}
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
/>
) : (
<span aria-hidden="true">{initials}</span>
)}
</div>
{/* Name and role — hidden when collapsed */}
{!collapsed && (
<>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--text)",
}}
>
{displayName}
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{role}
</div>
</div>
{/* Online status dot */}
<div
style={{
marginLeft: "auto",
width: "7px",
height: "7px",
borderRadius: "50%",
background: "var(--success)",
boxShadow: "0 0 6px var(--success)",
flexShrink: 0,
}}
aria-label="Online"
role="img"
/>
</>
)}
</div>
</footer>
);
}
// ---------------------------------------------------------------------------
// CollapseToggle component
// ---------------------------------------------------------------------------
interface CollapseToggleProps {
collapsed: boolean;
onToggle: () => void;
}
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
return (
<div
style={{
padding: "4px 10px 8px",
display: "flex",
justifyContent: collapsed ? "center" : "flex-end",
}}
>
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "26px",
height: "26px",
borderRadius: "6px",
color: "var(--muted)",
transition: "background 0.12s ease, color 0.12s ease",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLElement).style.background = "transparent";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Main AppSidebar component
// ---------------------------------------------------------------------------
/**
* Application sidebar — navigation groups, collapse toggle, and user card.
* Logo lives in AppHeader per MS15 design spec.
*/
export function AppSidebar(): React.JSX.Element {
const pathname = usePathname();
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
return (
<>
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
{isMobile && mobileOpen && (
<div
aria-hidden="true"
onClick={() => {
setMobileOpen(false);
}}
style={{
position: "fixed",
inset: 0,
top: "var(--topbar-h)",
background: "rgba(0,0,0,0.5)",
zIndex: 140,
}}
/>
)}
<aside
id="app-sidebar"
className="app-sidebar"
data-collapsed={collapsed ? "true" : undefined}
data-mobile-open={mobileOpen ? "true" : undefined}
aria-label="Application navigation"
>
{/* Sidebar body — scrollable nav area */}
<nav
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
padding: "10px 10px",
}}
aria-label="Main navigation"
>
{NAV_GROUPS.map((group) => (
<div key={group.label} style={{ marginBottom: "18px" }}>
{/* Group label — hidden when collapsed */}
{!collapsed && (
<p
style={{
fontSize: "0.67rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.09em",
color: "var(--muted)",
padding: "0 10px",
marginBottom: "4px",
userSelect: "none",
}}
>
{group.label}
</p>
)}
{/* Nav items */}
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{group.items.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: item.href.startsWith("#")
? false
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<li key={item.href}>
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
</li>
);
})}
</ul>
</div>
))}
{/* Collapse toggle — anchored at bottom of nav */}
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
</nav>
{/* User card footer */}
<UserCard collapsed={collapsed} />
</aside>
</>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
interface SidebarContextValue {
collapsed: boolean;
toggleCollapsed: () => void;
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
isMobile: boolean;
}
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
/** Breakpoint below which we treat the viewport as "mobile" (matches CSS max-width: 767px). */
const MOBILE_MAX_WIDTH = 767;
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// Initialise and track mobile breakpoint using matchMedia
useEffect((): (() => void) => {
const mql = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
const handleChange = (e: MediaQueryListEvent): void => {
setIsMobile(e.matches);
// Close mobile sidebar when viewport grows out of mobile range
if (!e.matches) {
setMobileOpen(false);
}
};
// Set initial value synchronously
setIsMobile(mql.matches);
mql.addEventListener("change", handleChange);
return (): void => {
mql.removeEventListener("change", handleChange);
};
}, []);
const toggleCollapsed = useCallback((): void => {
setCollapsed((prev) => !prev);
}, []);
const value: SidebarContextValue = {
collapsed,
toggleCollapsed,
mobileOpen,
setMobileOpen,
isMobile,
};
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
}
export function useSidebar(): SidebarContextValue {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within SidebarProvider");
}
return context;
}

View File

@@ -20,7 +20,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
// Sun icon for dark mode (click to switch to light)
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--semantic-warning))" }}
style={{ color: "var(--warn)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -33,7 +33,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
// Moon icon for light mode (click to switch to dark)
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--text-secondary))" }}
style={{ color: "var(--text-2)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"

View File

@@ -0,0 +1,257 @@
import type { ReactElement, CSSProperties } from "react";
export interface TerminalLine {
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
content: string;
}
export interface TerminalTab {
id: string;
label: string;
}
export interface TerminalPanelProps {
open: boolean;
onClose: () => void;
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
lines?: TerminalLine[];
className?: string;
}
const defaultTabs: TerminalTab[] = [
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const blinkKeyframes = `
@keyframes ms-terminal-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
let blinkStyleInjected = false;
function ensureBlinkStyle(): void {
if (blinkStyleInjected || typeof document === "undefined") return;
const styleEl = document.createElement("style");
styleEl.textContent = blinkKeyframes;
document.head.appendChild(styleEl);
blinkStyleInjected = true;
}
function getLineColor(type: TerminalLine["type"]): string {
switch (type) {
case "prompt":
return "var(--success)";
case "command":
return "var(--text-2)";
case "output":
return "var(--muted)";
case "error":
return "var(--danger)";
case "warning":
return "var(--warn)";
case "success":
return "var(--success)";
default:
return "var(--muted)";
}
}
export function TerminalPanel({
open,
onClose,
tabs,
activeTab,
onTabChange,
lines = [],
className = "",
}: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
const panelStyle: CSSProperties = {
background: "var(--bg-deep)",
borderTop: "1px solid var(--border)",
overflow: "hidden",
flexShrink: 0,
display: "flex",
flexDirection: "column",
height: open ? 280 : 0,
transition: "height 0.3s ease",
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "6px 16px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
};
const tabBarStyle: CSSProperties = {
display: "flex",
gap: 2,
};
const actionsStyle: CSSProperties = {
marginLeft: "auto",
display: "flex",
gap: 4,
};
const bodyStyle: CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "10px 16px",
fontFamily: "var(--mono)",
fontSize: "0.78rem",
lineHeight: 1.6,
};
const cursorStyle: CSSProperties = {
display: "inline-block",
width: 7,
height: 14,
background: "var(--success)",
marginLeft: 2,
animation: "ms-terminal-blink 1s step-end infinite",
verticalAlign: "text-bottom",
};
return (
<div
className={className}
style={panelStyle}
role="region"
aria-label="Terminal panel"
aria-hidden={!open}
>
{/* Header */}
<div style={headerStyle}>
{/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{resolvedTabs.map((tab) => {
const isActive = tab.id === resolvedActiveTab;
const tabStyle: CSSProperties = {
padding: "3px 10px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
};
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
style={tabStyle}
onClick={(): void => {
onTabChange?.(tab.id);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
>
{tab.label}
</button>
);
})}
</div>
{/* Action buttons */}
<div style={actionsStyle}>
<button
aria-label="Close terminal"
style={{
width: 22,
height: 22,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
}}
onClick={onClose}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon — simple X using SVG */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* Body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
{lines.map((line, index) => {
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel";

View File

@@ -0,0 +1,100 @@
"use client";
import type { CSSProperties } from "react";
export interface MosaicLogoProps {
/** Width and height in pixels (default: 36) */
size?: number;
/** Whether to animate rotation (default: false) */
spinning?: boolean;
/** Seconds for one full rotation (default: 20) */
spinDuration?: number;
/** Additional CSS classes for the root element */
className?: string;
}
/**
* MosaicLogo renders the 5-element Mosaic logo icon:
* - 4 corner squares (blue, purple, teal, amber)
* - 1 center circle (pink)
*
* Colors use CSS custom properties so they respond to theme changes.
* When `spinning` is true the logo rotates continuously, making it
* suitable for use as a loading indicator.
*/
export function MosaicLogo({
size = 36,
spinning = false,
spinDuration = 20,
className = "",
}: MosaicLogoProps): React.JSX.Element {
// Scale factor relative to the 36px reference design
const scale = size / 36;
// Derived dimensions
const squareSize = Math.round(14 * scale);
const circleSize = Math.round(11 * scale);
const borderRadius = Math.round(3 * scale);
const animationValue = spinning
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
: undefined;
const containerStyle: CSSProperties = {
width: size,
height: size,
position: "relative",
flexShrink: 0,
animation: animationValue,
transformOrigin: "center",
};
const baseSquareStyle: CSSProperties = {
position: "absolute",
width: squareSize,
height: squareSize,
borderRadius,
};
const circleStyle: CSSProperties = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: circleSize,
height: circleSize,
borderRadius: "50%",
background: "var(--ms-pink-500)",
};
return (
<>
{spinning && (
<style>{`
@keyframes mosaicLogoSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
)}
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
{/* Top-left: blue */}
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: "var(--ms-blue-500)" }} />
{/* Top-right: purple */}
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: "var(--ms-purple-500)" }} />
{/* Bottom-right: teal */}
<div
style={{ ...baseSquareStyle, bottom: 0, right: 0, background: "var(--ms-teal-500)" }}
/>
{/* Bottom-left: amber */}
<div
style={{ ...baseSquareStyle, bottom: 0, left: 0, background: "var(--ms-amber-500)" }}
/>
{/* Center: pink circle */}
<div style={circleStyle} />
</div>
</>
);
}
export default MosaicLogo;

View File

@@ -0,0 +1,49 @@
"use client";
import { MosaicLogo } from "./MosaicLogo";
import type { ReactElement } from "react";
export interface MosaicSpinnerProps {
/** Width and height of the logo in pixels (default: 36) */
size?: number;
/** Seconds for one full rotation (default: 20) */
spinDuration?: number;
/** Optional text label displayed below the spinner */
label?: string;
/**
* When true, wraps the spinner in a full-page centered overlay.
* When false (default), renders inline.
*/
fullPage?: boolean;
/** Additional CSS classes for the wrapper element */
className?: string;
}
/**
* MosaicSpinner wraps MosaicLogo with spinning enabled.
* It can be used as a full-page loading overlay or as an inline indicator.
*/
export function MosaicSpinner({
size = 36,
spinDuration = 20,
label,
fullPage = false,
className = "",
}: MosaicSpinnerProps): ReactElement {
const inner = (
<div className={`flex flex-col items-center gap-3 ${className}`}>
<MosaicLogo size={size} spinning spinDuration={spinDuration} />
{label !== undefined && label !== "" && (
<span className="text-sm text-gray-500">{label}</span>
)}
</div>
);
if (fullPage) {
return <div className="flex min-h-screen items-center justify-center">{inner}</div>;
}
return inner;
}
export default MosaicSpinner;

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const originalEnv = { ...process.env };
const mockFetch = vi.fn();
describe("API Client (mock auth mode)", (): void => {
beforeEach((): void => {
process.env = {
...originalEnv,
NODE_ENV: "development",
NEXT_PUBLIC_AUTH_MODE: "mock",
};
vi.resetModules();
mockFetch.mockReset();
global.fetch = mockFetch;
});
afterEach((): void => {
process.env = originalEnv;
vi.restoreAllMocks();
});
it("should return local mock data for active projects widget without network calls", async (): Promise<void> => {
const { apiPost } = await import("./client");
interface ProjectResponse {
id: string;
status: string;
}
const response = await apiPost<ProjectResponse[]>("/api/widgets/data/active-projects");
expect(response.length).toBeGreaterThan(0);
const firstProject = response[0];
expect(firstProject).toBeDefined();
if (firstProject) {
expect(typeof firstProject.id).toBe("string");
expect(typeof firstProject.status).toBe("string");
}
expect(mockFetch).not.toHaveBeenCalled();
});
it("should return local mock data for agent chains widget without network calls", async (): Promise<void> => {
const { apiPost } = await import("./client");
interface AgentChainResponse {
id: string;
status: string;
}
const response = await apiPost<AgentChainResponse[]>("/api/widgets/data/agent-chains");
expect(response.length).toBeGreaterThan(0);
expect(response.some((session) => session.status === "active")).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { API_BASE_URL } from "../config";
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "../config";
/**
* In-memory CSRF token storage
@@ -41,6 +41,74 @@ export interface ApiRequestOptions extends RequestInit {
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
}
const MOCK_ACTIVE_PROJECTS_RESPONSE = [
{
id: "project-dev-1",
name: "Mosaic Stack FE Go-Live",
status: "active",
lastActivity: new Date().toISOString(),
taskCount: 7,
eventCount: 2,
color: "#3B82F6",
},
{
id: "project-dev-2",
name: "Auth Flow Remediation",
status: "in-progress",
lastActivity: new Date(Date.now() - 12 * 60_000).toISOString(),
taskCount: 4,
eventCount: 0,
color: "#F59E0B",
},
] as const;
const MOCK_AGENT_CHAINS_RESPONSE = [
{
id: "agent-session-dev-1",
sessionKey: "dev-session-1",
label: "UI Validator Agent",
channel: "codex",
agentName: "jarvis-agent",
agentStatus: "WORKING",
status: "active",
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
lastMessageAt: new Date(Date.now() - 20_000).toISOString(),
runtimeMs: 42 * 60_000,
messageCount: 27,
contextSummary: "Validating dashboard, tasks, and auth-bypass UX for local development flow.",
},
{
id: "agent-session-dev-2",
sessionKey: "dev-session-2",
label: "Telemetry Stub Agent",
channel: "codex",
agentName: "jarvis-agent",
agentStatus: "TERMINATED",
status: "ended",
startedAt: new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
lastMessageAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
runtimeMs: 63 * 60_000,
messageCount: 41,
contextSummary: "Generated telemetry mock payloads for usage and widget rendering.",
},
] as const;
function getMockApiResponse(endpoint: string, method: string): unknown {
if (!IS_MOCK_AUTH_MODE || process.env.NODE_ENV !== "development") {
return undefined;
}
if (method === "POST" && endpoint === "/api/widgets/data/active-projects") {
return [...MOCK_ACTIVE_PROJECTS_RESPONSE];
}
if (method === "POST" && endpoint === "/api/widgets/data/agent-chains") {
return [...MOCK_AGENT_CHAINS_RESPONSE];
}
return undefined;
}
/**
* Fetch CSRF token from the API
* Token is stored in an httpOnly cookie and returned in response body
@@ -100,6 +168,12 @@ async function ensureCsrfToken(): Promise<string> {
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const { workspaceId, timeoutMs, _isRetry, ...fetchOptions } = options;
const method = (fetchOptions.method ?? "GET").toUpperCase();
const mockResponse = getMockApiResponse(endpoint, method);
if (mockResponse !== undefined) {
return mockResponse as T;
}
// Set up abort controller for timeout
const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
@@ -134,7 +208,6 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
}
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
const method = (fetchOptions.method ?? "GET").toUpperCase();
const isStateChanging = ["POST", "PUT", "PATCH", "DELETE"].includes(method);
if (isStateChanging) {

View File

@@ -11,6 +11,7 @@ import {
} from "react";
import type { AuthUser, AuthSession } from "@mosaic/shared";
import { apiGet, apiPost } from "../api/client";
import { IS_MOCK_AUTH_MODE } from "../config";
import { parseAuthError } from "./auth-errors";
/**
@@ -23,6 +24,11 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5;
/** Interval in milliseconds to check session expiry */
const SESSION_CHECK_INTERVAL_MS = 60_000;
const MOCK_AUTH_USER: AuthUser = {
id: "dev-user-local",
email: "dev@localhost",
name: "Local Dev User",
};
interface AuthContextValue {
user: AuthUser | null;
@@ -70,6 +76,14 @@ function logAuthError(message: string, error: unknown): void {
}
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
if (IS_MOCK_AUTH_MODE) {
return <MockAuthProvider>{children}</MockAuthProvider>;
}
return <RealAuthProvider>{children}</RealAuthProvider>;
}
function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authError, setAuthError] = useState<AuthErrorType>(null);
@@ -176,6 +190,33 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function MockAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [user, setUser] = useState<AuthUser | null>(MOCK_AUTH_USER);
const signOut = useCallback((): Promise<void> => {
setUser(null);
return Promise.resolve();
}, []);
const refreshSession = useCallback((): Promise<void> => {
setUser(MOCK_AUTH_USER);
return Promise.resolve();
}, []);
const value: AuthContextValue = {
user,
isLoading: false,
isAuthenticated: user !== null,
authError: null,
sessionExpiring: false,
sessionMinutesRemaining: 0,
signOut,
refreshSession,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {

View File

@@ -22,11 +22,16 @@ describe("API Configuration", () => {
it("should use default API URL when NEXT_PUBLIC_API_URL is not set", async () => {
delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
delete process.env.NEXT_PUBLIC_AUTH_MODE;
process.env = { ...process.env, NODE_ENV: "development" };
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
const { API_BASE_URL, ORCHESTRATOR_URL, AUTH_MODE, IS_MOCK_AUTH_MODE } =
await import("./config");
expect(API_BASE_URL).toBe("http://localhost:3001");
expect(ORCHESTRATOR_URL).toBe("http://localhost:3001");
expect(AUTH_MODE).toBe("mock");
expect(IS_MOCK_AUTH_MODE).toBe(true);
});
});
@@ -34,17 +39,22 @@ describe("API Configuration", () => {
it("should use NEXT_PUBLIC_API_URL when set", async () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
delete process.env.NEXT_PUBLIC_AUTH_MODE;
process.env = { ...process.env, NODE_ENV: "development" };
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
const { API_BASE_URL, ORCHESTRATOR_URL, AUTH_MODE } = await import("./config");
expect(API_BASE_URL).toBe("https://api.example.com");
// ORCHESTRATOR_URL should fall back to API_BASE_URL
expect(ORCHESTRATOR_URL).toBe("https://api.example.com");
expect(AUTH_MODE).toBe("mock");
});
it("should use separate NEXT_PUBLIC_ORCHESTRATOR_URL when set", async () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orchestrator.example.com";
process.env = { ...process.env, NODE_ENV: "development" };
delete process.env.NEXT_PUBLIC_AUTH_MODE;
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
@@ -57,6 +67,8 @@ describe("API Configuration", () => {
it("should build API URLs correctly", async () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
process.env = { ...process.env, NODE_ENV: "development" };
delete process.env.NEXT_PUBLIC_AUTH_MODE;
const { buildApiUrl } = await import("./config");
@@ -67,6 +79,8 @@ describe("API Configuration", () => {
it("should build orchestrator URLs correctly", async () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orch.example.com";
process.env = { ...process.env, NODE_ENV: "development" };
delete process.env.NEXT_PUBLIC_AUTH_MODE;
const { buildOrchestratorUrl } = await import("./config");
@@ -79,13 +93,44 @@ describe("API Configuration", () => {
it("should expose all configuration through apiConfig", async () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orch.example.com";
process.env = { ...process.env, NODE_ENV: "development" };
process.env.NEXT_PUBLIC_AUTH_MODE = "real";
const { apiConfig } = await import("./config");
expect(apiConfig.baseUrl).toBe("https://api.example.com");
expect(apiConfig.orchestratorUrl).toBe("https://orch.example.com");
expect(apiConfig.authMode).toBe("real");
expect(apiConfig.buildUrl("/test")).toBe("https://api.example.com/test");
expect(apiConfig.buildOrchestratorUrl("/test")).toBe("https://orch.example.com/test");
});
});
describe("auth mode", () => {
it("should enable mock mode only in development", async () => {
process.env = { ...process.env, NODE_ENV: "development" };
process.env.NEXT_PUBLIC_AUTH_MODE = "mock";
const { AUTH_MODE, IS_MOCK_AUTH_MODE } = await import("./config");
expect(AUTH_MODE).toBe("mock");
expect(IS_MOCK_AUTH_MODE).toBe(true);
});
it("should throw on invalid auth mode", async () => {
process.env = { ...process.env, NODE_ENV: "development" };
process.env.NEXT_PUBLIC_AUTH_MODE = "invalid";
await expect(import("./config")).rejects.toThrow("Invalid NEXT_PUBLIC_AUTH_MODE");
});
it("should throw when mock mode is set outside development", async () => {
process.env = { ...process.env, NODE_ENV: "production" };
process.env.NEXT_PUBLIC_AUTH_MODE = "mock";
await expect(import("./config")).rejects.toThrow(
"NEXT_PUBLIC_AUTH_MODE=mock is only allowed when NODE_ENV=development."
);
});
});
});

View File

@@ -4,28 +4,86 @@
* This module provides a single source of truth for all API endpoints and URLs.
* All components should import from here instead of reading environment variables directly.
*
* Runtime config injection:
* - In production containers, NEXT_PUBLIC_* vars are baked at build time and cannot
* be overridden via Docker env vars. The root layout injects runtime values into
* `window.__MOSAIC_ENV__` via a synchronous <script>, which this module reads first.
*
* Environment Variables:
* - NEXT_PUBLIC_API_URL: The main API server URL (default: http://localhost:3001)
* - NEXT_PUBLIC_ORCHESTRATOR_URL: The orchestrator service URL (default: same as API URL)
* - NEXT_PUBLIC_AUTH_MODE: Auth mode for web app (`real` or `mock`)
* - If unset: development defaults to `mock`, production defaults to `real`
*/
/**
* Read an env variable, preferring runtime-injected values on the client.
*
* Execution order guarantees this works:
* 1. Root layout emits `<script>window.__MOSAIC_ENV__={…}</script>` synchronously.
* 2. Next.js hydrates, loading client modules that call this helper.
*/
function getEnv(name: string): string | undefined {
if (typeof window !== "undefined") {
const w = window as Window & { __MOSAIC_ENV__?: Record<string, string> };
if (w.__MOSAIC_ENV__?.[name]) {
return w.__MOSAIC_ENV__[name];
}
}
// Server-side or build-time fallback
return process.env[name];
}
/**
* Default API server URL for local development
*/
const DEFAULT_API_URL = "http://localhost:3001";
const DEFAULT_AUTH_MODE = process.env.NODE_ENV === "development" ? "mock" : "real";
const VALID_AUTH_MODES = ["real", "mock"] as const;
export type AuthMode = (typeof VALID_AUTH_MODES)[number];
/**
* Main API server URL
* Used for authentication, tasks, events, knowledge, and all core API calls
*/
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL;
export const API_BASE_URL = getEnv("NEXT_PUBLIC_API_URL") ?? DEFAULT_API_URL;
function resolveAuthMode(): AuthMode {
const rawMode = (getEnv("NEXT_PUBLIC_AUTH_MODE") ?? DEFAULT_AUTH_MODE).toLowerCase();
if (!VALID_AUTH_MODES.includes(rawMode as AuthMode)) {
throw new Error(
`Invalid NEXT_PUBLIC_AUTH_MODE "${rawMode}". Expected one of: ${VALID_AUTH_MODES.join(", ")}.`
);
}
if (rawMode === "mock" && process.env.NODE_ENV !== "development") {
throw new Error("NEXT_PUBLIC_AUTH_MODE=mock is only allowed when NODE_ENV=development.");
}
return rawMode as AuthMode;
}
/**
* Authentication mode for frontend runtime.
* - real: uses normal BetterAuth/Backend session flow
* - mock: local-only seeded mock user for FE development
*/
export const AUTH_MODE: AuthMode = resolveAuthMode();
/**
* Whether local mock auth mode is enabled.
*/
export const IS_MOCK_AUTH_MODE = AUTH_MODE === "mock";
/**
* Orchestrator service URL
* Used for agent management, task progress, and orchestration features
* Falls back to main API URL if not specified (they may run on the same server)
*/
export const ORCHESTRATOR_URL = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? API_BASE_URL;
export const ORCHESTRATOR_URL = getEnv("NEXT_PUBLIC_ORCHESTRATOR_URL") ?? API_BASE_URL;
/**
* Build a full API endpoint URL
@@ -53,6 +111,8 @@ export const apiConfig = {
baseUrl: API_BASE_URL,
/** Orchestrator service URL */
orchestratorUrl: ORCHESTRATOR_URL,
/** Authentication mode (`real` or `mock`) */
authMode: AUTH_MODE,
/** Build full API URL for an endpoint */
buildUrl: buildApiUrl,
/** Build full orchestrator URL for an endpoint */

View File

@@ -29,6 +29,21 @@ function getStoredTheme(): Theme {
return "system";
}
/**
* Apply the resolved theme to the <html> element via data-theme attribute.
* The default (no attribute or data-theme="dark") renders dark — dark is default.
* Light theme requires data-theme="light".
*/
function applyThemeAttribute(resolved: "light" | "dark"): void {
const root = document.documentElement;
if (resolved === "light") {
root.setAttribute("data-theme", "light");
} else {
// Remove the attribute so the default (dark) CSS variables apply.
root.removeAttribute("data-theme");
}
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
@@ -46,19 +61,18 @@ export function ThemeProvider({
useEffect(() => {
setMounted(true);
const storedTheme = getStoredTheme();
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
setThemeState(storedTheme);
setResolvedTheme(storedTheme === "system" ? getSystemTheme() : storedTheme);
setResolvedTheme(resolved);
applyThemeAttribute(resolved);
}, []);
// Apply theme class to html element
// Apply theme via data-theme attribute on html element
useEffect(() => {
if (!mounted) return;
const root = document.documentElement;
const resolved = theme === "system" ? getSystemTheme() : theme;
root.classList.remove("light", "dark");
root.classList.add(resolved);
applyThemeAttribute(resolved);
setResolvedTheme(resolved);
}, [theme, mounted]);
@@ -68,9 +82,9 @@ export function ThemeProvider({
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent): void => {
setResolvedTheme(e.matches ? "dark" : "light");
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(e.matches ? "dark" : "light");
const resolved = e.matches ? "dark" : "light";
setResolvedTheme(resolved);
applyThemeAttribute(resolved);
};
mediaQuery.addEventListener("change", handleChange);

View File

@@ -0,0 +1,36 @@
import type { Config } from "tailwindcss";
const config: Config = {
// Use data-theme attribute selector for dark mode instead of .dark class
darkMode: ["selector", '[data-theme="dark"]'],
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-outfit)", "system-ui", "sans-serif"],
mono: ["var(--font-fira-code)", "Cascadia Code", "monospace"],
},
colors: {
// Expose Mosaic semantic tokens as Tailwind colors
bg: "var(--bg)",
"bg-deep": "var(--bg-deep)",
"bg-mid": "var(--bg-mid)",
surface: "var(--surface)",
"surface-2": "var(--surface-2)",
border: "var(--border)",
text: "var(--text)",
"text-2": "var(--text-2)",
muted: "var(--muted)",
primary: "var(--primary)",
"primary-l": "var(--primary-l)",
danger: "var(--danger)",
success: "var(--success)",
warn: "var(--warn)",
purple: "var(--purple)",
},
},
},
plugins: [],
};
export default config;

View File

@@ -24,3 +24,33 @@ Object.defineProperty(window, "matchMedia", {
dispatchEvent: () => false,
}),
});
// Ensure localStorage exists with a full Storage API for tests.
// Avoid touching the built-in accessor (which emits warnings in Node).
let storageStore: Record<string, string> = {};
const storageMock: Storage = {
getItem: (key: string): string | null => storageStore[key] ?? null,
setItem: (key: string, value: string): void => {
storageStore[key] = value;
},
removeItem: (key: string): void => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete storageStore[key];
},
clear: (): void => {
storageStore = {};
},
get length(): number {
return Object.keys(storageStore).length;
},
key: (index: number): string | null => {
const keys = Object.keys(storageStore);
return keys[index] ?? null;
},
};
Object.defineProperty(window, "localStorage", {
value: storageMock,
writable: true,
configurable: true,
});

280
docker-compose.coolify.yml Normal file
View File

@@ -0,0 +1,280 @@
# ==============================================
# Mosaic Stack — Coolify Core Deployment
# ==============================================
#
# Core services only. For Matrix, speech, and other optional
# services, deploy them as separate Coolify services or extend
# this file.
#
# Usage (Coolify):
# 1. New Resource -> Docker Compose
# 2. Paste this file
# 3. Set environment variables in Coolify UI
# 4. Configure domains for web + api in Coolify UI
# 5. Deploy
#
# NOTE: Traefik labels are NOT included here. Coolify manages
# routing and TLS via its own proxy integration. Configure
# domains in the Coolify service settings.
#
# ==============================================
services:
# ======================
# PostgreSQL Database
# ======================
postgres:
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_SHARED_BUFFERS=${POSTGRES_SHARED_BUFFERS:-256MB}
- POSTGRES_EFFECTIVE_CACHE_SIZE=${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
- POSTGRES_MAX_CONNECTIONS=${POSTGRES_MAX_CONNECTIONS:-100}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- internal
# ======================
# Valkey Cache
# ======================
valkey:
image: valkey/valkey:8-alpine
restart: unless-stopped
command:
- valkey-server
- --maxmemory ${VALKEY_MAXMEMORY:-256mb}
- --maxmemory-policy noeviction
- --appendonly yes
volumes:
- valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- internal
# ======================
# Mosaic API
# ======================
api:
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
# Coolify domain assignment (magic variable — tells Coolify this service gets a domain on port 3001)
- SERVICE_FQDN_API_3001
- NODE_ENV=production
- PORT=${API_PORT:-3001}
- API_HOST=${API_HOST:-0.0.0.0}
# Database
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# Cache
- VALKEY_URL=redis://valkey:6379
# Auth (external Authentik — optional)
- OIDC_ENABLED=${OIDC_ENABLED:-false}
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
# JWT
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRATION=${JWT_EXPIRATION:-24h}
# Better Auth
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-}
- CSRF_SECRET=${CSRF_SECRET}
- COOKIE_DOMAIN=${COOKIE_DOMAIN:-}
# Encryption
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# External services (optional — leave empty to disable)
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
- OPENBAO_ADDR=${OPENBAO_ADDR:-}
# Knowledge module
- KNOWLEDGE_CACHE_ENABLED=${KNOWLEDGE_CACHE_ENABLED:-true}
- KNOWLEDGE_CACHE_TTL=${KNOWLEDGE_CACHE_TTL:-300}
- SEMANTIC_SEARCH_SIMILARITY_THRESHOLD=${SEMANTIC_SEARCH_SIMILARITY_THRESHOLD:-0.5}
# Rate limiting
- RATE_LIMIT_TTL=${RATE_LIMIT_TTL:-60}
- RATE_LIMIT_GLOBAL_LIMIT=${RATE_LIMIT_GLOBAL_LIMIT:-100}
- RATE_LIMIT_STORAGE=${RATE_LIMIT_STORAGE:-redis}
# Speech services (disabled — not in core stack)
- STT_ENABLED=${STT_ENABLED:-false}
- TTS_ENABLED=${TTS_ENABLED:-false}
# Matrix bridge (disabled — not in core stack)
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN:-}
# Telemetry (disabled by default)
- MOSAIC_TELEMETRY_ENABLED=${MOSAIC_TELEMETRY_ENABLED:-false}
- MOSAIC_TELEMETRY_SERVER_URL=${MOSAIC_TELEMETRY_SERVER_URL:-}
- MOSAIC_TELEMETRY_API_KEY=${MOSAIC_TELEMETRY_API_KEY:-}
- MOSAIC_TELEMETRY_INSTANCE_ID=${MOSAIC_TELEMETRY_INSTANCE_ID:-}
- MOSAIC_TELEMETRY_DRY_RUN=${MOSAIC_TELEMETRY_DRY_RUN:-false}
# Frontend URLs (for CORS and auth redirects)
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
'node -e "require(''http'').get(''http://localhost:${API_PORT:-3001}/health'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- internal
# ======================
# Mosaic Web
# ======================
web:
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
# Coolify domain assignment (magic variable — tells Coolify this service gets a domain on port 3000)
- SERVICE_FQDN_WEB_3000
- NODE_ENV=production
- PORT=${WEB_PORT:-3000}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
- NEXT_PUBLIC_ORCHESTRATOR_URL=${NEXT_PUBLIC_ORCHESTRATOR_URL:-}
- NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE:-real}
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
depends_on:
api:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
'node -e "require(''http'').get(''http://localhost:${WEB_PORT:-3000}'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- internal
# ======================
# Mosaic Coordinator
# ======================
coordinator:
image: git.mosaicstack.dev/mosaic/stack-coordinator:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
- GITEA_WEBHOOK_SECRET=${GITEA_WEBHOOK_SECRET:-}
- GITEA_URL=${GITEA_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
- HOST=0.0.0.0
- PORT=8000
- COORDINATOR_POLL_INTERVAL=${COORDINATOR_POLL_INTERVAL:-5.0}
- COORDINATOR_MAX_CONCURRENT_AGENTS=${COORDINATOR_MAX_CONCURRENT_AGENTS:-10}
- COORDINATOR_ENABLED=${COORDINATOR_ENABLED:-true}
# Telemetry
- MOSAIC_TELEMETRY_ENABLED=${MOSAIC_TELEMETRY_ENABLED:-false}
- MOSAIC_TELEMETRY_SERVER_URL=${MOSAIC_TELEMETRY_SERVER_URL:-}
- MOSAIC_TELEMETRY_API_KEY=${MOSAIC_TELEMETRY_API_KEY:-}
- MOSAIC_TELEMETRY_INSTANCE_ID=${MOSAIC_TELEMETRY_INSTANCE_ID:-}
- MOSAIC_TELEMETRY_DRY_RUN=${MOSAIC_TELEMETRY_DRY_RUN:-false}
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- internal
# ======================
# Mosaic Orchestrator
# ======================
orchestrator:
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-latest}
restart: unless-stopped
user: "1000:1000"
environment:
- NODE_ENV=production
- ORCHESTRATOR_PORT=3001
- AI_PROVIDER=${AI_PROVIDER:-ollama}
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
- VALKEY_URL=redis://valkey:6379
- VALKEY_HOST=valkey
- VALKEY_PORT=6379
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
- DOCKER_SOCKET=/var/run/docker.sock
- GIT_USER_NAME=Mosaic Orchestrator
- GIT_USER_EMAIL=orchestrator@mosaicstack.dev
- KILLSWITCH_ENABLED=true
- SANDBOX_ENABLED=true
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- orchestrator_workspace:/workspace
depends_on:
valkey:
condition: service_healthy
api:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
'node -e "require(''http'').get(''http://localhost:3001/health'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- internal
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
tmpfs:
- /tmp:noexec,nosuid,size=100m
# ======================
# Volumes
# ======================
volumes:
postgres_data:
valkey_data:
orchestrator_workspace:
# ======================
# Networks
# ======================
networks:
internal:
driver: bridge

View File

@@ -14,7 +14,7 @@ services:
# OpenBao Secrets Vault
# ======================
openbao:
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
entrypoint: ["dumb-init", "--"]
command: ["bao", "server", "-config=/openbao/config/config.hcl"]
environment:
@@ -48,7 +48,7 @@ services:
# Has built-in retry logic (polls OpenBao API for 60 seconds).
# After init, runs an unseal watch loop to handle container restarts.
openbao-init:
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
command: /openbao/init.sh
environment:
VAULT_ADDR: http://openbao:8200

View File

@@ -248,7 +248,11 @@ services:
environment:
NODE_ENV: production
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
VALKEY_URL: redis://valkey:6379
VALKEY_HOST: valkey
VALKEY_PORT: 6379
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
DOCKER_SOCKET: /var/run/docker.sock
GIT_USER_NAME: "Mosaic Orchestrator"

View File

@@ -3,7 +3,7 @@ services:
# PostgreSQL Database
# ======================
postgres:
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
container_name: mosaic-postgres
restart: unless-stopped
environment:
@@ -251,7 +251,7 @@ services:
# OpenBao Secrets Management (Optional)
# ======================
openbao:
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
container_name: mosaic-openbao
restart: unless-stopped
user: root
@@ -283,7 +283,7 @@ services:
- "com.mosaic.description=OpenBao secrets management"
openbao-init:
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
container_name: mosaic-openbao-init
restart: unless-stopped
user: root
@@ -345,7 +345,7 @@ services:
# Mosaic API
# ======================
api:
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-latest}
container_name: mosaic-api
restart: unless-stopped
environment:
@@ -362,12 +362,15 @@ services:
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik}
# JWT
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
# Better Auth
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-}
# Encryption (required for federation credentials/private keys)
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# Ollama (optional)
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
# OpenBao (optional)
@@ -421,7 +424,7 @@ services:
# Mosaic Orchestrator
# ======================
orchestrator:
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-latest}
container_name: mosaic-orchestrator
restart: unless-stopped
# Run as non-root user (node:node, UID 1000)
@@ -430,9 +433,12 @@ services:
NODE_ENV: production
# Orchestrator Configuration
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey
VALKEY_URL: redis://valkey:6379
# Claude API
VALKEY_HOST: valkey
VALKEY_PORT: 6379
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker
DOCKER_SOCKET: /var/run/docker.sock
@@ -485,13 +491,14 @@ services:
# Mosaic Web
# ======================
web:
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-dev}
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-latest}
container_name: mosaic-web
restart: unless-stopped
environment:
NODE_ENV: production
PORT: ${WEB_PORT:-3000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
ports:
- "${WEB_PORT:-3000}:${WEB_PORT:-3000}"
depends_on:

View File

@@ -12,10 +12,10 @@ Pull and run the latest images from the Gitea container registry:
# Copy environment template
cp .env.example .env
# Edit .env and set IMAGE_TAG (optional, defaults to 'dev')
# IMAGE_TAG=dev # Development images (develop branch)
# IMAGE_TAG=latest # Production images (main branch)
# Edit .env and set IMAGE_TAG (optional, defaults to 'latest')
# IMAGE_TAG=latest # Latest images from main branch (default)
# IMAGE_TAG=658ec077 # Specific commit SHA
# IMAGE_TAG=v1.0.0 # Specific version tag
# Pull and start services
docker compose pull
@@ -49,8 +49,7 @@ docker compose -f docker-compose.build.yml up -d --build
The `IMAGE_TAG` environment variable controls which image version to pull:
- `dev` - Latest development build from `develop` branch (default)
- `latest` - Latest stable build from `main` branch
- `latest` - Latest build from `main` branch (default)
- `658ec077` - Specific commit SHA (first 8 characters)
- `v1.0.0` - Specific version tag
@@ -210,7 +209,7 @@ The repository includes three example compose files for common deployment scenar
```bash
# Set in .env
COMPOSE_PROFILES=full
IMAGE_TAG=dev
IMAGE_TAG=latest
# Start all services
docker compose up -d

View File

@@ -378,12 +378,13 @@ services:
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik}
# JWT
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
# Better Auth
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-}
# Security
CSRF_SECRET: ${CSRF_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
@@ -441,9 +442,12 @@ services:
NODE_ENV: production
# Orchestrator Configuration
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey
VALKEY_URL: redis://valkey:6379
# Claude API
VALKEY_HOST: valkey
VALKEY_PORT: 6379
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker
DOCKER_SOCKET: /var/run/docker.sock

View File

@@ -219,7 +219,7 @@ JWT_EXPIRATION=24h
OIDC_ISSUER=https://auth.example.com/application/o/mosaic/
OIDC_CLIENT_ID=prod-client-id
OIDC_CLIENT_SECRET=prod-client-secret
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik
```
### Compose Override for Production

View File

@@ -89,7 +89,7 @@ OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
# Callback URL (must match Authentik configuration)
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
```
See [Authentik Setup](2-authentik.md) for complete OIDC configuration.
@@ -229,7 +229,7 @@ JWT_EXPIRATION=24h
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# ======================
# Cache

View File

@@ -54,17 +54,17 @@ Sign up at [goauthentik.io](https://goauthentik.io) for managed Authentik.
4. **Configure Provider:**
| Field | Value |
| ------------------------------ | ----------------------------------------------- |
| **Name** | Mosaic Stack |
| **Authorization flow** | default-provider-authorization-implicit-consent |
| **Client type** | Confidential |
| **Client ID** | (auto-generated, save this) |
| **Client Secret** | (auto-generated, save this) |
| **Redirect URIs** | `http://localhost:3001/auth/callback` |
| **Scopes** | `openid`, `email`, `profile` |
| **Subject mode** | Based on User's UUID |
| **Include claims in id_token** | ✅ Enabled |
| Field | Value |
| ------------------------------ | ------------------------------------------------------ |
| **Name** | Mosaic Stack |
| **Authorization flow** | default-provider-authorization-implicit-consent |
| **Client type** | Confidential |
| **Client ID** | (auto-generated, save this) |
| **Client Secret** | (auto-generated, save this) |
| **Redirect URIs** | `http://localhost:3001/auth/oauth2/callback/authentik` |
| **Scopes** | `openid`, `email`, `profile` |
| **Subject mode** | Based on User's UUID |
| **Include claims in id_token** | ✅ Enabled |
5. **Click "Create"**
@@ -96,7 +96,7 @@ Update your `.env` file:
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
OIDC_CLIENT_ID=<your-client-id-from-step-2>
OIDC_CLIENT_SECRET=<your-client-secret-from-step-2>
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
```
**Important Notes:**
@@ -113,7 +113,7 @@ For production deployments:
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=prod-client-id
OIDC_CLIENT_SECRET=prod-client-secret
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik
```
Update Authentik redirect URIs to match your production URL.
@@ -143,7 +143,7 @@ docker compose restart api
```bash
# Initiate OIDC flow
curl http://localhost:3001/auth/callback/authentik
curl http://localhost:3001/auth/oauth2/callback/authentik
# This will return a redirect URL to Authentik
```
@@ -223,8 +223,8 @@ Customize Authentik's login page:
```bash
# Ensure exact match (including http vs https)
# In Authentik: http://localhost:3001/auth/callback
# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
# In Authentik: http://localhost:3001/auth/oauth2/callback/authentik
# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
```
### Error: "Invalid client credentials"

View File

@@ -89,7 +89,7 @@ AUTHENTIK_PORT_HTTPS=9443
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
```
**Bootstrap Credentials:**

View File

@@ -191,7 +191,7 @@ Authorization: Bearer {session_token}
OAuth callback handler for Authentik (and other OIDC providers).
```http
GET /auth/callback/authentik
GET /auth/oauth2/callback/authentik
```
**Query Parameters:**
@@ -226,7 +226,7 @@ This endpoint is called by the OIDC provider after successful authentication.
1. User clicks "Sign in with Authentik"
2. Frontend redirects to Authentik
3. User authenticates with Authentik
4. Authentik redirects to /auth/callback/authentik
4. Authentik redirects to /auth/oauth2/callback/authentik
5. Server exchanges code for tokens
6. Server creates/updates user
7. Server creates session

View File

@@ -29,12 +29,12 @@ Context = tokens = cost. Be smart.
2. Code → TDD: write test (RED), implement (GREEN), refactor
3. Test → pnpm test (must pass)
4. Push → git push origin feature/XX-description
5. PR → Create PR to develop (not main)
5. PR → Create PR to main
6. Review → Wait for approval or self-merge if authorized
7. Close → Close related issues via API
```
**Never merge directly to develop without a PR.**
**Never merge directly to main without a PR.**
### Issue Management
@@ -53,7 +53,7 @@ curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/
-d '{"state":"closed"}'
# Create PR (tea CLI works for this)
tea pulls create --repo mosaic/stack --base develop --head feature/XX-name \
tea pulls create --repo mosaic/stack --base main --head feature/XX-name \
--title "feat(#XX): Title" --description "Description"
```

View File

@@ -159,13 +159,12 @@ We follow a Git-based workflow with the following branch types:
### Workflow
1. Always branch from `develop`
2. Merge back to `develop` via pull request
3. `main` is for stable releases only
1. Always branch from `main`
2. Merge back to `main` via pull request
```bash
# Start a new feature
git checkout develop
git checkout main
git pull --rebase
git checkout -b feature/my-feature-name
@@ -269,7 +268,7 @@ Clarified pagination and filtering parameters.
2. Create a PR via GitLab at:
https://git.mosaicstack.dev/mosaic/stack/-/merge_requests
3. Target branch: `develop`
3. Target branch: `main`
4. Fill in the PR template:
- **Title:** `feat(#issue): Brief description` (follows commit format)

171
docs/COOLIFY-DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,171 @@
# Mosaic Stack — Coolify Deployment
## Overview
Coolify deployment on VM `10.1.1.44` (Proxmox). Replaces the Docker Swarm deployment on w-docker0 (`10.1.1.45`).
## Architecture
```
Internet → Cloudflare → Public IP (174.137.97.162)
→ Main Traefik (10.1.1.43) — TCP TLS passthrough for *.woltje.com
→ Coolify Traefik (10.1.1.44) — terminates TLS via Cloudflare DNS-01 wildcard certs
→ Service containers
```
## Services (Core Stack)
| Service | Image | Internal Port | External Domain |
| ------------ | ----------------------------------------------- | --------------- | ----------------------- |
| postgres | `git.mosaicstack.dev/mosaic/stack-postgres` | 5432 | — |
| valkey | `valkey/valkey:8-alpine` | 6379 | — |
| api | `git.mosaicstack.dev/mosaic/stack-api` | 3001 | `api.mosaic.woltje.com` |
| web | `git.mosaicstack.dev/mosaic/stack-web` | 3000 | `mosaic.woltje.com` |
| coordinator | `git.mosaicstack.dev/mosaic/stack-coordinator` | 8000 | — |
| orchestrator | `git.mosaicstack.dev/mosaic/stack-orchestrator` | 3001 (internal) | — |
Matrix (synapse, element-web) and speech services (speaches, kokoro-tts) are NOT included in the core stack. Deploy separately if needed.
## Compose File
`docker-compose.coolify.yml` in the repo root. This is the Coolify-compatible version of the deployment compose.
Key differences from the Swarm compose (`docker-compose.swarm.portainer.yml`):
- No `deploy:` blocks (Swarm-only)
- No Traefik labels (Coolify manages routing)
- Bridge network instead of overlay
- `restart: unless-stopped` instead of Swarm restart policies
- `SERVICE_FQDN_*` magic environment variables for Coolify domain assignment
- List-style environment syntax (required for Coolify magic vars)
## Coolify IDs
| Resource | UUID |
| ----------- | -------------------------- |
| Project | `rs04g008kgkkw4s0wgsk40w4` |
| Environment | `gko8csc804g8og0oosc8ccs8` |
| Service | `ug0ssok4g44wocok8kws8gg8` |
| Server | `as8kcogk08skskkcsok888g4` |
### Application UUIDs
| App | UUID |
| ------------ | --------------------------- |
| postgres | `jcw0ogskkw040os48ggkgkc8` |
| valkey | `skssgwcggc0c8owoogcso8og` |
| api | `mc40cgwwo8okwwoko84408k4k` |
| web | `c48gcwgc40ok44scscowc8cc` |
| coordinator | `s8gwog4c44w08c8sgkcg04k8` |
| orchestrator | `uo4wkg88co0ckc4c4k44sowc` |
## Coolify API
Base URL: `http://10.1.1.44:8000/api/v1`
Auth: Bearer token from `credentials.json``coolify.app_token`
### Patterns & Gotchas
- **Compose must be base64-encoded** when sending via `docker_compose_raw` field
- **`SERVICE_FQDN_*` magic vars**: Coolify reads these from the compose to auto-assign domains. Format: `SERVICE_FQDN_{NAME}_{PORT}` (e.g., `SERVICE_FQDN_API_3001`). Must use list-style env syntax (`- SERVICE_FQDN_API_3001`), NOT dict-style.
- **FQDN updates on sub-applications**: Coolify API doesn't support updating FQDNs on compose service sub-apps via REST. Workaround: update directly in Coolify's PostgreSQL DB (`coolify-db` container, `service_applications` table).
- **Environment variable management**: Use `PATCH /api/v1/services/{uuid}/envs` with `{ "key": "VAR_NAME", "value": "val", "is_preview": false }`
- **Service start**: `POST /api/v1/services/{uuid}/start`
- **Coolify uses PostgreSQL** (not SQLite) for its internal database — container `coolify-db`
### DB Access (for workarounds)
```bash
ssh localadmin@10.1.1.44
docker exec -it coolify-db psql -U coolify -d coolify
-- Check service app FQDNs
SELECT name, fqdn FROM service_applications WHERE service_id = (
SELECT id FROM services WHERE uuid = 'ug0ssok4g44wocok8kws8gg8'
);
```
## Environment Variables
All env vars are set via Coolify API and stored in `/data/coolify/services/{uuid}/.env` on the node.
Critical vars that were missing initially:
- `BETTER_AUTH_URL`**Required** in production. API won't start without it. Set to `https://api.mosaic.woltje.com`.
## Operations
### Restart Procedure (IMPORTANT)
Coolify's `CleanupDocker` action periodically prunes unused images. During a restart (stop → start), images become "unused" when containers stop and may be pruned before the start phase runs. This causes "No such image" failures.
**Always pre-pull images before any Coolify restart/start:**
```bash
ssh localadmin@10.1.1.44
# 1. Pre-pull all images (run in parallel)
docker pull git.mosaicstack.dev/mosaic/stack-postgres:latest &
docker pull valkey/valkey:8-alpine &
docker pull git.mosaicstack.dev/mosaic/stack-api:latest &
docker pull git.mosaicstack.dev/mosaic/stack-web:latest &
docker pull git.mosaicstack.dev/mosaic/stack-coordinator:latest &
docker pull git.mosaicstack.dev/mosaic/stack-orchestrator:latest &
wait
# 2. Remove stale internal network (prevents "already exists" errors)
docker network rm ug0ssok4g44wocok8kws8gg8_internal 2>/dev/null || true
# 3. Start via Coolify API
TOKEN="<from credentials.json>"
curl -X POST "http://10.1.1.44:8000/api/v1/services/ug0ssok4g44wocok8kws8gg8/start" \
-H "Authorization: Bearer $TOKEN"
# 4. Verify (wait ~30s for health checks)
docker ps --filter 'name=ug0ssok4g44wocok8kws8gg8' --format 'table {{.Names}}\t{{.Status}}'
```
### OTEL Configuration
The coordinator's Python OTLP exporter initializes at import time, before checking `MOSAIC_TELEMETRY_ENABLED`. To suppress OTLP connection noise, set the standard OpenTelemetry env var in the service `.env`:
```
OTEL_SDK_DISABLED=true
```
## Current State (2026-02-22)
### Verified Working
- All 6 containers running and healthy
- Web UI at `https://mosaic.woltje.com/login` — 200 OK
- API health at `https://api.mosaic.woltje.com/health` — healthy, PostgreSQL connected
- CORS: `access-control-allow-origin: https://mosaic.woltje.com`
- Runtime env injection: `NEXT_PUBLIC_API_URL=https://api.mosaic.woltje.com`, `AUTH_MODE=real`
- Valkey: PONG
- Coordinator: healthy, no OTLP noise (`OTEL_SDK_DISABLED=true`)
- Orchestrator: healthy
- TLS: Let's Encrypt certs (web + api), valid until May 23 2026
- Auth endpoint: `/auth/get-session` responds correctly
### Resolved Issues
- **#441**: Coordinator OTLP noise — fixed via `OTEL_SDK_DISABLED=true`
- **#442**: Coolify managed lifecycle — root cause was image pruning during restart + CoolifyTask timeout on large pulls. Fix: pre-pull images before start.
- **#443**: Full stack connectivity — all checks pass
### Known Limitations
- Coolify restart is NOT safe without pre-pulling images first (CleanupDocker prunes between stop/start)
- CoolifyTask has ~40s timeout — large image pulls will fail if not cached
## SSH Access
```bash
ssh localadmin@10.1.1.44
# Note: localadmin cannot sudo without TTY/password
# Use docker to access files:
docker run --rm -v /data/coolify/services:/srv alpine cat /srv/{uuid}/docker-compose.yml
# Use docker exec for Coolify DB:
docker exec -it coolify-db psql -U coolify -d coolify
```

View File

@@ -43,7 +43,10 @@ ENCRYPTION_KEY=$(openssl rand -hex 32)
ORCHESTRATOR_API_KEY=$(openssl rand -base64 32)
COORDINATOR_API_KEY=$(openssl rand -base64 32)
# Claude API Key
# AI Provider for Orchestrator
AI_PROVIDER=ollama
# Claude API Key (only required when AI_PROVIDER=claude)
CLAUDE_API_KEY=your-claude-api-key
# Authentik Bootstrap

View File

@@ -144,7 +144,7 @@ sleep 30
docker logs mosaic-openbao-init
# 3. Deploy swarm stack
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 4. Verify API connects to OpenBao
docker service logs mosaic_api | grep -i openbao
@@ -172,7 +172,7 @@ docker logs mosaic-openbao-init
# OPENBAO_SECRET_ID=...
# 2. Deploy stack (no OpenBao)
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 3. Verify API connects to external Vault
docker service logs mosaic_api | grep -i vault

View File

@@ -62,7 +62,7 @@ If using private registry images from `git.mosaicstack.dev`:
4. **Web editor:** Copy and paste contents of `docker-compose.portainer.yml`
5. **Environment variables:**
```
IMAGE_TAG=dev
IMAGE_TAG=latest
OPENBAO_PORT=8200
```
6. Click **Deploy the stack**
@@ -90,7 +90,7 @@ If using private registry images from `git.mosaicstack.dev`:
**Option A: Git Repository (Recommended)**
- Repository URL: `https://git.mosaicstack.dev/mosaic/stack`
- Repository reference: `refs/heads/develop`
- Repository reference: `refs/heads/main`
- Compose path: `docker-compose.swarm.yml`
- Authentication: Enable if repository is private
- Enable **Automatic updates** (optional)
@@ -103,7 +103,7 @@ If using private registry images from `git.mosaicstack.dev`:
4. **Environment variables:**
```
IMAGE_TAG=dev
IMAGE_TAG=latest
POSTGRES_PASSWORD=<your-secure-password>
JWT_SECRET=<your-jwt-secret>
BETTER_AUTH_SECRET=<your-auth-secret>
@@ -111,7 +111,7 @@ If using private registry images from `git.mosaicstack.dev`:
OIDC_CLIENT_ID=<your-oidc-client-id>
OIDC_CLIENT_SECRET=<your-oidc-client-secret>
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
OLLAMA_ENDPOINT=http://10.1.1.42:11434
```
@@ -148,7 +148,7 @@ If using private registry images from `git.mosaicstack.dev`:
```bash
# Image Configuration
IMAGE_TAG=dev # or 'latest' or specific commit SHA
IMAGE_TAG=latest # or 'latest' or specific commit SHA
# Database
POSTGRES_PASSWORD=<secure-password>
@@ -163,7 +163,7 @@ ENCRYPTION_KEY=<64-char-hex> # openssl rand -hex 32
OIDC_CLIENT_ID=<from-authentik>
OIDC_CLIENT_SECRET=<from-authentik>
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
# External Ollama
OLLAMA_ENDPOINT=http://10.1.1.42:11434
@@ -352,7 +352,7 @@ Update environment variables:
```bash
NEXT_PUBLIC_APP_URL=https://mosaic.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
OIDC_REDIRECT_URI=https://api.example.com/auth/callback/authentik
OIDC_REDIRECT_URI=https://api.example.com/auth/oauth2/callback/authentik
```
### Resource Limits

233
docs/PRD.md Normal file
View File

@@ -0,0 +1,233 @@
# PRD: Mosaic Stack Dashboard & Platform Implementation
## Metadata
- Owner: Jason Woltje
- Date: 2026-02-22
- Status: in-progress
- Best-Guess Mode: true
## Problem Statement
The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard that doesn't match the production-ready design vision. The reference design (dashboard.html) defines a comprehensive command center UI with sidebar navigation, topbar, terminal panel, and multiple page layouts. The current implementation uses mismatched design tokens (raw Tailwind colors vs CSS variables), has no collapsible sidebar, no global terminal, and lacks the polished design system from the reference.
## Objectives
1. Implement the dashboard.html reference design as the production UI foundation
2. Establish a consistent CSS design token system that supports multiple themes
3. Build a responsive, accessible app shell with collapsible sidebar and full-width header
4. Create a theme system supporting installable theme packages
5. Build all dashboard pages (Dashboard, Projects, Workspace, Kanban, Files, Logs, Settings, Profile)
6. Implement real backend integration (no mock data)
7. Support multi-tenant configuration with RBAC
8. Implement federation (master-master and master-slave)
9. Build global terminal, project chat, and master chat session
10. Configure telemetry with opt-out support
## Scope
### In Scope (Milestone 0.0.15 — Dashboard Shell & Design System)
1. CSS design token system overhaul (colors, fonts, spacing, radii from dashboard.html)
2. App shell layout: sidebar + full-width header + main content area
3. Full-width header with logo, search, system status, terminal toggle, notifications, theme toggle, user avatar dropdown
4. Collapsible sidebar with nav groups, icons, badges, active states, collapse/expand button
5. Responsive layout with hamburger button at small breakpoints, sidebar hidden by default at mobile
6. Light/dark theme matching the reference design
7. Mosaic logo icon as global loading spinner
8. Shared component updates in packages/ui (Card, Badge, Button, Dot, MetricsStrip, ProgressBar, FilterTabs, SectionHeader, Table, LogLine, Terminal panel)
9. Dashboard page: metrics strip, active orchestrator sessions, quick actions, activity feed, token budget
10. Grain overlay texture from reference design
### In Scope (Future Milestones — Documented for Planning)
11. Additional pages: Projects, Workspace, Kanban, File Manager, Logs & Telemetry, Settings, Profile
12. Theme system with installable theme packages
13. Widget system with installable widget packages, customizable sizes
14. Global terminal (project/orchestrator level, smart)
15. Project-level orchestrator chat
16. Master chat session (collapsible sidebar/slideout, always available)
17. Settings page for ALL environment variables, dynamically configurable via webUI
18. Multi-tenant configuration with admin user management
19. Team management with shared data spaces and chat rooms
20. RBAC for file access, resources, models
21. Federation: master-master and master-slave with key exchange
22. Federation testing: 3 instances on Coolify (woltje.com domain)
23. Agent task mapping configuration (system-level defaults, user-level overrides)
24. Telemetry: opt-out, customizable endpoint, sanitized data
25. File manager with WYSIWYG editing (system/user/project levels)
26. User-level and project-level Kanban with filtering
27. Break-glass authentication user
28. Playwright E2E tests for all pages
29. API documentation via Swagger
30. Backend endpoints for all dashboard data
### Out of Scope
1. Mobile native app
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
3. Production deployment to non-Coolify targets
4. Calendar system redesign (existing calendar implementation is retained)
## User/Stakeholder Requirements
1. The `jarvis` user must be able to log into mosaic.woltje.com via Authentik as administrator with access to all pages
2. A standard `jarvis-user` must operate at a lower permission level
3. A break-glass user must have access without Authentik authentication
4. All pages must be navigable without errors
5. Light and dark themes must work across all pages and components
6. Sidebar must be collapsible with open/close button; hidden by default at small breakpoints
7. Hamburger button visible at lower breakpoints for sidebar control
8. The Mosaic Stack logo icon must be the site-wide loading spinner
9. No mock data — all data pulled from backend APIs
## Functional Requirements
### FR-001: Design Token System
- CSS custom properties for all colors, spacing, typography, radii
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
- Fonts: Outfit (body), Fira Code (monospace)
- All components must use design tokens, never hardcoded colors
### FR-002: App Shell Layout
- CSS Grid: sidebar column + header row + main content
- Full-width header spanning above sidebar and content
- ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
### FR-003: Sidebar Navigation
- Nav groups: Overview (Dashboard), Workspace (Projects, Project Workspace, Kanban, File Manager), Operations (Logs & Telemetry, Terminal), System (Settings)
- Collapsible: icon-only mode when collapsed
- Active state indicator (left border accent)
- User card in footer with avatar, name, role, online status
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
### FR-004: Header/Topbar
- Logo + brand wordmark (left)
- Search bar with keyboard shortcut hint
- System status indicator
- Terminal toggle button
- Notification bell with badge
- Theme toggle (sun/moon icon)
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
### FR-005: Responsive Design
- Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px)
- Below md: sidebar hidden, hamburger button in header
- md-lg: sidebar can be toggled
- lg+: sidebar visible by default
### FR-006: Dashboard Page
- 6-cell metrics strip with colored top borders and trend indicators
- Active Orchestrator Sessions card with agent nodes
- Quick Actions 2x2 grid
- Activity Feed sidebar card
- Token Budget sidebar card with progress bars
### FR-007: Loading Spinner
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
- Used as global loading indicator across all pages
- Available as a shared component
### FR-008: Theme System (Future Milestone)
- Support multiple themes beyond default dark/light
- Themes are installable packages from Mosaic Stack repo
- Theme installation and selection from Settings page
- ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
### FR-009: Terminal Panel (Future Milestone)
- Bottom drawer panel, toggleable from header and sidebar
- Multiple tabs (Orchestrator, Shell, Build)
- Smart terminal operating at project/orchestrator level
- Global terminal for system interaction
### FR-010: Settings Page (Future Milestone)
- All environment variables configurable via UI
- Minimal launch env vars, rest configurable dynamically
- Settings stored in DB with RLS
- Theme selection, widget management, federation config, telemetry config
## Non-Functional Requirements
1. Security: All API endpoints require authentication. RBAC enforced. No PII in telemetry. Secrets never hardcoded.
2. Performance: Dashboard loads in <2s. No layout shift during theme toggle. Sidebar toggle is instant (<100ms animation).
3. Reliability: Break-glass auth ensures access when Authentik is down.
4. Observability: Telemetry with opt-out support. Wide-event logging. Customizable telemetry endpoint.
## Acceptance Criteria
### Milestone 0.0.15
1. Design tokens from dashboard.html are implemented in globals.css
2. App shell shows full-width header with logo, collapsible sidebar, main content area
3. Sidebar has all nav groups with icons, collapses to icon-only mode
4. Hamburger button appears at mobile breakpoints, sidebar hidden by default
5. Light/dark theme toggle works across all components
6. Mosaic logo spinner is used as site-wide loading indicator
7. Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget
8. All shared components in packages/ui use design tokens (no hardcoded colors)
9. Lint, typecheck, and existing tests pass
10. Grain overlay texture from reference is applied
### Full Project (All Milestones)
11. jarvis user logs in via Authentik, has admin access to all pages
12. jarvis-user has standard access at lower permission level
13. Break-glass user has access without Authentik
14. Three Mosaic Stack instances on Coolify with federation testing
15. Playwright tests confirm all pages, functions, theming work
16. No errors during site navigation
17. API documented via Swagger with proper auth gating
18. Telemetry working locally with wide-event logging
19. Mosaic Telemetry properly reporting to telemetry endpoint
## Constraints and Dependencies
1. Next.js 16 with App Router — all pages use server/client component patterns
2. Tailwind CSS 3.4 — design tokens must integrate with Tailwind's utility class system
3. BetterAuth for authentication — must maintain existing auth flow
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
5. PostgreSQL 17 with Prisma — all settings stored in DB
6. Coolify for deployment — 3 instances needed for federation testing
7. packages/ui is shared across apps — changes affect all consumers
## Risks and Open Questions
1. **Risk**: Changing globals.css design tokens may break existing pages (login, knowledge, calendar). Mitigation: Thorough regression testing.
2. **Risk**: packages/ui uses hardcoded Tailwind colors — migration to CSS variables needs care. Mitigation: Phase the migration, test each component.
3. **Open**: Exact federation protocol details for master-master vs master-slave data sync.
4. **Open**: Specific telemetry data points to collect.
5. **Open**: Agent task mapping configuration schema (informed by OpenClaw research).
## Testing and Verification
1. Baseline: `pnpm lint && pnpm build` must pass
2. Situational: Visual verification at sm/md/lg/xl breakpoints
3. Situational: Theme toggle across all pages
4. Situational: Sidebar collapse/expand at all breakpoints
5. E2E: Playwright tests for all page navigation
6. E2E: Auth flow with Authentik
7. Federation: Master-master and master-slave data access tests
## Delivery/Milestone Intent
| Milestone | Version | Focus |
| ----------------------- | ------- | ----------------------------------------------------------------- |
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page |
| MS16-Pages | 0.0.16 | Projects, Workspace, Kanban, Settings, Profile, Files, Logs pages |
| MS17-BackendIntegration | 0.0.17 | API endpoints, real data, Swagger docs |
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, dashboard customization |
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session |
| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth |
| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation |
| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging |
| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization |

View File

@@ -49,7 +49,7 @@ nano .env
- `OIDC_CLIENT_ID` - From your Authentik/OIDC provider
- `OIDC_CLIENT_SECRET` - From your Authentik/OIDC provider
- `OIDC_ISSUER` - Your OIDC provider URL (must end with `/`)
- `IMAGE_TAG` - `dev` or `latest` or specific commit SHA
- `IMAGE_TAG` - `latest` (default) or specific version/commit SHA
### 2. Configure for External Services (Optional)
@@ -131,10 +131,10 @@ See [OpenBao Deployment Guide](OPENBAO-DEPLOYMENT.md) for detailed options.
cd /opt/mosaic/stack
# Using the deploy script (recommended)
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# Or manually
IMAGE_TAG=dev docker stack deploy \
IMAGE_TAG=latest docker stack deploy \
-c docker-compose.swarm.yml \
--with-registry-auth mosaic
```
@@ -369,7 +369,7 @@ sleep 30
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
```
### Using External PostgreSQL

View File

@@ -0,0 +1,141 @@
# Mosaic Stack Design System
## Overview
The Mosaic Stack design system provides a unified visual language across the dashboard application. It uses CSS custom properties for theming, inline styles for `packages/ui` components, and a dark-first approach with light theme support.
## Design Tokens
All tokens are defined in `apps/web/src/app/globals.css` as CSS custom properties on `:root` (dark default) and `[data-theme="light"]`.
### Color Palette
| Token | Dark Value | Purpose |
| ----------------- | ---------- | ------------------------------ |
| `--ms-blue-400` | `#4d94ff` | Primary accent, active states |
| `--ms-blue-500` | `#2f80ff` | Primary brand color |
| `--ms-teal-400` | `#2dd4bf` | Success, positive metrics |
| `--ms-teal-500` | `#14b8a6` | Success emphasis |
| `--ms-purple-400` | `#a78bfa` | Orchestrator, secondary accent |
| `--ms-purple-500` | `#8b5cf6` | Purple emphasis |
| `--ms-amber-400` | `#fbbf24` | Warnings, caution |
| `--ms-red-400` | `#f87171` | Errors, danger |
| `--ms-cyan-500` | `#06b6d4` | Info, neutral accent |
### Semantic Tokens
| Token | Dark Value | Purpose |
| ------------- | --------------------- | ---------------------- |
| `--bg` | `#0a0e17` | Page background |
| `--bg-mid` | `#111827` | Card/panel background |
| `--bg-deep` | `#060a12` | Terminal/deep surfaces |
| `--surface` | `#151c2c` | Card surfaces |
| `--surface-2` | `#1e2940` | Hover/active surfaces |
| `--border` | `#1e2940` | Default borders |
| `--text` | `#e8ecf4` | Primary text |
| `--text-2` | `#94a3b8` | Secondary text |
| `--muted` | `#64748b` | Muted/label text |
| `--primary` | `var(--ms-blue-500)` | Primary action |
| `--success` | `var(--ms-teal-500)` | Success state |
| `--danger` | `var(--ms-red-400)` | Error state |
| `--warn` | `var(--ms-amber-400)` | Warning state |
### Typography
| Token | Value | Purpose |
| -------- | ------------------------------ | ------------------------- |
| `--font` | `Inter, system-ui, sans-serif` | Body text |
| `--mono` | `JetBrains Mono, monospace` | Code, metrics, timestamps |
### Spacing & Radii
| Token | Value | Purpose |
| -------- | ------ | ------------------------------- |
| `--r-sm` | `4px` | Small radius (buttons, inputs) |
| `--r` | `8px` | Default radius (cards, panels) |
| `--r-lg` | `12px` | Large radius (sections, modals) |
## App Shell Layout
The dashboard uses a CSS Grid layout:
```
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
```
- **Header** spans full width (`grid-column: 1 / -1`)
- **Sidebar** width: `240px` (expanded) / `60px` (collapsed)
- **Topbar** height: `52px`
### Responsive Breakpoints
| Breakpoint | Behavior |
| --------------------- | ----------------------------------------------- |
| `< 768px` (Mobile) | Single column, sidebar as overlay with backdrop |
| `768-1023px` (Tablet) | Sidebar toggleable, hidden by default |
| `>= 1024px` (Desktop) | Sidebar always visible |
## Shared Components (packages/ui)
All `packages/ui` components use inline styles with CSS custom properties since the package is built with `tsc` only (no CSS processing).
### Button
Variants: `primary` (blue), `secondary` (transparent), `ghost`, `danger` (red), `success` (teal)
### Badge
Variants: `badge-teal`, `badge-amber`, `badge-red`, `badge-blue`, `badge-muted`, `badge-purple`, `badge-pulse`
Pill shape with monospace font. `badge-pulse` includes an animated dot.
### Card
Flat design with `var(--surface)` background, `var(--border)` border, `var(--r-lg)` border-radius. Header and footer slots available.
### Dot
Status indicator (7x7px circle with glow). Variants: `teal`, `blue`, `amber`, `red`, `muted`.
### MetricsStrip
Grid of metric cells with colored top borders, hover state, and optional trend indicators. Each cell: value (large mono), label (small muted), trend (colored directional text).
### ProgressBar
4px track with fill animation. Variants: `blue`, `teal`, `purple`, `amber`. Full ARIA progressbar semantics.
### FilterTabs
Segmented control with active/hover states.
### SectionHeader
Flex row with title/subtitle on left, action slot on right.
### DataTable
Generic typed table with column render functions, row hover, and row click support.
### LogLine
3-column grid (timestamp, level, message) with color-coded log levels.
## Dashboard Page Layout
The dashboard page uses:
1. **MetricsStrip** (full width, 6 cells)
2. **Two-column grid** (`1fr 320px`):
- Main: OrchestratorSessions + QuickActions
- Sidebar: ActivityFeed + TokenBudget
## Terminal Panel
Bottom drawer component (`apps/web/src/components/terminal/TerminalPanel.tsx`) with:
- Height animation (0 → 280px)
- Tab bar (main, build, logs)
- Color-coded output lines (prompt, command, output, error, warning, success)
- Blinking cursor animation

View File

@@ -9,17 +9,15 @@ Images are tagged based on branch and event type:
| Trigger | Tags Applied | Example |
| ----------------- | ----------------- | -------------------- |
| Push to `main` | `{sha}`, `latest` | `658ec077`, `latest` |
| Push to `develop` | `{sha}`, `dev` | `a1b2c3d4`, `dev` |
| Git tag (release) | `{sha}`, `{tag}` | `658ec077`, `v1.0.0` |
### Tag Meanings
| Tag | Purpose | Stability |
| -------------------------- | ------------------------------------------ | --------- |
| `latest` | Current production-ready build from `main` | Stable |
| `dev` | Current development build from `develop` | Unstable |
| `v*` (e.g., `v1.0.0`) | Versioned release | Immutable |
| `{sha}` (e.g., `658ec077`) | Specific commit for traceability | Immutable |
| Tag | Purpose | Stability |
| -------------------------- | ---------------------------------- | --------- |
| `latest` | Current build from `main` | Latest |
| `v*` (e.g., `v1.0.0`) | Versioned release | Immutable |
| `{sha}` (e.g., `658ec077`) | Specific commit for traceability | Immutable |
## Retention Policy Configuration

View File

@@ -152,7 +152,7 @@ States:
Add `OIDC_REDIRECT_URI` to `REQUIRED_OIDC_ENV_VARS`. Add URL format validation:
- Must be a valid URL
- Path must start with `/auth/callback`
- Path must start with `/auth/oauth2/callback`
- Warn if using `localhost` in production
**Tests to add:** Missing var, invalid URL, invalid path, valid URL.
@@ -716,9 +716,9 @@ Browser NestJS API Authentik
├────────────────────────────────────────────────────►│
│ │ User authenticates│
│◄────────────────────────────────────────────────────┤
│ 302 → /auth/callback/authentik?code=X │
│ 302 → /auth/oauth2/callback/authentik?code=X │
│ │ │
│ 5. GET /auth/callback/authentik?code=X │
│ 5. GET /auth/oauth2/callback/authentik?code=X │
├───────────────────────────►│ │
│ BetterAuth exchanges code │
│ ├───────────────────────►│

View File

@@ -0,0 +1,240 @@
# 362 - Auth Session Chain Debug (Authentik -> BetterAuth -> API Guard)
## Context
- Date (UTC): 2026-02-19
- Environment under test: production domains
- Web: `https://app.mosaicstack.dev/login`
- API: `https://api.mosaicstack.dev`
- IdP: `https://auth.diversecanvas.com`
- Tooling: Playwright MCP + Chromium
## Problem Statement
Users can complete Authentik login and consent, but Mosaic web app returns to login and remains unauthenticated.
## Timeline and Evidence
1. Initial reproduction from web login:
- `POST /auth/sign-in/oauth2` returned `200` with Authentik authorize URL.
- Authentik login flow and consent screen loaded correctly.
2. First callback failure mode (before `jarvis` email fix):
- Callback ended at API error redirect with `error=email_is_missing`.
- Result URL: `https://api.mosaicstack.dev/?error=email_is_missing`.
3. User updated Authentik account:
- `jarvis` account email set to `jarvis@mosaic.local`.
- `email_is_missing` failure no longer occurs.
4. Current callback behavior (after email fix):
- `GET /auth/oauth2/callback/authentik?code=...&state=...` returns `302` to `https://app.mosaicstack.dev/`.
- Callback sets BetterAuth cookies:
- `__Secure-better-auth.state=...; Max-Age=0; ...`
- `__Secure-better-auth.session_token=...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax`
- Browser cookie jar confirms session cookie present for `api.mosaicstack.dev`.
5. Session validation mismatch (critical):
- BetterAuth direct session endpoint succeeds:
- `GET /auth/get-session` -> `200` with session payload.
- Guarded API session endpoint fails:
- `GET /auth/session` -> `401` with
`{"message":"Invalid or expired session", ...}`
- Reproduced repeatedly in same browser context immediately after callback.
## Config Sync Notes
User synced local files with deployed Portainer stack:
- `.env` updated with deployed values.
- `docker-compose.swarm.portainer.yml` changed:
- Removed `BETTER_AUTH_URL` env mapping from API service.
Observed auth behavior after sync:
- Improvement: removed `email_is_missing` callback error.
- Remaining failure: `/auth/session` still returns 401 despite valid BetterAuth cookie and successful `/auth/get-session`.
## Root Cause Hypothesis (Strong)
`AuthGuard` extracts BetterAuth session cookie token correctly, but `AuthService.verifySession()` validates it using `Authorization: Bearer <token>` instead of a BetterAuth cookie/header context.
Relevant code paths:
- `apps/api/src/auth/guards/auth.guard.ts`
- extracts `__Secure-better-auth.session_token` / `better-auth.session_token`
- `apps/api/src/auth/auth.service.ts`
- `verifySession()` calls `auth.api.getSession({ headers: { authorization: "Bearer ..." } })`
Why this matches evidence:
- `/auth/get-session` (native BetterAuth endpoint reading request cookie) succeeds.
- `/auth/session` (custom guard + verify path) fails for same browser session.
## Next Actions
1. Fix `verifySession()` to validate using BetterAuth-compatible cookie header candidates first, with bearer fallback for API clients.
2. Add/update unit tests in `auth.service.spec.ts` to cover cookie-first validation and bearer fallback.
3. Re-run targeted API auth tests.
4. Re-run Playwright auth chain to confirm:
- callback sets cookie
- `/auth/session` returns `200`
- web app transitions out of `/login`.
## Implementation Update (2026-02-19)
Completed items:
1. Updated backend session verification logic:
- File: `apps/api/src/auth/auth.service.ts`
- `verifySession()` now tries session resolution in this order:
- `cookie: __Secure-better-auth.session_token=<token>`
- `cookie: better-auth.session_token=<token>`
- `cookie: __Host-better-auth.session_token=<token>`
- `authorization: Bearer <token>` (fallback)
- Added helper methods:
- `buildSessionHeaderCandidates()`
- `isExpectedAuthError()`
2. Added/updated tests:
- File: `apps/api/src/auth/auth.service.spec.ts`
- Added RED->GREEN test:
- `should validate session token using secure BetterAuth cookie header`
- Updated fallback coverage test:
- `should fall back to Authorization header when cookie-based lookups miss`
3. Verification:
- Command: `pnpm --filter @mosaic/api test -- src/auth/auth.service.spec.ts`
- Result: pass (all tests green).
- Command: `pnpm --filter @mosaic/api lint`
- Result: pass.
Remaining step (requires deploy):
- Redeploy API with this patch and rerun live Playwright flow on `app.mosaicstack.dev` to confirm `/auth/session` returns `200` after callback.
## Playwright Re-Check (2026-02-19, later run)
Live flow evidence after previous deploy attempt:
1. OAuth callback succeeds:
- `GET https://api.mosaicstack.dev/auth/oauth2/callback/authentik?code=...&state=...` -> `302`
- Redirect target observed: `https://app.mosaicstack.dev/`
- Browser cookie jar includes:
- `__Secure-better-auth.session_token` on `api.mosaicstack.dev` (HttpOnly, Secure, SameSite=Lax)
2. Session bootstrap still fails immediately:
- `GET https://api.mosaicstack.dev/auth/session` -> `500`
- Response body shape:
- `{"success":false,"message":"An unexpected error occurred","errorId":"...","path":"/auth/session","statusCode":500}`
- Web app returns to login because session fetch fails.
3. Frontend version mismatch observed:
- Live `POST /auth/sign-in/oauth2` response from login flow still shows callback URL pointing to `/dashboard`.
- Current repository login page uses callback URL `/`.
- This indicates deployed web image is older than current `develop` code (or stale image tag in runtime).
## Additional Code Fix Applied Locally (pending push/deploy)
Refined cookie candidate construction in API session verification:
- File: `apps/api/src/auth/auth.service.ts`
- Removed URL-encoding of session token when constructing cookie headers.
- Cookie candidates now pass raw token value exactly as extracted from incoming cookie.
Why:
- BetterAuth cookie tokens can contain characters like `/`, `+`, and `=`.
- Re-encoding these values can mutate token bytes and cause lookup/parse failures.
Regression test added:
- File: `apps/api/src/auth/auth.service.spec.ts`
- `should preserve raw cookie token value without URL re-encoding`
## Deploy + Live Repro (after auth cookie fix deploy)
Deployment actions executed:
1. Pushed auth cookie fix commit to `develop`.
2. Waited for Woodpecker pipeline success (`mosaic/stack`, build `#514`).
3. On `10.1.1.90`:
- Ran `/home/localadmin/mosaic/pull_all.sh`.
- Updated swarm services to `:dev` images:
- `stack_api`
- `stack_web`
- `stack_coordinator`
- `stack_orchestrator`
- Verified service convergence.
Post-deploy behavior:
- Initial `/auth/session` without cookies now returns `401` (expected).
- OAuth callback succeeds and sets BetterAuth session cookie.
- `/auth/session` still fails after callback, now due to a new backend `500`.
## New Root Cause Discovered (RLS interceptor SQL)
Live `stack_api` logs showed:
- Auth guard successfully finds session cookie:
- `Session cookie found: __Secure-better-auth.session_token`
- Then failure inside RLS setup:
- PostgreSQL `42601` syntax error at or near `$1`
- Source: `RlsContextInterceptor` raw SQL while setting context vars
- Request ends as `500 Request processing failed` on `/auth/session`
Cause:
- `SET LOCAL app.current_user_id = ${userId}` became `SET LOCAL ... = $1` under parameterization.
- PostgreSQL does not accept bind placeholders in `SET` assignment syntax.
## RLS Fix Applied Locally (pending commit/deploy)
Files updated:
- `apps/api/src/common/interceptors/rls-context.interceptor.ts`
- Replaced `SET LOCAL` statements with parameter-safe, transaction-local calls:
- `SELECT set_config('app.current_user_id', ${userId}, true)`
- `SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`
- Keeps transaction scoping (`true` => local to transaction).
- `apps/api/src/common/interceptors/rls-context.interceptor.spec.ts`
- Updated expected SQL template fragments to `set_config(...)`.
- `apps/api/src/common/interceptors/rls-context.integration.spec.ts`
- Updated integration expectations to `set_config(...)`.
## Deploy + Verify (RLS fix commit `8424a28`)
Pipeline and deploy sequence:
1. Commit `8424a28` pushed to `develop`.
2. Woodpecker pipeline `mosaic/stack#515` completed successfully.
3. Host deploy actions on `10.1.1.90`:
- Ran `/home/localadmin/mosaic/pull_all.sh`
- Updated swarm services (`stack_api`, `stack_web`, `stack_coordinator`, `stack_orchestrator`) to `:dev`
Observed issue after first restart:
- Playwright still reproduced `/auth/session` `500` after Authentik callback.
- `stack_api` logs still showed old RLS SQL failure (`SET LOCAL ... $1`), indicating runtime image drift/stale task.
Resolution:
1. Checked host image digest for API:
- `git.mosaicstack.dev/mosaic/stack-api:dev` -> `sha256:fd0cbfe053ed27945577553d67da5cbda0bf71610006e5ccc197d5761e29a220`
2. Forced swarm API service to exact digest:
- `docker service update --with-registry-auth --image git.mosaicstack.dev/mosaic/stack-api@sha256:fd0cbfe053ed27945577553d67da5cbda0bf71610006e5ccc197d5761e29a220 stack_api`
3. Verified new running task uses digest-pinned image.
Final verification (Playwright MCP):
- Login flow: `https://app.mosaicstack.dev/login` -> Authentik (`jarvis` / `jarvis`) -> redirect back to app.
- Session endpoint: `GET https://api.mosaicstack.dev/auth/session` -> `200`.
- App landed authenticated on `https://app.mosaicstack.dev/tasks` (not bounced to login).
Status:
- Auth chain is functioning end-to-end after digest-forced API rollout.
- Remaining console noise observed: missing `favicon.ico` (`404`) on app domain (non-blocking for auth).

View File

@@ -166,7 +166,7 @@ To use the authentication system, configure these environment variables:
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# JWT Session Management
JWT_SECRET=change-this-to-a-random-secret-in-production
@@ -186,7 +186,7 @@ BetterAuth provides these endpoints automatically:
- `POST /auth/sign-up` - User registration
- `POST /auth/sign-out` - Logout
- `GET /auth/session` - Get current session
- `GET /auth/callback/authentik` - OAuth callback handler
- `GET /auth/oauth2/callback/authentik` - OAuth callback handler
- `GET /auth/profile` - Get authenticated user profile (custom)
---

View File

@@ -188,7 +188,7 @@ All components must follow TDD (tests first), achieve 85%+ coverage, and use PDA
### Existing Auth Implementation (from Issue #4)
- BetterAuth is configured in the API (`apps/api/src/auth/`)
- Endpoints: `/auth/callback/authentik`, `/auth/session`, `/auth/profile`
- Endpoints: `/auth/oauth2/callback/authentik`, `/auth/session`, `/auth/profile`
- Shared types available in `@mosaic/shared` package
- Session-based auth with JWT tokens
@@ -313,7 +313,7 @@ Based on existing backend (from Issue #4):
- `GET /auth/session` - Get current session
- `GET /auth/profile` - Get user profile
- `POST /auth/sign-out` - Logout
- `GET /auth/callback/authentik` - OIDC callback (redirect from Authentik)
- `GET /auth/oauth2/callback/authentik` - OIDC callback (redirect from Authentik)
### Tasks (to be implemented in future issue)

View File

@@ -161,7 +161,7 @@ Enhance `ConnectionService` to handle OIDC-based authentication:
**Integration Tests**:
- POST /auth/initiate starts OIDC flow with correct params
- GET /auth/callback handles OIDC response and creates identity
- GET /auth/oauth2/callback/:providerId handles OIDC response and creates identity
- POST /auth/validate validates tokens from federated instances
- GET /auth/identities returns user's federated identities
- Federated requests with valid tokens are authenticated

View File

@@ -0,0 +1,63 @@
# MS15 — Dashboard Shell & Design System
## Objective
Implement the dashboard.html reference design across the Mosaic Stack web app. Establish the design token system, app shell layout, shared components, and dashboard page.
## Design Reference
`/home/jwoltje/src/mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html`
## Key Architectural Decisions
1. **Theme approach**: CSS custom properties via `:root` + `[data-theme="light"]`. Tailwind configured to use these variables. ThemeProvider updated to set `data-theme` attribute on `<html>`.
2. **Logo placement**: Logo in full-width header (topbar), NOT in sidebar. User spec overrides reference design.
3. **Sidebar collapse**: Collapsible with icon-only mode. Hidden by default at mobile breakpoints. Hamburger button for small screens.
4. **Fonts**: Outfit (body) + Fira Code (mono). Loaded via `next/font/google`.
5. **Loading spinner**: Mosaic logo icon component with CSS rotation animation.
6. **Grain overlay**: Subtle noise texture via CSS pseudo-element, same as reference.
7. **Per-phase branches**: `feat/ms15-design-system`, `feat/ms15-shared-components`, `feat/ms15-dashboard-page`.
## Phases
### Phase 1: Foundation (Design System & App Shell)
- MS15-FE-001: Design token system overhaul
- MS15-FE-002: App shell grid layout
- MS15-FE-003: Sidebar component
- MS15-FE-004: Topbar/Header component
- MS15-FE-005: Responsive breakpoints
- MS15-FE-006: Loading spinner (Mosaic logo)
### Phase 2: Shared Components
- MS15-UI-001: packages/ui token alignment
- MS15-UI-002: Card, Badge, Button, Dot updates
- MS15-UI-003: MetricsStrip, ProgressBar, FilterTabs
- MS15-UI-004: SectionHeader, Table, LogLine
- MS15-UI-005: Terminal panel component
### Phase 3: Dashboard Page
- MS15-DASH-001: Metrics strip
- MS15-DASH-002: Active Orchestrator Sessions
- MS15-DASH-003: Quick Actions
- MS15-DASH-004: Activity Feed
- MS15-DASH-005: Token Budget
### Phase 4: Quality
- MS15-QA-001: Baseline tests
- MS15-QA-002: Situational tests
- MS15-DOC-001: Documentation
## Progress Log
- Session started: 2026-02-22
- Status: Bootstrap phase
## Risks
- Large surface area: globals.css change affects all existing pages
- packages/ui color system mismatch requires careful migration
- Existing pages (login, auth) may need adjustment after token changes

View File

@@ -0,0 +1,45 @@
# MS15-FE-006: MosaicLogo and MosaicSpinner Components
## Task
Create Mosaic logo icon component and spinner wrapper for use as the site-wide loading indicator.
## Files to Create
1. `apps/web/src/components/ui/MosaicLogo.tsx` — 5-element logo icon
2. `apps/web/src/components/ui/MosaicSpinner.tsx` — spinner wrapper
## Files to Modify
1. `apps/web/src/app/(authenticated)/layout.tsx` — replace loading spinner (isLoading block only)
## Design
- 4 corner squares: blue (TL), purple (TR), teal (BR), amber (BL)
- 1 center circle: pink
- CSS vars: --ms-blue-500, --ms-purple-500, --ms-teal-500, --ms-amber-500, --ms-pink-500
- Animation: linear 360deg rotation
## Props
### MosaicLogo
- size?: number (default 36)
- spinning?: boolean (default false)
- spinDuration?: number (default 20) seconds
- className?: string
### MosaicSpinner
- Wraps MosaicLogo with spinning=true
- label?: string — optional text label below
- fullPage?: boolean — center on screen
## Status
- [x] Scratchpad created
- [ ] MosaicLogo.tsx created
- [ ] MosaicSpinner.tsx created
- [ ] layout.tsx updated
- [ ] Lint clean
- [ ] Committed and pushed

Some files were not shown because too many files have changed in this diff Show More