Compare commits

..

No commits in common. "main" and "codex/feat/console-frontend-single-node-release" have entirely different histories.

190 changed files with 5077 additions and 5961 deletions

View File

@ -4,8 +4,6 @@ NEXT_PUBLIC_APP_BASE_URL=
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_LOGIN_URL=
NEXT_PUBLIC_DOCS_BASE_URL=
DOCS_SERVICE_URL=https://docs.svc.plus
DOCS_SERVICE_INTERNAL_URL=
SESSION_COOKIE_SECURE=true
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
RUNTIME_HOSTNAME=
@ -17,19 +15,12 @@ NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn
# Upstream service endpoints
# Use root service origins only. Do not point ACCOUNT_SERVICE_URL at console.svc.plus
# and do not include /api/auth or any other path suffix here.
ACCOUNT_SERVICE_URL=https://accounts.svc.plus
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
SERVER_SERVICE_URL=https://api.svc.plus
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
SERVER_SERVICE_INTERNAL_URL=
# XWorkmate bridge runtime
# Read server-side by /api/xworkmate/bridge. Do not expose the token as NEXT_PUBLIC_*.
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
BRIDGE_AUTH_TOKEN=
# OpenClaw assistant integrations
# Use environment variables to prefill the assistant and integrations page.
# Values are read server-side and are not hardcoded into the UI.
@ -52,8 +43,6 @@ NEXT_PUBLIC_GISCUS_CATEGORY_ID=DIC_kwDOQoiZ_s4Clj_q
INTERNAL_SERVICE_TOKEN=
# Cloudflare Web Analytics GraphQL credentials
CLOUDFLARE_DNS_API_TOKEN=
CLOUDFLARE_DNS_ZONE_TAG=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=

37
.github/actions/auto-tag/action.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: "Cloud-Neutral Auto Tag"
description: "Generate Docker tags for main, release, PR and dev branches"
inputs:
image:
description: "Base image name (e.g. ghcr.io/.../image)"
required: true
outputs:
tags:
description: "Generated Docker tags"
value: ${{ steps.meta.outputs.tags }}
labels:
description: "Generated Docker labels"
value: ${{ steps.meta.outputs.labels }}
runs:
using: composite
steps:
- name: Generate metadata (auto tags)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image }}
tags: |
# main → latest
type=raw,enable=${{ github.ref == 'refs/heads/main' }},value=latest
# release tagv1.2.3
type=ref,event=tag
type=semver,pattern={{version}}
# PR → pr-123
type=raw,enable=${{ startsWith(github.ref, 'refs/pull/') }},value=pr-${{ github.event.pull_request.number }}
# dev/feature branches → branch name
type=ref,event=branch

90
.github/actions/build/action.yml vendored Normal file
View File

@ -0,0 +1,90 @@
name: Build
description: Build artifacts for each service and platform with optional container publishing.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
runs:
using: composite
steps:
- name: Prepare matrix context
id: matrix
uses: ../matrix-support
with:
service: ${{ inputs.service }}
platform: ${{ inputs.platform }}
environment: ${{ inputs.environment }}
enable_docker: 'true'
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: |
build/${{ inputs.service }}
dashboard/.next
key: build-${{ inputs.service }}-${{ inputs.platform }}-${{ hashFiles('**/go.sum', 'dashboard/yarn.lock') }}-${{ inputs.environment }}
restore-keys: |
build-${{ inputs.service }}-${{ inputs.platform }}-
build-${{ inputs.service }}-
- name: Prepare Go toolchain
if: inputs.service != 'dashboard'
uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: true
- name: Build Go binaries
if: inputs.service != 'dashboard'
shell: bash
run: |
set -euo pipefail
goos="${{ steps.matrix.outputs.goos }}"
goarch="${{ steps.matrix.outputs.goarch }}"
mkdir -p build/${{ inputs.service }}/"${goos}-${goarch}"
declare -a targets
if [[ "${{ inputs.service }}" == "rag-server" ]]; then
targets=("rag-server/cmd/xcontrol-server" "rag-server/cmd/rag-server-cli")
elif [[ "${{ inputs.service }}" == "account" ]]; then
targets=("account/cmd/accountsvc")
else
targets=("./...")
fi
for target in "${targets[@]}"; do
binary_name=$(basename "$target")
GOOS="$goos" GOARCH="$goarch" go build -o build/${{ inputs.service }}/"${goos}-${goarch}"/"${binary_name}" "$target"
done
- name: Upload Go artifacts
if: inputs.service != 'dashboard'
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.service }}-${{ inputs.platform }}-${{ inputs.environment }}
path: build/${{ inputs.service }}/
- name: Install dashboard dependencies
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn install --frozen-lockfile
- name: Build dashboard
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
env:
NEXT_PUBLIC_ENV: ${{ inputs.environment }}
run: yarn build
- name: Upload dashboard build output
if: inputs.service == 'dashboard'
uses: actions/upload-artifact@v4
with:
name: dashboard-${{ inputs.platform }}-${{ inputs.environment }}
path: dashboard/.next

53
.github/actions/code-quality/action.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Code Quality
description: Run linting and basic quality checks per service/platform/environment matrix entry.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
runs:
using: composite
steps:
- name: Prepare matrix context
id: matrix
uses: ./.github/actions/matrix-support
with:
service: ${{ inputs.service }}
platform: ${{ inputs.platform }}
environment: ${{ inputs.environment }}
- name: Install git-secrets
shell: bash
run: |
set -euo pipefail
git clone https://github.com/awslabs/git-secrets.git
sudo make install -C git-secrets
git secrets --install
git secrets --scan
- name: Go vet
if: inputs.service != 'dashboard'
shell: bash
run: go vet ./...
- name: Go unit tests (quality gate)
if: inputs.service != 'dashboard'
shell: bash
run: go test ./...
- name: Install dashboard dependencies
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn install --frozen-lockfile
- name: Dashboard lint
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn lint

48
.github/actions/deploy/action.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Deploy
description: Coordinate deployments per service/environment.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
runs:
using: composite
steps:
- name: Prepare matrix context
id: matrix
uses: ./.github/actions/matrix-support
with:
service: ${{ inputs.service }}
platform: ${{ inputs.platform }}
environment: ${{ inputs.environment }}
- name: Prepare rollout context
id: context
shell: bash
run: |
set -euo pipefail
echo "service=${{ inputs.service }}" >> "$GITHUB_OUTPUT"
echo "environment=${{ inputs.environment }}" >> "$GITHUB_OUTPUT"
echo "platform=${{ inputs.platform }}" >> "$GITHUB_OUTPUT"
echo "release_channel=${{ steps.matrix.outputs.is_prod == 'true' && 'prod' || 'dev' }}" >> "$GITHUB_OUTPUT"
- name: Deploy placeholder
shell: bash
env:
TARGET_ENV: ${{ steps.context.outputs.environment }}
TARGET_SERVICE: ${{ steps.context.outputs.service }}
TARGET_PLATFORM: ${{ steps.context.outputs.platform }}
RELEASE_CHANNEL: ${{ steps.context.outputs.release_channel }}
run: |
echo "Deploying ${TARGET_SERVICE} (${TARGET_PLATFORM}) to ${TARGET_ENV} namespace via ${RELEASE_CHANNEL} rollout"
echo "Hook in Helm/kubectl/ArgoCD rollouts here"
- name: Rollback plan
shell: bash
run: |
echo "Rollback can be re-run per matrix entry by dispatching with allow_deploy=true"

View File

@ -0,0 +1,111 @@
name: Docker Build Push
description: Build and push Docker images using docker buildx
inputs:
context:
description: Build context
required: false
default: .
file:
description: Dockerfile path
required: false
default: Dockerfile
platforms:
description: Build platforms
required: false
default: linux/amd64
push:
description: Push image
required: false
default: false
tags:
description: Image tags
required: false
build-args:
description: Build arguments
required: false
labels:
description: Build labels
required: false
cache-from:
description: Cache sources
required: false
cache-to:
description: Cache destinations
required: false
outputs:
description: Build outputs
required: false
outputs:
digest:
description: Image digest
value: ${{ steps.build.outputs.digest }}
runs:
using: composite
steps:
- shell: bash
id: build
run: |
set -euo pipefail
# Parse build args
BUILD_ARGS=""
if [ -n "${{ inputs.build-args }}" ]; then
echo "${{ inputs.build-args }}" | while IFS= read -r line; do
[ -n "$line" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $line"
done
fi
# Parse labels
LABELS=""
if [ -n "${{ inputs.labels }}" ]; then
echo "${{ inputs.labels }}" | while IFS= read -r line; do
[ -n "$line" ] && LABELS="$LABELS --label $line"
done
fi
# Parse outputs
OUTPUTS=""
if [ -n "${{ inputs.outputs }}" ]; then
OUTPUTS="--output ${{ inputs.outputs }}"
elif [ "${{ inputs.push }}" = "true" ]; then
OUTPUTS="--type=registry"
else
OUTPUTS="--type=image,push=false"
fi
# Parse tags
TAGS_ARG=""
if [ -n "${{ inputs.tags }}" ]; then
TAGS_ARG="--tag $(echo '${{ inputs.tags }}' | tr '\n' ' ' | sed 's/ */ --tag /g')"
fi
# Build command
echo "::group::Docker Buildx Build"
docker buildx build \
--platform ${{ inputs.platforms }} \
--file ${{ inputs.file }} \
$TAGS_ARG \
$BUILD_ARGS \
$LABELS \
$OUTPUTS \
--progress=plain \
${{ inputs.context }}
BUILD_STATUS=$?
# Get digest if successful
if [ $BUILD_STATUS -eq 0 ] && [ "${{ inputs.push }}" = "true" ]; then
# Extract first tag
FIRST_TAG=$(echo "${{ inputs.tags }}" | head -n1)
# Get image digest by pulling image info
DIGEST=$(docker buildx imagetools inspect $FIRST_TAG --format '{{json .Manifest.Digest}}' 2>/dev/null || echo "")
if [ -n "$DIGEST" ]; then
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
fi
fi
exit $BUILD_STATUS
echo "::endgroup::"

20
.github/actions/docker-login/action.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Docker Login
description: Login to Docker registry using docker CLI
inputs:
registry:
description: Docker registry URL
required: true
username:
description: Username for registry login
required: true
password:
description: Password or token for registry login
required: true
runs:
using: composite
steps:
- shell: bash
run: |
echo "${{ inputs.password }}" | docker login -u "${{ inputs.username }}" --password-stdin ${{ inputs.registry }}

View File

@ -0,0 +1,7 @@
name: docker-setup-buildx
description: Wrapper around docker/setup-buildx-action pinned to a SHA inside cloud-neutral-toolkit repo.
runs:
using: composite
steps:
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f

View File

@ -0,0 +1,10 @@
name: Docker Setup QEMU
description: Set up QEMU for multi-platform builds
runs:
using: composite
steps:
- shell: bash
run: |
# Install QEMU emulation support
docker run --privileged --rm tonistiigi/binfmt --install all

View File

@ -0,0 +1,22 @@
name: Download Artifact
description: Download artifact files
inputs:
name:
description: Artifact name
required: true
runs:
using: composite
steps:
- shell: bash
run: |
# Restore artifact from artifacts directory
if [ -d "artifacts/${{ inputs.name }}" ]; then
cp -r artifacts/${{ inputs.name }}/* . || true
elif [ -f "${{ inputs.name }}" ]; then
echo "Artifact file found: ${{ inputs.name }}"
else
echo "Artifact not found: ${{ inputs.name }}"
exit 1
fi

View File

@ -0,0 +1,106 @@
name: Matrix Support
description: Common setup for matrix-driven workflows with language and cache bootstrapping.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
enable_docker:
description: Enable Docker buildx/QEMU setup
required: false
default: 'false'
outputs:
goos:
description: Derived GOOS from the platform input
value: ${{ steps.platforms.outputs.goos }}
goarch:
description: Derived GOARCH from the platform input
value: ${{ steps.platforms.outputs.goarch }}
is_prod:
description: Whether the environment is prod or the ref is a tag
value: ${{ steps.flags.outputs.is_prod }}
target_platforms:
description: Platform list for builds (single in dev, multi-arch in prod)
value: ${{ steps.flags.outputs.target_platforms }}
runs:
using: composite
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive platform matrix values
id: platforms
shell: bash
run: |
set -euo pipefail
platform="${{ inputs.platform }}"
goos="${platform%%/*}"
goarch="${platform##*/}"
echo "goos=${goos}" >> "$GITHUB_OUTPUT"
echo "goarch=${goarch}" >> "$GITHUB_OUTPUT"
- name: Resolve environment flags
id: flags
shell: bash
run: |
set -euo pipefail
if [[ "${{ inputs.environment }}" == "prod" || "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
echo "is_prod=true" >> "$GITHUB_OUTPUT"
echo "target_platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
else
echo "is_prod=false" >> "$GITHUB_OUTPUT"
echo "target_platforms=${{ inputs.platform }}" >> "$GITHUB_OUTPUT"
fi
- name: Set up Go
if: inputs.service != 'dashboard'
uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: true
- name: Cache Go build data
if: inputs.service != 'dashboard'
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ inputs.service }}-${{ inputs.platform }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
go-${{ inputs.service }}-${{ inputs.platform }}-
go-${{ inputs.service }}-
- name: Set up Node.js
if: inputs.service == 'dashboard'
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
cache-dependency-path: dashboard/yarn.lock
- name: Cache dashboard artifacts
if: inputs.service == 'dashboard'
uses: actions/cache@v4
with:
path: |
dashboard/.next/cache
~/.cache/yarn
key: dashboard-${{ inputs.platform }}-${{ hashFiles('dashboard/yarn.lock') }}
restore-keys: |
dashboard-${{ inputs.platform }}-
dashboard-
- name: Enable Docker build tooling
if: inputs.enable_docker == 'true'
uses: docker/setup-qemu-action@v3
- name: Set up buildx
if: inputs.enable_docker == 'true'
uses: docker/setup-buildx-action@v3

76
.github/actions/security/action.yml vendored Normal file
View File

@ -0,0 +1,76 @@
name: Security
description: Security scanning per service/platform/environment.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
runs:
using: composite
steps:
- name: Prepare matrix context
id: matrix
uses: ./.github/actions/matrix-support
with:
service: ${{ inputs.service }}
platform: ${{ inputs.platform }}
environment: ${{ inputs.environment }}
- name: Run golangci-lint
if: inputs.service != 'dashboard'
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: ./...
- name: Install gosec
if: inputs.service != 'dashboard'
shell: bash
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
- name: Run gosec
if: inputs.service != 'dashboard'
shell: bash
run: gosec ./...
- name: Trivy filesystem scan
if: inputs.service != 'dashboard'
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
ignore-unfixed: true
format: table
exit-code: "0"
- name: Install dashboard dependencies
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn install --frozen-lockfile
- name: Run ESLint
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn lint
- name: Semgrep security rules
if: inputs.service == 'dashboard'
uses: returntocorp/semgrep-action@v1
with:
config: p/ci
paths: dashboard
- name: npm audit (production)
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: npm audit --production
continue-on-error: true

52
.github/actions/test/action.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Test
description: Run service-specific tests.
inputs:
service:
description: Target service name
required: true
platform:
description: Target platform (e.g., linux/amd64)
required: true
environment:
description: Deployment environment (dev or prod)
required: true
runs:
using: composite
steps:
- name: Prepare matrix context
id: matrix
uses: ./.github/actions/matrix-support
with:
service: ${{ inputs.service }}
platform: ${{ inputs.platform }}
environment: ${{ inputs.environment }}
- name: Run Go integration tests
if: inputs.service != 'dashboard'
shell: bash
run: |
set -euo pipefail
go test ./... -run Integration -count=1
- name: Install dashboard dependencies
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
run: yarn install --frozen-lockfile
- name: Run dashboard unit tests
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
env:
NODE_ENV: ${{ inputs.environment }}
run: yarn test:unit
- name: Run dashboard e2e tests
if: inputs.service == 'dashboard'
working-directory: dashboard
shell: bash
env:
PORT: 3100
NODE_ENV: ${{ inputs.environment }}
run: yarn test:e2e

View File

@ -0,0 +1,30 @@
name: Upload Artifact
description: Upload artifact files
inputs:
name:
description: Artifact name
required: true
path:
description: File paths to upload
required: true
runs:
using: composite
steps:
- shell: bash
run: |
# Create artifacts directory
mkdir -p artifacts
# Copy files to artifacts directory
cp -r ${{ inputs.path }} artifacts/
# Save artifact metadata
echo "${{ inputs.name }}" > artifacts/metadata.txt
echo "${{ inputs.path }}" >> artifacts/metadata.txt
ls -la artifacts/ >> artifacts/metadata.txt
# For now, just keep files in workspace
# In CI/CD system, this would be collected
echo "Artifact uploaded to artifacts/ directory"

View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
: "${ORG:?ORG is required}"
: "${MANIFEST_DIGEST:?MANIFEST_DIGEST is required}"
: "${TARGET_NS:?TARGET_NS is required}"
: "${GHCR_USERNAME:?GHCR_USERNAME is required}"
: "${GHCR_TOKEN:?GHCR_TOKEN is required}"
: "${DOCKERHUB_USERNAME:?DOCKERHUB_USERNAME is required}"
: "${DOCKERHUB_TOKEN:?DOCKERHUB_TOKEN is required}"
sudo apt-get update -y
sudo apt-get install -y skopeo
src="docker://ghcr.io/${ORG}/dashboard@${MANIFEST_DIGEST}"
dst="docker://docker.io/${TARGET_NS}/dashboard:latest"
skopeo login ghcr.io -u "${GHCR_USERNAME}" -p "${GHCR_TOKEN}"
skopeo login docker.io -u "${DOCKERHUB_USERNAME}" -p "${DOCKERHUB_TOKEN}"
skopeo copy --all "${src}" "${dst}"

View File

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
: "${REGISTRY:?REGISTRY is required}"
: "${ORG:?ORG is required}"
: "${IMAGE_SHA:?IMAGE_SHA is required}"
: "${AMD_DIGEST:?AMD_DIGEST is required}"
: "${ARM_DIGEST:?ARM_DIGEST is required}"
: "${TAGS_CSV:?TAGS_CSV is required}"
: "${GITHUB_ENV:?GITHUB_ENV is required}"
src_amd="${REGISTRY}/${ORG}/dashboard:build-${IMAGE_SHA}-linux-amd64@${AMD_DIGEST}"
src_arm="${REGISTRY}/${ORG}/dashboard:build-${IMAGE_SHA}-linux-arm64@${ARM_DIGEST}"
first_tag=""
while IFS= read -r tag; do
[ -n "${tag}" ] || continue
if [ -z "${first_tag}" ]; then
first_tag="${tag}"
fi
docker buildx imagetools create -t "${tag}" "${src_amd}" "${src_arm}"
done < <(printf '%s' "${TAGS_CSV}" | tr ',' '\n')
[ -n "${first_tag}" ] || {
echo "No tags were generated." >&2
exit 1
}
manifest_digest="$(docker buildx imagetools inspect "${first_tag}" --format '{{.Digest}}')"
echo "MANIFEST_DIGEST=${manifest_digest}" >> "${GITHUB_ENV}"
echo "FINAL_TAG=${first_tag}" >> "${GITHUB_ENV}"

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DIGEST_FILE:?DIGEST_FILE is required}"
: "${GITHUB_ENV:?GITHUB_ENV is required}"
echo "IMAGE_DIGEST=$(tr -d '\n' < "${DIGEST_FILE}")" >> "${GITHUB_ENV}"

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
: "${AMD_DIGEST_FILE:?AMD_DIGEST_FILE is required}"
: "${ARM_DIGEST_FILE:?ARM_DIGEST_FILE is required}"
: "${GITHUB_ENV:?GITHUB_ENV is required}"
echo "AMD_DIGEST=$(tr -d '\n' < "${AMD_DIGEST_FILE}")" >> "${GITHUB_ENV}"
echo "ARM_DIGEST=$(tr -d '\n' < "${ARM_DIGEST_FILE}")" >> "${GITHUB_ENV}"

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
: "${IMAGE_DIGEST:?IMAGE_DIGEST is required}"
: "${OUTPUT_FILE:?OUTPUT_FILE is required}"
printf '%s\n' "${IMAGE_DIGEST}" > "${OUTPUT_FILE}"

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
: "${REGISTRY:?REGISTRY is required}"
: "${ORG:?ORG is required}"
: "${SERVICE_NAME:?SERVICE_NAME is required}"
: "${IMAGE_DIGEST:?IMAGE_DIGEST is required}"
: "${IMAGE_SHA:?IMAGE_SHA is required}"
: "${IMAGE_ARTIFACT:?IMAGE_ARTIFACT is required}"
: "${GITHUB_ENV:?GITHUB_ENV is required}"
echo "IMG=${REGISTRY}/${ORG}/${SERVICE_NAME}:build-${IMAGE_SHA}-${IMAGE_ARTIFACT}@${IMAGE_DIGEST}" >> "${GITHUB_ENV}"

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
: "${IMAGE:?IMAGE is required}"
: "${KNOWLEDGE_CONTENT_DIR:?KNOWLEDGE_CONTENT_DIR is required}"
docker pull "${IMAGE}"
docker run --rm \
-v "${KNOWLEDGE_CONTENT_DIR}:/app/dashboard/src/content/blog:ro" \
"${IMAGE}" \
sh -c 'test -d /app/dashboard/src/content/blog'

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
: "${TAG:?TAG is required}"
: "${IMAGE_LIST_FILE:?IMAGE_LIST_FILE is required}"
while IFS= read -r image; do
[ -n "${image}" ] || continue
echo "Checking ${image}:${TAG}"
docker manifest inspect "${image}:${TAG}" > /dev/null
docker pull "${image}:${TAG}" > /dev/null
done < "${IMAGE_LIST_FILE}"

12
.github/scripts/check-image/images.txt vendored Normal file
View File

@ -0,0 +1,12 @@
ghcr.io/cloud-neutral-toolkit/openresty-geoip
ghcr.io/cloud-neutral-toolkit/postgres-runtime
ghcr.io/cloud-neutral-toolkit/account
ghcr.io/cloud-neutral-toolkit/dashboard
ghcr.io/cloud-neutral-toolkit/rag-server
ghcr.io/cloud-neutral-toolkit/xcontrol-init
docker.io/cloudneutral/openresty-geoip
docker.io/cloudneutral/postgres-runtime
docker.io/cloudneutral/account
docker.io/cloudneutral/dashboard
docker.io/cloudneutral/rag-server
docker.io/cloudneutral/xcontrol-init

9
.github/scripts/cosign/sign.sh vendored Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
REG="ghcr.io/cloud-neutral-toolkit"
cosign sign --yes "$REG/node-builder@$NODE_BUILDER_DIGEST"
cosign sign --yes "$REG/node-runtime@$NODE_RUNTIME_DIGEST"
cosign sign --yes "$REG/openresty-geoip@$OPENRESTY_GEOIP_DIGEST"
cosign sign --yes "$REG/postgres-runtime@$POSTGRES_RUNTIME_DIGEST"

28
.github/scripts/metadata/gen.py vendored Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env python3
import json, sys
if len(sys.argv) < 4:
print("Usage: gen.py <image-name> <digest> <tags>")
sys.exit(1)
name = sys.argv[1]
digest = sys.argv[2]
raw_tags = sys.argv[3]
tags = raw_tags.splitlines()
preferred = next((t for t in tags if t.endswith(":latest")), tags[0] if tags else "")
metadata = {
"name": name,
"digest": digest,
"tags": tags,
"preferred_tag": preferred,
"image": f"ghcr.io/cloud-neutral-toolkit/{name}",
"image_with_digest": f"ghcr.io/cloud-neutral-toolkit/{name}@{digest}",
}
outfile = f"image-metadata-{name}.json"
with open(outfile, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
print(f"[metadata] Wrote: {outfile}")

7
.github/scripts/sbom/generate.sh vendored Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
IMAGE="$1"
OUT="$2"
anchore-cli sbom generate "$IMAGE" -o "$OUT"

15
.github/scripts/utils/preferred-tag.sh vendored Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
tags="$1"
preferred=""
while IFS= read -r line; do
[[ "$line" == *":latest" ]] && preferred="$line" && break
done <<< "$tags"
if [[ -z "$preferred" ]]; then
preferred="$(echo "$tags" | head -n 1)"
fi
echo "$preferred"

274
.github/workflows/build-images.yml vendored Normal file
View File

@ -0,0 +1,274 @@
name: Build Dashboard Images
on:
workflow_call:
inputs:
push_images:
description: "Push service images instead of local builds"
type: boolean
default: true
dockerhub_namespace:
description: "Docker Hub namespace (user/org)"
type: string
skip_security:
description: "Skip security scans and signing"
type: boolean
default: false
node_builder_image:
type: string
default: "node:22-bookworm"
node_runtime_image:
type: string
default: "node:22-slim"
workflow_dispatch:
inputs:
push_images:
type: boolean
default: true
dockerhub_namespace:
description: "Docker Hub namespace (user/org)"
type: string
default: "cloudneutral"
skip_security:
description: "Skip security scans and signing"
type: boolean
default: false
node_builder_image:
type: string
default: "node:22-bookworm"
node_runtime_image:
type: string
default: "node:22-slim"
push:
branches: [ main ]
permissions:
contents: read
packages: write
id-token: write
env:
REGISTRY: ghcr.io
# ✅ 不硬编码:默认推到 ghcr.io/<当前仓库 owner>/...
ORG: ${{ github.repository_owner }}
SKIP_SECURITY: ${{ inputs.skip_security || github.event.inputs.skip_security || 'false' }}
NODE_BUILDER_IMAGE: ${{ inputs.node_builder_image || github.event.inputs.node_builder_image || 'node:22-bookworm' }}
NODE_RUNTIME_IMAGE: ${{ inputs.node_runtime_image || github.event.inputs.node_runtime_image || 'node:22-slim' }}
PUSH_IMAGES: ${{ github.event_name == 'push'
|| (github.event_name == 'workflow_call' && inputs.push_images)
|| (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true') }}
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
arch:
- { platform: linux/amd64, artifact: linux-amd64 }
- { platform: linux/arm64, artifact: linux-arm64 }
service:
- { name: dashboard, workdir: ., dockerfile: Dockerfile }
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Auto Tags
id: meta
uses: ./.github/actions/auto-tag
with:
image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.service.name }}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Clone knowledge content
run: git clone https://github.com/Cloud-Neutral-Workshop/knowledge.git knowledge
- name: Build Service Image (per-arch)
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: ${{ matrix.service.workdir }}
file: ${{ matrix.service.dockerfile }}
platforms: ${{ matrix.arch.platform }}
push: ${{ env.PUSH_IMAGES }}
tags: |
${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.service.name }}:build-${{ github.sha }}-${{ matrix.arch.artifact }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GO_RUNTIME_IMAGE=${{ env.GO_RUNTIME_IMAGE }}
NODE_BUILDER_IMAGE=${{ env.NODE_BUILDER_IMAGE }}
NODE_RUNTIME_IMAGE=${{ env.NODE_RUNTIME_IMAGE }}
CONTENTLAYER_BUILD=true
- name: Record digest
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
OUTPUT_FILE: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
run: bash .github/scripts/build-images/record-digest.sh
- uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
path: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
security:
runs-on: ubuntu-latest
needs: build
if: ${{ (github.event_name == 'push' || inputs.push_images == true || github.event.inputs.push_images == 'true') && !((inputs.skip_security == true) || (github.event.inputs.skip_security == 'true')) }}
strategy:
matrix:
arch:
- { platform: linux/amd64, artifact: linux-amd64 }
- { platform: linux/arm64, artifact: linux-arm64 }
service:
- { name: dashboard }
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/download-artifact@v4
with:
name: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
- name: Load image digest
env:
DIGEST_FILE: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
run: bash .github/scripts/build-images/load-image-digest.sh
- name: Set image ref
env:
SERVICE_NAME: ${{ matrix.service.name }}
IMAGE_SHA: ${{ github.sha }}
IMAGE_ARTIFACT: ${{ matrix.arch.artifact }}
run: bash .github/scripts/build-images/set-image-ref.sh
- uses: anchore/sbom-action@v0
with:
image: ${{ env.IMG }}
output-file: sbom.spdx.json
- uses: actions/upload-artifact@v4
with:
name: sbom-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
path: sbom.spdx.json
- uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ${{ env.IMG }}
severity: HIGH,CRITICAL
exit-code: '1'
- uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.4.1'
- name: Cosign Sign Image
env:
COSIGN_EXPERIMENTAL: "true"
run: |
set -euo pipefail
cosign sign --yes "${{ env.IMG }}"
push:
runs-on: ubuntu-latest
needs:
- build
- security
if: ${{ needs.build.result == 'success' && (github.event_name == 'push' || inputs.push_images == true || github.event.inputs.push_images == 'true') && ((inputs.skip_security == true) || (github.event.inputs.skip_security == 'true') || (needs.security.result == 'success')) }}
strategy:
fail-fast: false
matrix:
registry:
- ghcr.io
- docker.io
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- uses: actions/download-artifact@v4
with:
name: digest-dashboard-linux-amd64
- uses: actions/download-artifact@v4
with:
name: digest-dashboard-linux-arm64
- name: Load digests
env:
AMD_DIGEST_FILE: digest-dashboard-linux-amd64.txt
ARM_DIGEST_FILE: digest-dashboard-linux-arm64.txt
run: bash .github/scripts/build-images/load-manifest-digests.sh
- name: Generate Auto Tags
id: meta
uses: ./.github/actions/auto-tag
with:
image: ${{ env.REGISTRY }}/${{ env.ORG }}/dashboard
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
if: matrix.registry == 'ghcr.io'
with:
registry: ${{ matrix.registry }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create & Push Multi-Arch Manifests (GHCR)
if: matrix.registry == 'ghcr.io'
env:
IMAGE_SHA: ${{ github.sha }}
TAGS_CSV: ${{ steps.meta.outputs.tags }}
run: bash .github/scripts/build-images/create-ghcr-manifest.sh
- name: Clone knowledge content
if: matrix.registry == 'ghcr.io'
run: git clone https://github.com/Cloud-Neutral-Workshop/knowledge.git knowledge
- name: Validate blog content mount
if: matrix.registry == 'ghcr.io'
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.ORG }}/dashboard@${{ env.MANIFEST_DIGEST }}
KNOWLEDGE_CONTENT_DIR: ${{ github.workspace }}/knowledge/content
run: bash .github/scripts/build-images/validate-blog-content-mount.sh
- name: Login to Docker Hub
if: matrix.registry == 'docker.io'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Copy Multi-Arch Image to Docker Hub (skopeo)
if: matrix.registry == 'docker.io'
env:
TARGET_NS: ${{ inputs.dockerhub_namespace || github.event.inputs.dockerhub_namespace || 'cloudneutral' }}
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: bash .github/scripts/build-images/copy-image-to-dockerhub.sh

29
.github/workflows/check-image.yaml vendored Normal file
View File

@ -0,0 +1,29 @@
name: Check XControl Image Ready
on:
workflow_dispatch:
inputs:
tag:
required: false
default: latest
permissions:
contents: read
packages: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Authenticate to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check images exist and are pullable
env:
TAG: ${{ inputs.tag }}
IMAGE_LIST_FILE: .github/scripts/check-image/images.txt
run: bash .github/scripts/check-image/check-images.sh

View File

@ -1,240 +0,0 @@
name: Console Service Pipeline
on:
push:
branches:
- main
paths:
- ".github/workflows/pipeline.yaml"
- "Dockerfile"
- "deploy/single-node/**"
- "package.json"
- "yarn.lock"
- "scripts/github-actions/build-and-push-frontend-image.sh"
- "scripts/github-actions/compute-frontend-release-metadata.sh"
- "scripts/github-actions/render-frontend-build-args.sh"
- "scripts/github-actions/render-frontend-runtime-env.sh"
- "scripts/github-actions/prepare-frontend-build-context.sh"
- "scripts/github-actions/run-console-deploy-playbook.sh"
- "scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh"
- "scripts/github-actions/verify-frontend-release-over-ssh.sh"
- "scripts/github-actions/verify-frontend-release.sh"
- "scripts/prebuild.sh"
- "contentlayer.config.ts"
- "next.config.js"
- "next.config.mjs"
- "src/**"
- "public/**"
workflow_dispatch:
inputs:
target_host:
description: Ansible host or alias
required: false
default: "jp-xhttp-contabo.svc.plus"
type: string
run_apply:
description: Apply deployment
required: true
default: true
type: boolean
permissions:
contents: read
packages: write
concurrency:
group: console-pipeline-${{ github.ref_name }}
cancel-in-progress: false
env:
CANONICAL_DOMAIN: www.svc.plus
SERVED_DOMAINS: www.svc.plus,console.svc.plus
APP_BASE_URL: https://www.svc.plus
NEXT_PUBLIC_APP_BASE_URL: https://www.svc.plus
NEXT_PUBLIC_SITE_URL: https://www.svc.plus
RUNTIME_HOSTNAME: www.svc.plus
NEXT_RUNTIME_HOSTNAME: www.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod
NEXT_PUBLIC_RUNTIME_REGION: cn
ACCOUNT_SERVICE_URL: https://accounts.svc.plus
CLOUDFLARE_ZONE_TAG: bf3427f83a2c52c8285ab3d741a6ee27
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG: 0973e84ec8872c67c570f8072e92e21b
CLOUDFLARE_ACCOUNT_ID: e71be5efb76a6c54f78f008da4404f00
GHCR_REGISTRY: ghcr.io
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN }}
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
jobs:
prep:
name: Prep
runs-on: ubuntu-latest
outputs:
target_host: ${{ steps.inputs.outputs.target_host }}
run_apply: ${{ steps.inputs.outputs.run_apply }}
image_tag: ${{ steps.metadata.outputs.image_tag }}
image_ref: ${{ steps.metadata.outputs.image_ref }}
image_latest_ref: ${{ steps.metadata.outputs.image_latest_ref }}
ghcr_namespace: ${{ steps.metadata.outputs.ghcr_namespace }}
push_latest: ${{ steps.push.outputs.push_latest }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Resolve Inputs
id: inputs
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TARGET_HOST: ${{ inputs.target_host }}
INPUT_RUN_APPLY: ${{ inputs.run_apply }}
run: bash scripts/github-actions/resolve-workflow-inputs.sh
- name: Compute Image Metadata
id: metadata
run: |
bash scripts/github-actions/compute-frontend-release-metadata.sh
- name: Resolve Push Latest
id: push
env:
REF: ${{ github.ref }}
run: bash scripts/github-actions/resolve-push-latest.sh
build:
name: Build
runs-on: ubuntu-latest
needs: prep
outputs:
image_ref: ${{ steps.publish.outputs.image_ref }}
image_tag: ${{ steps.publish.outputs.image_tag }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set Up Docker Buildx
run: bash scripts/github-actions/setup-docker-buildx.sh
- name: Log In To GHCR
env:
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: bash scripts/github-actions/login-ghcr.sh
- name: Publish Frontend Image
id: publish
env:
IMAGE_REF: ${{ needs.prep.outputs.image_ref }}
IMAGE_TAG: ${{ needs.prep.outputs.image_tag }}
IMAGE_LATEST_REF: ${{ needs.prep.outputs.image_latest_ref }}
PUSH_LATEST: ${{ needs.prep.outputs.push_latest }}
run: bash scripts/github-actions/publish-frontend-image.sh
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- prep
- build
env:
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check Out Playbooks Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
# Intentionally pinned: playbooks@main regressed deploy reliability on 2026-04-12.
# Any future bump must pass a full Deploy + Validate run before becoming the default.
repository: x-evor/playbooks
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
path: playbooks
token: ${{ github.token }}
- name: Set Up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install Ansible
run: |
python -m pip install --upgrade pip
python -m pip install ansible
- name: Configure SSH For Deploy Host
env:
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
- name: Run Deploy Playbook
working-directory: playbooks
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
run: bash ../scripts/github-actions/run-console-deploy-playbook.sh
validate:
name: Validate
runs-on: ubuntu-latest
needs:
- prep
- build
- deploy
if: ${{ always() && needs.deploy.result == 'success' }}
env:
EXPECTED_FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Configure SSH For Validate Host
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
- name: Verify Frontend Release On Host
run: bash scripts/github-actions/verify-frontend-release-over-ssh.sh
update_dns:
name: Update DNS
runs-on: ubuntu-latest
needs:
- prep
- build
- deploy
if: ${{ always() && needs.deploy.result == 'success' }}
continue-on-error: true
env:
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
steps:
- name: Check Out Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check Out Playbooks Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: x-evor/playbooks
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
path: playbooks
token: ${{ github.token }}
- name: Set Up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Install Ansible
run: |
python -m pip install --upgrade pip
python -m pip install ansible
- name: Update Cloudflare svc.plus DNS
working-directory: playbooks
env:
ANSIBLE_HOST_KEY_CHECKING: "False"
run: bash ../scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh

View File

@ -0,0 +1,162 @@
name: Service Release Frontend Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: Optional image tag override. Defaults to the current commit SHA.
required: false
type: string
push:
branches:
- main
paths:
- ".github/workflows/service_release_frontend-deploy.yml"
- "deploy/single-node/**"
- "scripts/github-actions/**"
- "src/**"
- "public/**"
- "scripts/**"
- "config/**"
- "package.json"
- "Dockerfile"
- ".env.example"
- "next.config.mjs"
- "tailwind.config.js"
- "postcss.config.mjs"
- "tsconfig.json"
- "contentlayer.config.ts"
concurrency:
group: frontend-prod
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
DEPLOY_HOST: 47.120.61.35
DEPLOY_USER: root
DEPLOY_DIR: /opt/console-svc-plus
PRIMARY_DOMAIN: cn.svc.plus
SECONDARY_DOMAIN: cn.onwalk.net
GHCR_REGISTRY: ghcr.io
jobs:
stage-1-build-image:
name: "1. Build and push frontend image"
runs-on: ubuntu-latest
environment: production
outputs:
ghcr_namespace: ${{ steps.meta.outputs.ghcr_namespace }}
image_tag: ${{ steps.meta.outputs.image_tag }}
image_ref: ${{ steps.meta.outputs.image_ref }}
steps:
- name: Compute image metadata
id: meta
run: bash scripts/github-actions/compute-frontend-release-metadata.sh "${{ github.event.inputs.image_tag }}"
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Clone knowledge content
run: git clone --depth=1 https://github.com/Cloud-Neutral-Workshop/knowledge.git knowledge
- uses: ./.github/actions/docker-login
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
- uses: ./.github/actions/docker-setup-buildx
- name: Build and push frontend image
uses: ./.github/actions/docker-build-push
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.image_ref }}
build-args: |
NODE_BUILDER_IMAGE=node:22-bookworm
NODE_RUNTIME_IMAGE=node:22-slim
CONTENTLAYER_BUILD=true
NEXT_PUBLIC_APP_BASE_URL=${{ vars.NEXT_PUBLIC_APP_BASE_URL || format('https://{0}', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL || format('https://{0}', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_LOGIN_URL=${{ vars.NEXT_PUBLIC_LOGIN_URL || format('https://{0}/login', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_DOCS_BASE_URL=${{ vars.NEXT_PUBLIC_DOCS_BASE_URL || format('https://{0}/docs', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${{ vars.NEXT_PUBLIC_RUNTIME_ENVIRONMENT || 'prod' }}
NEXT_PUBLIC_RUNTIME_REGION=${{ vars.NEXT_PUBLIC_RUNTIME_REGION || 'cn' }}
NEXT_PUBLIC_GISCUS_REPO=${{ vars.NEXT_PUBLIC_GISCUS_REPO || 'cloud-neutral-toolkit/console.svc.plus' }}
NEXT_PUBLIC_GISCUS_REPO_ID=${{ vars.NEXT_PUBLIC_GISCUS_REPO_ID }}
NEXT_PUBLIC_GISCUS_CATEGORY=${{ vars.NEXT_PUBLIC_GISCUS_CATEGORY || 'General' }}
NEXT_PUBLIC_GISCUS_CATEGORY_ID=${{ vars.NEXT_PUBLIC_GISCUS_CATEGORY_ID }}
NEXT_PUBLIC_PAYPAL_CLIENT_ID=${{ vars.NEXT_PUBLIC_PAYPAL_CLIENT_ID }}
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION }}
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION }}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION }}
stage-2-deploy:
name: "2. Deploy frontend stack"
runs-on: ubuntu-latest
needs: stage-1-build-image
environment: production
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Deploy frontend stack
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_PASSWORD: ${{ github.token }}
SSH_PRIVATE_KEY: ${{ secrets.FRONTEND_DEPLOY_SSH_KEY }}
FRONTEND_IMAGE: ${{ needs.stage-1-build-image.outputs.image_ref }}
APP_BASE_URL: ${{ vars.APP_BASE_URL || format('https://{0}', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_APP_BASE_URL: ${{ vars.NEXT_PUBLIC_APP_BASE_URL || format('https://{0}', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL || format('https://{0}', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_LOGIN_URL: ${{ vars.NEXT_PUBLIC_LOGIN_URL || format('https://{0}/login', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_DOCS_BASE_URL: ${{ vars.NEXT_PUBLIC_DOCS_BASE_URL || format('https://{0}/docs', env.PRIMARY_DOMAIN) }}
NEXT_PUBLIC_RUNTIME_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_RUNTIME_ENVIRONMENT || 'prod' }}
NEXT_PUBLIC_RUNTIME_REGION: ${{ vars.NEXT_PUBLIC_RUNTIME_REGION || 'cn' }}
RUNTIME_HOSTNAME: ${{ vars.RUNTIME_HOSTNAME || env.PRIMARY_DOMAIN }}
NEXT_RUNTIME_HOSTNAME: ${{ vars.NEXT_RUNTIME_HOSTNAME || env.PRIMARY_DOMAIN }}
DEPLOYMENT_HOSTNAME: ${{ vars.DEPLOYMENT_HOSTNAME || env.PRIMARY_DOMAIN }}
ACCOUNT_SERVICE_URL: ${{ vars.ACCOUNT_SERVICE_URL || 'https://accounts.svc.plus' }}
NEXT_PUBLIC_ACCOUNT_SERVICE_URL: ${{ vars.NEXT_PUBLIC_ACCOUNT_SERVICE_URL || vars.ACCOUNT_SERVICE_URL || 'https://accounts.svc.plus' }}
SERVER_SERVICE_URL: ${{ vars.SERVER_SERVICE_URL || 'https://api.svc.plus' }}
NEXT_PUBLIC_SERVER_SERVICE_URL: ${{ vars.NEXT_PUBLIC_SERVER_SERVICE_URL || vars.SERVER_SERVICE_URL || 'https://api.svc.plus' }}
SERVER_SERVICE_INTERNAL_URL: ${{ vars.SERVER_SERVICE_INTERNAL_URL }}
ROOT_EMAIL_WHITELIST: ${{ vars.ROOT_EMAIL_WHITELIST || 'admin@svc.plus' }}
OPENCLAW_GATEWAY_REMOTE_URL: ${{ vars.OPENCLAW_GATEWAY_REMOTE_URL }}
OPENCLAW_GATEWAY_TOKEN: ${{ secrets.OPENCLAW_GATEWAY_TOKEN }}
VAULT_SERVER_URL: ${{ vars.VAULT_SERVER_URL }}
VAULT_NAMESPACE: ${{ vars.VAULT_NAMESPACE }}
VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
APISIX_AI_GATEWAY_URL: ${{ vars.APISIX_AI_GATEWAY_URL }}
AI_GATEWAY_ACCESS_TOKEN: ${{ secrets.AI_GATEWAY_ACCESS_TOKEN }}
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_SITE_TAG }}
CLOUDFLARE_ZONE_TAG: ${{ vars.CLOUDFLARE_ZONE_TAG }}
NEXT_PUBLIC_GISCUS_REPO: ${{ vars.NEXT_PUBLIC_GISCUS_REPO || 'cloud-neutral-toolkit/console.svc.plus' }}
NEXT_PUBLIC_GISCUS_REPO_ID: ${{ vars.NEXT_PUBLIC_GISCUS_REPO_ID }}
NEXT_PUBLIC_GISCUS_CATEGORY: ${{ vars.NEXT_PUBLIC_GISCUS_CATEGORY || 'General' }}
NEXT_PUBLIC_GISCUS_CATEGORY_ID: ${{ vars.NEXT_PUBLIC_GISCUS_CATEGORY_ID }}
NEXT_PUBLIC_PAYPAL_CLIENT_ID: ${{ vars.NEXT_PUBLIC_PAYPAL_CLIENT_ID }}
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION }}
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION }}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO }}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION: ${{ vars.NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION }}
run: bash scripts/github-actions/deploy-frontend-single-node.sh
- name: Verify primary domain
run: curl -fsSIL "https://${PRIMARY_DOMAIN}"
- name: Verify secondary domain redirect
run: curl -fsSIL "https://${SECONDARY_DOMAIN}"

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
.env
models/
*.tsbuildinfo
knowledge/
# macOS
.DS_Store

View File

@ -1 +0,0 @@
registry "https://registry.npmjs.org"

View File

@ -4,7 +4,7 @@ enableGlobalCache: false
nodeLinker: node-modules
npmRegistryServer: "https://registry.npmjs.org"
npmRegistryServer: "https://registry.npmmirror.com"
packageExtensions:
"next-contentlayer@*":

View File

@ -51,20 +51,7 @@ yarn test:e2e path/to/spec.test.ts
---
## 2. Release Traceability Default Rule
For any change touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate` behavior:
- Treat `skills/release-traceability/SKILL.md` as the default reference before implementation.
- Prefer release metadata that can be traced from `build` to `deploy` to `validate` without manual injection.
- Keep the published image reference, runtime version, and validation output aligned.
- Do not introduce a deploy path that rebuilds images on the target host.
When in doubt, follow the skill first and keep the release chain fully auditable end to end.
---
## 3. Repository Mental Model (Read This First)
## 2. Repository Mental Model (Read This First)
This repository has **three clearly separated layers**:
@ -104,7 +91,7 @@ Used for build-time or runtime wiring only.
---
## 4. Import & Alias Rules (Critical)
## 3. Import & Alias Rules (Critical)
### Dashboard code (src/\*\*)
@ -125,7 +112,7 @@ import { UserCard } from "@/components/UserCard";
---
## 5. TypeScript & Formatting Rules
## 4. TypeScript & Formatting Rules
- Strict mode enabled
- Use `type` for type definitions, `interface` for object shapes
@ -136,7 +123,7 @@ import { UserCard } from "@/components/UserCard";
---
## 6. Naming Conventions
## 5. Naming Conventions
- Components: PascalCase (`UserProfile.tsx`)
- Files: kebab-case for utilities (`user-utils.ts`), PascalCase for components
@ -146,7 +133,7 @@ import { UserCard } from "@/components/UserCard";
---
## 7. Error Handling & Logging
## 6. Error Handling & Logging
- Use try/catch for async operations
- Return Result types or throw errors consistently
@ -155,7 +142,7 @@ import { UserCard } from "@/components/UserCard";
---
## 8. React Patterns
## 7. React Patterns
- Use `'use client'` directive for client components
- Prefer function components with hooks
@ -164,7 +151,7 @@ import { UserCard } from "@/components/UserCard";
---
## 9. Global State Rules (Dashboard Only)
## 8. Global State Rules (Dashboard Only)
✅ Zustand is the **only** allowed global state mechanism
❌ React Context for shared/global state is forbidden
@ -175,7 +162,7 @@ Rule: If state must survive navigation or be shared → it lives in Zustand.
---
## 10. URL-Synchronized State
## 9. URL-Synchronized State
Anything involving:
@ -192,7 +179,7 @@ MUST be handled inside Zustand slices.
---
## 11. Component State Rules
## 10. Component State Rules
Allowed:
@ -207,7 +194,7 @@ Forbidden:
---
## 12. packages/neurapress Rules (Very Important)
## 11. packages/neurapress Rules (Very Important)
packages/neurapress is treated as a vendored internal library.
@ -227,7 +214,7 @@ MUST NOT:
---
## 13. Testing Guidelines
## 12. Testing Guidelines
- Unit tests: Vitest with jsdom environment
- E2E tests: Playwright
@ -237,7 +224,7 @@ MUST NOT:
---
## 14. Environment & Runtime Config
## 13. Environment & Runtime Config
- No new environment variables without approval
- Runtime config must live in: src/config/runtime-service-config\*.yaml
@ -246,13 +233,13 @@ MUST NOT:
---
## 15. Cursor / Copilot Rules
## 14. Cursor / Copilot Rules
- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found
---
## 16. TL;DR for AI Agents
## 15. TL;DR for AI Agents
- dashboard = application
- packages = libraries

View File

@ -21,10 +21,6 @@ ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=
ARG NEXT_PUBLIC_RELEASE_IMAGE=
ARG NEXT_PUBLIC_RELEASE_TAG=
ARG NEXT_PUBLIC_RELEASE_COMMIT=
ARG NEXT_PUBLIC_RELEASE_VERSION=
# -------------------------------------------------------
# Stage 1 — Builder (Turbopack + standalone)
@ -50,10 +46,6 @@ ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION
ARG NEXT_PUBLIC_RELEASE_IMAGE
ARG NEXT_PUBLIC_RELEASE_TAG
ARG NEXT_PUBLIC_RELEASE_COMMIT
ARG NEXT_PUBLIC_RELEASE_VERSION
ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PRIVATE_TURBOPACK=1 \
@ -73,11 +65,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO} \
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION} \
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO} \
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION} \
NEXT_PUBLIC_RELEASE_IMAGE=${NEXT_PUBLIC_RELEASE_IMAGE} \
NEXT_PUBLIC_RELEASE_TAG=${NEXT_PUBLIC_RELEASE_TAG} \
NEXT_PUBLIC_RELEASE_COMMIT=${NEXT_PUBLIC_RELEASE_COMMIT} \
NEXT_PUBLIC_RELEASE_VERSION=${NEXT_PUBLIC_RELEASE_VERSION}
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION}
# ---------------------------
# 基础镜像升级到最新
@ -123,6 +111,8 @@ RUN apt-get update \
COPY --from=builder /app/dashboard/.next/standalone ./
COPY --from=builder /app/dashboard/.next/static ./static
COPY --from=builder /app/dashboard/public ./public
COPY --from=builder /app/dashboard/knowledge ./knowledge
COPY --from=builder /app/dashboard/src/content/blog ./src/content/blog
# ---------------------------
# 额外瘦身(可减少 1540 MB

View File

@ -49,6 +49,7 @@ init:
echo "❌ Unsupported OS. Please install Yarn manually."; exit 1; \
fi; \
fi
yarn config set npmRegistryServer https://registry.npmmirror.com
yarn install --immutable
ensure-deps:
@ -82,6 +83,7 @@ test:
@yarn test || echo "No tests configured"
build: init
yarn config set npmRegistryServer https://registry.npmmirror.com
@if [ -z "$(SKIP_SYNC)" ]; then \
$(MAKE) sync-dl-index; \
fi

View File

@ -20,7 +20,7 @@ Cloud Neutral Toolkit 的开放云控制面板 (Open Cloud Control Panel).
### 一键初始化 (Setup Script)
```bash
curl -fsSL "https://raw.githubusercontent.com/x-evor/console.svc.plus/main/scripts/setup.sh?$(date +%s)" \
curl -fsSL "https://raw.githubusercontent.com/cloud-neutral-toolkit/console.svc.plus/main/scripts/setup.sh?$(date +%s)" \
| bash -s -- console.svc.plus
```
@ -41,29 +41,22 @@ cp .env.example .env
## 主要入口 (Key Routes)
- `/services`:服务导航页,保留现有控制台布局。
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 `xworkmate-bridge``/acp/rpc` 接入。
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入。
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
## AI 助手与集成能力 (Assistant & Integrations)
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
- 侧栏助手模式保留现有交互方式,但 `/xworkmate` 主工作区直接对接 `xworkmate-bridge`
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
- `/panel/api` 仍保留旧集成配置入口;`/xworkmate` 主路径不依赖它
- bridge 地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
## 环境变量 (Environment Variables)
以下变量用于 `/xworkmate` 主工作区的服务端 bridge 代理:
| 变量 | 用途 |
| ------------------- | ------------------------------------- |
| `BRIDGE_SERVER_URL` | XWorkmate bridge 服务根地址 |
| `BRIDGE_AUTH_TOKEN` | XWorkmate bridge bearer token服务端 |
以下变量用于旧助手和集成页的服务端默认值预填:
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
| 变量 | 用途 |
| ----------------------------- | ------------------------------------ |

View File

@ -16,12 +16,6 @@ You are an AI agent working inside this repository.
- Keep changes scoped to the request; avoid unrelated refactors.
- Prefer minimal edits that preserve existing behavior and style.
## Release Traceability Default Rule
- For changes touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate`, treat `skills/release-traceability/SKILL.md` as the default reference first.
- Keep build output, runtime version, and validate output aligned through the whole release chain.
- Do not add a deploy path that rebuilds images on the target host.
## Repository Constraints (Quick View)
- App layer: src/app/**, src/components/**, src/lib/**, src/state/**, src/modules/\*\*

View File

@ -1,332 +0,0 @@
# Feature flag inventory for the dashboard and public site.
# This file is a human-readable catalog of page-level and module-level flags.
meta:
app: console.svc.plus
scope:
- public-site
- dashboard
- xworkmate
- cloud-iac
- docs
source:
- src/config/feature-toggles.json
- src/modules/extensions/builtin
- src/app
runtime:
current_implementation:
toggles_json: src/config/feature-toggles.json
loader: src/lib/featureToggles.ts
extension_flags: src/lib/featureFlags.ts
notes:
- "feature-toggles.json is the active runtime source for path-based gating."
- "This YAML is an inventory and planning file; it does not currently drive runtime behavior."
sections:
globalNavigation:
enabled: true
description: Top-level navigation and auth entry points.
default_channel: stable
routes:
- path: /
status: enabled
channel: stable
- path: /docs
status: enabled
channel: beta
- path: /blogs
status: enabled
note: Public content listing, not currently toggle-gated in code.
- path: /services
status: enabled
note: Public service directory.
- path: /prices
status: enabled
- path: /support
status: enabled
- path: /about
status: enabled
- path: /login
status: enabled
channel: stable
- path: /register
status: enabled
channel: stable
- path: /panel
status: enabled
channel: stable
- path: /panel/management
status: enabled
note: Shown only to admin/operator users.
- path: /cloud_iac
status: enabled
channel: develop
- path: /download
status: enabled
channel: stable
- path: /insight
status: enabled
channel: develop
- path: /xworkmate
status: enabled
appModules:
enabled: true
description: Path-based module gating used by route handlers and content loaders.
routes:
- path: /docs
status: enabled
uses: src/app/docs/page.tsx
- path: /docs/[collection]
status: enabled
uses: src/app/docs/[collection]/page.tsx
- path: /docs/[collection]/[...slug]
status: enabled
uses: src/app/docs/[collection]/[...slug]/page.tsx
- path: /download
status: enabled
uses: src/app/download/page.tsx
- path: /download/[...segments]
status: enabled
uses: src/app/download/[...segments]/page.tsx
- path: /cloud_iac
status: enabled
uses: src/app/cloud_iac/page.tsx
- path: /cloud_iac/[provider]
status: enabled
uses: src/app/cloud_iac/[provider]/page.tsx
- path: /cloud_iac/[provider]/[service]
status: enabled
uses: src/app/cloud_iac/[provider]/[service]/page.tsx
- path: /insight
status: enabled
uses: src/app/services/insight/page.tsx
- path: /editor
status: enabled
uses: src/app/editor/page.tsx
- path: /editor/wechat
status: enabled
- path: /editor/xiaohongshu
status: enabled
- path: /xworkmate
status: enabled
uses: src/app/xworkmate/page.tsx
- path: /xworkmate/admin
status: enabled
uses: src/app/xworkmate/admin/page.tsx
- path: /xworkmate/integrations
status: enabled
uses: src/app/xworkmate/integrations/page.tsx
cmsExperience:
enabled: true
description: CMS/homepage experience gating.
routes:
- path: /homepage
status: enabled
children:
- path: /homepage/dynamic
status: enabled
extensions:
builtin.user-center:
enabled: true
description: Core dashboard user center and account management extension.
routes:
- path: /panel
id: dashboard
enabled: true
sidebar_section: workspace
- path: /panel/agent
id: agents
enabled: true
env_var: NEXT_PUBLIC_FEATURE_AGENT_MODULE
default_enabled: true
sidebar_section: productivity
- path: /panel/api
id: apis
enabled: true
env_var: NEXT_PUBLIC_FEATURE_API_MODULE
default_enabled: true
sidebar_section: productivity
- path: /panel/account
id: accounts
enabled: true
sidebar_section: management
- path: /panel/subscription
id: subscription
enabled: true
env_var: NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE
default_enabled: true
sidebar_section: management
- path: /panel/ldp
id: ldp
enabled: true
env_var: NEXT_PUBLIC_FEATURE_LDP_MODULE
default_enabled: false
sidebar_section: management
- path: /panel/appearance
id: appearance
enabled: true
sidebar_section: preferences
- path: /panel/management
id: management
enabled: true
roles:
- admin
- operator
permissions:
- admin.settings.read
- admin.users.metrics.read
- admin.users.list.read
- admin.agents.status.read
- admin.blacklist.read
sidebar_hidden: true
builtin.infra:
enabled: true
description: Infrastructure and ops extension.
routes:
- path: /panel/deployments
id: deployments
enabled: true
sidebar_section: infra
- path: /panel/resources
id: resources
enabled: true
sidebar_section: infra
- path: /panel/api-keys
id: apiKeys
enabled: true
sidebar_section: infra
- path: /panel/observability
id: logs
enabled: true
sidebar_section: infra
- path: /panel/settings
id: settings
enabled: true
sidebar_section: preferences
pages:
public:
- path: /
component: src/app/page.tsx
status: enabled
- path: /services
component: src/app/services/page.tsx
status: enabled
- path: /about
component: src/app/about/page.tsx
status: enabled
- path: /prices
component: src/app/prices/page.tsx
status: enabled
- path: /support
component: src/app/support/page.tsx
status: enabled
- path: /support/discussions
component: src/app/support/discussions/page.tsx
status: enabled
- path: /blogs
component: src/app/blogs/page.tsx
status: enabled
- path: /blogs/[...slug]
component: src/app/blogs/[...slug]/page.tsx
status: enabled
- path: /terms
component: src/app/terms/page.tsx
status: enabled
- path: /privacy
component: src/app/privacy/page.tsx
status: enabled
- path: /download
component: src/app/download/page.tsx
status: gated_by_appModules
- path: /download/[...segments]
component: src/app/download/[...segments]/page.tsx
status: gated_by_appModules
- path: /docs
component: src/app/docs/page.tsx
status: gated_by_appModules
- path: /docs/[collection]
component: src/app/docs/[collection]/page.tsx
status: gated_by_appModules
- path: /docs/[collection]/[...slug]
component: src/app/docs/[collection]/[...slug]/page.tsx
status: gated_by_appModules
- path: /cloud_iac
component: src/app/cloud_iac/page.tsx
status: gated_by_appModules
- path: /cloud_iac/[provider]
component: src/app/cloud_iac/[provider]/page.tsx
status: gated_by_appModules
- path: /cloud_iac/[provider]/[service]
component: src/app/cloud_iac/[provider]/[service]/page.tsx
status: gated_by_appModules
- path: /editor
component: src/app/editor/page.tsx
status: redirect_external
- path: /editor/wechat
component: src/app/editor/wechat/page.tsx
status: enabled
- path: /editor/xiaohongshu
component: src/app/editor/xiaohongshu/page.tsx
status: enabled
- path: /xworkmate
component: src/app/xworkmate/page.tsx
status: enabled
- path: /xworkmate/admin
component: src/app/xworkmate/admin/page.tsx
status: enabled
- path: /xworkmate/integrations
component: src/app/xworkmate/integrations/page.tsx
status: enabled
auth:
- path: /login
component: src/app/(auth)/login/page.tsx
status: gated_by_globalNavigation
- path: /register
component: src/app/(auth)/register/page.tsx
status: gated_by_globalNavigation
- path: /email-verification
component: src/app/(auth)/email-verification/page.tsx
status: gated_by_globalNavigation
- path: /logout
component: src/app/logout/page.tsx
status: enabled
panel:
- path: /panel
component: src/app/panel/page.tsx
status: extension_route
- path: /panel/account
component: src/app/panel/account/page.tsx
status: extension_route
- path: /panel/agent
component: src/app/panel/agent/page.tsx
status: extension_route
- path: /panel/api
component: src/app/panel/api/page.tsx
status: extension_route
- path: /panel/appearance
component: src/app/panel/appearance/page.tsx
status: extension_route
- path: /panel/ldp
component: src/app/panel/ldp/page.tsx
status: extension_route
- path: /panel/management
component: src/app/panel/management/page.tsx
status: extension_route
- path: /panel/subscription
component: src/app/panel/subscription/page.tsx
status: extension_route
- path: /panel/[...segments]
component: src/app/panel/[...segments]/page.tsx
status: catch_all_extension
recommendations:
- "If this file is intended to become runtime-configurable, wire it into src/lib/featureToggles.ts and keep feature-toggles.json as the generated artifact."
- "If this file is intended only for documentation, keep it synchronized with feature-toggles.json and extension definitions."

View File

@ -1,22 +1,22 @@
# Compose settings
FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me
CANONICAL_DOMAIN=www.svc.plus
SERVED_DOMAINS=www.svc.plus,console.svc.plus
PRIMARY_DOMAIN=cn.svc.plus
SECONDARY_DOMAIN=cn.onwalk.net
# Frontend runtime
NODE_ENV=production
PORT=3000
RUNTIME_ENV=prod
REGION=cn
APP_BASE_URL=https://www.svc.plus
NEXT_PUBLIC_APP_BASE_URL=https://www.svc.plus
NEXT_PUBLIC_SITE_URL=https://www.svc.plus
NEXT_PUBLIC_LOGIN_URL=https://www.svc.plus/login
NEXT_PUBLIC_DOCS_BASE_URL=https://www.svc.plus/docs
APP_BASE_URL=https://cn.svc.plus
NEXT_PUBLIC_APP_BASE_URL=https://cn.svc.plus
NEXT_PUBLIC_SITE_URL=https://cn.svc.plus
NEXT_PUBLIC_LOGIN_URL=https://cn.svc.plus/login
NEXT_PUBLIC_DOCS_BASE_URL=https://cn.svc.plus/docs
SESSION_COOKIE_SECURE=true
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
RUNTIME_HOSTNAME=www.svc.plus
DEPLOYMENT_HOSTNAME=www.svc.plus
RUNTIME_HOSTNAME=cn.svc.plus
DEPLOYMENT_HOSTNAME=cn.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn

View File

@ -1,7 +1,11 @@
{$SERVED_DOMAINS} {
{$PRIMARY_DOMAIN}, {$SECONDARY_DOMAIN} {
encode zstd gzip
handle_path /_next/static/* {
@secondary host {$SECONDARY_DOMAIN}
redir @secondary https://{$PRIMARY_DOMAIN}{uri} permanent
@next_static path /_next/static/*
handle @next_static {
root * /srv
header Cache-Control "public, max-age=31536000, immutable"
file_server

View File

@ -7,9 +7,9 @@ services:
- -c
- |
set -eu
rm -rf /assets/_next /assets/chunks /assets/public
mkdir -p /assets /assets/public
cp -R /app/dashboard/static/. /assets/
rm -rf /assets/_next /assets/public
mkdir -p /assets/_next/static /assets/public
cp -R /app/dashboard/static/. /assets/_next/static
cp -R /app/dashboard/public/. /assets/public
volumes:
- frontend_static:/assets
@ -22,8 +22,6 @@ services:
environment:
NODE_ENV: production
PORT: 3000
volumes:
- frontend_static:/app/dashboard/.next/static:ro
networks:
- frontend
@ -36,7 +34,8 @@ services:
- "80:80"
- "443:443"
environment:
SERVED_DOMAINS: ${SERVED_DOMAINS:?set SERVED_DOMAINS in .env.runtime}
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?set PRIMARY_DOMAIN in .env.runtime}
SECONDARY_DOMAIN: ${SECONDARY_DOMAIN:?set SECONDARY_DOMAIN in .env.runtime}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- frontend_static:/srv:ro

58
dev.log
View File

@ -1,39 +1,31 @@
▲ Next.js 16.1.6 (Turbopack)
> dashboard@1.0.0 dev
> bash scripts/Dev-MCP-Server.sh && next dev --turbo
[MCP] Cleaning stale Chrome processes...
[MCP] Starting Chrome (remote debugging)...
[MCP] Waiting for Chrome DevTools endpoint...
[MCP] Chrome DevTools ready on port 9222
[MCP] (Optional) Pre-warming chrome-devtools-mcp...
[MCP] Done. You can now run: npm run dev
▲ Next.js 16.1.2 (Turbopack)
- Local: http://localhost:3000
- Network: http://192.168.0.2:3000
✓ Starting...
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry
✓ Ready in 4.4s
○ Compiling / ...
GET / 200 in 11.8s (compile: 11.0s, render: 755ms)
GET / 200 in 513ms (compile: 159ms, render: 354ms)
GET /api/integrations/defaults 200 in 1276ms (compile: 1242ms, render: 35ms)
✓ Ready in 2.5s
○ Compiling /services ...
GET /services 200 in 6.8s (compile: 6.4s, render: 327ms)
[runtime-config] Loaded env: PROD
GET /api/auth/session 200 in 2.2s (compile: 2.2s, render: 15ms)
GET /api/marketing/home-stats 200 in 1435ms (compile: 1425ms, render: 9ms)
GET / 200 in 150ms (compile: 5ms, render: 145ms)
GET /api/auth/session 200 in 16ms (compile: 4ms, render: 12ms)
GET /api/integrations/defaults 200 in 20ms (compile: 12ms, render: 8ms)
GET /api/marketing/home-stats 200 in 29ms (compile: 22ms, render: 7ms)
GET /api/blogs/latest?limit=7 200 in 37ms (compile: 29ms, render: 8ms)
GET /api/auth/session 200 in 1021ms (compile: 993ms, render: 29ms)
GET /services 200 in 87ms (compile: 5ms, render: 82ms)
GET /api/auth/session 200 in 11ms (compile: 3ms, render: 7ms)
✓ Compiled in 310ms
GET /services 200 in 441ms (compile: 149ms, render: 293ms)
GET /api/auth/session 200 in 16ms (compile: 5ms, render: 10ms)
GET /services 200 in 252ms (compile: 100ms, render: 152ms)
GET /api/auth/session 200 in 14ms (compile: 5ms, render: 8ms)
GET /services 200 in 251ms (compile: 86ms, render: 165ms)
GET /api/auth/session 200 in 13ms (compile: 4ms, render: 8ms)
GET /services 200 in 281ms (compile: 94ms, render: 187ms)
GET /api/auth/session 200 in 12ms (compile: 4ms, render: 8ms)

View File

@ -214,7 +214,7 @@ export default function NotFound() {
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://www.svc.plus'),
metadataBase: new URL('https://console.svc.plus'),
title: {
default: 'Cloud-Neutral | Unified Cloud Native Tools',
template: '%s | Cloud-Neutral',
@ -232,7 +232,7 @@ export const metadata: Metadata = {
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://www.svc.plus',
url: 'https://console.svc.plus',
title: 'Cloud-Neutral | Unified Cloud Native Tools',
description: 'Unified tools for your cloud native stack',
siteName: 'Cloud-Neutral',
@ -274,7 +274,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6366f1" />
<link rel="canonical" href="https://www.svc.plus" />
<link rel="canonical" href="https://console.svc.plus" />
{/* ... rest of head */}
</head>
{/* ... rest of layout */}
@ -343,7 +343,7 @@ Disallow: /admin/
Disallow: /api/
Disallow: /internal/
Sitemap: https://www.svc.plus/sitemap.xml
Sitemap: https://console.svc.plus/sitemap.xml
```
---
@ -360,11 +360,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Cloud-Neutral',
url: 'https://www.svc.plus',
logo: 'https://www.svc.plus/logo.png',
url: 'https://console.svc.plus',
logo: 'https://console.svc.plus/logo.png',
sameAs: [
'https://twitter.com/cloudneutral',
'https://github.com/x-evor',
'https://github.com/cloud-neutral-toolkit',
],
}
@ -372,10 +372,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Cloud-Neutral',
url: 'https://www.svc.plus',
url: 'https://console.svc.plus',
potentialAction: {
'@type': 'SearchAction',
target: 'https://www.svc.plus/search?q={search_term_string}',
target: 'https://console.svc.plus/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
}

View File

@ -1,96 +0,0 @@
# console.svc.plus Web Console Architecture
## Scope
`console.svc.plus` is the browser-facing control plane. It is a Next.js App Router application that combines public pages, docs browsing, account/admin panels, and a BFF layer that forwards requests to downstream services. It never reads PostgreSQL or Prometheus directly for billing or usage.
## Architecture
```mermaid
flowchart TB
subgraph Pages["src/app pages"]
Root["/ -> landing page"]
Docs["/docs, /docs/[collection], /docs/[collection]/[...slug]\nDocs reader"]
Auth["/login, /register, /email-verification, /logout\nAuth flows"]
Panel["/panel/*\nUser / admin console"]
Tools["/editor/*, /download/*, /cloud_iac/*, /xworkmate/*\nProduct tools"]
Content["/blogs/*, /services/*, /support/*, /prices, /about, /privacy, /terms, /[slug]"]
Admin["/dashboard/cms\nCMS/admin entry"]
end
subgraph BFF["src/app/api route handlers"]
AuthAPI["/api/auth/*"]
AdminAPI["/api/admin/*"]
AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"]
RagAPI["/api/rag/query\n/api/askai"]
UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"]
GuestAPI["/api/guest/*"]
end
Accounts["accounts.svc.plus"]
Rag["rag-server.svc.plus"]
DocsSvc["docs.svc.plus"]
Grafana["observability.svc.plus / Grafana"]
External["Other upstream services"]
Subscription["/panel/subscription\nUsage / billing panel"]
AuthAPI --> Accounts
AdminAPI --> Accounts
AgentAPI --> Accounts
RagAPI --> Rag
UtilAPI --> DocsSvc
UtilAPI --> External
SandboxAPI --> Accounts
Subscription --> Accounts
Subscription -.-> Grafana
```
## Frontend Routes
| Route family | Path | Purpose |
| --- | --- | --- |
| Home | `/` | Public landing page and site entry |
| Docs | `/docs`, `/docs/[collection]`, `/docs/[collection]/[...slug]` | Documentation reader, sidebar, and TOC |
| Auth | `/login`, `/register`, `/email-verification`, `/logout` | Sign in / sign up / email verification / session cleanup |
| Panel | `/panel`, `/panel/account`, `/panel/agent`, `/panel/api`, `/panel/appearance`, `/panel/ldp`, `/panel/management`, `/panel/subscription`, `/panel/[...segments]` | Signed-in account and admin console |
| Tools | `/editor`, `/editor/wechat`, `/editor/xiaohongshu`, `/download`, `/download/[...segments]`, `/cloud_iac`, `/cloud_iac/[provider]`, `/cloud_iac/[provider]/[service]`, `/xworkmate`, `/xworkmate/admin`, `/xworkmate/integrations` | Product tools and service explorers |
| Content | `/blogs`, `/blogs/[...slug]`, `/services`, `/services/openclaw`, `/services/insight`, `/support`, `/support/discussions`, `/about`, `/prices`, `/privacy`, `/terms`, `/[slug]` | Marketing / informational pages |
| Admin / CMS | `/dashboard/cms` | CMS or content-management entry |
| Error pages | `/404`, `/500` | Static error surfaces |
## BFF / API Routes
| API family | Path | Purpose | Upstream target |
| --- | --- | --- | --- |
| Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` |
| MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` |
| OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` |
| Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*` | Account admin operations | `accounts.svc.plus/api/*` |
| Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` |
| RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` |
| Guest / demo runtime | `/api/guest/binding` | Guest read-only node resolution for demo access | `accounts.svc.plus/api/sandbox/binding` |
| Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service |
| Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services |
| Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services |
## Auth and Session Notes
- Browser calls use the session cookie and BFF logic in `src/server/account/session.ts`.
- Service-to-service calls use `INTERNAL_SERVICE_TOKEN` when configured.
- `api/agent-server/[...segments]` keeps caller `Authorization` untouched when an agent token is already present.
- `api/agent-server/[...segments]` forwards the dashboard session token for browser-driven calls.
## Dependencies
- `accounts.svc.plus` for identity, profile, sandbox, billing, and admin actions.
- `accounts.svc.plus` for authoritative usage and billing summaries sourced from PostgreSQL.
- `rag-server.svc.plus` for RAG query and AskAI.
- `docs.svc.plus` for docs content and navigation data.
- `observability.svc.plus` for Grafana dashboards and operational views only.
- CDN / external providers for content, analytics, and integration checks.
## Notes
- Route groups in parentheses, such as `(auth)`, are Next.js organizational folders and do not appear in the public URL.
- The BFF layer is the main place where console-specific auth shaping, cookie management, and upstream proxying happen.
- The subscription panel displays usage and billing data from accounts only and treats Grafana as an embedded observability surface, not a billing source.

View File

@ -3,37 +3,29 @@
## Production Baseline
- Runtime: `Caddy + Docker Compose`
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains:
- `www.svc.plus`
- `console.svc.plus`
- Canonical public origin: `https://www.svc.plus`
- Frontend release workflow: `.github/workflows/pipeline.yaml`
- Deploy host: `47.120.61.35`
- Domains:
- `cn.svc.plus`
- `cn.onwalk.net`
- Frontend release workflow: `.github/workflows/service_release_frontend-deploy.yml`
## Operating Model
The frontend is built in GitHub Actions and shipped as a prebuilt `linux/amd64` image. The host only pulls the image and starts containers; it does not build locally.
`yarn prebuild` now generates only console-owned marketing artifacts. `/docs` and `/blogs` no longer bundle `knowledge/` or synced markdown content into the frontend image. Those routes fetch rendered content from `docs.svc.plus` at request time through the server-side `docsServiceClient`.
`yarn prebuild` bundles the docs, blog, and static content needed by the console. During that phase the CI container runs `scripts/sync-doc-content.sh` (pulling docs from this repo plus `accounts.svc.plus`, `rag-server.svc.plus`, and `postgresql.svc.plus`) and `scripts/sync-blog-content.sh` (cloning `https://github.com/cloud-neutral-workshop/knowledge.git`), so the `knowledge/` directory and all documentation assets already live inside the image before the runtime stage begins.
The stack is static-first:
- Caddy serves `/_next/static/*` and public assets from a shared read-only volume.
- The Next.js standalone container serves dynamic HTML, auth endpoints, and API proxy routes. Static assets and hashed CSS/JS files are extracted by the `frontend-assets` helper task, so the runtime no longer needs to compile anything on the single-node host.
- `docs.svc.plus` is the source of truth for rendered docs/blog pages; the browser does not call it directly.
- `knowledge/` and the synced docs/blog assets are copied into the image during the Docker build via the GitHub Actions workflow.
Releases are orchestrated through `.github/workflows/pipeline.yaml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane DNS automation then updates Cloudflare DNS for the release domains (via `scripts/github-actions/update-release-dns.sh`) so both `www.svc.plus` and `console.svc.plus` resolve to the same environment.
Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow clones the knowledge repository, runs the Docker build/push sequence, renders `.env.runtime`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn.svc.plus` and the redirected alias `cn.onwalk.net` point at the new environment.
The release contract now uses:
This baseline is intentional for the weak-IO single-node host (47.120.61.35). No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
- `CANONICAL_DOMAIN=www.svc.plus`
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
Validation must pass for both domains. A release is incomplete if either host serves a different runtime version, static asset family, or `dashboardUrl`.
This baseline is intentional for the weak-IO single-node host `root@jp-xhttp-contabo.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
`docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path.
If `docs.svc.plus` is later refactored into a dedicated API service, revisit this writeup (and the runbook) so the GitHub Actions pipeline only bundles the API payloads that belong to that new service.
## Related Docs

View File

@ -1,6 +1,6 @@
# Release Process
This page tracks release summaries for published versions of the public web console served under `www.svc.plus` and `console.svc.plus`.
This page tracks release summaries for published versions of `console.svc.plus`.
## Current Release
@ -31,7 +31,7 @@ Published commit: `0fab89e`
- Split observability into a tri-view workspace and refined panel assistant routing.
- Unified navigation structure and persistent AI sidebar behavior.
- Improved login and registration flows by using server-resolved account service URLs.
- Guest and demo access must not expose any backing account identity in public UI or session payloads.
- Consolidated demo and experience account handling around `sandbox@svc.plus`.
- Added vault-backed token lookup for integrations.
#### Docs And Setup
@ -47,7 +47,5 @@ Published commit: `0fab89e`
## Notes
- GitHub Release: `https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
- GitHub Release: `https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
- Related docs: `docs/README.md`, `docs/en/README.md`, `docs/zh/README.md`
- Release validation must verify both `www.svc.plus` and `console.svc.plus` against the same `releaseImageRef`, `releaseImageTag`, and `releaseCommit`.
- `www.svc.plus` is the canonical public domain for metadata, sitemap, `dashboardUrl`, and shared links.

View File

@ -48,7 +48,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
### 本地开发
写入当前前端仓库的 `.env.local`
写入 `console.svc.plus/.env.local`
```bash
CLOUDFLARE_API_TOKEN=...
@ -58,7 +58,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
### 线上部署
把同名变量写入前端部署环境
把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)
> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`
@ -67,7 +67,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
部署后访问:
```bash
curl -fsSL https://www.svc.plus/api/marketing/home-stats
curl -fsSL https://console.svc.plus/api/marketing/home-stats
```
期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。
@ -77,3 +77,4 @@ curl -fsSL https://www.svc.plus/api/marketing/home-stats
1. token 权限是否包含 Analytics Read
2. Account ID 是否与 siteTag 属于同一账号
3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)

View File

@ -6,7 +6,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Browser │ │ www.svc.plus │ │accounts.svc.plus │
│ Browser │ │ console.svc.plus │ │accounts.svc.plus │
│ (User) │ │ (Frontend) │ │ (Backend) │
└──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘
│ 1. Click "Login │ │
@ -40,7 +40,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
- A GitHub account with access to **Settings > Developer Settings**
- A Google account with access to [Google Cloud Console](https://console.cloud.google.com/)
- Running `accounts.svc.plus` and the frontend served under `www.svc.plus` / `console.svc.plus`
- Running `accounts.svc.plus` and `console.svc.plus` services
---
@ -55,7 +55,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
| Field | Value |
|---|---|
| **Application name** | `Cloud Neutral Console` |
| **Homepage URL** | `https://www.svc.plus` |
| **Homepage URL** | `https://console.svc.plus` |
| **Authorization callback URL** | `https://accounts.svc.plus/api/auth/oauth/callback/github` |
| **Enable Device Flow** | ☐ (unchecked) |
@ -119,7 +119,7 @@ No additional GitHub permissions are required.
|---|---|
| **Application type** | `Web application` |
| **Name** | `Cloud Neutral Console` |
| **Authorized JavaScript origins** | `https://www.svc.plus` |
| **Authorized JavaScript origins** | `https://console.svc.plus` |
| **Authorized redirect URIs** | `https://accounts.svc.plus/api/auth/oauth/callback/google` |
4. Click **"Create"**
@ -150,7 +150,7 @@ GOOGLE_CLIENT_SECRET=<your_google_client_secret>
# ── General OAuth ──
OAUTH_REDIRECT_URL=https://accounts.svc.plus/api/auth/oauth/callback
OAUTH_FRONTEND_URL=https://www.svc.plus
OAUTH_FRONTEND_URL=https://console.svc.plus
```
These variables are referenced in `config/account.yaml`:
@ -159,7 +159,7 @@ These variables are referenced in `config/account.yaml`:
auth:
oauth:
redirectUrl: "${OAUTH_REDIRECT_URL}"
frontendUrl: "${OAUTH_FRONTEND_URL:-https://www.svc.plus}"
frontendUrl: "${OAUTH_FRONTEND_URL:-https://console.svc.plus}"
github:
clientId: "${GITHUB_CLIENT_ID}"
clientSecret: "${GITHUB_CLIENT_SECRET}"
@ -172,7 +172,7 @@ auth:
---
## 4. Frontend Configuration (`www.svc.plus` canonical, `console.svc.plus` secondary)
## 4. Frontend Configuration (console.svc.plus)
The frontend resolves the accounts service URL **server-side** via `getAccountServiceBaseUrl()`, which reads:
@ -209,7 +209,7 @@ If not set, the function falls back to a runtime default. **No `NEXT_PUBLIC_*` e
### OAuth login redirects to wrong domain
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the canonical public domain where users should be redirected after authentication. The current default is `https://www.svc.plus`.
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the console domain where users should be redirected after authentication.
### Google "Access blocked: This app's request is invalid"

View File

@ -16,7 +16,7 @@
- **区域**: asia-northeast1
- **项目 ID**: xzerolab-480008
- **服务 URL**: https://rag-server-svc-plus-266500572462.asia-northeast1.run.app
- **代码仓库**: https://github.com/x-evor/rag-server.svc.plus
- **代码仓库**: https://github.com/cloud-neutral-toolkit/rag-server.svc.plus
### 服务功能
RAG (Retrieval-Augmented Generation) 服务器提供以下功能:
@ -68,7 +68,7 @@ rag-server.svc.plus/
#### 1. 代码变更
```bash
# 克隆仓库
git clone https://github.com/x-evor/rag-server.svc.plus.git
git clone https://github.com/cloud-neutral-toolkit/rag-server.svc.plus.git
cd rag-server.svc.plus
# 创建功能分支
@ -407,7 +407,7 @@ gcloud run services update rag-server-svc-plus \
- **紧急联系**: [电话]
### 相关链接
- **GitHub**: https://github.com/x-evor/rag-server.svc.plus
- **GitHub**: https://github.com/cloud-neutral-toolkit/rag-server.svc.plus
- **Cloud Console**: https://console.cloud.google.com/run/detail/asia-northeast1/rag-server-svc-plus
- **监控面板**: [Monitoring Dashboard URL]
- **文档**: [Documentation URL]

View File

@ -3,10 +3,10 @@
## Scope
- Repository: `console.svc.plus`
- Target host: `root@cn-console.svc.plus`
- Target host: `root@47.120.61.35`
- Public domains:
- `cn-console.svc.plus`
- `cn-console.onwalk.net`
- `cn.svc.plus`
- `cn.onwalk.net`
- Delivery mode: `GitHub Actions + GHCR + Caddy + Docker Compose`
This document defines the deployment baseline for the China-facing frontend node. The source of truth is this upstream repository. The control-plane repository may consume the repo through git submodule, but should not become the primary place where this deployment design lives.
@ -26,7 +26,7 @@ The result should support repeatable releases, quick rollback by image tag, and
### Host constraints
- `cn-console.svc.plus` is a single-node host
- `47.120.61.35` is a single-node host
- deployment user is `root`
- local image build on the host is explicitly disallowed
- IO pressure should be minimized during release
@ -140,16 +140,16 @@ Temporary nature:
Primary domain:
- `cn-console.svc.plus`
- `cn.svc.plus`
Secondary domain:
- `cn-console.onwalk.net`
- `cn.onwalk.net`
Current routing decision:
- Caddy accepts both domains
- requests for `cn-console.onwalk.net` are redirected permanently to `cn-console.svc.plus`
- requests for `cn.onwalk.net` are redirected permanently to `cn.svc.plus`
Reason:
@ -200,7 +200,7 @@ Rollback steps:
1. set `FRONTEND_IMAGE` to a previous known-good tag
2. rerun `frontend-assets`
3. restart `dashboard` and `caddy`
4. verify `cn-console.svc.plus`
4. verify `cn.svc.plus`
This avoids rebuilding and keeps rollback cheap on the weak-IO host.
@ -208,7 +208,7 @@ This avoids rebuilding and keeps rollback cheap on the weak-IO host.
Secrets must not be committed to the repo. The workflow should consume:
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`
- `FRONTEND_DEPLOY_SSH_KEY`
- service tokens
- vault tokens
- internal service token
@ -261,7 +261,7 @@ Mitigation:
### Near term
- populate required GitHub `vars` and `secrets`
- run the workflow against `root@cn-console.svc.plus`
- run the workflow against `47.120.61.35`
- validate DNS, TLS, static assets, login flow, and upstream API proxy behavior
### Later

View File

@ -4,11 +4,11 @@
- Runtime: `console.svc.plus`
- Topology: `Caddy + Docker Compose + GitHub Actions`
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Deploy host: `root@47.120.61.35`
- Public domains:
- `https://www.svc.plus`
- `https://console.svc.plus`
- Canonical public origin: `https://www.svc.plus`
- `https://cn.svc.plus`
- `https://cn.onwalk.net`
- Primary origin: `https://cn.svc.plus`
## Current Delivery Model
@ -17,14 +17,18 @@ The production frontend is deployed as a prebuilt container image from GitHub Ac
- The target host does not build images locally.
- The workflow builds an `linux/amd64` image and pushes it to `ghcr.io/<owner>/dashboard:<sha>`.
- The host only performs `docker login`, `docker compose pull`, static asset extraction, and `docker compose up`.
- `/docs` and `/blogs` fetch their content from `docs.svc.plus` at runtime; the frontend image no longer packs `knowledge/` or synced markdown payloads.
- `knowledge/` is cloned during CI build (via `scripts/sync-blog-content.sh`) and synced with other docs (via `scripts/sync-doc-content.sh`) before being packed into the image.
- Static assets are extracted from the image into a shared Docker volume so Caddy can serve `/_next/static/*` and checked-in public files directly.
This is intentionally static-first for the current weak-IO single-node host. Dynamic HTML, auth routes, and API proxy routes still run through the Next.js container, but docs/blog content delivery is now delegated to `docs.svc.plus`.
This is intentionally static-first for the current weak-IO single-node host. Dynamic HTML, auth routes, and API proxy routes still run through the Next.js container. When `docs.svc.plus` is later split into an API/service, revisit this runbook and remove docs content from the frontend image.
## Control Plane & DNS Stage
The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/pipeline.yaml` finishes pushing the new image, the control-plane DNS automation calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable through the current production host `jp-xhttp-contabo.svc.plus`.
The control repo (`github-org-cloud-neutral-toolkit`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/service_release_frontend-deploy.yml` finishes pushing the new image, the control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable under `cn.svc.plus` and `cn.onwalk.net`.
## Future Docs Strategy
Because the frontend currently ships docs content directly (knowledge/blog + rendered markdown), any future split where `docs.svc.plus` becomes an API-backed service should include a repo-level migration plan: stop syncing docs into the frontend image, move documentation storage/serving into the dedicated API, and adjust the runbook/workflow notes above accordingly.
## Runtime Layout
@ -53,23 +57,20 @@ Containers:
Workflow:
```text
.github/workflows/pipeline.yaml
.github/workflows/service_release_frontend-deploy.yml
```
Secrets required:
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`
- `FRONTEND_DEPLOY_SSH_KEY`
- `OPENCLAW_GATEWAY_TOKEN` if used
- `VAULT_TOKEN` if used
- `AI_GATEWAY_ACCESS_TOKEN` if used
- `INTERNAL_SERVICE_TOKEN` if used
- `CLOUDFLARE_DNS_API_TOKEN` for the Cloudflare DNS stage
- `CLOUDFLARE_API_TOKEN` if homepage Cloudflare analytics are enabled at runtime
- `CLOUDFLARE_API_TOKEN` if used
Repository/environment variables recommended:
- `CANONICAL_DOMAIN`
- `SERVED_DOMAINS`
- `APP_BASE_URL`
- `NEXT_PUBLIC_APP_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
@ -81,26 +82,22 @@ Repository/environment variables recommended:
- `NEXT_PUBLIC_SERVER_SERVICE_URL`
- `RUNTIME_HOSTNAME`
- `DEPLOYMENT_HOSTNAME`
- `DOCS_SERVICE_URL`
- `DOCS_SERVICE_INTERNAL_URL`
- `NEXT_PUBLIC_RUNTIME_ENVIRONMENT`
- `NEXT_PUBLIC_RUNTIME_REGION`
- `NEXT_PUBLIC_GISCUS_*`
- `NEXT_PUBLIC_STRIPE_*`
- `NEXT_PUBLIC_PAYPAL_CLIENT_ID`
- `CLOUDFLARE_ZONE_TAG` if homepage Cloudflare analytics are enabled at runtime
- `CLOUDFLARE_DNS_ZONE_TAG` only for single-domain manual DNS override; the GitHub Actions DNS stage resolves zones from each domain automatically
## Release Flow
1. GitHub Actions checks out the repo.
2. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time.
3. The image is pushed to GHCR.
4. The workflow updates Cloudflare DNS for the release domain.
5. The workflow renders `.env.runtime`, including the canonical public origin and both served frontend domains.
2. GitHub Actions clones `knowledge/`.
3. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time.
4. The image is pushed to GHCR.
5. The workflow renders `.env.runtime`.
6. The workflow uploads `docker-compose.yml`, `Caddyfile`, and `.env.runtime` to the host.
7. The host pulls the new image, refreshes the static asset volume, and starts `dashboard + caddy`.
8. The workflow verifies both `www.svc.plus` and `console.svc.plus`, and fails the release if either domain serves a different runtime version.
8. The workflow verifies `cn.svc.plus` and `cn.onwalk.net`.
## Verification Commands
@ -116,7 +113,7 @@ rm -f deploy/single-node/.env.runtime
python3 - <<'PY'
from pathlib import Path
import yaml
yaml.safe_load(Path('.github/workflows/pipeline.yaml').read_text())
yaml.safe_load(Path('.github/workflows/service_release_frontend-deploy.yml').read_text())
print('workflow yaml ok')
PY
```
@ -124,11 +121,10 @@ PY
Remote checks:
```bash
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: www.svc.plus' http://127.0.0.1/"
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: console.svc.plus' http://127.0.0.1/"
curl -fsSIL https://www.svc.plus
curl -fsSIL https://console.svc.plus
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
ssh root@47.120.61.35 "curl -fsSI -H 'Host: cn.svc.plus' http://127.0.0.1/"
curl -fsSIL https://cn.svc.plus
curl -fsSIL https://cn.onwalk.net
```
## Failure Signatures
@ -137,8 +133,10 @@ curl -fsSIL https://console.svc.plus
The workflow token or package visibility is wrong.
- `frontend-assets` fails
The image layout changed and no longer contains `/app/dashboard/static` or `/app/dashboard/public`.
- `www.svc.plus` or `console.svc.plus` returns `502`
- `cn.svc.plus` returns `502`
Caddy is up, but the `dashboard` container failed or is not reachable on port `3000`.
- `cn.onwalk.net` does not redirect
Check the deployed `Caddyfile` and domain DNS.
## Rollback
@ -147,8 +145,8 @@ curl -fsSIL https://console.svc.plus
3. Restart the services:
```bash
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
```
4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident.
4. Verify `https://cn.svc.plus` again before closing the incident.

View File

@ -3,37 +3,29 @@
## 生产基线
- 运行拓扑: `Caddy + Docker Compose`
- 目标主机: `root@jp-xhttp-contabo.svc.plus`
- 目标主机: `47.120.61.35`
- 域名:
- `www.svc.plus`
- `console.svc.plus`
- 公开首选域名: `www.svc.plus`
- 前端独立发布流水线: `.github/workflows/pipeline.yaml`
- `cn.svc.plus`
- `cn.onwalk.net`
- 前端独立发布流水线: `.github/workflows/service_release_frontend-deploy.yml`
## 运行方式
前端镜像在 GitHub Actions 中完成构建并推送到镜像仓库,目标主机只负责拉取镜像和启动容器,不在机器上本地构建。
`yarn prebuild` 现在只生成 console 自己的营销内容工件。`/docs` 与 `/blogs` 不再把 `knowledge/` 或 Markdown 文档打进前端镜像,而是在请求时通过服务端 `docsServiceClient``docs.svc.plus` 拉取渲染后的内容
`yarn prebuild` 会同步 docs、博客和其它静态内容。CI 在该阶段执行 `scripts/sync-doc-content.sh`(从 `console.svc.plus`、`accounts.svc.plus`、`rag-server.svc.plus` 和 `postgresql.svc.plus` 拉取文档)以及 `scripts/sync-blog-content.sh`(克隆 `https://github.com/cloud-neutral-workshop/knowledge.git`),因此 `knowledge/` 目录和所有文档/博客资产在构建镜像时就已存在
当前方案尽量以静态模式运行:
- Caddy 直接服务 `/_next/static/*``public/` 里的静态资源,并配合 `frontend_static` 共享卷。
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static`
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它
- `knowledge/` 与同步的文档/博客内容在 GitHub Actions 的 Docker 构建阶段就被写入镜像
发布由 `.github/workflows/pipeline.yaml` 驱动CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL` `.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面 DNS 自动化会通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使当前生产主机 `jp-xhttp-contabo.svc.plus` 承载更新后的环境。
发布由 `.github/workflows/service_release_frontend-deploy.yml` 驱动CI 构建/推送镜像、渲染 `.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面工作流 `.github/workflows/service_release_apiserver-deploy.yml` 通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS使 `cn.svc.plus` 与别名 `cn.onwalk.net` 指向更新后的环境。
这是针对弱 IO 单机主机 `root@jp-xhttp-contabo.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy`
这是针对弱 IO 单机主机 `47.120.61.35` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard``caddy`
`docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。
当前发布合同要求:
- `CANONICAL_DOMAIN=www.svc.plus`
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
- 流水线必须同时校验两个域名的首页静态资源与 `/api/ping` 版本元数据完全一致
- `dashboardUrl`、canonical、structured data 与 sitemap 默认统一输出 `https://www.svc.plus`
未来如果 `docs.svc.plus` 被拆分成独立的 API 服务,必须同步更新这份说明(以及运行手册),让 GitHub Actions 只打包属于新服务的内容。
## 相关文档

View File

@ -2,7 +2,7 @@
> English: `../../governance/release-process.md`
本页用于记录公开控制台在 `www.svc.plus``console.svc.plus` 下发布版本的说明与变更摘要。
本页用于记录 `console.svc.plus` 已发布版本的发布说明与变更摘要。
## 当前版本
@ -33,7 +33,7 @@
- 将 observability 工作区拆分为 tri-view并优化 panel 助手路由。
- 统一导航结构与持久化 AI sidebar 行为。
- 登录与注册流程改为使用服务端解析后的 account service URL。
- 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`
- 为集成配置增加基于 vault 的 token 查询能力。
#### 文档与安装
@ -49,7 +49,5 @@
## 备注
- GitHub Release`https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
- 发布校验必须同时验证 `www.svc.plus``console.svc.plus``releaseImageRef`、`releaseImageTag`、`releaseCommit` 完全一致。
- `www.svc.plus` 是 metadata、sitemap、`dashboardUrl` 与公开分享链接的首选域名。
- GitHub Release`https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
- 相关文档:`docs/README.md`、`docs/en/README.md`、`docs/zh/README.md`

View File

@ -1,55 +0,0 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "./src/lib/authGateway";
const PUBLIC_EXACT_PATHS = new Set([
"/",
"/services",
"/login",
"/register",
"/email-verification",
"/logout",
"/404",
"/500",
]);
function isDocsPath(pathname: string): boolean {
return pathname === "/docs" || pathname.startsWith("/docs/");
}
function isPublicPath(pathname: string): boolean {
return PUBLIC_EXACT_PATHS.has(pathname) || isDocsPath(pathname);
}
function buildRedirectTarget(request: NextRequest): string {
const query = request.nextUrl.search;
return `${request.nextUrl.pathname}${query}`;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) {
return undefined;
}
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value?.trim();
if (token) {
return undefined;
}
const loginUrl = new URL("/login", request.url);
const redirect = buildRedirectTarget(request);
if (redirect && redirect !== "/login") {
loginUrl.searchParams.set("redirect", redirect);
}
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|gif|ico|jpg|jpeg|js|map|png|svg|txt|webp|woff|woff2|xml)$).*)",
],
};

View File

@ -4,7 +4,6 @@ import { withContentlayer } from "next-contentlayer";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const docsSiteBaseUrl = (process.env.NEXT_PUBLIC_DOCS_BASE_URL || "https://docs.svc.plus").replace(/\/$/, "");
const nextConfig = {
// ===============================
@ -86,16 +85,6 @@ const nextConfig = {
export async function redirects() {
return [
{
source: '/docs',
destination: `${docsSiteBaseUrl}/docs`,
permanent: true,
},
{
source: '/docs/:path*',
destination: `${docsSiteBaseUrl}/docs/:path*`,
permanent: true,
},
{
source: '/XStream',
destination: '/xstream',

View File

@ -63,7 +63,6 @@
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.5.0",
"react-grid-layout": "^1.4.4",
"react-pdf": "^9.1.0",
"react-resizable": "^3.0.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,44 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
require_env IMAGE_REF
require_env CANONICAL_DOMAIN
BUILD_ARGS_FILE="$(mktemp)"
trap 'rm -f "${BUILD_ARGS_FILE}"' EXIT
"${SCRIPT_DIR}/render-frontend-build-args.sh" --stdout > "${BUILD_ARGS_FILE}"
build_args=()
while IFS= read -r line; do
if [[ -z "${line}" ]]; then
continue
fi
build_args+=(--build-arg "${line}")
done < "${BUILD_ARGS_FILE}"
tag_args=(--tag "${IMAGE_REF}")
if [[ "${PUSH_LATEST:-false}" == "true" ]]; then
require_env IMAGE_LATEST_REF
tag_args+=(--tag "${IMAGE_LATEST_REF}")
fi
docker buildx build \
--platform "${DOCKER_PLATFORM:-linux/amd64}" \
--file "${REPO_ROOT}/Dockerfile" \
"${tag_args[@]}" \
"${build_args[@]}" \
--push \
"${REPO_ROOT}"

View File

@ -4,7 +4,7 @@ set -euo pipefail
IMAGE_TAG_INPUT="${1-}"
IMAGE_TAG="${IMAGE_TAG_INPUT}"
if [[ -z "${IMAGE_TAG}" ]]; then
IMAGE_TAG="${GITHUB_SHA:?GITHUB_SHA is required}"
IMAGE_TAG="${GITHUB_SHA}"
fi
GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}"
@ -18,6 +18,5 @@ fi
{
printf 'ghcr_namespace=%s\n' "${GHCR_NAMESPACE}"
printf 'image_tag=%s\n' "${IMAGE_TAG}"
printf 'image_ref=%s/%s/console:%s\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}" "${IMAGE_TAG}"
printf 'image_latest_ref=%s/%s/console:latest\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}"
printf 'image_ref=%s/%s/dashboard:%s\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}" "${IMAGE_TAG}"
} >> "${GITHUB_OUTPUT}"

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
private_key="${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:?SINGLE_NODE_VPS_SSH_PRIVATE_KEY is required}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${private_key}" | tr -d '\r' > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${target_host}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts

View File

@ -17,23 +17,12 @@ require_env() {
require_env DEPLOY_HOST
require_env DEPLOY_USER
require_env DEPLOY_DIR
require_env SINGLE_NODE_VPS_SSH_PRIVATE_KEY
require_env SSH_PRIVATE_KEY
require_env GHCR_USERNAME
require_env GHCR_PASSWORD
require_env FRONTEND_IMAGE
require_env CANONICAL_DOMAIN
require_env SERVED_DOMAINS
GHCR_REGISTRY="${GHCR_REGISTRY:-ghcr.io}"
reject_remote_build_configuration() {
local compose_file="$1"
if grep -Eq '^[[:space:]]*(build|dockerfile):' "${compose_file}"; then
echo "Deployment package must reference prebuilt images only; compose build directives are forbidden." >&2
exit 1
fi
}
require_env PRIMARY_DOMAIN
require_env SECONDARY_DOMAIN
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}"' EXIT
@ -49,14 +38,12 @@ bash "${SCRIPT_DIR}/render-frontend-runtime-env.sh" "${RUNTIME_ENV_FILE}"
cp "${DEPLOY_SOURCE_DIR}/docker-compose.yml" "${WORK_DIR}/docker-compose.yml"
cp "${DEPLOY_SOURCE_DIR}/Caddyfile" "${WORK_DIR}/Caddyfile"
reject_remote_build_configuration "${WORK_DIR}/docker-compose.yml"
tar -C "${WORK_DIR}" -czf "${RELEASE_ARCHIVE}" \
docker-compose.yml \
Caddyfile \
.env.runtime
printf '%s\n' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY}" > "${SSH_KEY_FILE}"
printf '%s\n' "${SSH_PRIVATE_KEY}" > "${SSH_KEY_FILE}"
chmod 600 "${SSH_KEY_FILE}"
ssh-keyscan -H "${DEPLOY_HOST}" > "${KNOWN_HOSTS_FILE}"
@ -75,72 +62,7 @@ SCP_BASE=(
-o UserKnownHostsFile="${KNOWN_HOSTS_FILE}"
)
printf '%s' "${GHCR_PASSWORD}" | "${SSH_BASE[@]}" \
"GHCR_REGISTRY='${GHCR_REGISTRY}' GHCR_USERNAME='${GHCR_USERNAME}' bash -s" <<'EOF'
set -euo pipefail
require_sudo_prefix() {
if [[ "${EUID}" -eq 0 ]]; then
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
printf 'sudo -n'
return 0
fi
echo "Remote host requires root or passwordless sudo to install or manage Docker." >&2
exit 1
}
ensure_docker() {
if command -v docker >/dev/null 2>&1; then
return 0
fi
local sudo_prefix
sudo_prefix="$(require_sudo_prefix)"
if command -v apt-get >/dev/null 2>&1; then
${sudo_prefix} apt-get update
if ! DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-plugin; then
DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-v2
fi
if command -v systemctl >/dev/null 2>&1; then
${sudo_prefix} systemctl enable --now docker
else
${sudo_prefix} service docker start
fi
return 0
fi
echo "Docker is not installed and this script only knows how to install it on apt-based hosts." >&2
exit 1
}
docker_runner() {
if docker info >/dev/null 2>&1; then
return 0
fi
local sudo_prefix
sudo_prefix="$(require_sudo_prefix)"
if ${sudo_prefix} docker info >/dev/null 2>&1; then
printf '%s' "${sudo_prefix}"
return 0
fi
echo "Docker is installed but not accessible for the deploy user." >&2
exit 1
}
ensure_docker
docker_prefix="$(docker_runner)"
printf '%s' "${GHCR_PASSWORD}" | ${docker_prefix:+${docker_prefix} }docker login "${GHCR_REGISTRY}" -u "${GHCR_USERNAME}" --password-stdin
EOF
printf '%s' "${GHCR_PASSWORD}" | "${SSH_BASE[@]}" "docker login ghcr.io -u '${GHCR_USERNAME}' --password-stdin"
"${SCP_BASE[@]}" "${RELEASE_ARCHIVE}" "${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_ARCHIVE}"
@ -148,70 +70,9 @@ EOF
"DEPLOY_DIR='${DEPLOY_DIR}' REMOTE_ARCHIVE='${REMOTE_ARCHIVE}' PROJECT_NAME='console-svc-plus' bash -s" <<'EOF'
set -euo pipefail
require_sudo_prefix() {
if [[ "${EUID}" -eq 0 ]]; then
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
printf 'sudo -n'
return 0
fi
echo "Remote host requires root or passwordless sudo to install or manage Docker." >&2
exit 1
}
ensure_docker() {
if command -v docker >/dev/null 2>&1; then
return 0
fi
local sudo_prefix
sudo_prefix="$(require_sudo_prefix)"
if command -v apt-get >/dev/null 2>&1; then
${sudo_prefix} apt-get update
if ! DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-plugin; then
DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-v2
fi
if command -v systemctl >/dev/null 2>&1; then
${sudo_prefix} systemctl enable --now docker
else
${sudo_prefix} service docker start
fi
return 0
fi
echo "Docker is not installed and this script only knows how to install it on apt-based hosts." >&2
exit 1
}
docker_runner() {
if docker info >/dev/null 2>&1; then
return 0
fi
local sudo_prefix
sudo_prefix="$(require_sudo_prefix)"
if ${sudo_prefix} docker info >/dev/null 2>&1; then
printf '%s' "${sudo_prefix}"
return 0
fi
echo "Docker is installed but not accessible for the deploy user." >&2
exit 1
}
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}" "${REMOTE_ARCHIVE}"' EXIT
ensure_docker
docker_prefix="$(docker_runner)"
mkdir -p "${DEPLOY_DIR}"
tar -xzf "${REMOTE_ARCHIVE}" -C "${tmp_dir}"
@ -220,8 +81,8 @@ install -m 0644 "${tmp_dir}/Caddyfile" "${DEPLOY_DIR}/Caddyfile"
install -m 0600 "${tmp_dir}/.env.runtime" "${DEPLOY_DIR}/.env.runtime"
cd "${DEPLOY_DIR}"
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime pull dashboard caddy
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime run --rm frontend-assets
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime up -d --remove-orphans dashboard caddy
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime ps
docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime pull dashboard caddy
docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime run --rm frontend-assets
docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime up -d --remove-orphans dashboard caddy
docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime ps
EOF

View File

@ -1,140 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=false
if [[ "${1-}" == "--dry-run" ]]; then
DRY_RUN=true
shift
fi
TARGET_IP="${1:?usage: ensure-frontend-dns.sh [--dry-run] <target-ip> <domain> [domain...]}"
shift
if [[ "$#" -lt 1 ]]; then
echo "usage: ensure-frontend-dns.sh [--dry-run] <target-ip> <domain> [domain...]" >&2
exit 1
fi
PROXIED="${CLOUDFLARE_PROXIED:-true}"
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
json_get() {
local expression="$1"
python3 -c "import json,sys; data=json.load(sys.stdin); ${expression}"
}
cloudflare_api_get() {
local path="$1"
curl -fsS \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4${path}"
}
resolve_zone_for_domain() {
local domain="$1"
local candidate="${domain%.}"
local response
local zone_id
if [[ -n "${CLOUDFLARE_DNS_ZONE_TAG-}" ]]; then
printf '%s\t%s\n' "${CLOUDFLARE_DNS_ZONE_TAG}" "override"
return 0
fi
while [[ "${candidate}" == *.* ]]; do
response="$(cloudflare_api_get "/zones?name=${candidate}")"
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
echo "Failed to query Cloudflare zones for ${candidate}" >&2
exit 1
fi
zone_id="$(printf '%s' "${response}" | json_get 'result=data.get("result", []); print(result[0]["id"] if result else "")')"
if [[ -n "${zone_id}" ]]; then
printf '%s\t%s\n' "${zone_id}" "${candidate}"
return 0
fi
candidate="${candidate#*.}"
done
echo "Unable to resolve Cloudflare zone for ${domain}" >&2
exit 1
}
dns_payload() {
local domain="$1"
DOMAIN="${domain}" TARGET_IP="${TARGET_IP}" PROXIED="${PROXIED}" python3 -c \
'import json, os; print(json.dumps({"type": "A", "name": os.environ["DOMAIN"], "content": os.environ["TARGET_IP"], "ttl": 1, "proxied": os.environ["PROXIED"].lower() == "true"}))'
}
upsert_record() {
local domain="$1"
local payload
local response
local record_id
local zone_id
local zone_name
payload="$(dns_payload "${domain}")"
if [[ "${DRY_RUN}" == "true" ]]; then
printf 'dry-run: upsert %s -> %s\n' "${domain}" "${TARGET_IP}"
printf '%s\n' "${payload}"
return 0
fi
IFS=$'\t' read -r zone_id zone_name <<EOF
$(resolve_zone_for_domain "${domain}")
EOF
response="$(cloudflare_api_get "/zones/${zone_id}/dns_records?type=A&name=${domain}")"
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
echo "Failed to query Cloudflare DNS records for ${domain}" >&2
exit 1
fi
record_id="$(printf '%s' "${response}" | json_get 'result=data.get("result", []); print(result[0]["id"] if result else "")')"
if [[ -n "${record_id}" ]]; then
response="$(
curl -fsS -X PUT \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${payload}" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${record_id}"
)"
else
response="$(
curl -fsS -X POST \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${payload}" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records"
)"
fi
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
echo "Failed to upsert Cloudflare DNS record for ${domain}" >&2
exit 1
fi
printf 'updated: %s -> %s (zone %s)\n' "${domain}" "${TARGET_IP}" "${zone_name}"
}
if [[ "${DRY_RUN}" != "true" ]]; then
require_env CLOUDFLARE_DNS_API_TOKEN
fi
for domain in "$@"; do
upsert_record "${domain}"
done

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ghcr_token="${GHCR_TOKEN:?GHCR_TOKEN is required}"
ghcr_username="${GHCR_USERNAME:?GHCR_USERNAME is required}"
printf '%s' "${ghcr_token}" | docker login ghcr.io -u "${ghcr_username}" --password-stdin

View File

@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "frontend build context no longer syncs docs/blog content; using docs.svc.plus at runtime."

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
bash scripts/github-actions/build-and-push-frontend-image.sh
printf 'image_ref=%s\n' "${IMAGE_REF:?IMAGE_REF is required}" >> "${GITHUB_OUTPUT}"
printf 'image_tag=%s\n' "${IMAGE_TAG:?IMAGE_TAG is required}" >> "${GITHUB_OUTPUT}"

View File

@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:---github-output}"
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
emit_lines() {
require_env CANONICAL_DOMAIN
local canonical_domain="${CANONICAL_DOMAIN}"
local release_image_ref="${IMAGE_REF-}"
local release_image_tag=""
local release_commit=""
local release_version=""
if [[ -n "${release_image_ref}" ]]; then
release_image_tag="$(printf '%s' "${release_image_ref}" | sed -E 's#^.*:([^:@]+)$#\1#')"
release_version="${release_image_tag}"
if [[ "${release_image_tag}" =~ ^sha-([0-9a-f]{7,40})$ ]]; then
release_commit="${BASH_REMATCH[1]}"
elif [[ "${release_image_tag}" =~ ^[0-9a-f]{7,40}$ ]]; then
release_commit="${release_image_tag}"
fi
fi
printf 'NODE_BUILDER_IMAGE=%s\n' "${NODE_BUILDER_IMAGE:-node:22-bookworm}"
printf 'NODE_RUNTIME_IMAGE=%s\n' "${NODE_RUNTIME_IMAGE:-node:22-slim}"
printf 'CONTENTLAYER_BUILD=%s\n' "${CONTENTLAYER_BUILD:-true}"
printf 'NEXT_PUBLIC_APP_BASE_URL=%s\n' "${NEXT_PUBLIC_APP_BASE_URL:-https://${canonical_domain}}"
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "${NEXT_PUBLIC_SITE_URL:-https://${canonical_domain}}"
printf 'NEXT_PUBLIC_LOGIN_URL=%s\n' "${NEXT_PUBLIC_LOGIN_URL:-https://${canonical_domain}/login}"
printf 'NEXT_PUBLIC_DOCS_BASE_URL=%s\n' "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${canonical_domain}/docs}"
printf 'NEXT_PUBLIC_RUNTIME_ENVIRONMENT=%s\n' "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
printf 'NEXT_PUBLIC_RUNTIME_REGION=%s\n' "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
printf 'NEXT_PUBLIC_GISCUS_REPO=%s\n' "${NEXT_PUBLIC_GISCUS_REPO:-x-evor/console.svc.plus}"
printf 'NEXT_PUBLIC_GISCUS_REPO_ID=%s\n' "${NEXT_PUBLIC_GISCUS_REPO_ID-}"
printf 'NEXT_PUBLIC_GISCUS_CATEGORY=%s\n' "${NEXT_PUBLIC_GISCUS_CATEGORY:-General}"
printf 'NEXT_PUBLIC_GISCUS_CATEGORY_ID=%s\n' "${NEXT_PUBLIC_GISCUS_CATEGORY_ID-}"
printf 'NEXT_PUBLIC_PAYPAL_CLIENT_ID=%s\n' "${NEXT_PUBLIC_PAYPAL_CLIENT_ID-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO-}"
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION-}"
printf 'NEXT_PUBLIC_RELEASE_IMAGE=%s\n' "${release_image_ref}"
printf 'NEXT_PUBLIC_RELEASE_TAG=%s\n' "${release_image_tag}"
printf 'NEXT_PUBLIC_RELEASE_COMMIT=%s\n' "${release_commit}"
printf 'NEXT_PUBLIC_RELEASE_VERSION=%s\n' "${release_version}"
}
if [[ "${MODE}" == "--stdout" ]]; then
emit_lines
exit 0
fi
if [[ -z "${GITHUB_OUTPUT-}" ]]; then
echo "GITHUB_OUTPUT is not set" >&2
exit 1
fi
{
echo "build_args<<EOF"
emit_lines
echo "EOF"
} >> "${GITHUB_OUTPUT}"

View File

@ -22,27 +22,27 @@ require_env() {
}
require_env FRONTEND_IMAGE
require_env CANONICAL_DOMAIN
require_env SERVED_DOMAINS
require_env PRIMARY_DOMAIN
require_env SECONDARY_DOMAIN
append_env FRONTEND_IMAGE "${FRONTEND_IMAGE}"
append_env CANONICAL_DOMAIN "${CANONICAL_DOMAIN}"
append_env SERVED_DOMAINS "${SERVED_DOMAINS}"
append_env PRIMARY_DOMAIN "${PRIMARY_DOMAIN}"
append_env SECONDARY_DOMAIN "${SECONDARY_DOMAIN}"
append_env NODE_ENV "production"
append_env PORT "${PORT:-3000}"
append_env RUNTIME_ENV "${RUNTIME_ENV:-prod}"
append_env REGION "${REGION:-cn}"
append_env APP_BASE_URL "${APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_APP_BASE_URL "${NEXT_PUBLIC_APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_SITE_URL "${NEXT_PUBLIC_SITE_URL:-https://${CANONICAL_DOMAIN}}"
append_env NEXT_PUBLIC_LOGIN_URL "${NEXT_PUBLIC_LOGIN_URL:-https://${CANONICAL_DOMAIN}/login}"
append_env NEXT_PUBLIC_DOCS_BASE_URL "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${CANONICAL_DOMAIN}/docs}"
append_env APP_BASE_URL "${APP_BASE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_APP_BASE_URL "${NEXT_PUBLIC_APP_BASE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_SITE_URL "${NEXT_PUBLIC_SITE_URL:-https://${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_LOGIN_URL "${NEXT_PUBLIC_LOGIN_URL:-https://${PRIMARY_DOMAIN}/login}"
append_env NEXT_PUBLIC_DOCS_BASE_URL "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${PRIMARY_DOMAIN}/docs}"
append_env SESSION_COOKIE_SECURE "${SESSION_COOKIE_SECURE:-true}"
append_env NEXT_PUBLIC_SESSION_COOKIE_SECURE "${NEXT_PUBLIC_SESSION_COOKIE_SECURE:-true}"
append_env RUNTIME_HOSTNAME "${RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
append_env NEXT_RUNTIME_HOSTNAME "${NEXT_RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
append_env DEPLOYMENT_HOSTNAME "${DEPLOYMENT_HOSTNAME-}"
append_env RUNTIME_HOSTNAME "${RUNTIME_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env NEXT_RUNTIME_HOSTNAME "${NEXT_RUNTIME_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env DEPLOYMENT_HOSTNAME "${DEPLOYMENT_HOSTNAME:-${PRIMARY_DOMAIN}}"
append_env NEXT_PUBLIC_RUNTIME_ENVIRONMENT "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
append_env NEXT_PUBLIC_RUNTIME_REGION "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
append_env ACCOUNT_SERVICE_URL "${ACCOUNT_SERVICE_URL:-https://accounts.svc.plus}"
@ -50,8 +50,6 @@ append_env NEXT_PUBLIC_ACCOUNT_SERVICE_URL "${NEXT_PUBLIC_ACCOUNT_SERVICE_URL:-$
append_env SERVER_SERVICE_URL "${SERVER_SERVICE_URL:-https://api.svc.plus}"
append_env NEXT_PUBLIC_SERVER_SERVICE_URL "${NEXT_PUBLIC_SERVER_SERVICE_URL:-${SERVER_SERVICE_URL:-https://api.svc.plus}}"
append_env SERVER_SERVICE_INTERNAL_URL "${SERVER_SERVICE_INTERNAL_URL-}"
append_env DOCS_SERVICE_URL "${DOCS_SERVICE_URL:-https://docs.svc.plus}"
append_env DOCS_SERVICE_INTERNAL_URL "${DOCS_SERVICE_INTERNAL_URL-}"
append_env OPENCLAW_GATEWAY_REMOTE_URL "${OPENCLAW_GATEWAY_REMOTE_URL-}"
append_env OPENCLAW_GATEWAY_TOKEN "${OPENCLAW_GATEWAY_TOKEN-}"
append_env VAULT_SERVER_URL "${VAULT_SERVER_URL-}"
@ -66,7 +64,7 @@ append_env CLOUDFLARE_WEB_ANALYTICS_SITE_TAG "${CLOUDFLARE_WEB_ANALYTICS_SITE_TA
append_env CLOUDFLARE_ZONE_TAG "${CLOUDFLARE_ZONE_TAG-}"
append_env ROOT_EMAIL_WHITELIST "${ROOT_EMAIL_WHITELIST:-admin@svc.plus}"
append_env NEXT_PUBLIC_PAYPAL_CLIENT_ID "${NEXT_PUBLIC_PAYPAL_CLIENT_ID-}"
append_env NEXT_PUBLIC_GISCUS_REPO "${NEXT_PUBLIC_GISCUS_REPO:-x-evor/console.svc.plus}"
append_env NEXT_PUBLIC_GISCUS_REPO "${NEXT_PUBLIC_GISCUS_REPO:-cloud-neutral-toolkit/console.svc.plus}"
append_env NEXT_PUBLIC_GISCUS_REPO_ID "${NEXT_PUBLIC_GISCUS_REPO_ID-}"
append_env NEXT_PUBLIC_GISCUS_CATEGORY "${NEXT_PUBLIC_GISCUS_CATEGORY:-General}"
append_env NEXT_PUBLIC_GISCUS_CATEGORY_ID "${NEXT_PUBLIC_GISCUS_CATEGORY_ID-}"

View File

@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ref="${REF:?REF is required}"
if [[ "${ref}" == "refs/heads/main" ]]; then
echo "push_latest=true" >> "${GITHUB_OUTPUT}"
else
echo "push_latest=false" >> "${GITHUB_OUTPUT}"
fi

View File

@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
event_name="${EVENT_NAME:?EVENT_NAME is required}"
input_target_host="${INPUT_TARGET_HOST:-}"
input_run_apply="${INPUT_RUN_APPLY:-}"
if [[ "${event_name}" == "workflow_dispatch" ]]; then
target_host="${input_target_host}"
run_apply="${input_run_apply}"
else
target_host="jp-xhttp-contabo.svc.plus"
run_apply="true"
fi
printf 'target_host=%s\n' "${target_host}" >> "${GITHUB_OUTPUT}"
printf 'run_apply=%s\n' "${run_apply}" >> "${GITHUB_OUTPUT}"

View File

@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
cloudflare_zone_id="${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
cloudflare_dns_token="${CLOUDFLARE_DNS_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
if [[ -z "${cloudflare_dns_token}" ]]; then
echo "CLOUDFLARE_DNS_API_TOKEN or CLOUDFLARE_API_TOKEN is required" >&2
exit 1
fi
ansible_args=(
-i inventory.ini
update_cloudflare_svc_plus_dns.yml
-e "{\"cloudflare_dns_source_hosts\":[\"${target_host}\"]}"
-e "cloudflare_dns_zone_id=${cloudflare_zone_id}"
-e "CLOUDFLARE_DNS_API_TOKEN=${cloudflare_dns_token}"
)
if [[ "${run_apply}" != "true" ]]; then
ansible_args=(-C "${ansible_args[@]}")
fi
ansible-playbook "${ansible_args[@]}"

View File

@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
frontend_image="${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}"
ansible_args=(
-i inventory.ini
deploy_console_svc_plus.yml
-D
-l "${target_host}"
-e "FRONTEND_IMAGE=${frontend_image}"
-e "GHCR_USERNAME=${GHCR_USERNAME:?GHCR_USERNAME is required}"
-e "GHCR_PASSWORD=${GHCR_PASSWORD:?GHCR_PASSWORD is required}"
-e "INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}"
-e "ACCOUNT_SERVICE_URL=${ACCOUNT_SERVICE_URL:?ACCOUNT_SERVICE_URL is required}"
-e "CANONICAL_DOMAIN=${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
-e "SERVED_DOMAINS=${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
-e "NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:?NEXT_PUBLIC_RUNTIME_ENVIRONMENT is required}"
-e "NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION:?NEXT_PUBLIC_RUNTIME_REGION is required}"
-e "CLOUDFLARE_ZONE_TAG=${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
-e "CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG:?CLOUDFLARE_WEB_ANALYTICS_SITE_TAG is required}"
-e "CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required}"
-e "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
-e "CLOUDFLARE_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN:?CLOUDFLARE_DNS_API_TOKEN is required}"
)
if [[ "${run_apply}" != "true" ]]; then
ansible_args=(-C "${ansible_args[@]}")
fi
ansible-playbook "${ansible_args[@]}"

View File

@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
docker buildx create --name console-builder --use >/dev/null 2>&1 || docker buildx use console-builder
docker buildx inspect --bootstrap

View File

@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
target_host="${TARGET_HOST:?TARGET_HOST is required}"
canonical_domain="${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
served_domains="${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
expected_image_ref="${EXPECTED_FRONTEND_IMAGE:?EXPECTED_FRONTEND_IMAGE is required}"
request_base_url="${REQUEST_BASE_URL:-http://127.0.0.1:3000}"
remote_args=(
"$(printf '%q' "${canonical_domain}")"
"$(printf '%q' "${served_domains}")"
"$(printf '%q' "${expected_image_ref}")"
"$(printf '%q' "${request_base_url}")"
)
ssh -o BatchMode=yes "root@${target_host}" "bash -s -- ${remote_args[*]}" \
< scripts/github-actions/verify-frontend-release.sh

View File

@ -1,221 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
CANONICAL_DOMAIN="${1:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
SERVED_DOMAINS="${2:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
EXPECTED_IMAGE_REF="${3:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
REQUEST_BASE_URL="${4:-https://${CANONICAL_DOMAIN}}"
curl_headers=(
-H 'user-agent: Mozilla/5.0 (compatible; console-release-validator/1.0; +https://www.svc.plus)'
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
-H 'accept-language: en-US,en;q=0.9'
)
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
parse_image_ref() {
local image_ref="$1"
IMAGE_REF="${image_ref}" python3 - <<'PY'
import os
import re
import sys
image_ref = os.environ["IMAGE_REF"].strip()
match = re.search(r":([^:@]+)$", image_ref)
tag = match.group(1) if match else ""
commit = ""
version = tag
if re.fullmatch(r"[0-9a-f]{7,40}", tag, flags=re.IGNORECASE):
commit = tag
else:
prefixed_match = re.fullmatch(r"sha-([0-9a-f]{7,40})", tag, flags=re.IGNORECASE)
if prefixed_match:
commit = prefixed_match.group(1)
if not image_ref or not tag or not commit or not version:
sys.exit(1)
print(image_ref)
print(tag)
print(commit)
print(version)
PY
}
parse_homepage_release_metadata() {
local homepage_html="$1"
HOMEPAGE_HTML="${homepage_html}" python3 - <<'PY'
import os
import re
html = os.environ["HOMEPAGE_HTML"]
def extract_meta(name: str) -> str:
pattern = rf'<meta[^>]+name=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)["\']'
match = re.search(pattern, html, flags=re.IGNORECASE)
return match.group(1).strip() if match else ""
print(extract_meta("svc-plus-release-image"))
print(extract_meta("svc-plus-release-tag"))
print(extract_meta("svc-plus-release-commit"))
print(extract_meta("svc-plus-release-version"))
PY
}
require_http_200() {
local url="$1"
shift
local http_code
http_code="$(curl -sS -o /dev/null -w '%{http_code}' "$@" "${url}")"
if [[ "${http_code}" != "200" ]]; then
echo "Expected HTTP 200 from ${url}, got ${http_code}" >&2
exit 1
fi
}
verify_domain() {
local domain="$1"
local request_base_url="${REQUEST_BASE_URL%/}"
local request_headers=("${curl_headers[@]}" -H "host: ${domain}")
local homepage_html asset_path release_metadata
local actual_image_ref actual_image_tag actual_release_commit actual_release_version
local release_lines
require_http_200 "${request_base_url}" "${request_headers[@]}"
printf 'verified homepage for %s: 200\n' "${domain}" >&2
homepage_html="$(curl -fsSL "${request_headers[@]}" "${request_base_url}")"
asset_path="$(printf '%s' "${homepage_html}" | grep -Eo '/_next/static/[^"'"'"' ]+\.(css|js)' | head -n 1)"
if [[ -z "${asset_path}" ]]; then
echo "Could not find a _next/static asset on ${domain} via ${request_base_url}" >&2
exit 1
fi
require_http_200 "${request_base_url}${asset_path}" "${request_headers[@]}"
printf 'verified static asset for %s: %s%s\n' "${domain}" "${request_base_url}" "${asset_path}" >&2
release_metadata="$(parse_homepage_release_metadata "${homepage_html}")"
mapfile -t release_lines <<< "${release_metadata}"
actual_image_ref="${release_lines[0]-}"
actual_image_tag="${release_lines[1]-}"
actual_release_commit="${release_lines[2]-}"
actual_release_version="${release_lines[3]-}"
if [[ -z "${actual_image_ref}" || -z "${actual_image_tag}" || -z "${actual_release_commit}" || -z "${actual_release_version}" ]]; then
echo "Homepage release metadata is incomplete for ${domain}" >&2
exit 1
fi
if [[ ! "${actual_release_commit}" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "Homepage release commit must contain a commit id for ${domain}, got: ${actual_release_commit}" >&2
exit 1
fi
if [[ "${actual_release_version}" != "${actual_image_tag}" ]]; then
echo "Homepage release version mismatch for ${domain}: expected ${actual_image_tag}, got ${actual_release_version}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Homepage release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
exit 1
fi
printf 'verified homepage release image for %s: %s\n' "${domain}" "${actual_image_ref}" >&2
printf 'verified homepage release commit for %s: %s\n' "${domain}" "${actual_release_commit}" >&2
printf 'verified homepage release version for %s: %s\n' "${domain}" "${actual_release_version}" >&2
printf '%s\t%s\t%s\t%s\t%s\n' "${domain}" "${actual_image_ref}" "${actual_image_tag}" "${actual_release_commit}" "${actual_release_version}"
}
mapfile -t expected_release_lines < <(parse_image_ref "${EXPECTED_IMAGE_REF}")
EXPECTED_RELEASE_IMAGE_REF="${expected_release_lines[0]-}"
EXPECTED_RELEASE_IMAGE_TAG="${expected_release_lines[1]-}"
EXPECTED_RELEASE_COMMIT="${expected_release_lines[2]-}"
EXPECTED_RELEASE_VERSION="${expected_release_lines[3]-}"
if [[ -z "${EXPECTED_RELEASE_IMAGE_REF}" || -z "${EXPECTED_RELEASE_IMAGE_TAG}" || -z "${EXPECTED_RELEASE_COMMIT}" || -z "${EXPECTED_RELEASE_VERSION}" ]]; then
echo "Expected image ref is invalid: ${EXPECTED_IMAGE_REF}" >&2
exit 1
fi
mapfile -t served_domains < <(
printf '%s' "${SERVED_DOMAINS}" | tr ',' '\n' | while IFS= read -r domain; do
domain="$(trim "${domain}")"
if [[ -n "${domain}" ]]; then
printf '%s\n' "${domain}"
fi
done
)
if [[ "${#served_domains[@]}" -eq 0 ]]; then
echo "No served domains were provided." >&2
exit 1
fi
canonical_found=false
declare -a verification_rows=()
for domain in "${served_domains[@]}"; do
if [[ "${domain}" == "${CANONICAL_DOMAIN}" ]]; then
canonical_found=true
fi
verification_rows+=("$(verify_domain "${domain}")")
done
if [[ "${canonical_found}" != "true" ]]; then
echo "Canonical domain ${CANONICAL_DOMAIN} must be included in served domains: ${SERVED_DOMAINS}" >&2
exit 1
fi
reference_image_ref=""
reference_image_tag=""
reference_release_commit=""
reference_release_version=""
for row in "${verification_rows[@]}"; do
IFS=$'\t' read -r domain actual_image_ref actual_image_tag actual_release_commit actual_release_version <<< "${row}"
if [[ "${actual_image_ref}" != "${EXPECTED_RELEASE_IMAGE_REF}" ]]; then
echo "Release image mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_REF}, got ${actual_image_ref}" >&2
exit 1
fi
if [[ "${actual_image_tag}" != "${EXPECTED_RELEASE_IMAGE_TAG}" ]]; then
echo "Release tag mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_TAG}, got ${actual_image_tag}" >&2
exit 1
fi
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
echo "Release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
exit 1
fi
if [[ "${actual_release_version}" != "${EXPECTED_RELEASE_VERSION}" ]]; then
echo "Release version mismatch for ${domain}: expected ${EXPECTED_RELEASE_VERSION}, got ${actual_release_version}" >&2
exit 1
fi
if [[ -z "${reference_image_ref}" ]]; then
reference_image_ref="${actual_image_ref}"
reference_image_tag="${actual_image_tag}"
reference_release_commit="${actual_release_commit}"
reference_release_version="${actual_release_version}"
continue
fi
if [[ "${actual_image_ref}" != "${reference_image_ref}" || "${actual_image_tag}" != "${reference_image_tag}" || "${actual_release_commit}" != "${reference_release_commit}" || "${actual_release_version}" != "${reference_release_version}" ]]; then
echo "Release metadata drift detected across served domains." >&2
exit 1
fi
done

View File

@ -11,14 +11,24 @@ echo "======================================"
echo "Starting prebuild process..."
echo "======================================"
# Step 1: Generate local marketing content artifacts
# Step 1: Sync documentation from service repositories
echo ""
echo "[1/2] Generating marketing content..."
echo "[1/4] Syncing documentation content..."
bash scripts/sync-doc-content.sh
# Step 2: Sync blog content
echo ""
echo "[2/4] Syncing blog content..."
bash scripts/sync-blog-content.sh
# Step 3: Generate static content (homepage, products)
echo ""
echo "[3/4] Generating static content..."
npx tsx scripts/generate-content.ts
# Step 2: Build contentlayer artifacts used by non-doc pages
# Step 4: Build contentlayer
echo ""
echo "[2/2] Building contentlayer..."
echo "[4/4] Building contentlayer..."
node scripts/build-contentlayer.mjs
echo ""

View File

@ -8,7 +8,7 @@ Usage:
Examples:
# Remote install:
# curl -fsSL "https://raw.githubusercontent.com/x-evor/<repo>/main/scripts/setup.sh?$(date +%s)" | bash -s -- <repo>
# curl -fsSL "https://raw.githubusercontent.com/cloud-neutral-toolkit/<repo>/main/scripts/setup.sh?$(date +%s)" | bash -s -- <repo>
#
# Local:
# bash scripts/setup.sh <repo>
@ -50,7 +50,7 @@ while [[ $# -gt 0 ]]; do
done
if [[ -z "${REPO_URL}" ]]; then
REPO_URL="https://github.com/x-evor/${NAME}.git"
REPO_URL="https://github.com/cloud-neutral-toolkit/${NAME}.git"
fi
need_cmd git

View File

@ -1,3 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
echo "blog content sync has moved to docs.svc.plus; script retained as a no-op."
CONTENT_DIR="src/content/blogs"
REPO_URL="https://github.com/cloud-neutral-workshop/knowledge.git"
# Ensure we're in the project root
cd "$(dirname "$0")/.."
if [ -d "${CONTENT_DIR}/.git" ]; then
echo "Updating existing git repo in ${CONTENT_DIR}..."
git -C "${CONTENT_DIR}" fetch --depth=1 origin main
git -C "${CONTENT_DIR}" reset --hard origin/main
git -C "${CONTENT_DIR}" clean -fdx
exit 0
fi
echo "Syncing content from ${REPO_URL} to ${CONTENT_DIR}..."
TMP_DIR=$(mktemp -d)
trap 'rm -rf "${TMP_DIR}"' EXIT
git clone --depth=1 "${REPO_URL}" "${TMP_DIR}/repo"
mkdir -p "${CONTENT_DIR}"
# Remove existing content but keep the directory
# Find and delete all files and directories inside CONTENT_DIR, but ignore errors if empty
find "${CONTENT_DIR}" -mindepth 1 -delete 2>/dev/null || true
# Copy content from repo to content dir, excluding .git
tar -C "${TMP_DIR}/repo" --exclude='.git' -cf - . | tar -C "${CONTENT_DIR}" -xf -
echo "Content synced successfully."

View File

@ -1,3 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
echo "docs content sync has moved to docs.svc.plus; script retained as a no-op."
# Sync documentation from multiple service repositories into the application
# This script pulls docs from each service repo and organizes them into src/content/doc/
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DOCS_DIR="${REPO_ROOT}/src/content/doc"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "${TMP_DIR}"' EXIT
echo "==> Syncing service documentation to ${DOCS_DIR}"
# Define service repositories and their target directories
declare -A SERVICES=(
["https://github.com/cloud-neutral-toolkit/console.svc.plus.git"]="01-console"
["https://github.com/cloud-neutral-toolkit/accounts.svc.plus.git"]="02-accounts"
["https://github.com/cloud-neutral-toolkit/rag-server.svc.plus.git"]="03-rag-server"
["https://github.com/cloud-neutral-toolkit/postgresql.svc.plus.git"]="04-postgresql"
)
# Ensure docs directory exists
mkdir -p "${DOCS_DIR}"
# Clean up existing subdirectories (but keep index.md if we aren't regenerating it immediately, though we will)
# Actually, let's clean everything except .git and maybe custom files if any
find "${DOCS_DIR}" -mindepth 1 -maxdepth 1 -type d -not -name ".git" -exec rm -rf {} +
# Sync each service
for repo_url in "${!SERVICES[@]}"; do
target_dir="${SERVICES[$repo_url]}"
service_name=$(basename "${repo_url}" .git)
echo ""
echo "==> Processing ${service_name} -> docs/${target_dir}"
# Clone the repository
clone_dir="${TMP_DIR}/${service_name}"
echo " Cloning ${repo_url}..."
git clone --depth=1 --single-branch --branch main "${repo_url}" "${clone_dir}" 2>/dev/null || {
echo " Warning: Failed to clone ${repo_url}, skipping..."
continue
}
# Check if docs directory exists in the service repo
if [ ! -d "${clone_dir}/docs" ]; then
echo " Warning: No docs/ directory found in ${service_name}, skipping..."
continue
fi
# Create target directory
target_path="${DOCS_DIR}/${target_dir}"
mkdir -p "${target_path}"
# Copy documentation files
echo " Copying documentation files..."
cp -r "${clone_dir}/docs/"* "${target_path}/" 2>/dev/null || {
echo " Warning: Failed to copy docs from ${service_name}"
continue
}
# Add collection metadata if index.md exists
if [ -f "${target_path}/index.md" ]; then
# Check if frontmatter already has collection field
if ! grep -q "^collection:" "${target_path}/index.md"; then
# Add collection metadata to frontmatter
temp_file=$(mktemp)
awk -v collection="${target_dir}" '
BEGIN { in_frontmatter=0; added=0 }
/^---$/ {
in_frontmatter++
print
if (in_frontmatter == 1) {
print "collection: " collection
print "collectionLabel: " toupper(substr(collection, 4, 1)) substr(collection, 5)
added=1
}
next
}
{ print }
' "${target_path}/index.md" > "${temp_file}"
mv "${temp_file}" "${target_path}/index.md"
fi
fi
echo " ✓ Successfully synced ${service_name}"
done
echo ""
echo "==> Generating src/content/doc/index.md..."
# Generate the main index.md file
cat > "${DOCS_DIR}/index.md" << 'EOF'
---
title: Cloud-Neutral Toolkit Documentation
description: Comprehensive documentation for all Cloud-Neutral Toolkit services
---
# Cloud-Neutral Toolkit Documentation
Welcome to the **Cloud-Neutral Toolkit** documentation. This comprehensive guide covers all services in the toolkit, helping you build, deploy, and manage cloud-native applications across any vendor.
## 🚀 Services
EOF
# Add service sections dynamically
declare -A SERVICE_TITLES=(
["01-console"]="Console Service"
["02-accounts"]="Accounts & Identity Service"
["03-rag-server"]="RAG Server (AI/ML)"
["04-postgresql"]="PostgreSQL Service"
)
declare -A SERVICE_DESCRIPTIONS=(
["01-console"]="The main dashboard and control plane for managing your cloud-neutral infrastructure."
["02-accounts"]="Centralized authentication, authorization, and identity management with OIDC support."
["03-rag-server"]="Retrieval-Augmented Generation service for AI-powered features and intelligent assistance."
["04-postgresql"]="Managed PostgreSQL database service with cloud-neutral deployment options."
)
for target_dir in $(echo "${SERVICES[@]}" | tr ' ' '\n' | sort); do
if [ -d "${DOCS_DIR}/${target_dir}" ]; then
service_title="${SERVICE_TITLES[$target_dir]}"
service_desc="${SERVICE_DESCRIPTIONS[$target_dir]}"
cat >> "${DOCS_DIR}/index.md" << EOF
### ${service_title}
${service_desc}
**[View ${service_title} Documentation →](/docs/${target_dir}/index)**
EOF
fi
done
# Add footer
cat >> "${DOCS_DIR}/index.md" << 'EOF'
## 📚 Quick Links
- **[Getting Started](/docs/01-console/index)** - Begin with the Console Service
- **[Architecture Overview](/docs/01-console/architecture)** - Understand the system design
- **[API Reference](/docs/02-accounts/api)** - Explore the APIs
## 🔗 Resources
- [GitHub Organization](https://github.com/cloud-neutral-toolkit)
- [Community Forum](https://github.com/orgs/cloud-neutral-toolkit/discussions)
- [Issue Tracker](https://github.com/cloud-neutral-toolkit/console.svc.plus/issues)
---
*Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")*
EOF
echo " ✓ Generated index.md"
echo ""
echo "==> Documentation sync complete!"

View File

@ -3,7 +3,7 @@ name: git-history-secret-remediation
description: Use when a user asks to detect secrets in git commit history, clean tracked sensitive data, rewrite history with git-filter-repo, or verify cleanup with gitleaks. Covers gitleaks detect -v, replacement mapping, path removal, ref inventory, history rewrites, force-push planning, and post-cleanup coordination.
license: Internal use only
metadata:
owner: x-evor
owner: cloud-neutral-toolkit
distribution: clawhub-compatible
package-format: .skill
---

View File

@ -1,32 +0,0 @@
---
name: release-traceability
description: Default reference for release-traceability work in this repository. Use when changing CI/CD, image tags, deploy contracts, /api/ping, validate, or any build-to-deploy-to-verify release path.
---
# Release Traceability
## Default Rule
When working on release flow changes in this repository, treat this skill as the first reference.
## Scope
Use for:
- CI/CD orchestration
- Image tag and image reference generation
- Deploy contract changes
- `/api/ping` release metadata
- `validate` release verification
## Required Invariants
- Build output must carry a traceable commit-based version.
- Deploy must consume the published image only.
- Target hosts must not rebuild images.
- `/api/ping` must expose the active release identity.
- `validate` must compare build output, runtime output, and deployed image metadata.
## Working Rule
Prefer the simplest implementation that keeps the release chain auditable from commit to build artifact to runtime verification.

View File

@ -1,62 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ACCOUNT_PREFIX = '/api/account'
function createHandler() {
const upstreamBaseUrl = getAccountServiceBaseUrl()
return createUpstreamProxyHandler({
upstreamBaseUrl,
upstreamPathPrefix: ACCOUNT_PREFIX,
getAdditionalHeaders: async (request) => {
if (request.headers.get('authorization')) {
return undefined
}
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
}
const handler = createHandler()
export function GET(request: NextRequest) {
return handler(request)
}
export function POST(request: NextRequest) {
return handler(request)
}
export function PUT(request: NextRequest) {
return handler(request)
}
export function PATCH(request: NextRequest) {
return handler(request)
}
export function DELETE(request: NextRequest) {
return handler(request)
}
export function HEAD(request: NextRequest) {
return handler(request)
}
export function OPTIONS(request: NextRequest) {
return handler(request)
}

View File

@ -1,28 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_COLLECTOR_PREFIX = '/api/admin/collector'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_COLLECTOR_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -0,0 +1,120 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_ROLES: AccountUserRole[] = ["admin"];
const READ_PERMISSIONS = ["admin.settings.read"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
type ErrorPayload = {
error: string;
};
async function proxyRequest(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const { pathname, search } = new URL(request.url);
// Map /api/admin/sandbox/... to backend /admin/sandbox/...
const segments = pathname.replace(/^\/api\/admin\/sandbox/, "");
const targetUrl = `${ACCOUNT_API_BASE}/admin/sandbox${segments}${search}`;
const method = request.method;
const isWrite = method !== "GET" && method !== "HEAD";
if (isWrite) {
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, WRITE_PERMISSIONS))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
} else {
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
});
let body: string | undefined;
if (isWrite) {
body = await request.text();
const contentType =
request.headers.get("content-type") ?? "application/json";
headers.set("Content-Type", contentType);
}
try {
const response = await fetch(targetUrl, {
method,
headers,
body,
cache: "no-store",
});
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status });
} catch (err: any) {
return NextResponse.json<ErrorPayload>(
{ error: err.message },
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
return proxyRequest(request);
}
export async function POST(request: NextRequest) {
return proxyRequest(request);
}
export async function PUT(request: NextRequest) {
return proxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return proxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request);
}

View File

@ -0,0 +1,71 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
type ErrorPayload = {
error: string
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
const body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
} catch (error) {
console.error('Failed to proxy sandbox bind', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -0,0 +1,64 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const READ_PERMISSIONS = ['admin.settings.read']
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: READ_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
} catch (error) {
console.error('Failed to proxy sandbox binding', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -1,28 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_SCHEDULER_PREFIX = '/api/admin/scheduler'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_SCHEDULER_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -1,28 +0,0 @@
export const dynamic = 'force-dynamic'
import type { NextRequest } from 'next/server'
import { createUpstreamProxyHandler } from '@lib/apiProxy'
import { getAccountSession } from '@server/account/session'
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
const ADMIN_TRAFFIC_PREFIX = '/api/admin/traffic'
const handler = createUpstreamProxyHandler({
upstreamBaseUrl: getAccountServiceBaseUrl(),
upstreamPathPrefix: ADMIN_TRAFFIC_PREFIX,
getAdditionalHeaders: async (request) => {
const session = await getAccountSession(request)
if (!session.token) {
return undefined
}
return {
authorization: `Bearer ${session.token}`,
'x-account-session': session.token,
}
},
})
export function GET(request: NextRequest) {
return handler(request)
}

View File

@ -1,89 +0,0 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const ORIGINAL_ENV = { ...process.env };
describe("/api/auth/login", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
process.env = { ...ORIGINAL_ENV };
delete process.env.ACCOUNT_SERVICE_URL;
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("fails fast when account service points back to console", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { POST } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
host: "console.svc.plus",
},
body: JSON.stringify({
email: "admin@svc.plus",
password: "Secret123!",
}),
});
const response = await POST(request);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "account_service_misconfigured",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("preserves upstream 404 responses", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "user_not_found" }), {
status: 404,
headers: {
"Content-Type": "application/json",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
const { POST } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
host: "console.svc.plus",
},
body: JSON.stringify({
email: "admin@svc.plus",
password: "Secret123!",
}),
});
const response = await POST(request);
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "user_not_found",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://accounts.svc.plus/api/auth/login",
expect.objectContaining({
method: "POST",
}),
);
});
});

View File

@ -9,10 +9,7 @@ import {
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
} from "@lib/authGateway";
import {
getAccountServiceApiBaseUrl,
isSelfReferentialServiceTarget,
} from "@server/serviceConfig";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
@ -69,22 +66,6 @@ export async function POST(request: NextRequest) {
);
}
if (
isSelfReferentialServiceTarget(
ACCOUNT_API_BASE,
request.headers.get("host"),
)
) {
return NextResponse.json(
{
success: false,
error: "account_service_misconfigured",
needMfa: false,
},
{ status: 502 },
);
}
try {
const loginBody: Record<string, string> = { email, password };
if (totpCode) {

View File

@ -1,99 +0,0 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const cookiesMock = vi.hoisted(() => vi.fn());
const ORIGINAL_ENV = { ...process.env };
vi.mock("next/headers", () => ({
cookies: cookiesMock,
}));
describe("/api/auth/mfa/status", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
cookiesMock.mockReset();
process.env = { ...ORIGINAL_ENV };
delete process.env.ACCOUNT_SERVICE_URL;
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("fails fast when account service points back to console", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest(
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
{
headers: {
host: "console.svc.plus",
},
},
);
const response = await GET(request);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
error: "account_service_misconfigured",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("forwards identifier and session token to accounts", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
cookiesMock.mockResolvedValue({
get(name: string) {
if (name === "xc_session") {
return { value: "session-token" };
}
return undefined;
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ mfa: { totpEnabled: true } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest(
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
{
headers: {
host: "console.svc.plus",
},
},
);
const response = await GET(request);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
mfa: { totpEnabled: true },
});
expect(fetchMock).toHaveBeenCalledWith(
"https://accounts.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
expect.any(Object),
);
expect(init.headers).toMatchObject({
Accept: "application/json",
Authorization: "Bearer session-token",
});
});
});

View File

@ -2,23 +2,11 @@ import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
import {
getAccountServiceApiBaseUrl,
isSelfReferentialServiceTarget,
} from '@server/serviceConfig'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function GET(request: NextRequest) {
if (isSelfReferentialServiceTarget(ACCOUNT_API_BASE, request.headers.get('host'))) {
return NextResponse.json(
{
error: 'account_service_misconfigured',
},
{ status: 502 },
)
}
const cookieStore = await cookies()
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
const storedMfaToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''

View File

@ -1,69 +0,0 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const cookiesMock = vi.hoisted(() => vi.fn());
const ORIGINAL_ENV = { ...process.env };
vi.mock("next/headers", () => ({
cookies: cookiesMock,
}));
describe("/api/auth/session", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
cookiesMock.mockReset();
process.env = { ...ORIGINAL_ENV };
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("drops guest sessions instead of exposing them as authenticated users", async () => {
cookiesMock.mockResolvedValue({
get(name: string) {
if (name === "xc_session") {
return { value: "guest-session-token" };
}
return undefined;
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
user: {
id: "guest-1",
email: "guest@svc.plus",
role: "guest",
username: "guest",
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
),
);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/session", {
headers: {
host: "console.svc.plus",
},
});
const response = await GET(request);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ user: null });
});
});

View File

@ -2,14 +2,23 @@ import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
import { resolvePublicUserEmail } from "@lib/publicUserIdentity";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountServiceApiBaseUrl,
getAccountServiceBaseUrl,
} from "@server/serviceConfig";
import {
buildInternalServiceHeaders,
isServiceTokenConfigured,
} from "@server/internalServiceAuth";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ACCOUNT_BASE = getAccountServiceBaseUrl();
type AccountUser = {
id?: string;
uuid?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
name?: string;
username?: string;
email: string;
@ -39,15 +48,20 @@ type SessionResponse = {
error?: string;
};
type AuthenticatedRole = "user" | "operator" | "admin";
type SandboxGuestResponse = {
email?: string;
proxyUuid?: string;
proxyUuidExpiresAt?: string;
error?: string;
};
function normalizeRole(role: unknown): AuthenticatedRole | null {
function normalizeRole(role: unknown): string {
if (typeof role !== "string") {
return null;
return "user";
}
const normalized = role.trim().toLowerCase();
if (!normalized) {
return null;
return "user";
}
if (normalized === "root" || normalized === "super_admin") {
return "admin";
@ -55,14 +69,7 @@ function normalizeRole(role: unknown): AuthenticatedRole | null {
if (normalized === "readonly" || normalized === "read_only") {
return "user";
}
if (
normalized === "user" ||
normalized === "operator" ||
normalized === "admin"
) {
return normalized;
}
return null;
return normalized;
}
async function fetchSession(token: string, requestHost?: string | null) {
@ -87,10 +94,69 @@ async function fetchSession(token: string, requestHost?: string | null) {
}
}
async function fetchSandboxGuest(): Promise<AccountUser | null> {
if (!isServiceTokenConfigured()) {
return null;
}
try {
const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, {
method: "GET",
headers: buildInternalServiceHeaders({
Accept: "application/json",
}),
cache: "no-store",
});
if (!response.ok) {
return null;
}
const payload = (await response
.json()
.catch(() => null)) as SandboxGuestResponse | null;
const proxyUuid =
typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : "";
if (!proxyUuid) {
return null;
}
const proxyUuidExpiresAt =
typeof payload?.proxyUuidExpiresAt === "string" &&
payload.proxyUuidExpiresAt.trim().length > 0
? payload.proxyUuidExpiresAt.trim()
: undefined;
// Shape this as a pseudo-session user for the Guest/Demo experience.
return {
id: proxyUuid,
uuid: proxyUuid,
proxyUuid,
proxyUuidExpiresAt,
name: "Guest user",
username: "guest",
email: "sandbox@svc.plus",
role: "guest",
groups: ["guest", "sandbox"],
permissions: ["read"],
readOnly: true,
tenantId: "guest-sandbox",
tenants: [{ id: "guest-sandbox", name: "Guest Sandbox", role: "guest" }],
mfaEnabled: false,
mfaPending: false,
};
} catch (error) {
console.error("Sandbox guest session proxy failed", error);
return null;
}
}
export async function GET(request: NextRequest) {
void request;
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ user: null });
const sandboxGuest = await fetchSandboxGuest();
return NextResponse.json({ user: sandboxGuest });
}
const requestHost = request.headers.get("host");
@ -120,11 +186,6 @@ export async function GET(request: NextRequest) {
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled;
const normalizedRole = normalizeRole(rawUser.role);
if (!normalizedRole) {
const response = NextResponse.json({ user: null });
clearSessionCookie(response, requestHost ?? undefined);
return response;
}
const rawRole =
typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : "";
const normalizedGroups = Array.isArray(rawUser.groups)
@ -154,7 +215,19 @@ export async function GET(request: NextRequest) {
Boolean(rawUser.readOnly) ||
normalizedGroups.some((group) => group.toLowerCase() === "readonly role") ||
rawRole === "readonly" ||
rawRole === "read_only";
rawRole === "read_only" ||
String(rawUser.email ?? "")
.trim()
.toLowerCase() === "sandbox@svc.plus";
const normalizedProxyUuid =
typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()
: undefined;
const normalizedProxyUuidExpiresAt =
typeof rawUser.proxyUuidExpiresAt === "string" &&
rawUser.proxyUuidExpiresAt.trim().length > 0
? rawUser.proxyUuidExpiresAt.trim()
: undefined;
const normalizedTenantId =
typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0
@ -216,15 +289,10 @@ export async function GET(request: NextRequest) {
const normalizedUser = identifier
? { ...rawUser, id: identifier, uuid: identifier }
: rawUser;
const publicEmail = resolvePublicUserEmail({
email: normalizedUser.email,
role: normalizedRole,
});
return NextResponse.json({
user: {
...normalizedUser,
email: publicEmail,
mfaEnabled: derivedMfaEnabled,
mfaPending: derivedMfaPending,
mfa: normalizedMfa,
@ -232,6 +300,8 @@ export async function GET(request: NextRequest) {
groups: normalizedGroups,
permissions: normalizedPermissions,
readOnly: normalizedReadOnly,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},

View File

@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getLatestBlogPosts } from "@/lib/docsServiceClient";
import { getBlogPosts } from "@/lib/blogContent";
export const dynamic = "force-dynamic";
@ -24,7 +24,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = parseLimit(searchParams.get("limit"));
const posts = await getLatestBlogPosts(limit);
const posts = await getBlogPosts();
const latestPosts = posts.slice(0, limit).map((post) => ({
slug: post.slug,
title: post.title,

View File

@ -2,43 +2,9 @@ import { NextResponse } from 'next/server'
import { loadRuntimeConfig } from '@server/runtime-loader'
type TReleaseImageMetadata = {
releaseImageRef: string | null
releaseImageTag: string | null
releaseCommit: string | null
}
function resolveReleaseCommit(releaseImageTag: string | null): string | null {
if (!releaseImageTag) {
return null
}
const normalizedTag = releaseImageTag.trim()
const prefixedShaMatch = normalizedTag.match(/^sha-([0-9a-f]{7,40})$/i)
if (prefixedShaMatch) {
return prefixedShaMatch[1] ?? null
}
return /^[0-9a-f]{7,40}$/i.test(normalizedTag) ? normalizedTag : null
}
function resolveReleaseImageMetadata(frontendImage: string | undefined): TReleaseImageMetadata {
const releaseImageRef = frontendImage?.trim() || null
const releaseImageTagMatch = releaseImageRef?.match(/:([^:@]+)$/)
const releaseImageTag = releaseImageTagMatch?.[1] ?? null
const releaseCommit = resolveReleaseCommit(releaseImageTag)
return {
releaseImageRef,
releaseImageTag,
releaseCommit,
}
}
export async function GET(request: Request) {
const hostnameHeader = request.headers.get('host') ?? undefined
const runtimeConfig = loadRuntimeConfig({ hostname: hostnameHeader })
const releaseMetadata = resolveReleaseImageMetadata(process.env.FRONTEND_IMAGE)
const payload = {
status: 'ok' as const,
@ -48,7 +14,6 @@ export async function GET(request: Request) {
authUrl: runtimeConfig.authUrl,
dashboardUrl: runtimeConfig.dashboardUrl,
logLevel: runtimeConfig.logLevel,
...releaseMetadata,
}
console.info('[runtime-config] /api/ping resolved config snippet', payload)

View File

@ -0,0 +1,97 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie } from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const ROOT_BACKUP_COOKIE = "xc_session_root";
type ErrorPayload = {
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === "production") {
return true;
}
const baseUrl =
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
return baseUrl.toLowerCase().startsWith("https://");
}
async function verifyRootToken(token: string): Promise<boolean> {
try {
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) {
return false;
}
const payload = (await res.json().catch(() => null)) as any;
const email =
typeof payload?.user?.email === "string"
? payload.user.email.trim().toLowerCase()
: "";
return email === "admin@svc.plus";
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
const rootToken =
request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? "";
if (!rootToken) {
return NextResponse.json<ErrorPayload>(
{ error: "not_assuming" },
{ status: 400 },
);
}
if (!(await verifyRootToken(rootToken))) {
return NextResponse.json<ErrorPayload>(
{ error: "root_token_invalid" },
{ status: 403 },
);
}
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
try {
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
method: "POST",
headers: {
Authorization: `Bearer ${rootToken}`,
Accept: "application/json",
},
cache: "no-store",
});
} catch (error) {
console.error("Failed to audit assume revert", error);
}
const response = NextResponse.json({ ok: true });
applySessionCookie(
response,
rootToken,
undefined,
request.headers.get("host") ?? undefined,
);
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: "",
httpOnly: true,
secure: secureCookies(),
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}

View File

@ -0,0 +1,119 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
import { evaluateAccountAdminAccess } from "@server/account/adminAccess";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import { getAccountSession } from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const REQUIRED_ROLES: AccountUserRole[] = ["admin"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
const ROOT_BACKUP_COOKIE = "xc_session_root";
const SANDBOX_EMAIL = "sandbox@svc.plus";
type ErrorPayload = {
error: string;
};
function secureCookies(): boolean {
if (process.env.NODE_ENV === "production") {
return true;
}
const baseUrl =
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
return baseUrl.toLowerCase().startsWith("https://");
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
});
if (!access.allowed) {
return NextResponse.json<ErrorPayload>(
{ error: access.reason ?? "forbidden" },
{ status: 403 },
);
}
try {
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: SANDBOX_EMAIL }),
cache: "no-store",
});
const contentType = upstream.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
const text = await upstream.text().catch(() => "");
return NextResponse.json(
{
error: "upstream_non_json",
upstreamStatus: upstream.status,
upstreamBody: text.slice(0, 2048),
} as any,
{ status: 502 },
);
}
const payload = (await upstream.json().catch(() => null)) as any;
if (!payload || typeof payload.token !== "string") {
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL });
// Backup current root session token only if it's NOT already an assumed session.
// Check if the current user is NOT the sandbox user.
if (user.email.toLowerCase() !== SANDBOX_EMAIL) {
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: session.token,
httpOnly: true,
secure: secureCookies(),
sameSite: "lax",
path: "/",
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
});
}
// Switch main session to sandbox token.
applySessionCookie(
response,
payload.token,
deriveMaxAgeFromExpires(payload.expiresAt),
request.headers.get("host") ?? undefined,
);
return response;
} catch (error) {
console.error("Failed to assume sandbox", error);
return NextResponse.json<ErrorPayload>(
{ error: "upstream_unreachable" },
{ status: 502 },
);
}
}

View File

@ -0,0 +1,11 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
const ROOT_BACKUP_COOKIE = 'xc_session_root'
export async function GET(request: NextRequest) {
const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim())
return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' })
}

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