Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecdc089858 | ||
|
|
8a604750b1 | ||
|
|
d53b82d5ce | ||
|
|
64f31f2f55 | ||
|
|
33c26607a6 | ||
|
|
d1d7d4df25 | ||
|
|
8ce12d8d8b | ||
|
|
ec9c105a6e | ||
|
|
4d2580772f | ||
|
|
7594b95570 | ||
|
|
e0fd669598 | ||
|
|
cebbd5a42c | ||
|
|
8c6478cb82 | ||
|
|
a31f4517c1 | ||
|
|
8b4edcf187 | ||
|
|
de5ae59f68 | ||
|
|
a65406448c | ||
|
|
908cd5493e | ||
|
|
364da76d9e | ||
|
|
eb15c057c8 | ||
|
|
16e3b451db | ||
|
|
79e884dd44 | ||
|
|
256595062b | ||
|
|
f0312ac278 | ||
|
|
b4988a2a6c | ||
|
|
ab14835c8b | ||
|
|
fff0b4cf27 |
@@ -1,49 +1,14 @@
|
||||
name: Update Test Server
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Update Test Server (sync notice)"
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Docker Dev Branch Images Build"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: hanzo-deploy-linux-amd64
|
||||
if: |
|
||||
github.repository == 'danny-avila/Chat' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'))
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH Key
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
with:
|
||||
key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
|
||||
known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
|
||||
|
||||
- name: Run update script on DigitalOcean Droplet
|
||||
env:
|
||||
DO_HOST: ${{ secrets.DO_HOST }}
|
||||
DO_USER: ${{ secrets.DO_USER }}
|
||||
run: |
|
||||
ssh ${DO_USER}@${DO_HOST} << EOF
|
||||
sudo -i -u danny bash << 'EEOF'
|
||||
cd ~/Chat && \
|
||||
git fetch origin main && \
|
||||
sudo npm run stop:deployed && \
|
||||
sudo docker images --format "{{.Repository}}:{{.ID}}" | grep -E "lc-dev|chat" | cut -d: -f2 | xargs -r sudo docker rmi -f || true && \
|
||||
sudo npm run update:deployed && \
|
||||
git checkout dev && \
|
||||
git pull origin dev && \
|
||||
git checkout do-deploy && \
|
||||
git rebase dev && \
|
||||
sudo npm run start:deployed && \
|
||||
echo "Update completed. Application should be running now."
|
||||
EEOF
|
||||
EOF
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,95 +1,14 @@
|
||||
name: Docker Dev Branch Images Build
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Dev Branch Images Build (sync notice)"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.multi'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 130
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-build
|
||||
file: Dockerfile.multi
|
||||
image_name: lc-dev-api
|
||||
- target: node
|
||||
file: Dockerfile
|
||||
image_name: lc-dev
|
||||
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Prepare the environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,91 +1,14 @@
|
||||
name: Docker Dev Images Build
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Dev Images Build (sync notice)"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.multi'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 130
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-build
|
||||
file: Dockerfile.multi
|
||||
image_name: chat-dev-api
|
||||
- target: node
|
||||
file: Dockerfile
|
||||
image_name: chat-dev
|
||||
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Prepare the environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,79 +1,14 @@
|
||||
name: Docker Dev Staging Images Build
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Dev Staging Images Build (sync notice)"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-build
|
||||
file: Dockerfile.multi
|
||||
image_name: lc-dev-staging-api
|
||||
- target: node
|
||||
file: Dockerfile
|
||||
image_name: lc-dev-staging
|
||||
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Prepare the environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,71 +1,14 @@
|
||||
# Hanzo Chat — image build (amd64, self-hosted hanzoai pool).
|
||||
#
|
||||
# chat is a PUBLIC repo, so it cannot reuse the private hanzoai/.github
|
||||
# reusable workflow (GitHub blocks public->private workflow reuse).
|
||||
# This is therefore self-contained.
|
||||
#
|
||||
# Builds ghcr.io/hanzoai/chat on:
|
||||
# - push to main -> immutable sha-<sha7> tag
|
||||
# - v* tags -> semver image tag (e.g. v0.7.10 -> 0.7.10)
|
||||
# No :latest, no floating tags (semver-only policy). amd64 only
|
||||
# (DOKS is all amd64 — no arm64 runners).
|
||||
name: Docker Release
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Release (sync notice)"
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
# hanzoai org pool — ARC routes by runnerScaleSetName, not labels.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/hanzoai/chat
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push amd64 image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=ghcr.io/hanzoai/chat-cache:amd64
|
||||
cache-to: type=registry,ref=ghcr.io/hanzoai/chat-cache:amd64,mode=max
|
||||
provenance: false
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,92 +1,14 @@
|
||||
name: Docker Compose Build Latest Main Image Tag (Manual Dispatch)
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Compose Build Latest Main Image Tag (Manual Dispatch) (sync notice)"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-build
|
||||
file: Dockerfile.multi
|
||||
image_name: chat-api
|
||||
- target: node
|
||||
file: Dockerfile
|
||||
image_name: chat
|
||||
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch tags and set the latest tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags --force
|
||||
LATEST_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1)
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "::error::No stable v<semver> tag found"
|
||||
exit 1
|
||||
fi
|
||||
printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV"
|
||||
printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Prepare the environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -1,116 +1,14 @@
|
||||
name: Docker Images Build on Tag
|
||||
|
||||
# Neutralized — the native deploy pipeline is .hanzo/workflows/deploy.yml
|
||||
# (Hanzo Git push -> in-cluster act_runner -> BuildKit builds ./Dockerfile ->
|
||||
# ghcr.io/hanzoai/chat:<sha> -> kubectl patch app/chat -> operator reconcile).
|
||||
# GitHub is a mirror; this workflow no longer builds, publishes, or deploys.
|
||||
name: "Docker Images Build on Tag (sync notice)"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-build
|
||||
file: Dockerfile.multi
|
||||
image_name: chat-api
|
||||
- target: node
|
||||
file: Dockerfile
|
||||
image_name: chat
|
||||
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate release tag
|
||||
id: release-tag
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$'
|
||||
STABLE_TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+$'
|
||||
if [[ ! "$REF_NAME" =~ $TAG_REGEX ]]; then
|
||||
echo "::error::Docker release tags must use v<semver> or v<semver>-rcN, for example v0.8.5 or v0.8.5-rc1"
|
||||
exit 1
|
||||
fi
|
||||
printf 'image_tag=%s\n' "$REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$REF_NAME" =~ $STABLE_TAG_REGEX ]]; then
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Prepare the environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
- name: Resolve image tags
|
||||
id: image-tags
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
IMAGE_NAME: ${{ matrix.image_name }}
|
||||
IMAGE_TAG: ${{ steps.release-tag.outputs.image_tag }}
|
||||
IS_STABLE: ${{ steps.release-tag.outputs.is_stable }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo 'tags<<EOF'
|
||||
printf 'ghcr.io/%s/%s:%s\n' "$GITHUB_REPOSITORY_OWNER" "$IMAGE_NAME" "$IMAGE_TAG"
|
||||
printf '%s/%s:%s\n' "$DOCKERHUB_USERNAME" "$IMAGE_NAME" "$IMAGE_TAG"
|
||||
if [ "$IS_STABLE" = "true" ]; then
|
||||
printf 'ghcr.io/%s/%s:latest\n' "$GITHUB_REPOSITORY_OWNER" "$IMAGE_NAME"
|
||||
printf '%s/%s:latest\n' "$DOCKERHUB_USERNAME" "$IMAGE_NAME"
|
||||
fi
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
push: true
|
||||
tags: ${{ steps.image-tags.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
- name: Sync notice
|
||||
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: hanzo-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build + push image
|
||||
run: |
|
||||
SHA="${GITHUB_SHA::8}"
|
||||
buildctl-daemonless.sh build --frontend=dockerfile.v0 \
|
||||
--opt context="${{ github.server_url }}/${{ github.repository }}.git#${GITHUB_SHA}" \
|
||||
--opt filename=Dockerfile --opt platform=linux/amd64 \
|
||||
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
|
||||
--output "type=image,name=ghcr.io/hanzoai/chat:${SHA},push=true" --progress=plain
|
||||
env:
|
||||
GIT_AUTH_TOKEN: ${{ secrets.GIT_CLONE_TOKEN }}
|
||||
- name: Deploy — declare tag to operator
|
||||
run: |
|
||||
for app in chat; do
|
||||
kubectl -n hanzo patch app "$app" --type=merge -p "{\"spec\":{\"image\":{\"repository\":\"ghcr.io/hanzoai/chat\",\"tag\":\"${GITHUB_SHA::8}\"}}}"
|
||||
done
|
||||
@@ -95,7 +95,7 @@ CREDS_KEY= CREDS_IV= # Credential encryption
|
||||
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, the landing IS the chat
|
||||
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
|
||||
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
|
||||
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
|
||||
Zen model (`GUEST_MODEL`, default `zen5-flash`) via the `Hanzo` custom endpoint
|
||||
(`api.hanzo.ai`). Prod uses `GUEST_MESSAGE_MAX=2`. Exhausting the quota returns
|
||||
`402 {type:'GUEST_LIMIT'}` and the client opens the existing OpenID/hanzo.id login.
|
||||
|
||||
@@ -117,27 +117,36 @@ Security model (fail-closed, server-enforced):
|
||||
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP
|
||||
(`guestTokenLimiter`, `GUEST_TOKEN_MAX`/`GUEST_TOKEN_WINDOW`) so tokens can't be
|
||||
spam-minted.
|
||||
- `requireGuestOrJwtAuth` (chat-completion route ONLY) accepts guest tokens;
|
||||
the standard `jwt` strategy rejects them everywhere else (no DB user), so
|
||||
every other route stays closed. `enforceGuestScope` pins endpoint+model and
|
||||
strips agents/tools/files/spec/preset. Guests always use the shared, capped
|
||||
`HANZO_API_KEY` (per-user `hk-` billing is skipped for `guest` principals).
|
||||
- `requireGuestOrJwtAuth` (chat-completion + guest-safe bootstrap routes: models,
|
||||
endpoints, user, convos, favorites, agents `/chat/active`) accepts guest tokens;
|
||||
the standard `jwt` strategy rejects them everywhere else (no DB user), so every
|
||||
other route stays closed. `enforceGuestScope` pins endpoint+model and strips
|
||||
agents/tools/files/spec/preset. Guests use the shared, capped guest gateway key
|
||||
`GUEST_API_KEY` (the KMS `chat-guest-key`; `HANZO_API_KEY` is the dev fallback),
|
||||
resolved in `packages/api/src/endpoints/custom/initialize.ts` — the guest key's
|
||||
OWN org is metered+capped at the gateway, and per-user `hk-` billing is skipped
|
||||
for `guest` principals (they carry no forwardable bearer and no `X-Org-Id`).
|
||||
- `guestMessageLimiter` enforces the quota against the REAL client IP
|
||||
(`utils/guestClientIp` → Cloudflare `CF-Connecting-IP`, falls back to `req.ip`),
|
||||
NOT the token — clearing cookies / incognito / minting a fresh token does NOT
|
||||
reset it. Backed by the shared Redis `limiterCache` so it holds across replicas.
|
||||
`USE_REDIS=true` is MANDATORY (a memory store would let a visitor round-robin
|
||||
pods to multiply their quota).
|
||||
reset it. The store is `limiterCache`, which returns `undefined` when `USE_REDIS`
|
||||
is off → `express-rate-limit` uses its in-process MemoryStore. That store is
|
||||
authoritative at the live deploy's `replicas: 1` + `Recreate` (never two live
|
||||
pods to round-robin); Redis is NOT required (it was killed platform-wide). The
|
||||
only reset is a pod restart — operational, not attacker-triggerable. If guest
|
||||
chat ever scales past one replica, set `USE_REDIS=true` so the count holds
|
||||
across pods.
|
||||
- Key files: `api/server/services/guestConfig.js`,
|
||||
`api/server/controllers/auth/GuestController.js`,
|
||||
`api/server/middleware/{requireGuestOrJwtAuth,enforceGuestScope}.js`,
|
||||
`api/server/middleware/limiters/{guestLimiters,guestMessageLimiter}.js`,
|
||||
`api/server/utils/guestClientIp.js`,
|
||||
router wiring in `api/server/routes/agents/index.js`.
|
||||
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MESSAGE_MAX`, `GUEST_ENDPOINT`, `GUEST_MODEL`,
|
||||
`GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`, `GUEST_TOKEN_WINDOW`. Requires
|
||||
`HANZO_API_KEY` (the free publishable gateway key) and `USE_REDIS` for the
|
||||
shared per-IP quota across replicas.
|
||||
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MODEL` (prod `zen5-flash`), `GUEST_ENDPOINT`
|
||||
(`Hanzo`), `GUEST_MESSAGE_MAX` (prod `2`), `GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`,
|
||||
`GUEST_TOKEN_WINDOW`. Requires the shared, capped guest key `GUEST_API_KEY`
|
||||
(KMS `chat-guest-key`; falls back to `HANZO_API_KEY`). No Redis dependency at
|
||||
`replicas: 1` — the in-process MemoryStore is the quota's single source of truth.
|
||||
|
||||
## Cloud Agents (canonical /v1/agents)
|
||||
|
||||
|
||||
@@ -82,4 +82,4 @@ packages/ data-provider · data-schemas · api · client · agents · mcp
|
||||
|
||||
## License
|
||||
|
||||
MIT. Forked from LibreChat (MIT). See [LICENSE](./LICENSE).
|
||||
MIT. MIT licensed. See [LICENSE](./LICENSE) for the full attribution.
|
||||
|
||||
+1
-1
@@ -82,4 +82,4 @@ packages/ data-provider · data-schemas · api · client · agents · mcp
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证。基于 LibreChat(MIT)分支开发。见 [LICENSE](./LICENSE)。
|
||||
MIT 许可证。MIT 许可证。见 [LICENSE](./LICENSE) 获取完整署名。
|
||||
|
||||
@@ -94,6 +94,16 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Guests (anonymous preview) are NOT balance-gated here — they have no org and no
|
||||
// DB balance record, so both the Commerce and legacy local gates would false-block
|
||||
// them. Guest spend is bounded two other ways: (1) the per-IP guest message
|
||||
// limiter (GUEST_MESSAGE_MAX) and (2) the shared, small-capped guest key
|
||||
// (GUEST_API_KEY) whose OWN org's balance the cloud gateway debits and 402s when
|
||||
// empty. Never an authed user's org balance.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
// Tenant billing key = the org (the token `owner`). cloud is the authoritative
|
||||
// meter+gate and bills per-org (see AUTH_BILLING_CONTRACT.md); this optional
|
||||
|
||||
@@ -11,7 +11,7 @@ const { resolveTenantBearer } = require('@hanzochat/api');
|
||||
* validates it and scopes to the caller's OWN org (same trust model as CloudAgentsClient
|
||||
* / RoutingDefaults). The token never reaches the browser.
|
||||
*
|
||||
* This is a SEPARATE concern from the Mongo `/v1/chat/usage` tab (LibreChat's own token-
|
||||
* This is a SEPARATE concern from the Mongo `/v1/chat/usage` tab (the legacy token-
|
||||
* credit accounting): the client renders this beside it with the shared @hanzo/usage
|
||||
* shape — nothing is re-derived here.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
const { CacheKeys } = require('@hanzochat/data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,9 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ const { resolveTenantBearer } = require('@hanzochat/api');
|
||||
|
||||
/**
|
||||
* Server-driven auto-routing defaults for the caller's org. Proxies cloud's
|
||||
* `GET /v1/get-routing-defaults` (authenticated, org-scoped) so ops can enable
|
||||
* `GET /v1/router/defaults` (authenticated, org-scoped) so ops can enable
|
||||
* routing per-default in production from admin.hanzo.ai. The user's hanzo.id
|
||||
* bearer is resolved server-side and forwarded on-behalf-of — cloud validates it
|
||||
* and scopes to the caller's own org (same trust model as CloudAgentsClient); the
|
||||
@@ -44,7 +44,7 @@ async function routingDefaultsController(req, res) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||
try {
|
||||
const resp = await fetch(`${endpoint}/v1/get-routing-defaults`, {
|
||||
const resp = await fetch(`${endpoint}/v1/router/defaults`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -40,12 +40,16 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -0,0 +1,206 @@
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
const { SystemRoles } = require('@hanzochat/data-provider');
|
||||
const { findOpenIDUser, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { verifyIamToken } = require('~/server/services/iamToken');
|
||||
const { setAuthTokens, persistOpenIDTokensToSession } = require('~/server/services/AuthService');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { findUser, createUser, updateUser, countUsers } = require('~/models');
|
||||
|
||||
/**
|
||||
* The session-bridge for the @hanzo/iam SPA login. This is the ONE server entry
|
||||
* that turns an IAM-verified identity into a Chat session.
|
||||
*
|
||||
* The SPA runs the Authorization-Code + PKCE flow entirely in the browser
|
||||
* (@hanzo/iam), then POSTs the resulting `{ accessToken, idToken }` here. We:
|
||||
* 1. JWKS-validate the id_token against hanzo.id (issuer + signature + expiry),
|
||||
* 2. reconcile the Mongo User by `sub` (openidId), creating/migrating as needed,
|
||||
* 3. issue the EXISTING Chat session — refresh cookie + Mongo `Session` +
|
||||
* `token_provider=chat` + a Chat JWT (via `setAuthTokens`), so reload-persist
|
||||
* and the 401->refresh interceptor keep working byte-identically to before,
|
||||
* 4. persist the id_token/access_token server-side (`req.session.openidTokens`)
|
||||
* so downstream on-behalf-of cloud calls run as this hanzo.id principal.
|
||||
*
|
||||
* The IAM tokens are NEVER stored in a browser-readable place: the Chat JWT is the
|
||||
* only bearer returned to the SPA; the id_token stays server-side for OBO.
|
||||
*/
|
||||
|
||||
/** Coerce a claim to a string, else ''. */
|
||||
function claimStr(value) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** Best-effort display name from OIDC claims. */
|
||||
function fullNameFromClaims(claims) {
|
||||
if (claimStr(claims.name)) {
|
||||
return claims.name;
|
||||
}
|
||||
const parts = [claims.given_name, claims.family_name].filter((p) => typeof p === 'string' && p);
|
||||
if (parts.length) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
return claimStr(claims.preferred_username) || claimStr(claims.email) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile (find/create/migrate) the Mongo User for a verified IAM identity.
|
||||
* Mirrors the openid strategy's `openidId = sub` model and the `findOpenIDUser`
|
||||
* migration path, minus the userinfo/avatar enrichment (not needed for login/OBO;
|
||||
* cloud derives the tenant org from the forwarded id_token's `owner` claim).
|
||||
* @param {Record<string, any>} claims - verified IAM token claims
|
||||
* @returns {Promise<import('@hanzochat/data-schemas').IUser>}
|
||||
*/
|
||||
async function reconcileUser(claims) {
|
||||
const openidId = claimStr(claims.sub);
|
||||
const email = claimStr(claims.email);
|
||||
const { user: found, error } = await findOpenIDUser({
|
||||
findUser,
|
||||
openidId,
|
||||
email: email || undefined,
|
||||
idOnTheSource: claimStr(claims.oid) || undefined,
|
||||
strategyName: 'iamSessionBridge',
|
||||
});
|
||||
if (error) {
|
||||
const err = new Error(error);
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const username =
|
||||
claimStr(claims.preferred_username) ||
|
||||
claimStr(claims.username) ||
|
||||
(email ? email.split('@')[0] : openidId);
|
||||
const name = fullNameFromClaims(claims);
|
||||
const organization =
|
||||
claimStr(claims.owner) || claimStr(claims.organization) || claimStr(claims.org) || '';
|
||||
const project = claimStr(claims.project) || '';
|
||||
const groups = Array.isArray(claims.groups) ? claims.groups : [];
|
||||
|
||||
if (found) {
|
||||
const update = { provider: 'openid', openidId };
|
||||
if (name && name !== found.name) {
|
||||
update.name = name;
|
||||
}
|
||||
if (username && username !== found.username) {
|
||||
update.username = username;
|
||||
}
|
||||
if (organization) {
|
||||
update.organization = organization;
|
||||
}
|
||||
if (project) {
|
||||
update.project = project;
|
||||
}
|
||||
if (groups.length) {
|
||||
update.groups = groups;
|
||||
}
|
||||
if (!found.role) {
|
||||
update.role = SystemRoles.USER;
|
||||
}
|
||||
const updated = await updateUser(found._id, update);
|
||||
return updated || found;
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const isFirstRegisteredUser = (await countUsers()) === 0;
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const created = await createUser(
|
||||
{
|
||||
provider: 'openid',
|
||||
openidId,
|
||||
username,
|
||||
email: email || '',
|
||||
emailVerified: claims.email_verified === true,
|
||||
name,
|
||||
idOnTheSource: claimStr(claims.oid),
|
||||
organization,
|
||||
project,
|
||||
groups,
|
||||
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
|
||||
},
|
||||
balanceConfig,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /oauth/iam/session — exchange an IAM SPA token for a Chat session.
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async function iamSessionController(req, res) {
|
||||
try {
|
||||
const { accessToken, idToken } = req.body || {};
|
||||
if (!idToken && !accessToken) {
|
||||
return res.status(400).json({ message: 'idToken or accessToken is required' });
|
||||
}
|
||||
|
||||
/**
|
||||
* The OIDC id_token is the identity assertion (JWKS-validatable, `aud` = the
|
||||
* client the SPA authenticated with). Fall back to the access token only when
|
||||
* no id_token was issued.
|
||||
*/
|
||||
const identityToken = idToken || accessToken;
|
||||
let claims;
|
||||
try {
|
||||
claims = await verifyIamToken(identityToken);
|
||||
} catch (err) {
|
||||
logger.warn('[iamSession] IAM token verification failed:', err?.message || err);
|
||||
return res.status(401).json({ message: 'invalid IAM token' });
|
||||
}
|
||||
if (!claims?.sub) {
|
||||
return res.status(401).json({ message: 'IAM token missing subject' });
|
||||
}
|
||||
|
||||
const user = await reconcileUser(claims);
|
||||
if (!user?._id) {
|
||||
return res.status(401).json({ message: 'user reconciliation failed' });
|
||||
}
|
||||
|
||||
/** Ban check against the resolved principal (mirrors the OAuth handler). */
|
||||
req.user = user;
|
||||
await checkBan(req, res);
|
||||
if (req.banned) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue the Chat session exactly as a local login does: refresh cookie +
|
||||
* Mongo Session + `token_provider=chat` + the Chat JWT. This keeps the
|
||||
* 401->refresh interceptor and reload-persist working unchanged.
|
||||
*/
|
||||
const token = await setAuthTokens(user._id, res);
|
||||
|
||||
/**
|
||||
* Persist the IAM tokens server-side for on-behalf-of cloud calls. The
|
||||
* decoupled default: no OIDC refresh credential is bound (session refreshes
|
||||
* via the Chat JWT cookie), so this only carries the bearer material that
|
||||
* `resolveTenantBearer` forwards. Best-effort — never break login.
|
||||
*/
|
||||
try {
|
||||
persistOpenIDTokensToSession(req, {
|
||||
access_token: accessToken || idToken,
|
||||
id_token: idToken,
|
||||
});
|
||||
} catch (persistErr) {
|
||||
logger.warn('[iamSession] failed to persist IAM tokens for on-behalf-of:', persistErr);
|
||||
}
|
||||
|
||||
const safeUser = typeof user.toObject === 'function' ? user.toObject() : { ...user };
|
||||
delete safeUser.password;
|
||||
delete safeUser.totpSecret;
|
||||
delete safeUser.backupCodes;
|
||||
|
||||
logger.info(
|
||||
`[iamSession] session established openidId: ${claimStr(claims.sub)} | email: ${safeUser.email}`,
|
||||
);
|
||||
return res.status(200).json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
logger.error('[iamSession] error establishing session:', err);
|
||||
const status = err.status || 500;
|
||||
return res.status(status).json({ message: err.message || 'session bridge failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { iamSessionController, reconcileUser };
|
||||
@@ -0,0 +1,281 @@
|
||||
const mockVerifyIamToken = jest.fn();
|
||||
const mockSetAuthTokens = jest.fn();
|
||||
const mockPersistOpenIDTokensToSession = jest.fn();
|
||||
const mockGetAppConfig = jest.fn();
|
||||
const mockCheckBan = jest.fn();
|
||||
const mockFindOpenIDUser = jest.fn();
|
||||
const mockGetBalanceConfig = jest.fn();
|
||||
const mockFindUser = jest.fn();
|
||||
const mockCreateUser = jest.fn();
|
||||
const mockUpdateUser = jest.fn();
|
||||
const mockCountUsers = jest.fn();
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() };
|
||||
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/data-provider', () => ({
|
||||
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
findOpenIDUser: (...args) => mockFindOpenIDUser(...args),
|
||||
getBalanceConfig: (...args) => mockGetBalanceConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/iamToken', () => ({
|
||||
verifyIamToken: (...args) => mockVerifyIamToken(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: (...args) => mockGetAppConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
checkBan: (...args) => mockCheckBan(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
findUser: (...args) => mockFindUser(...args),
|
||||
createUser: (...args) => mockCreateUser(...args),
|
||||
updateUser: (...args) => mockUpdateUser(...args),
|
||||
countUsers: (...args) => mockCountUsers(...args),
|
||||
}));
|
||||
|
||||
const { iamSessionController } = require('./iamSession');
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.cookie = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('iamSessionController — @hanzo/iam session-bridge', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSetAuthTokens.mockResolvedValue('chat.jwt.token');
|
||||
mockPersistOpenIDTokensToSession.mockReturnValue(true);
|
||||
mockGetAppConfig.mockResolvedValue({ balance: { enabled: false } });
|
||||
mockGetBalanceConfig.mockReturnValue({ enabled: false });
|
||||
mockCheckBan.mockImplementation(async (req) => {
|
||||
req.banned = false;
|
||||
});
|
||||
mockCountUsers.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
it('400 when neither token is provided', async () => {
|
||||
const req = { body: {}, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(mockVerifyIamToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401 when token verification fails', async () => {
|
||||
mockVerifyIamToken.mockRejectedValue(new Error('bad signature'));
|
||||
const req = { body: { idToken: 'x.y.z' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401 when verified claims have no sub', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ email: 'a@b.com' });
|
||||
const req = { body: { idToken: 'x.y.z' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('validates the id_token (not the access token) as the identity assertion', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/alice', email: 'alice@hanzo.ai' });
|
||||
mockFindOpenIDUser.mockResolvedValue({
|
||||
user: { _id: 'u1', provider: 'openid', openidId: 'hanzo/alice', role: 'USER' },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
mockUpdateUser.mockResolvedValue({
|
||||
_id: 'u1',
|
||||
provider: 'openid',
|
||||
openidId: 'hanzo/alice',
|
||||
email: 'alice@hanzo.ai',
|
||||
role: 'USER',
|
||||
});
|
||||
const req = {
|
||||
body: { idToken: 'ID.TOK', accessToken: 'ACC.TOK' },
|
||||
session: {},
|
||||
headers: {},
|
||||
};
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
expect(mockVerifyIamToken).toHaveBeenCalledWith('ID.TOK');
|
||||
});
|
||||
|
||||
it('existing user: reconciles, issues chat session, persists id_token for OBO', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({
|
||||
sub: 'hanzo/alice',
|
||||
email: 'alice@hanzo.ai',
|
||||
name: 'Alice',
|
||||
owner: 'hanzo',
|
||||
});
|
||||
mockFindOpenIDUser.mockResolvedValue({
|
||||
user: { _id: 'u1', provider: 'openid', openidId: 'hanzo/alice', role: 'USER', name: 'Al' },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
mockUpdateUser.mockResolvedValue({
|
||||
_id: 'u1',
|
||||
provider: 'openid',
|
||||
openidId: 'hanzo/alice',
|
||||
email: 'alice@hanzo.ai',
|
||||
name: 'Alice',
|
||||
role: 'USER',
|
||||
toObject() {
|
||||
return { ...this };
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
body: { idToken: 'ID.TOK', accessToken: 'ACC.TOK' },
|
||||
session: {},
|
||||
headers: {},
|
||||
};
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith(
|
||||
'u1',
|
||||
expect.objectContaining({ provider: 'openid', openidId: 'hanzo/alice' }),
|
||||
);
|
||||
expect(mockCreateUser).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith('u1', res);
|
||||
// OBO: the id_token is persisted server-side (never a browser cookie).
|
||||
expect(mockPersistOpenIDTokensToSession).toHaveBeenCalledWith(req, {
|
||||
access_token: 'ACC.TOK',
|
||||
id_token: 'ID.TOK',
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.token).toBe('chat.jwt.token');
|
||||
expect(payload.user.openidId).toBe('hanzo/alice');
|
||||
});
|
||||
|
||||
it('new user: creates a provider=openid user keyed by sub', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({
|
||||
sub: 'hanzo/bob',
|
||||
email: 'bob@hanzo.ai',
|
||||
preferred_username: 'bob',
|
||||
email_verified: true,
|
||||
owner: 'hanzo',
|
||||
});
|
||||
mockFindOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
|
||||
mockCountUsers.mockResolvedValue(3);
|
||||
mockCreateUser.mockResolvedValue({
|
||||
_id: 'u2',
|
||||
provider: 'openid',
|
||||
openidId: 'hanzo/bob',
|
||||
email: 'bob@hanzo.ai',
|
||||
role: 'USER',
|
||||
toObject() {
|
||||
return { ...this };
|
||||
},
|
||||
});
|
||||
|
||||
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
|
||||
expect(mockCreateUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'openid',
|
||||
openidId: 'hanzo/bob',
|
||||
username: 'bob',
|
||||
email: 'bob@hanzo.ai',
|
||||
emailVerified: true,
|
||||
role: 'USER',
|
||||
}),
|
||||
{ enabled: false },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith('u2', res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('first user becomes ADMIN', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/root', email: 'root@hanzo.ai' });
|
||||
mockFindOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
|
||||
mockCountUsers.mockResolvedValue(0);
|
||||
mockCreateUser.mockResolvedValue({ _id: 'u0', openidId: 'hanzo/root', role: 'ADMIN' });
|
||||
|
||||
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
|
||||
expect(mockCreateUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: 'ADMIN' }),
|
||||
expect.anything(),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('403 when findOpenIDUser blocks cross-provider takeover', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/eve', email: 'eve@hanzo.ai' });
|
||||
mockFindOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
|
||||
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('banned principal: no session issued', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/ban', email: 'ban@hanzo.ai' });
|
||||
mockFindOpenIDUser.mockResolvedValue({
|
||||
user: { _id: 'u3', provider: 'openid', openidId: 'hanzo/ban', role: 'USER' },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
mockUpdateUser.mockResolvedValue({ _id: 'u3', openidId: 'hanzo/ban', role: 'USER' });
|
||||
mockCheckBan.mockImplementation(async (req) => {
|
||||
req.banned = true;
|
||||
});
|
||||
|
||||
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OBO persist failure never breaks login', async () => {
|
||||
mockVerifyIamToken.mockResolvedValue({ sub: 'hanzo/al', email: 'al@hanzo.ai' });
|
||||
mockFindOpenIDUser.mockResolvedValue({
|
||||
user: { _id: 'u4', provider: 'openid', openidId: 'hanzo/al', role: 'USER' },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
mockUpdateUser.mockResolvedValue({ _id: 'u4', openidId: 'hanzo/al', role: 'USER' });
|
||||
mockPersistOpenIDTokensToSession.mockImplementation(() => {
|
||||
throw new Error('no session');
|
||||
});
|
||||
|
||||
const req = { body: { idToken: 'ID.TOK' }, session: {}, headers: {} };
|
||||
const res = makeRes();
|
||||
await iamSessionController(req, res);
|
||||
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith('u4', res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -136,7 +136,7 @@ const startServer = async () => {
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://hanzo.app https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://hanzo.app https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
);
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Integration test for the guest chat middleware chain composition:
|
||||
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
|
||||
* that keeps management routes (abort/active/...) on the JWT-only parent router.
|
||||
*/
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () =>
|
||||
jest.fn((req, res, next) => {
|
||||
req.user = { id: 'real-user', role: 'USER' };
|
||||
next();
|
||||
}),
|
||||
);
|
||||
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const buildRouter = () => {
|
||||
const router = express.Router();
|
||||
const RESERVED = new Set(['abort', 'active']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
chatRouter.post('/', (req, res) =>
|
||||
res
|
||||
.status(200)
|
||||
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
|
||||
);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// JWT-only management route on the parent (after the guest router)
|
||||
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
|
||||
res.status(200).json({ aborted: true, role: req.user.role }),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/v1/chat/agents', buildRouter());
|
||||
return app;
|
||||
};
|
||||
|
||||
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
describe('guest chat middleware chain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_MESSAGE_MAX = '2';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen3-nano';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/v1/chat/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.1')
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
|
||||
});
|
||||
|
||||
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
|
||||
const app = buildApp();
|
||||
const ip = '10.1.0.2';
|
||||
const send = () =>
|
||||
request(app)
|
||||
.post('/v1/chat/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', ip)
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
|
||||
expect((await send()).status).toBe(200);
|
||||
expect((await send()).status).toBe(200);
|
||||
const blocked = await send();
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.3')
|
||||
.send({ streamId: 'x' });
|
||||
// requireJwtAuth mock forces a real USER; guest never reaches abort.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ aborted: true, role: 'USER' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
// Mock @hanzochat/api's isEnabled (the only symbol guestConfig needs) so the real
|
||||
// package dist — which eagerly pulls the agents/langchain bundle (ESM, unparseable
|
||||
// by this jest config) — never enters the module graph. Same behavior guestConfig
|
||||
// relies on, kept hermetic like the sibling guest specs.
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
const { EModelEndpoint } = require('@hanzochat/data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -8,6 +8,8 @@ const accessResources = require('./accessResources');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -34,6 +36,8 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
canDeleteAccount,
|
||||
configMiddleware,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
|
||||
const parsePositiveInt = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
|
||||
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
|
||||
|
||||
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
|
||||
const max = GUEST_TOKEN_MAX;
|
||||
const windowInMinutes = windowMs / 60000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(429).json({
|
||||
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for guest token issuance.
|
||||
*
|
||||
* Caps how many guest tokens a single client IP can mint per window so nobody can
|
||||
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
|
||||
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`). The store comes from
|
||||
* `limiterCache`, which returns `undefined` when `USE_REDIS` is off — so
|
||||
* `express-rate-limit` uses its in-process MemoryStore. That is the correct
|
||||
* single source of truth for hanzo.chat's `replicas: 1` / `Recreate` deployment
|
||||
* (never two live pods, so no cross-pod round-robin to defeat), and it does NOT
|
||||
* require Redis (killed platform-wide). This is defense in depth only: even
|
||||
* unlimited tokens cannot multiply the message quota, which is itself keyed
|
||||
* per-IP (see `guestMessageLimiter`).
|
||||
*/
|
||||
const guestTokenLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
store: limiterCache('guest_token_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestTokenLimiter };
|
||||
@@ -0,0 +1,41 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(402).json({
|
||||
type: 'GUEST_LIMIT',
|
||||
message: 'Guest message limit reached. Log in to continue.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP quota limiter for anonymous guest chat completions.
|
||||
*
|
||||
* The key is the REAL client IP (`guestClientIp` → Cloudflare `CF-Connecting-IP`),
|
||||
* NOT the guest token, so clearing cookies, opening incognito, or minting a fresh
|
||||
* guest token does not reset the count. It only counts requests made by an
|
||||
* authenticated guest principal; logged-in users skip the counter entirely. On
|
||||
* exhaustion it returns `402 { type: 'GUEST_LIMIT' }`, the signal the client maps
|
||||
* to the login gate.
|
||||
*
|
||||
* The store comes from `limiterCache`, which returns `undefined` when `USE_REDIS`
|
||||
* is off — so `express-rate-limit` uses its in-process MemoryStore. At
|
||||
* `replicas: 1` with a `Recreate` rollout there is never a second live pod to
|
||||
* round-robin, so the in-memory count is the authoritative per-IP quota; Redis is
|
||||
* NOT required (it was killed platform-wide). The only reset is a pod restart — an
|
||||
* infrequent operational event, not an attacker-triggerable action.
|
||||
*/
|
||||
const guestMessageLimiter = rateLimit({
|
||||
windowMs: GUEST_LIMIT_WINDOW_MS,
|
||||
max: () => getGuestConfig().messageMax,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
skip: (req) => req.user?.guest !== true,
|
||||
store: limiterCache('guest_message_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestMessageLimiter };
|
||||
@@ -0,0 +1,78 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
|
||||
const buildApp = (user) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use(guestMessageLimiter);
|
||||
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guestMessageLimiter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
messageMax: 3,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
const ip = '10.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('counts quota per IP independently', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
}
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
expect(blocked.status).toBe(402);
|
||||
|
||||
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
|
||||
expect(fresh.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never counts or blocks authenticated (non-guest) users', async () => {
|
||||
const app = buildApp({ id: 'real-user', role: 'USER' });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const { guestTokenLimiter } = require('./guestLimiters');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
@@ -18,6 +20,8 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion (and guest-safe
|
||||
* bootstrap) routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction:
|
||||
* an OpenID bearer (signed by hanzo.id, not `JWT_SECRET`) fails `jwt.verify` here
|
||||
* and falls through to `requireJwtAuth`, preserving the OIDC-reuse path.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* Proves:
|
||||
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
|
||||
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/convos return guest-scoped data,
|
||||
* - a non-bootstrap protected route still 401s for a guest.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('@hanzochat/data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
SystemRoles: { USER: 'USER' },
|
||||
}));
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
|
||||
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(async (id) => {
|
||||
if (String(id).startsWith('guest_')) {
|
||||
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById } = require('~/models');
|
||||
const jwtLogin = require('~/strategies/jwtStrategy');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
|
||||
|
||||
const JWT_SECRET = 'integration-guest-secret';
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(passport.initialize());
|
||||
passport.use(jwtLogin());
|
||||
|
||||
// Bootstrap (guest-aware) routes.
|
||||
app.get('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
|
||||
});
|
||||
app.get('/v1/chat/user', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
return res.status(200).send(req.user);
|
||||
});
|
||||
app.get('/v1/chat/convos', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
return res.status(200).json({ conversations: ['real'] });
|
||||
});
|
||||
|
||||
// Non-bootstrap protected route (JWT-only): must reject guests.
|
||||
app.get('/v1/chat/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guest bootstrap auth chain (integration)', () => {
|
||||
let app;
|
||||
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
app = buildApp();
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('GET /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.get('/v1/chat/endpoints')
|
||||
.set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(res.text);
|
||||
expect(Object.keys(body)).toEqual(['Hanzo']);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
|
||||
const res = await request(app).get('/v1/chat/user').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
|
||||
expect(res.body).not.toHaveProperty('email');
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/convos → 200 empty list for a guest', async () => {
|
||||
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
|
||||
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
// The guest id must never reach getUserById (that is what caused the CastError → 500).
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
|
||||
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
|
||||
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
|
||||
const res = await request(app).get('/v1/chat/prompts');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
messageIpLimiter,
|
||||
configMiddleware,
|
||||
messageUserLimiter,
|
||||
enforceGuestScope,
|
||||
guestMessageLimiter,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
const { saveMessage } = require('~/models');
|
||||
const openai = require('./openai');
|
||||
@@ -35,11 +38,17 @@ router.use('/v1/responses', responses);
|
||||
router.use('/v1', openai);
|
||||
|
||||
/**
|
||||
* Chat completion router. Every request is gated by the strict `requireJwtAuth`
|
||||
* (there is no guest chat — a signed-in IAM identity is required so the request
|
||||
* bills to the user's own org via their forwarded bearer). Split from the parent
|
||||
* router only so the completion middleware chain (ban/uaParser/config/limiters)
|
||||
* applies to completions but not to management/CRUD routes.
|
||||
* Guest-capable chat completion router.
|
||||
*
|
||||
* Auth here accepts either a real JWT or a guest token (`requireGuestOrJwtAuth`);
|
||||
* when guest chat is off, or the token isn't a valid guest token, it falls through
|
||||
* to the strict `requireJwtAuth`. `enforceGuestScope` then pins guests to the free
|
||||
* Zen endpoint/model and strips every other capability (agents/tools/files/spec/
|
||||
* preset), and `guestMessageLimiter` enforces the per-IP guest quota (real users
|
||||
* are skipped). A signed-in user still bills to their own org via their forwarded
|
||||
* bearer; a guest uses the shared, capped guest key (see custom/initialize.ts).
|
||||
* Every OTHER agents route (management, CRUD, files) stays gated by the strict
|
||||
* `requireJwtAuth` below, which rejects guest tokens.
|
||||
*/
|
||||
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
|
||||
|
||||
@@ -56,10 +65,12 @@ chatRouter.use((req, res, next) => {
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireJwtAuth);
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(checkBan);
|
||||
chatRouter.use(uaParser);
|
||||
chatRouter.use(configMiddleware);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
@@ -73,6 +84,33 @@ chatRouter.use('/', chat);
|
||||
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
/**
|
||||
* Guest-safe active-jobs poll. Guests never own a generation job, so this returns
|
||||
* an empty set without a DB/user lookup. Registered BEFORE the strict JWT guard
|
||||
* below (and reachable because 'active' is a RESERVED_CHAT_SUBPATH deferred out of
|
||||
* chatRouter) so the composer's bootstrap poll doesn't 401-loop for guests; every
|
||||
* other agents route stays JWT-only and rejects guest tokens. Non-guests fall
|
||||
* through to the authoritative `/chat/active` handler further down.
|
||||
*/
|
||||
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.json({ activeJobIds: [] });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guest-safe SSE read-back: a guest MUST be able to read its OWN generation
|
||||
* stream. All chat streaming is unified under this route; reached via the
|
||||
* reserved-subpath deferral ('stream'), it is registered with
|
||||
* `requireGuestOrJwtAuth` ABOVE the strict JWT guard so a guest token is accepted
|
||||
* (the strict `jwt` strategy would 401 it → the streamed reply never reaches the
|
||||
* guest browser → empty bubble). `streamHandler` (hoisted, defined below) pins
|
||||
* every caller to their OWN job (`job.metadata.userId === req.user.id` → foreign
|
||||
* 403, missing 404), so a guest can never read another principal's stream.
|
||||
*/
|
||||
router.get('/chat/stream/:streamId', requireGuestOrJwtAuth, streamHandler);
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
@@ -99,7 +137,7 @@ router.use('/', v1);
|
||||
* @description Sends sync event with resume state, replays missed chunks, then streams live
|
||||
* @query resume=true - Indicates this is a reconnection (sends sync event)
|
||||
*/
|
||||
router.get('/chat/stream/:streamId', async (req, res) => {
|
||||
async function streamHandler(req, res) {
|
||||
const { streamId } = req.params;
|
||||
const isResume = req.query.resume === 'true';
|
||||
|
||||
@@ -179,7 +217,7 @@ router.get('/chat/stream/:streamId', async (req, res) => {
|
||||
logger.debug(`[AgentStream] Client disconnected from ${streamId}`);
|
||||
result.unsubscribe();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /chat/active
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
} = require('~/server/controllers/TwoFactorController');
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
|
||||
const middleware = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -22,6 +23,9 @@ const router = express.Router();
|
||||
// refresh, 2FA and the graph token — all guarded by IAM-issued JWTs.
|
||||
router.post('/logout', middleware.requireJwtAuth, logoutController);
|
||||
router.post('/refresh', refreshController);
|
||||
// Anonymous guest-preview token. Gated on ALLOW_GUEST_CHAT inside the controller
|
||||
// (404 when off) and per-IP rate-limited so tokens can't be spam-minted.
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
||||
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
|
||||
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
|
||||
|
||||
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { Constants, CacheKeys, defaultSocialLogins } = require('@hanzochat/data-provider');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -59,6 +60,8 @@ router.get('/', async function (req, res) {
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
const guestConfig = getGuestConfig();
|
||||
|
||||
// Strip server-internal fields from balance config before sending to client.
|
||||
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
|
||||
const clientBalanceConfig = balanceConfig
|
||||
@@ -121,6 +124,8 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
allowGuestChat: guestConfig.enabled,
|
||||
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -26,8 +27,16 @@ const assistantClients = {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** Conversation list — JWT-only (no guest chat). */
|
||||
router.get('/', requireJwtAuth, async (req, res) => {
|
||||
/**
|
||||
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
||||
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
||||
* history pane without a DB lookup. Registered with guest-aware auth; every
|
||||
* mutation / read-by-id route below stays JWT-only and rejects guest tokens.
|
||||
*/
|
||||
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,6 +7,7 @@ const { ErrorTypes } = require('@hanzochat/data-provider');
|
||||
const { createSetBalanceConfig } = require('@hanzochat/api');
|
||||
const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware');
|
||||
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
|
||||
const { iamSessionController } = require('~/server/controllers/auth/iamSession');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
@@ -37,7 +38,21 @@ router.get('/error', (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Hanzo IAM (OpenID Connect) Routes
|
||||
* Hanzo IAM session-bridge — the ONE way the @hanzo/iam SPA establishes a Chat
|
||||
* session. The SPA runs Authorization-Code + PKCE in the browser and POSTs its
|
||||
* token here; the controller JWKS-validates it, reconciles the user, mints the
|
||||
* Chat session (refresh cookie + Mongo Session + Chat JWT), and persists the
|
||||
* id_token server-side for on-behalf-of cloud calls.
|
||||
*/
|
||||
router.post('/iam/session', iamSessionController);
|
||||
|
||||
/**
|
||||
* Server-initiated OpenID Connect routes (dormant fallback).
|
||||
*
|
||||
* The @hanzo/iam SPA does NOT use these — user login flows entirely through the
|
||||
* SPA + `/iam/session` bridge above. These remain only as an untouched safety
|
||||
* net for any deployment still pinned to the server-side redirect flow; they are
|
||||
* scheduled for removal once the SPA bridge is verified live end-to-end.
|
||||
*/
|
||||
router.get('/openid', loginLimiter, (req, res, next) => {
|
||||
return passport.authenticate('openid', {
|
||||
|
||||
@@ -3,12 +3,12 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Favorites — JWT-only (no guest chat).
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const settings = require('./settings');
|
||||
@@ -20,7 +21,7 @@ const settings = require('./settings');
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/settings', settings);
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
const { isEnabled } = require('@hanzochat/api');
|
||||
const { EModelEndpoint } = require('@hanzochat/data-provider');
|
||||
|
||||
const GUEST_ROLE = 'GUEST';
|
||||
const GUEST_NAME = 'Guest';
|
||||
const DEFAULT_GUEST_MESSAGE_MAX = 3;
|
||||
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
|
||||
const DEFAULT_GUEST_MODEL = 'zen5-flash';
|
||||
|
||||
/**
|
||||
* Resolves the guest-chat configuration from the environment.
|
||||
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
|
||||
*
|
||||
* @returns {{
|
||||
* enabled: boolean,
|
||||
* messageMax: number,
|
||||
* tokenExpiryMs: number,
|
||||
* endpoint: string,
|
||||
* model: string,
|
||||
* }}
|
||||
*/
|
||||
const getGuestConfig = () => {
|
||||
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
|
||||
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
|
||||
|
||||
return {
|
||||
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
|
||||
messageMax:
|
||||
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
|
||||
tokenExpiryMs:
|
||||
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
|
||||
? tokenExpiryMs
|
||||
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
|
||||
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the ephemeral guest principal for a verified guest token.
|
||||
*
|
||||
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
|
||||
* plain object — never a DB document — so no route ever reads or writes real
|
||||
* user data on behalf of a guest. No email, no DB id.
|
||||
*
|
||||
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
|
||||
* @returns {{ id: string, role: string, name: string, guest: true }}
|
||||
*/
|
||||
const buildGuestPrincipal = (id) => ({
|
||||
id,
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped `/v1/chat/user` response: the ephemeral principal only.
|
||||
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
|
||||
*
|
||||
* @param {{ id: string }} principal
|
||||
* @returns {object}
|
||||
*/
|
||||
const buildGuestUser = (principal) => ({
|
||||
id: principal.id,
|
||||
username: GUEST_NAME,
|
||||
name: GUEST_NAME,
|
||||
role: GUEST_ROLE,
|
||||
provider: 'guest',
|
||||
emailVerified: false,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
|
||||
* with no builder/agent/file/preset capabilities. Everything else is omitted so
|
||||
* the client cannot surface any other endpoint to a guest.
|
||||
*
|
||||
* @returns {Record<string, object>}
|
||||
*/
|
||||
const buildGuestEndpointsConfig = () => {
|
||||
const { endpoint } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: {
|
||||
type: EModelEndpoint.custom,
|
||||
userProvide: false,
|
||||
modelDisplayLabel: endpoint,
|
||||
order: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped models config: the single configured guest model under
|
||||
* the guest endpoint. The client pins the composer to exactly this one model.
|
||||
*
|
||||
* @returns {Record<string, string[]>}
|
||||
*/
|
||||
const buildGuestModelsConfig = () => {
|
||||
const { endpoint, model } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: [model],
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
}));
|
||||
|
||||
const {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
} = require('./guestConfig');
|
||||
|
||||
describe('getGuestConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_TOKEN_EXPIRY;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('is disabled by default (fail closed)', () => {
|
||||
expect(getGuestConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
expect(getGuestConfig().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses defaults when env is unset', () => {
|
||||
const config = getGuestConfig();
|
||||
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
|
||||
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
|
||||
});
|
||||
|
||||
it('honors GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = '7';
|
||||
expect(getGuestConfig().messageMax).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = 'abc';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '0';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '-5';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
});
|
||||
|
||||
it('honors the configurable guest endpoint and model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen4-mini';
|
||||
const config = getGuestConfig();
|
||||
expect(config.endpoint).toBe('Hanzo');
|
||||
expect(config.model).toBe('zen4-mini');
|
||||
});
|
||||
|
||||
it('exposes GUEST_ROLE constant', () => {
|
||||
expect(GUEST_ROLE).toBe('GUEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest principal + scoped-config builders', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
|
||||
const principal = buildGuestPrincipal('guest_abc');
|
||||
expect(principal).toEqual({
|
||||
id: 'guest_abc',
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
expect(principal).not.toHaveProperty('email');
|
||||
expect(principal).not.toHaveProperty('_id');
|
||||
});
|
||||
|
||||
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
|
||||
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
|
||||
expect(user.id).toBe('guest_abc');
|
||||
expect(user.role).toBe(GUEST_ROLE);
|
||||
expect(user.name).toBe(GUEST_NAME);
|
||||
expect(user.guest).toBe(true);
|
||||
expect(user).not.toHaveProperty('email');
|
||||
expect(user).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
const config = buildGuestEndpointsConfig();
|
||||
expect(Object.keys(config)).toEqual(['Hanzo']);
|
||||
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const jwksRsa = require('jwks-rsa');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logger } = require('@hanzochat/data-schemas');
|
||||
|
||||
/**
|
||||
* JWKS verification for Hanzo IAM (hanzo.id) tokens — the ONE server-side proof
|
||||
* that a token presented by the @hanzo/iam SPA was really issued by IAM.
|
||||
*
|
||||
* This is the JWKS verify path promoted out of the per-request `openIdJwtStrategy`
|
||||
* (which uses the same `jwks-rsa` signing-key source) into a standalone, callable
|
||||
* verifier the session-bridge uses. It is self-contained: it discovers the JWKS
|
||||
* URI from `OPENID_ISSUER` (falling back to the already-initialized openid-client
|
||||
* config's metadata when present), so it does NOT depend on the Passport OpenID
|
||||
* login strategy being registered.
|
||||
*
|
||||
* Security properties enforced: RS256 signature against IAM's JWKS (key matched by
|
||||
* `kid`), unexpired `exp` (jsonwebtoken default), and issuer equality with
|
||||
* `OPENID_ISSUER` (trailing-slash-normalized). Audience is intentionally NOT
|
||||
* pinned here — the SPA client id (`VITE_HANZO_IAM_APP`) and the backend
|
||||
* `OPENID_CLIENT_ID` may differ per deployment, and cloud is the authoritative
|
||||
* audience/claim validator on the on-behalf-of path — matching the existing
|
||||
* `openIdJwtStrategy` which likewise verifies signature, not audience.
|
||||
*/
|
||||
|
||||
const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration';
|
||||
|
||||
let _jwksClient;
|
||||
let _discoveredJwksUri;
|
||||
|
||||
/** Normalize an origin/issuer by stripping trailing slashes. */
|
||||
function normalizeIssuer(value) {
|
||||
return (value || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve IAM's JWKS URI. Prefers the openid-client Configuration's discovered
|
||||
* metadata (already fetched at boot when OpenID is configured); otherwise runs a
|
||||
* one-time OIDC discovery against `OPENID_ISSUER`. Cached process-wide.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function resolveJwksUri() {
|
||||
try {
|
||||
// Lazy require to avoid a circular import at module load and to stay
|
||||
// decoupled from whether the Passport OpenID strategy is registered.
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
const metaUri = getOpenIdConfig?.()?.serverMetadata?.().jwks_uri;
|
||||
if (metaUri) {
|
||||
return metaUri;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('[iamToken] openid-client config unavailable; discovering JWKS', err?.message);
|
||||
}
|
||||
|
||||
if (_discoveredJwksUri) {
|
||||
return _discoveredJwksUri;
|
||||
}
|
||||
|
||||
const issuer = normalizeIssuer(process.env.OPENID_ISSUER);
|
||||
if (!issuer) {
|
||||
throw new Error('OPENID_ISSUER is not configured');
|
||||
}
|
||||
|
||||
const res = await fetch(`${issuer}${OIDC_DISCOVERY_PATH}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`OIDC discovery failed (${res.status}) for ${issuer}`);
|
||||
}
|
||||
const meta = await res.json();
|
||||
if (!meta?.jwks_uri) {
|
||||
throw new Error('OIDC discovery response has no jwks_uri');
|
||||
}
|
||||
_discoveredJwksUri = meta.jwks_uri;
|
||||
return _discoveredJwksUri;
|
||||
}
|
||||
|
||||
/** Build (once) the cached jwks-rsa client. */
|
||||
async function getJwksClient() {
|
||||
if (_jwksClient) {
|
||||
return _jwksClient;
|
||||
}
|
||||
const jwksUri = await resolveJwksUri();
|
||||
const options = {
|
||||
cache: true,
|
||||
cacheMaxAge: 600000,
|
||||
rateLimit: true,
|
||||
jwksUri,
|
||||
};
|
||||
if (process.env.PROXY) {
|
||||
options.requestAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
_jwksClient = jwksRsa(options);
|
||||
return _jwksClient;
|
||||
}
|
||||
|
||||
/** jsonwebtoken key resolver backed by the JWKS client. */
|
||||
function keyResolver(client) {
|
||||
return (header, callback) => {
|
||||
client.getSigningKey(header.kid, (err, key) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
callback(null, key.getPublicKey());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Hanzo IAM token and return its claims. Throws on any failure
|
||||
* (bad signature, expired, wrong issuer, unreachable JWKS).
|
||||
* @param {string} token - a hanzo.id-issued JWT (id_token preferred)
|
||||
* @returns {Promise<Record<string, unknown>>} verified claims
|
||||
*/
|
||||
async function verifyIamToken(token) {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('missing token');
|
||||
}
|
||||
const client = await getJwksClient();
|
||||
const claims = await new Promise((resolve, reject) => {
|
||||
jwt.verify(token, keyResolver(client), { algorithms: ['RS256'] }, (err, decoded) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(decoded);
|
||||
});
|
||||
});
|
||||
|
||||
const expectedIssuer = normalizeIssuer(process.env.OPENID_ISSUER);
|
||||
if (expectedIssuer && claims?.iss && normalizeIssuer(claims.iss) !== expectedIssuer) {
|
||||
throw new Error(`unexpected token issuer: ${claims.iss}`);
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** Reset the memoized JWKS client + discovery cache (tests only). */
|
||||
function _resetIamToken() {
|
||||
_jwksClient = undefined;
|
||||
_discoveredJwksUri = undefined;
|
||||
}
|
||||
|
||||
module.exports = { verifyIamToken, _resetIamToken };
|
||||
@@ -0,0 +1,75 @@
|
||||
const mockVerify = jest.fn();
|
||||
const mockGetSigningKey = jest.fn();
|
||||
const mockJwksRsa = jest.fn(() => ({ getSigningKey: mockGetSigningKey }));
|
||||
const mockGetOpenIdConfig = jest.fn();
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
|
||||
jest.mock('jwks-rsa', () => (...args) => mockJwksRsa(...args));
|
||||
jest.mock('https-proxy-agent', () => ({ HttpsProxyAgent: class {} }));
|
||||
jest.mock('@hanzochat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock(
|
||||
'~/strategies',
|
||||
() => ({ getOpenIdConfig: (...args) => mockGetOpenIdConfig(...args) }),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
const { verifyIamToken, _resetIamToken } = require('~/server/services/iamToken');
|
||||
|
||||
describe('verifyIamToken — JWKS verification for hanzo.id tokens', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
_resetIamToken();
|
||||
process.env.OPENID_ISSUER = 'https://hanzo.id';
|
||||
// Resolve JWKS URI from the already-initialized openid-client config (no network).
|
||||
mockGetOpenIdConfig.mockReturnValue({
|
||||
serverMetadata: () => ({ jwks_uri: 'https://hanzo.id/v1/iam/.well-known/jwks' }),
|
||||
});
|
||||
mockGetSigningKey.mockImplementation((kid, cb) => cb(null, { getPublicKey: () => 'PUBKEY' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENID_ISSUER;
|
||||
});
|
||||
|
||||
it('throws on a missing token', async () => {
|
||||
await expect(verifyIamToken('')).rejects.toThrow('missing token');
|
||||
});
|
||||
|
||||
it('returns claims for a valid, correctly-issued token', async () => {
|
||||
mockVerify.mockImplementation((token, key, opts, cb) =>
|
||||
cb(null, { sub: 'hanzo/alice', iss: 'https://hanzo.id', email: 'alice@hanzo.ai' }),
|
||||
);
|
||||
const claims = await verifyIamToken('good.token');
|
||||
expect(claims.sub).toBe('hanzo/alice');
|
||||
// RS256 only, JWKS-backed key resolver used.
|
||||
expect(mockVerify).toHaveBeenCalledWith(
|
||||
'good.token',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ algorithms: ['RS256'] }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a token whose issuer does not match OPENID_ISSUER', async () => {
|
||||
mockVerify.mockImplementation((token, key, opts, cb) =>
|
||||
cb(null, { sub: 'hanzo/eve', iss: 'https://evil.example' }),
|
||||
);
|
||||
await expect(verifyIamToken('forged.iss')).rejects.toThrow('unexpected token issuer');
|
||||
});
|
||||
|
||||
it('accepts a trailing-slash issuer variant (normalized)', async () => {
|
||||
process.env.OPENID_ISSUER = 'https://hanzo.id/';
|
||||
mockVerify.mockImplementation((token, key, opts, cb) =>
|
||||
cb(null, { sub: 'hanzo/al', iss: 'https://hanzo.id' }),
|
||||
);
|
||||
const claims = await verifyIamToken('ok.token');
|
||||
expect(claims.sub).toBe('hanzo/al');
|
||||
});
|
||||
|
||||
it('propagates a signature-verification failure', async () => {
|
||||
mockVerify.mockImplementation((token, key, opts, cb) => cb(new Error('invalid signature')));
|
||||
await expect(verifyIamToken('bad.sig')).rejects.toThrow('invalid signature');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const removePorts = require('./removePorts');
|
||||
|
||||
/**
|
||||
* Resolves the real client IP for per-IP guest rate limiting.
|
||||
*
|
||||
* hanzo.chat is served behind Cloudflare → the DO LB → the ingress. With that
|
||||
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
|
||||
* address, which would let anonymous users share/reset their free-message bucket
|
||||
* (or collapse everyone into one bucket). Cloudflare always sets
|
||||
* `CF-Connecting-IP` to the true originating client and — unlike a
|
||||
* client-supplied `X-Forwarded-For` entry — a browser cannot forge it through
|
||||
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
|
||||
* request did not transit Cloudflare (e.g. in-cluster/local).
|
||||
*
|
||||
* The returned string is the SOLE identity the guest quota keys on, so it must be
|
||||
* stable across guest tokens, cookie clears, and incognito sessions from the same
|
||||
* network origin.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string}
|
||||
*/
|
||||
const guestClientIp = (req) => {
|
||||
const cf = req.headers?.['cf-connecting-ip'];
|
||||
if (typeof cf === 'string' && cf.trim()) {
|
||||
return cf.trim();
|
||||
}
|
||||
return removePorts(req);
|
||||
};
|
||||
|
||||
module.exports = guestClientIp;
|
||||
@@ -1,4 +1,5 @@
|
||||
const removePorts = require('./removePorts');
|
||||
const guestClientIp = require('./guestClientIp');
|
||||
const handleText = require('./handleText');
|
||||
const sendEmail = require('./sendEmail');
|
||||
const queue = require('./queue');
|
||||
@@ -7,6 +8,7 @@ const files = require('./files');
|
||||
module.exports = {
|
||||
...handleText,
|
||||
removePorts,
|
||||
guestClientIp,
|
||||
sendEmail,
|
||||
...files,
|
||||
...queue,
|
||||
|
||||
@@ -13,11 +13,11 @@ const jwtLogin = () =>
|
||||
async (payload, done) => {
|
||||
try {
|
||||
/**
|
||||
* Guest chat is removed (every request requires a signed-in IAM identity).
|
||||
* A legacy guest token left in a browser after deploy still carries
|
||||
* `guest: true` and a synthetic `guest_<uuid>` id that is NOT a Mongo
|
||||
* ObjectId — reject it cleanly here (401) rather than letting `getUserById`
|
||||
* throw a Mongoose CastError (→ 500). Fail closed.
|
||||
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
|
||||
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
|
||||
* strategy fails with a 401 instead of letting `getUserById` throw a
|
||||
* Mongoose CastError (→ 500). Guests reach their scoped routes through
|
||||
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
|
||||
*/
|
||||
if (payload?.guest === true) {
|
||||
return done(null, false);
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ interface:
|
||||
# de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH)
|
||||
# subLabel:
|
||||
# en: |
|
||||
# Librechat hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. <a href="https://google.de" target="_blank"><strong>Learn more.</strong></a>
|
||||
# Hanzo hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. <a href="https://google.de" target="_blank"><strong>Learn more.</strong></a>
|
||||
# de: |
|
||||
# Chat hat diesen MCP-Server nicht überprüft. Angreifer könnten versuchen, Ihre Daten zu stehlen oder das Modell zu unbeabsichtigten Aktionen zu verleiten, einschließlich der Zerstörung von Daten. <a href="https://google.de" target="_blank"><strong>Mehr erfahren.</strong></a>
|
||||
|
||||
|
||||
@@ -96,10 +96,13 @@ endpoints:
|
||||
models:
|
||||
# LIVE catalog only — zen4 generation was SUNSET 2026-05-30 (gateway
|
||||
# config.yaml); offering it routed to a dead fallback. Current families
|
||||
# the gateway serves: zen5-* (flagship) + zen3-* (omni/vision). zen5-mini
|
||||
# leads as the default (smart current-gen small model); larger zen5 tiers
|
||||
# meter against balance. Order = the default-selected model for new users.
|
||||
# the gateway serves: zen5-* (flagship) + zen3-* (omni/vision) + enso (the
|
||||
# orchestrated router). enso (Enso Pro) leads as the default — it routes
|
||||
# each request to the best-fit model, so an easy turn bills cheap and only a
|
||||
# hard one escalates; larger zen5 tiers meter against balance. Order = the
|
||||
# default-selected model for new users.
|
||||
default:
|
||||
- "enso"
|
||||
- "zen5-flash"
|
||||
- "zen5"
|
||||
- "zen5-pro"
|
||||
|
||||
+4
-1
@@ -46,7 +46,10 @@
|
||||
</script>
|
||||
<script defer type="module" src="/src/main.jsx"></script>
|
||||
<!-- Hanzo Analytics -->
|
||||
<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="%VITE_ANALYTICS_SITE_ID%"></script>
|
||||
<script async src="https://analytics.hanzo.ai/hz.js" data-site="hanzo.chat"></script>
|
||||
<!-- Hanzo Edit — ever-present "improve this page" widget (fork→edit→PR; anyone can suggest) -->
|
||||
<meta name="hanzo:repo" content="hanzoai/chat" />
|
||||
<script async src="https://hanzo.app/edit.js"></script>
|
||||
<!-- Hanzo Insights (Insights) - only loaded when VITE_INSIGHTS_KEY is set -->
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
+9
-6
@@ -29,18 +29,22 @@
|
||||
},
|
||||
"homepage": "https://hanzo.ai/chat",
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.1",
|
||||
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
|
||||
"@hanzo/event": "^0.2.0",
|
||||
"@ariakit/react": "^0.4.15",
|
||||
"@ariakit/react-core": "^0.4.17",
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzo/ai": "^0.2.1",
|
||||
"@hanzo/brand": "^1.4.1",
|
||||
"@hanzo/event": "^0.2.0",
|
||||
"@hanzo/iam": "^0.13.8",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@hanzochat/client": "workspace:*",
|
||||
"@hanzogui/shell": "^7.4.2",
|
||||
"@hanzochat/data-provider": "workspace:*",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mcp-ui/client": "^5.7.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
@@ -81,7 +85,6 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jotai": "^2.12.5",
|
||||
"js-cookie": "^3.0.5",
|
||||
"@hanzochat/data-provider": "workspace:*",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^8.1.0",
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* OAuth Callback Handler for Hanzo IAM (hanzo.id)
|
||||
* OAuth Callback Handler for Hanzo IAM (hanzo.id) — the @hanzo/iam session-bridge.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User clicks "Sign in" -> redirects to hanzo.id OIDC (via IAM)
|
||||
* 2. After auth, hanzo.id redirects back with ?code=xxx&state=yyy
|
||||
* 3. IAM.handleCallback() exchanges the code for tokens (PKCE)
|
||||
* 4. We set the access token header and redirect to /c/new
|
||||
* 1. User clicks "Log in with Hanzo" -> @hanzo/iam redirects to hanzo.id (PKCE).
|
||||
* 2. hanzo.id redirects back here with ?code=xxx&state=yyy.
|
||||
* 3. IAM.handleCallback() exchanges the code for the IAM tokens (PKCE).
|
||||
* 4. We POST { accessToken, idToken } to the backend session-bridge
|
||||
* (/oauth/iam/session), which JWKS-validates the token, reconciles the user,
|
||||
* and issues the Chat session (refresh cookie + Mongo Session + Chat JWT) plus
|
||||
* persists the id_token server-side for on-behalf-of cloud calls.
|
||||
* 5. We set the returned Chat JWT as the app bearer and redirect to /c/new.
|
||||
*
|
||||
* The IAM token is never used as the app bearer — the JWKS-issued Chat JWT is;
|
||||
* the id_token stays server-side for OBO.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { setTokenHeader } from '@hanzochat/data-provider';
|
||||
import { request, iamSession } from '@hanzochat/data-provider';
|
||||
import { Spinner } from '@hanzochat/client';
|
||||
import { getHanzoIamSdk } from '~/utils/iam';
|
||||
|
||||
@@ -34,13 +41,26 @@ export default function OAuthCallback() {
|
||||
try {
|
||||
const tokens = await iamSdk.handleCallback();
|
||||
|
||||
if (tokens.accessToken) {
|
||||
setTokenHeader(tokens.accessToken);
|
||||
/** Trade the IAM token for a Chat session via the backend bridge. */
|
||||
const { token } = await request.post(iamSession(), {
|
||||
accessToken: tokens.accessToken,
|
||||
idToken: tokens.idToken,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error('session bridge returned no token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Chat JWT as the app bearer and notify AuthContext (which then
|
||||
* fetches the user). The refresh cookie the bridge set drives reload
|
||||
* persistence via the silent-refresh path.
|
||||
*/
|
||||
request.dispatchTokenUpdatedEvent(token);
|
||||
|
||||
navigate('/c/new', { replace: true });
|
||||
} catch (err) {
|
||||
console.error('OAuth code exchange failed:', err);
|
||||
console.error('IAM session bridge failed:', err);
|
||||
navigate('/login?error=auth_failed', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||
import { TemporaryChat } from './TemporaryChat';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
import MeetHanzoLauncher from '~/components/Nav/MeetHanzoLauncher';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -80,14 +81,18 @@ export default function Header() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSmallScreen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
<TemporaryChat />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isSmallScreen && (
|
||||
<>
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
<TemporaryChat />
|
||||
</>
|
||||
)}
|
||||
{/* Cross-app "Meet Hanzo" launcher — unified ecosystem nav, far right. */}
|
||||
<MeetHanzoLauncher />
|
||||
</div>
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
<div />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { HanzoHeader, HanzoPreFooterCTA, HanzoFooter } from '@hanzogui/shell';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { getHanzoIamSdk } from '~/utils/iam';
|
||||
|
||||
@@ -65,24 +66,6 @@ const IconArrowRight = ({ className = 'ml-2 size-4' }: { className?: string }) =
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* Hanzo geometric H logo */
|
||||
const HanzoLogo = ({ className = 'size-5', style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg viewBox="0 0 67 67" className={className} style={style} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* GitHub icon */
|
||||
const IconGitHub = ({ className = 'size-5' }: { className?: string }) => (
|
||||
<svg className={className} role="img" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -157,55 +140,8 @@ export default function LandingPage() {
|
||||
fontFamily: "'Inter', 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* ---- Navbar (matches fd HomeLayout nav) ---- */}
|
||||
<nav
|
||||
className="sticky top-0 z-50 backdrop-blur-md"
|
||||
style={{
|
||||
backgroundColor: 'rgba(5, 5, 5, 0.8)',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HanzoLogo className="size-5 text-white" />
|
||||
<span className="text-sm font-bold tracking-tight">Hanzo Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://docs.hanzo.ai/chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden text-sm transition-colors sm:inline"
|
||||
style={{ color: colors.mutedFg }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.color = colors.fg)}
|
||||
onMouseOut={(e) => (e.currentTarget.style.color = colors.mutedFg)}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/hanzoai/chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden transition-colors sm:inline"
|
||||
style={{ color: colors.mutedFg }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.color = colors.fg)}
|
||||
onMouseOut={(e) => (e.currentTarget.style.color = colors.mutedFg)}
|
||||
>
|
||||
<IconGitHub className="size-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href={loginHref}
|
||||
onClick={handleLoginClick}
|
||||
className="rounded-full px-5 py-2 text-sm font-medium tracking-tight transition-colors"
|
||||
style={{ backgroundColor: colors.brand, color: '#000' }}
|
||||
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
|
||||
>
|
||||
Log in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{/* ---- Unified Hanzo marketing header (shared @hanzogui/shell) ---- */}
|
||||
<HanzoHeader surface="hanzo.chat" />
|
||||
|
||||
{/* ---- Hero (bordered card like dev.hanzo.ai) ---- */}
|
||||
<div className="mx-auto w-full max-w-[1400px] px-4 pt-4">
|
||||
@@ -557,39 +493,9 @@ zen5-coder: I'll help you refactor the auth module.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Footer ---- */}
|
||||
<footer
|
||||
className="mt-0 py-8"
|
||||
style={{ borderTop: `1px solid ${colors.border}` }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] flex-col items-center justify-between gap-6 px-6 sm:flex-row md:px-12">
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: 'hsl(0, 0%, 40%)' }}>
|
||||
<HanzoLogo className="size-4" style={{ color: 'hsl(0, 0%, 40%)' }} />
|
||||
Powered by Hanzo AI
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm" style={{ color: 'hsl(0, 0%, 40%)' }}>
|
||||
{[
|
||||
{ label: 'hanzo.ai', href: 'https://hanzo.ai' },
|
||||
{ label: 'Documentation', href: 'https://docs.hanzo.ai/chat' },
|
||||
{ label: 'Console', href: 'https://console.hanzo.ai' },
|
||||
{ label: 'Privacy', href: 'https://hanzo.ai/privacy' },
|
||||
{ label: 'Terms', href: 'https://hanzo.ai/terms' },
|
||||
].map(({ label, href }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-colors"
|
||||
onMouseOver={(e) => (e.currentTarget.style.color = colors.fg)}
|
||||
onMouseOut={(e) => (e.currentTarget.style.color = 'hsl(0, 0%, 40%)')}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/* ---- Unified Hanzo pre-footer CTA + ecosystem footer (shared shell) ---- */}
|
||||
<HanzoPreFooterCTA surface="hanzo.chat" />
|
||||
<HanzoFooter currentProductId="chat" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Meet Hanzo — the cross-app "9-dot grid" launcher from the shared
|
||||
* @hanzogui/shell. Added ALONGSIDE LibreChat's conversation nav so Hanzo Chat
|
||||
* joins the unified Hanzo ecosystem nav.
|
||||
*
|
||||
* Click-only: the launcher's global ⌘/Ctrl-K quick-switch listener is disabled
|
||||
* (`quickSwitchKey={false}`) so adopting the shell never claims an app-wide
|
||||
* keyboard shortcut on the chat surface.
|
||||
*/
|
||||
import { HanzoAppLauncher } from '@hanzogui/shell';
|
||||
|
||||
export default function MeetHanzoLauncher() {
|
||||
return (
|
||||
<HanzoAppLauncher
|
||||
currentApp="chat"
|
||||
align="right"
|
||||
quickSwitchKey={false}
|
||||
label="Meet Hanzo"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useAuthContext } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Cloud usage — the canonical, org-scoped AI usage from `GET /v1/get-cloud-usages`
|
||||
* (proxied on-behalf-of by the chat backend). Rendered as a native, LibreChat-styled
|
||||
* (proxied on-behalf-of by the chat backend). Rendered as a native, Hanzo Chat-styled
|
||||
* view over the SHARED `@hanzo/usage` `CloudUsageOverview` shape: nothing is re-derived,
|
||||
* the numbers come straight from the ledger, and `normalizeCloudUsage` is the ONE
|
||||
* boundary guard (a partial/absent field degrades to honest zeros, never fabricated).
|
||||
|
||||
@@ -147,7 +147,7 @@ function Usage() {
|
||||
<SmartRoutingToggle />
|
||||
|
||||
{/* Canonical cloud usage (api.hanzo.ai) — renders nothing when unavailable.
|
||||
A separate concern from the LibreChat token-credit view below. */}
|
||||
A separate concern from the legacy token-credit view below. */}
|
||||
<CloudUsage />
|
||||
|
||||
{/* Spend header card */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MARK_PATHS, MARK_VIEWBOX } from '@hanzo/logo';
|
||||
|
||||
export interface HanzoLogoIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Pixel size; when set, applies to width and height. */
|
||||
@@ -8,20 +9,22 @@ export interface HanzoLogoIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Hanzo ▼/H mark — monochrome, `currentColor` fill.
|
||||
* Official Hanzo block-H mark — monochrome, `currentColor` fill.
|
||||
*
|
||||
* Canonical brand source: `@hanzo/logo` (hanzo.app / hanzo.ai favicon set).
|
||||
* Drop-in replacement for the upstream Chat lucide `Feather` brand fallback.
|
||||
* Geometry comes from `@hanzo/logo` (`MARK_PATHS` / `MARK_VIEWBOX`, the ONE home
|
||||
* of the mark) — this component no longer re-types the paths. Drop-in
|
||||
* replacement for the upstream Chat lucide `Feather` brand fallback.
|
||||
*/
|
||||
export default function HanzoLogoIcon({
|
||||
className = '',
|
||||
size,
|
||||
strokeWidth: _strokeWidth,
|
||||
children: _children,
|
||||
...props
|
||||
}: HanzoLogoIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 67 67"
|
||||
viewBox={MARK_VIEWBOX}
|
||||
className={className}
|
||||
{...(size != null ? { width: size, height: size } : {})}
|
||||
fill="currentColor"
|
||||
@@ -29,12 +32,8 @@ export default function HanzoLogoIcon({
|
||||
aria-label="Hanzo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
|
||||
</svg>
|
||||
// MARK_PATHS is a build-time-trusted @hanzo/logo constant — never user input.
|
||||
dangerouslySetInnerHTML={{ __html: MARK_PATHS }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -906,7 +906,7 @@
|
||||
"com_ui_latest_footer": "Toda IA para Todos.",
|
||||
"com_ui_latest_production_version": "Última versão produtiva",
|
||||
"com_ui_latest_version": "Última versão",
|
||||
"com_ui_chat_code_api_key": "Obtem a tua chave da API do Interpretador de código Librechat",
|
||||
"com_ui_chat_code_api_key": "Obtem a tua chave da API do Interpretador de código Hanzo",
|
||||
"com_ui_chat_code_api_subtitle": "Seguro. Multilíngua. Entrada/Saída de Ficheiros.",
|
||||
"com_ui_chat_code_api_title": "Correr código AI",
|
||||
"com_ui_loading": "A Carregar....",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/* Shared Hanzo brand tokens (monochrome --hanzo-* scale). Imported first so the
|
||||
chat :root below overrides any collision — chat keeps its own grey ramp while
|
||||
the --hanzo-* palette becomes the single shared source across every surface. */
|
||||
@import '@hanzo/brand/styles/variables.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -96,6 +96,24 @@ module.exports = {
|
||||
850: '#171717',
|
||||
900: '#0d0d0d',
|
||||
},
|
||||
// Shared @hanzo/brand monochrome scale (from variables.css). Additive —
|
||||
// chat's own `gray`/`mono` ramp is untouched; new shared chrome can use
|
||||
// `hanzo-*` so the palette resolves to the ONE brand source of truth.
|
||||
hanzo: {
|
||||
black: 'var(--hanzo-black)',
|
||||
white: 'var(--hanzo-white)',
|
||||
50: 'var(--hanzo-mono-50)',
|
||||
100: 'var(--hanzo-mono-100)',
|
||||
200: 'var(--hanzo-mono-200)',
|
||||
300: 'var(--hanzo-mono-300)',
|
||||
400: 'var(--hanzo-mono-400)',
|
||||
500: 'var(--hanzo-mono-500)',
|
||||
600: 'var(--hanzo-mono-600)',
|
||||
700: 'var(--hanzo-mono-700)',
|
||||
800: 'var(--hanzo-mono-800)',
|
||||
900: 'var(--hanzo-mono-900)',
|
||||
950: 'var(--hanzo-mono-950)',
|
||||
},
|
||||
green: mono,
|
||||
red: mono,
|
||||
blue: mono,
|
||||
|
||||
@@ -289,6 +289,10 @@ export default defineConfig(({ command }) => ({
|
||||
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||
},
|
||||
},
|
||||
// Pre-bundle the compiled-ESM shell so the dev server resolves it cleanly.
|
||||
optimizeDeps: {
|
||||
include: ['@hanzogui/shell'],
|
||||
},
|
||||
}));
|
||||
|
||||
interface SourcemapExclude {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
# services:
|
||||
|
||||
# # USE LIBRECHAT CONFIG FILE
|
||||
# # USE HANZO CHAT CONFIG FILE
|
||||
# api:
|
||||
# volumes:
|
||||
# - type: bind
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// the jest coverage; keep the semantics in lock-step.
|
||||
|
||||
/**
|
||||
* Org-scoped auto-routing defaults from cloud (`GET /v1/get-routing-defaults`),
|
||||
* Org-scoped auto-routing defaults from cloud (`GET /v1/router/defaults`),
|
||||
* proxied by the chat backend at `/v1/chat/routing-defaults`. `available` is
|
||||
* false when the endpoint is absent (older cloud-api) or the fetch failed soft —
|
||||
* the client then behaves exactly as today (local preference only).
|
||||
|
||||
@@ -798,7 +798,7 @@ export function emitError(
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* LIBRECHAT EXTENSION EVENTS
|
||||
* HANZO CHAT EXTENSION EVENTS
|
||||
* Custom events prefixed with 'chat:' per Open Responses spec
|
||||
* @see https://openresponses.org/specification#extending-streaming-events
|
||||
* ============================================================================= */
|
||||
|
||||
@@ -689,7 +689,7 @@ export interface ErrorEvent extends BaseEvent {
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* LIBRECHAT EXTENSION TYPES
|
||||
* HANZO CHAT EXTENSION TYPES
|
||||
* Per Open Responses spec, custom types MUST be prefixed with implementor slug
|
||||
* @see https://openresponses.org/specification#extending-streaming-events
|
||||
* ============================================================================= */
|
||||
|
||||
@@ -210,3 +210,55 @@ describe('initializeCustom – SSRF guard wiring', () => {
|
||||
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeCustom – guest principal billing (shared capped key)', () => {
|
||||
const OPENID_BEARER_SENTINEL = '{{CHAT_OPENID_TOKEN}}';
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
const guestParams = () => {
|
||||
const params = createParams({
|
||||
apiKey: OPENID_BEARER_SENTINEL,
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
});
|
||||
params.req.user = { id: 'guest_abc', guest: true } as unknown as typeof params.req.user;
|
||||
params.req.body = { model: 'zen5-flash' };
|
||||
return params;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.GUEST_API_KEY;
|
||||
delete process.env.HANZO_API_KEY;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it('routes a guest to the shared GUEST_API_KEY, never a per-user bearer', async () => {
|
||||
process.env.GUEST_API_KEY = 'hk-guest-capped';
|
||||
await initializeCustom(guestParams());
|
||||
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
|
||||
'hk-guest-capped',
|
||||
expect.any(Object),
|
||||
'test-custom',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to HANZO_API_KEY when GUEST_API_KEY is unset', async () => {
|
||||
process.env.HANZO_API_KEY = 'hk-fallback';
|
||||
await initializeCustom(guestParams());
|
||||
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
|
||||
'hk-fallback',
|
||||
expect.any(Object),
|
||||
'test-custom',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for a guest when no guest key is configured', async () => {
|
||||
await expect(initializeCustom(guestParams())).rejects.toThrow(
|
||||
'Guest chat is temporarily unavailable',
|
||||
);
|
||||
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,16 +114,33 @@ export async function initializeCustom({
|
||||
// whose membership the gateway checks it against.
|
||||
let tenantHeaders: Record<string, string> | undefined;
|
||||
if (apiKey === OPENID_BEARER_SENTINEL) {
|
||||
const bearer = resolveTenantBearer(req as unknown as Parameters<typeof resolveTenantBearer>[0]);
|
||||
if (!bearer) {
|
||||
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
|
||||
}
|
||||
apiKey = bearer;
|
||||
const activeOrg = resolveActiveOrg(
|
||||
req as unknown as Parameters<typeof resolveActiveOrg>[0],
|
||||
);
|
||||
if (activeOrg) {
|
||||
tenantHeaders = { 'X-Org-Id': activeOrg };
|
||||
const isGuest = (req.user as { guest?: boolean } | undefined)?.guest === true;
|
||||
if (isGuest) {
|
||||
// Anonymous guest preview: there is NO IAM identity to forward and NO org to
|
||||
// bill. Use the shared, capped guest gateway key — the guest key's OWN org is
|
||||
// metered + capped at api.hanzo.ai (402 when exhausted, surfaced by
|
||||
// wrapHanzoGatewayFetch below), never a real user's org. Per-user hk- billing
|
||||
// is skipped by construction: a guest carries no bearer and no active org, so
|
||||
// NO `X-Org-Id` is sent. The guest key never leaves the server. `GUEST_API_KEY`
|
||||
// (the KMS `chat-guest-key`) is preferred; `HANZO_API_KEY` is the dev fallback.
|
||||
// Fail closed if neither is configured.
|
||||
const guestKey = process.env.GUEST_API_KEY || process.env.HANZO_API_KEY || '';
|
||||
if (!guestKey) {
|
||||
throw new Error('Guest chat is temporarily unavailable.');
|
||||
}
|
||||
apiKey = guestKey;
|
||||
} else {
|
||||
const bearer = resolveTenantBearer(
|
||||
req as unknown as Parameters<typeof resolveTenantBearer>[0],
|
||||
);
|
||||
if (!bearer) {
|
||||
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
|
||||
}
|
||||
apiKey = bearer;
|
||||
const activeOrg = resolveActiveOrg(req as unknown as Parameters<typeof resolveActiveOrg>[0]);
|
||||
if (activeOrg) {
|
||||
tenantHeaders = { 'X-Org-Id': activeOrg };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -578,9 +578,9 @@ function getMCPProxyConfig(options: t.MCPOptions): MCPProxyConfig | undefined {
|
||||
return { type: 'explicit', proxyUrl: configuredProxy };
|
||||
}
|
||||
|
||||
const libreChatProxy = process.env.PROXY?.trim() ?? '';
|
||||
if (libreChatProxy) {
|
||||
return { type: 'explicit', proxyUrl: libreChatProxy };
|
||||
const chatProxy = process.env.PROXY?.trim() ?? '';
|
||||
if (chatProxy) {
|
||||
return { type: 'explicit', proxyUrl: chatProxy };
|
||||
}
|
||||
|
||||
const httpProxy = getTrimmedEnv('http_proxy', 'HTTP_PROXY');
|
||||
|
||||
@@ -196,6 +196,12 @@ export const loginGoogle = () => `${BASE_URL}/v1/chat/auth/google`;
|
||||
export const refreshToken = (retry?: boolean) =>
|
||||
`${BASE_URL}/v1/chat/auth/refresh${retry === true ? '?retry=true' : ''}`;
|
||||
|
||||
/**
|
||||
* Session-bridge: exchange an @hanzo/iam SPA token for a Chat session
|
||||
* (refresh cookie + Mongo Session + Chat JWT). POST { accessToken, idToken }.
|
||||
*/
|
||||
export const iamSession = () => `${BASE_URL}/oauth/iam/session`;
|
||||
|
||||
export const guestToken = () => `${BASE_URL}/v1/chat/auth/guest`;
|
||||
|
||||
export const requestPasswordReset = () => `${BASE_URL}/v1/chat/auth/requestPasswordReset`;
|
||||
|
||||
@@ -36,7 +36,14 @@ export * from './accessPermissions';
|
||||
export * from './keys';
|
||||
/* api call helpers */
|
||||
export * from './headers-helpers';
|
||||
export { loginPage, registerPage, apiBaseUrl, setApiBaseUrl, zapUrl } from './api-endpoints';
|
||||
export {
|
||||
loginPage,
|
||||
registerPage,
|
||||
apiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
zapUrl,
|
||||
iamSession,
|
||||
} from './api-endpoints';
|
||||
export { default as request, setPublishableKey, getWithPk } from './request';
|
||||
export { dataService };
|
||||
import * as dataService from './data-service';
|
||||
|
||||
@@ -732,7 +732,7 @@ export type TUsageResponse = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Org-scoped auto-routing defaults from cloud (`GET /v1/get-routing-defaults`),
|
||||
* Org-scoped auto-routing defaults from cloud (`GET /v1/router/defaults`),
|
||||
* proxied by the chat backend. `available` is false when the endpoint is absent
|
||||
* (older cloud-api) or the fetch failed soft — the client then behaves exactly as
|
||||
* today (local preference only).
|
||||
|
||||
@@ -67,7 +67,7 @@ interface PoolStats {
|
||||
available: number;
|
||||
}
|
||||
|
||||
// ─── ALL 29 LIBRECHAT SCHEMAS ───────────────────────────────────────────────
|
||||
// ─── ALL 29 HANZO CHAT SCHEMAS ───────────────────────────────────────────────
|
||||
|
||||
const MODEL_SCHEMAS: Record<string, Schema> = {
|
||||
User: userSchema,
|
||||
|
||||
Generated
+55
-6
@@ -348,7 +348,7 @@ importers:
|
||||
devDependencies:
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
version: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
|
||||
mongodb-memory-server:
|
||||
specifier: ^10.1.4
|
||||
version: 10.4.3
|
||||
@@ -379,12 +379,18 @@ importers:
|
||||
'@hanzo/ai':
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
'@hanzo/brand':
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1(react@18.3.1)
|
||||
'@hanzo/event':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(react@18.3.1)
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1(react@18.3.1)
|
||||
specifier: ^0.13.8
|
||||
version: 0.13.8(react@18.3.1)
|
||||
'@hanzo/logo':
|
||||
specifier: ^1.0.13
|
||||
version: 1.0.13(react@18.3.1)
|
||||
'@hanzo/ui':
|
||||
specifier: npm:@hanzo/ui-shadcn@^5.7.4
|
||||
version: '@hanzo/ui-shadcn@5.7.4(@hookform/resolvers@3.10.0(react-hook-form@7.71.2(react@18.3.1)))(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(@tanstack/react-query@4.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@3.6.0)(embla-carousel@8.6.0)(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lucide-react@0.394.0(react@18.3.1))(mermaid@11.13.0)(mobx@6.15.0)(next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.71.2(react@18.3.1))(react-resizable-panels@3.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(yaml@2.8.3))(validator@13.15.26)'
|
||||
@@ -397,6 +403,9 @@ importers:
|
||||
'@hanzochat/data-provider':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/data-provider
|
||||
'@hanzogui/shell':
|
||||
specifier: ^7.4.2
|
||||
version: 7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@headlessui/react':
|
||||
specifier: ^2.1.2
|
||||
version: 2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -3794,6 +3803,15 @@ packages:
|
||||
resolution: {integrity: sha512-m0k6V6Im8m5RXWHUy/E7DvpZ9tFBYUw21NTYFxNxUiPI1l+yEvHEVytqEbEtHZOCa9Ytb8nfuNzJIsfCeF/zZw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@hanzo/brand@1.4.1':
|
||||
resolution: {integrity: sha512-tApXct8gE/HOYa61h6TWQfDp07l1pmB+vqmxlJtU+MhocbwEkW4VPJrQdPuwF/5GPZYQauNT2uaRpnZdCK8ikw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/event@0.2.0':
|
||||
resolution: {integrity: sha512-jx+xlT5no88PYg/k465/pQ7a+oQcfImB5X0dJ4/ZSSF5bwqPCItvN/9Znt4SQpq18EQeMuR5GiLmxo1wSP5uqw==}
|
||||
peerDependencies:
|
||||
@@ -3802,8 +3820,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/iam@0.13.1':
|
||||
resolution: {integrity: sha512-MAqXY7jL5pcPkaUE0Qb/ePL2WKvk3p6EQDLHGZENj+/gbNKs7e/9qG+nmRlPMFVcwXPk+suzkBAEyIMT2EiJ8w==}
|
||||
'@hanzo/iam@0.13.8':
|
||||
resolution: {integrity: sha512-6D4lZSSuTBWV/bof5u5l6VEE0VZhNnpF99svirmHTRko5MsZ5sEd3+7xZ28KH0UL0oquvHwRJ6tkvn+E+f5YuQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
@@ -3811,6 +3829,14 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/logo@1.0.13':
|
||||
resolution: {integrity: sha512-94VA6S7oVY5UZ816295Ez12p8a76ZGEct14JQwE6r5UCutD3K9TBgx5V01gjwxwVL9tWnXn/yut4PHRAY/9RNQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@hanzo/ui-shadcn@5.7.4':
|
||||
resolution: {integrity: sha512-MFGPp+GYzaBUyd79F2mrk40SDLTFgUBEoPiSFVuJjekOVr5bJvUVNIWdoNtrl5ITL86N2ruiCNRhKGmEJyHFQA==}
|
||||
hasBin: true
|
||||
@@ -3914,6 +3940,13 @@ packages:
|
||||
'@anthropic-ai/sandbox-runtime':
|
||||
optional: true
|
||||
|
||||
'@hanzogui/shell@7.4.2':
|
||||
resolution: {integrity: sha512-Haig4FLJUAgNbHvYxgsX/Y4D+VZW9fzvYoYi6Z19ZKkYCXnFG6S0wQmwZhqFW5KCmlnwUV7jUSJw9vOdvqj7nw==}
|
||||
peerDependencies:
|
||||
'@hanzo/iam': ^0.13.1
|
||||
react: '*'
|
||||
react-dom: '*'
|
||||
|
||||
'@headlessui/react@2.2.9':
|
||||
resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -17693,11 +17726,17 @@ snapshots:
|
||||
|
||||
'@hanzo/ai@0.2.1': {}
|
||||
|
||||
'@hanzo/brand@1.4.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@hanzo/logo': 1.0.13(react@18.3.1)
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/event@0.2.0(react@18.3.1)':
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/iam@0.13.1(react@18.3.1)':
|
||||
'@hanzo/iam@0.13.8(react@18.3.1)':
|
||||
dependencies:
|
||||
jose: 6.2.2
|
||||
libphonenumber-js: 1.13.7
|
||||
@@ -17705,6 +17744,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/logo@1.0.13(react@18.3.1)':
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@hanzo/ui-shadcn@5.7.4(@hookform/resolvers@3.10.0(react-hook-form@7.71.2(react@18.3.1)))(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(@tanstack/react-query@4.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@3.6.0)(embla-carousel@8.6.0)(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lucide-react@0.394.0(react@18.3.1))(mermaid@11.13.0)(mobx@6.15.0)(next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.71.2(react@18.3.1))(react-resizable-panels@3.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(yaml@2.8.3))(validator@13.15.26)':
|
||||
dependencies:
|
||||
'@hookform/resolvers': 3.10.0(react-hook-form@7.71.2(react@18.3.1))
|
||||
@@ -17842,6 +17885,12 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@hanzogui/shell@7.4.2(@hanzo/iam@0.13.8(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@hanzo/iam': 0.13.8(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@headlessui/react@2.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -7,7 +7,7 @@ cd ${DIR}/../..
|
||||
TAG=$1
|
||||
|
||||
if [[ -z "${TAG}" ]]; then
|
||||
TAG=${LIBRE_CHAT_DOCKER_TAG}
|
||||
TAG=${CHAT_DOCKER_TAG}
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]]; then
|
||||
|
||||
@@ -7,7 +7,7 @@ cd ${DIR}/../..
|
||||
TAG=$1
|
||||
|
||||
if [[ -z "${TAG}" ]]; then
|
||||
TAG=${LIBRE_CHAT_DOCKER_TAG}
|
||||
TAG=${CHAT_DOCKER_TAG}
|
||||
fi
|
||||
|
||||
if [[ -z "${TAG}" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user