Compare commits

..

No commits in common. "main" and "codex/gateway-home-hero" have entirely different histories.

185 changed files with 4771 additions and 6733 deletions

View File

@ -1,15 +0,0 @@
.git
.github
.next
.contentlayer
node_modules
coverage
dist
build
test-results
*.log
.env
.env.local
.env.*.local
deploy/single-node/.env.runtime
knowledge/.git

View File

@ -1,35 +1,3 @@
# Frontend site base URLs
APP_BASE_URL=
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=
NEXT_RUNTIME_HOSTNAME=
DEPLOYMENT_HOSTNAME=
RUNTIME_ENV=prod
REGION=cn
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,12 +20,9 @@ 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=
CLOUDFLARE_ZONE_TAG=
# Root email whitelist for privileged user-creation actions (comma-separated)
# Default: admin@svc.plus
@ -65,7 +30,6 @@ ROOT_EMAIL_WHITELIST=admin@svc.plus
# Stripe public price ids used by /prices, product pages, and /panel/subscription
# These values are safe to expose to the browser. Use Stripe test-mode price ids for local/dev.
NEXT_PUBLIC_PAYPAL_CLIENT_ID=
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=

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,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,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@v4
- uses: docker/login-action@v3
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@v3
- 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@v6
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@v4
- 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@v4
- uses: docker/setup-buildx-action@v3
- 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@v3
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@v3
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@v3
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

2
.gitignore vendored
View File

@ -1,7 +1,6 @@
.env
models/
*.tsbuildinfo
knowledge/
# macOS
.DS_Store
@ -56,7 +55,6 @@ coverage/
.env
.env.local
.env.*.local
deploy/single-node/.env.runtime
# Build artifacts
build/

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

@ -4,27 +4,6 @@
ARG NODE_BUILDER_IMAGE=node:22-bookworm
ARG NODE_RUNTIME_IMAGE=node:22-slim
ARG CONTENTLAYER_BUILD=true
ARG NEXT_PUBLIC_APP_BASE_URL=
ARG NEXT_PUBLIC_SITE_URL=
ARG NEXT_PUBLIC_LOGIN_URL=
ARG NEXT_PUBLIC_DOCS_BASE_URL=
ARG NEXT_PUBLIC_RUNTIME_ENVIRONMENT=
ARG NEXT_PUBLIC_RUNTIME_REGION=
ARG NEXT_PUBLIC_GISCUS_REPO=
ARG NEXT_PUBLIC_GISCUS_REPO_ID=
ARG NEXT_PUBLIC_GISCUS_CATEGORY=
ARG NEXT_PUBLIC_GISCUS_CATEGORY_ID=
ARG NEXT_PUBLIC_PAYPAL_CLIENT_ID=
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
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)
@ -33,51 +12,8 @@ FROM ${NODE_BUILDER_IMAGE} AS builder
WORKDIR /app/dashboard
ARG NEXT_PUBLIC_APP_BASE_URL
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_LOGIN_URL
ARG NEXT_PUBLIC_DOCS_BASE_URL
ARG NEXT_PUBLIC_RUNTIME_ENVIRONMENT
ARG NEXT_PUBLIC_RUNTIME_REGION
ARG NEXT_PUBLIC_GISCUS_REPO
ARG NEXT_PUBLIC_GISCUS_REPO_ID
ARG NEXT_PUBLIC_GISCUS_CATEGORY
ARG NEXT_PUBLIC_GISCUS_CATEGORY_ID
ARG NEXT_PUBLIC_PAYPAL_CLIENT_ID
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION
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 \
NEXT_PUBLIC_APP_BASE_URL=${NEXT_PUBLIC_APP_BASE_URL} \
NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} \
NEXT_PUBLIC_LOGIN_URL=${NEXT_PUBLIC_LOGIN_URL} \
NEXT_PUBLIC_DOCS_BASE_URL=${NEXT_PUBLIC_DOCS_BASE_URL} \
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT} \
NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION} \
NEXT_PUBLIC_GISCUS_REPO=${NEXT_PUBLIC_GISCUS_REPO} \
NEXT_PUBLIC_GISCUS_REPO_ID=${NEXT_PUBLIC_GISCUS_REPO_ID} \
NEXT_PUBLIC_GISCUS_CATEGORY=${NEXT_PUBLIC_GISCUS_CATEGORY} \
NEXT_PUBLIC_GISCUS_CATEGORY_ID=${NEXT_PUBLIC_GISCUS_CATEGORY_ID} \
NEXT_PUBLIC_PAYPAL_CLIENT_ID=${NEXT_PUBLIC_PAYPAL_CLIENT_ID} \
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO} \
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION} \
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_PRIVATE_TURBOPACK=1
# ---------------------------
# 基础镜像升级到最新
@ -123,6 +59,7 @@ 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/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,54 +0,0 @@
# 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
# 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
SESSION_COOKIE_SECURE=true
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
RUNTIME_HOSTNAME=www.svc.plus
DEPLOYMENT_HOSTNAME=www.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn
# Upstream service URLs
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=
# Optional integrations
OPENCLAW_GATEWAY_REMOTE_URL=
OPENCLAW_GATEWAY_TOKEN=
VAULT_SERVER_URL=
VAULT_NAMESPACE=
VAULT_TOKEN=
APISIX_AI_GATEWAY_URL=
AI_GATEWAY_ACCESS_TOKEN=
INTERNAL_SERVICE_TOKEN=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
CLOUDFLARE_ZONE_TAG=
ROOT_EMAIL_WHITELIST=admin@svc.plus
NEXT_PUBLIC_PAYPAL_CLIENT_ID=
NEXT_PUBLIC_GISCUS_REPO=cloud-neutral-toolkit/console.svc.plus
NEXT_PUBLIC_GISCUS_REPO_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=General
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=

View File

@ -1,28 +0,0 @@
{$SERVED_DOMAINS} {
encode zstd gzip
handle_path /_next/static/* {
root * /srv
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
@public_assets {
file {
root /srv/public
try_files {path}
}
}
handle @public_assets {
root * /srv/public
header Cache-Control "public, max-age=3600"
file_server
}
reverse_proxy dashboard:3000 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}

View File

@ -1,55 +0,0 @@
services:
frontend-assets:
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE in .env.runtime}
restart: "no"
command:
- /bin/sh
- -c
- |
set -eu
rm -rf /assets/_next /assets/chunks /assets/public
mkdir -p /assets /assets/public
cp -R /app/dashboard/static/. /assets/
cp -R /app/dashboard/public/. /assets/public
volumes:
- frontend_static:/assets
dashboard:
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE in .env.runtime}
restart: unless-stopped
env_file:
- .env.runtime
environment:
NODE_ENV: production
PORT: 3000
volumes:
- frontend_static:/app/dashboard/.next/static:ro
networks:
- frontend
caddy:
image: caddy:2.10-alpine
restart: unless-stopped
depends_on:
- dashboard
ports:
- "80:80"
- "443:443"
environment:
SERVED_DOMAINS: ${SERVED_DOMAINS:?set SERVED_DOMAINS in .env.runtime}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- frontend_static:/srv:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
networks:
frontend:
driver: bridge
volumes:
frontend_static:
caddy_data:
caddy_config:

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

@ -1,42 +1,31 @@
# Deployment
## Production Baseline
This repository primarily delivers a web frontend experience and should document product flows, UI boundaries, and integration touchpoints.
- 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`
Use this page to standardize deployment prerequisites, supported topologies, operational checks, and rollback notes.
## Operating Model
## Current code-aligned notes
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.
- Documentation target: `console.svc.plus`
- Repo kind: `frontend`
- Manifest and build evidence: package.json (`dashboard`)
- Primary implementation and ops directories: `src/`, `scripts/`, `tests/`, `config/`, `public/`
- Package scripts snapshot: `dev`, `prebuild`, `build`, `build:static`, `start`, `lint`
`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`.
## Existing docs to reconcile
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.
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.
The release contract now uses:
- `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.
## Related Docs
- `usage/deployment.md`
- `governance/release-process.md`
- `development/dev-setup.md`
- `getting-started/installation.md`
- `getting-started/quickstart.md`
- `governance/release-process.md`
- `operations/runbooks/README.md`
- `operations/runbooks/rag-server.md`
- `usage/deployment.md`
- `zh/development/dev-setup.md`
## What this page should cover next
- Describe the current implementation rather than an aspirational future-only design.
- Keep terminology aligned with the repository root README, manifests, and actual directories.
- Link deeper runbooks, specs, or subsystem notes from the legacy docs listed above.
- Verify deployment steps against current scripts, manifests, CI/CD flow, and environment contracts before each release.

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

@ -1,281 +0,0 @@
# Console Frontend Single-Node Deployment Design
## Scope
- Repository: `console.svc.plus`
- Target host: `root@cn-console.svc.plus`
- Public domains:
- `cn-console.svc.plus`
- `cn-console.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.
## Objective
Provide an independent frontend deployment pipeline for `console.svc.plus` that fits the current host constraints:
- the host IO is weak
- the host must not build Docker images locally
- the frontend should run in a static-first mode where possible
- deployment logic should stay in checked-in scripts, not be embedded in GitHub Actions YAML
The result should support repeatable releases, quick rollback by image tag, and minimal work on the target machine.
## Constraints
### Host constraints
- `cn-console.svc.plus` 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
### Application constraints
- `console.svc.plus` is not a purely static site
- auth routes, same-origin API proxy routes, and selected dynamic pages still require a running Next.js server
- some `NEXT_PUBLIC_*` variables are compiled into the frontend bundle at image build time
- `prebuild` pulls documentation and `knowledge` content, so CI must prepare those inputs before building the image
### Repository constraints
- workflow YAML should remain orchestration-only
- service-local operational notes should remain in this repo
- downstream control repos can reference this repo through submodule updates after upstream changes are pushed
## Recommended Topology
### 1. CI build on GitHub Actions
The workflow builds a single `linux/amd64` image in GitHub Actions and pushes it to GHCR.
Reasons:
- matches the target host architecture
- avoids multi-arch overhead for this single-node release path
- avoids local host build IO and CPU pressure
- keeps release artifacts immutable and rollback-friendly
### 2. Runtime on the host
Use `docker compose` with three services:
- `dashboard`: Next.js standalone runtime
- `frontend-assets`: one-shot container that copies static files from the image into a Docker volume
- `caddy`: TLS termination, redirect handling, static file serving, and reverse proxy
This keeps the host work limited to:
- image pull
- asset extraction from the image
- container restart
### 3. Static-first request flow
Caddy serves:
- `/_next/static/*`
- checked-in `public/` assets
Next.js serves:
- HTML responses
- `/api/*` routes
- auth/session flows
- dynamic pages that still depend on server runtime
This reduces repeat disk reads and network hops for the bulk of frontend traffic while preserving the dynamic behavior the app still needs.
## Build-Time vs Runtime Configuration
### Build-time config
These values must be available during Docker build because the frontend bundle reads them directly:
- `NEXT_PUBLIC_APP_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
- `NEXT_PUBLIC_LOGIN_URL`
- `NEXT_PUBLIC_DOCS_BASE_URL`
- `NEXT_PUBLIC_RUNTIME_ENVIRONMENT`
- `NEXT_PUBLIC_RUNTIME_REGION`
- `NEXT_PUBLIC_GISCUS_*`
- `NEXT_PUBLIC_PAYPAL_CLIENT_ID`
- `NEXT_PUBLIC_STRIPE_*`
These are injected in GitHub Actions as Docker build args.
### Runtime config
These values are rendered into `.env.runtime` and copied to the host:
- upstream service URLs such as `ACCOUNT_SERVICE_URL`
- tokens used only on the server side
- Cloudflare analytics credentials
- internal service token
- runtime hostname hints
This separation avoids rebuilding for purely server-side secret or endpoint changes when the public frontend bundle does not change.
## Knowledge and Docs Handling
Current decision:
- `knowledge/` is cloned during CI
- the cloned content is included in the image build context
- the built image contains the resulting content needed by the current frontend
Reason:
- `prebuild` depends on this material
- the host should not fetch or generate content during deployment
Temporary nature:
- today the frontend still carries docs-related payload
- later, when `docs.svc.plus` becomes an API/service, docs delivery should move out of the frontend image
- that future change should reduce image size and simplify the runtime responsibilities of `console.svc.plus`
## Domain Handling
Primary domain:
- `cn-console.svc.plus`
Secondary domain:
- `cn-console.onwalk.net`
Current routing decision:
- Caddy accepts both domains
- requests for `cn-console.onwalk.net` are redirected permanently to `cn-console.svc.plus`
Reason:
- avoid duplicate canonical origins
- keep cookie and login behavior centered on one primary host
- simplify SEO and observability interpretation
## Release Workflow
### Trigger
Independent workflow:
- `.github/workflows/service_release_frontend-deploy.yml`
### Steps
1. check out repository
2. clone `knowledge`
3. build and push `ghcr.io/<owner>/dashboard:<tag>`
4. render `.env.runtime`
5. upload compose/caddy/env files to the host
6. log in to GHCR on the host
7. pull the new image
8. run `frontend-assets`
9. start or refresh `dashboard` and `caddy`
10. verify both domains
### Why separate from the existing image workflow
The existing image workflow is broader and oriented toward generic image publishing. This single-node frontend workflow needs tighter control over:
- build-time public env injection
- production deployment sequencing
- SSH-based single-host rollout
- host-specific runtime file rendering
So the frontend release path should remain explicit and independent.
## Rollback Model
Rollback unit:
- image tag reference in `.env.runtime`
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`
This avoids rebuilding and keeps rollback cheap on the weak-IO host.
## Security and Secret Handling
Secrets must not be committed to the repo. The workflow should consume:
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`
- service tokens
- vault tokens
- internal service token
- optional Cloudflare credentials
Public defaults and non-secret values belong in checked-in examples or GitHub repository/environment variables. Secret-only values stay in GitHub Secrets and are rendered into the host runtime env during deployment.
## Operational Risks
### Risk 1: build-time public env mismatch
If GitHub environment variables are incomplete, the image may build successfully but the frontend can render wrong links or lose third-party integration IDs.
Mitigation:
- keep `.env.example` aligned
- document required GitHub `vars`
- keep the build args list explicit
### Risk 2: image layout drift
If the Docker image no longer contains `/app/dashboard/static` or `/app/dashboard/public`, the `frontend-assets` step fails.
Mitigation:
- keep asset extraction paths documented
- update deploy scripts whenever Dockerfile output layout changes
### Risk 3: docs payload growth
Bundling docs and `knowledge` into the frontend image increases image size.
Mitigation:
- accept it temporarily
- revisit once `docs.svc.plus` is externalized
### Risk 4: single-node blast radius
The host handles both reverse proxy and app runtime. Misconfiguration affects the whole frontend surface.
Mitigation:
- keep compose simple
- keep Caddy config minimal
- use image-tag rollback
## Future Follow-Up
### Near term
- populate required GitHub `vars` and `secrets`
- run the workflow against `root@cn-console.svc.plus`
- validate DNS, TLS, static assets, login flow, and upstream API proxy behavior
### Later
- move docs delivery out of the frontend image after `docs.svc.plus` is service/API based
- consider splitting static assets to object storage or CDN if traffic grows
- evaluate whether the host should keep only Caddy plus one app container, or whether docs can be removed entirely from this runtime
## Source of Truth Rule
For this deployment design:
- upstream repo source of truth: `console.svc.plus`
- service-local design note location: `docs/plans/`
- control-plane repo role: consume via git submodule after upstream commit is pushed
Do not move the primary design ownership to the control-plane repository.

View File

@ -1,5 +0,0 @@
# Plans
This directory stores service-local design notes and implementation plans for `console.svc.plus`.
The source of truth stays in this upstream repository. Control-plane repositories may reference these documents through git submodule updates after upstream changes are pushed.

View File

@ -3,152 +3,146 @@
## Scope
- Runtime: `console.svc.plus`
- Topology: `Caddy + Docker Compose + GitHub Actions`
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
- Public domains:
- `https://www.svc.plus`
- `https://console.svc.plus`
- Canonical public origin: `https://www.svc.plus`
- Frontend host: Vercel
- Edge: Cloudflare
- Auth backend: `https://accounts.svc.plus`
## Current Delivery Model
This runbook is the minimum checklist for production incidents where login or MFA stops working and browser devtools show `/api/auth/login` or `/api/auth/mfa/*` failures.
The production frontend is deployed as a prebuilt container image from GitHub Actions.
## Expected Request Flow
- 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.
- Static assets are extracted from the image into a shared Docker volume so Caddy can serve `/_next/static/*` and checked-in public files directly.
1. Browser loads `https://console.svc.plus/login`
2. Browser calls same-origin Next routes on `console.svc.plus`
3. Next route proxies server-side to `https://accounts.svc.plus/api/auth/*`
4. `accounts.svc.plus` returns either a session token or an MFA challenge
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`.
The browser should not call `accounts.svc.plus` directly for login.
## Control Plane & DNS Stage
## Fast Triage
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`.
## Runtime Layout
Remote directory:
Run these checks first:
```bash
/opt/console-svc-plus
curl -si https://console.svc.plus/login | sed -n '1,20p'
curl -si https://console.svc.plus/api/auth/login | sed -n '1,20p'
curl -si https://accounts.svc.plus/healthz | sed -n '1,20p'
curl -si https://accounts.svc.plus/api/auth/login | sed -n '1,20p'
```
Files deployed there:
Interpretation:
```bash
docker-compose.yml
Caddyfile
.env.runtime
```
- `console.svc.plus` returns `403` with `cf-mitigated: challenge`
Cloudflare is blocking the page or auth API before Vercel sees it.
- `console.svc.plus/api/auth/login` returns `404`
Vercel production is not serving the expected Next route, or Cloudflare is pointing at the wrong origin/deployment behavior.
- `accounts.svc.plus/healthz` fails
Back-end outage. Fix backend first.
- `accounts.svc.plus/api/auth/login` returns `200` with `mfaRequired`
Backend is healthy; continue on console/Vercel/Cloudflare.
Containers:
## Application Checks
- `dashboard`: Next.js standalone runtime on port `3000`
- `frontend-assets`: one-shot task that copies `static/` and `public/` into a shared volume
- `caddy`: TLS termination and reverse proxy
## GitHub Actions Inputs
Workflow:
```text
.github/workflows/pipeline.yaml
```
Secrets required:
- `SINGLE_NODE_VPS_SSH_PRIVATE_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
Repository/environment variables recommended:
- `CANONICAL_DOMAIN`
- `SERVED_DOMAINS`
- `APP_BASE_URL`
- `NEXT_PUBLIC_APP_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
- `NEXT_PUBLIC_LOGIN_URL`
- `NEXT_PUBLIC_DOCS_BASE_URL`
- `ACCOUNT_SERVICE_URL`
- `NEXT_PUBLIC_ACCOUNT_SERVICE_URL`
- `SERVER_SERVICE_URL`
- `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.
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.
## Verification Commands
Local syntax checks:
Verify the current build still contains the auth routes:
```bash
cd /Users/shenlan/workspaces/cloud-neutral-toolkit/console.svc.plus
bash -n scripts/github-actions/render-frontend-runtime-env.sh
bash -n scripts/github-actions/deploy-frontend-single-node.sh
cp deploy/single-node/.env.runtime.example deploy/single-node/.env.runtime
docker compose -f deploy/single-node/docker-compose.yml --env-file deploy/single-node/.env.runtime config >/tmp/console-compose.rendered.yaml
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())
print('workflow yaml ok')
PY
yarn build
cat .next/app-path-routes-manifest.json | jq 'with_entries(select(.key|test("/api/auth/")))'
```
Remote checks:
Verify the login page still uses same-origin routes:
```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
nl -ba 'src/app/(auth)/login/LoginForm.tsx' | sed -n '64,180p'
nl -ba 'src/app/api/auth/login/route.ts' | sed -n '1,180p'
nl -ba 'src/app/api/auth/mfa/verify/route.ts' | sed -n '1,180p'
```
## Failure Signatures
Expected behavior:
- `docker login ghcr.io` fails
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`
Caddy is up, but the `dashboard` container failed or is not reachable on port `3000`.
- `LoginForm` posts to `/api/auth/login`
- login proxy accepts backend `mfaRequired` / `mfaTicket`
- MFA verify proxy calls `/api/auth/mfa/verify`
## Rollback
## Vercel Checks
1. Re-run the workflow with a previous known-good image tag.
2. Or update `/opt/console-svc-plus/.env.runtime` and set `FRONTEND_IMAGE=ghcr.io/<owner>/dashboard:<previous-tag>`.
3. Restart the services:
In the Vercel project for `console-svc-plus`, verify:
1. The production deployment corresponds to the intended git commit.
2. Framework preset is `Next.js`.
3. Build command is `yarn build` or the project default, not a static export command.
4. Output is not being overridden to static export.
5. Production Functions include `app/api/auth/login` and the other `app/api/auth/*` handlers.
6. Required runtime env vars are present for the auth proxy path if they are managed in Vercel.
If the route exists locally but Vercel returns `404`, suspect:
- wrong production deployment selected
- wrong root directory/project link
- stale alias or domain assignment
- build output mismatch between local and Vercel
## Cloudflare Checks
If `curl` shows `cf-mitigated: challenge`, check Cloudflare first.
Look for:
1. Managed Challenge or WAF custom rules affecting `/login`
2. Managed Challenge or WAF custom rules affecting `/api/auth/*`
3. Bot Fight Mode or Super Bot Fight Mode interactions
4. Transform/redirect/cache rules that alter `/api/auth/*`
5. Page Rules or Ruleset Engine policies applied only to the production hostname
Recommended policy for auth API:
- Do not cache `/api/auth/*`
- Do not apply JS challenge to `/api/auth/*`
- Keep standard security headers, but let requests reach Vercel
## Backend Verification
Use the backend directly to prove whether auth is healthy:
```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"
cd /Users/shenlan/workspaces/cloud-neutral-toolkit/accounts.svc.plus
set -a; source .env; set +a
payload=$(printf '{"identifier":"admin@svc.plus","password":"%s"}' "$SUPERADMIN_PASSWORD")
curl -sS -X POST https://accounts.svc.plus/api/auth/login \
-H 'Content-Type: application/json' \
-d "$payload"
```
4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident.
Expected for an MFA-enabled admin:
- HTTP `200`
- response contains `mfaRequired`
- response contains `mfaTicket` or `mfaToken`
## Known Failure Signatures
- `POST https://console.svc.plus/api/auth/login 404`
Likely Vercel deployment mismatch or route not published.
- `403` with `cf-mitigated: challenge`
Cloudflare blocked request before Vercel.
- login returns generic failure even though backend returns MFA challenge
Console auth proxy is not parsing MFA fields correctly.
- MFA code accepted by authenticator but web login still fails
Console proxy may be calling the setup endpoint instead of the login MFA endpoint.
## Rollback Strategy
When a release breaks auth:
1. Remove or relax Cloudflare rules affecting `/login` and `/api/auth/*`
2. Re-point domain to last known-good Vercel production deployment
3. Roll back `console.svc.plus`
4. Only then consider `accounts.svc.plus` rollback
## Related Files
- `src/app/(auth)/login/LoginForm.tsx`
- `src/app/api/auth/login/route.ts`
- `src/app/api/auth/mfa/status/route.ts`
- `src/app/api/auth/mfa/verify/route.ts`
- `src/server/serviceConfig.ts`

View File

@ -1,42 +1,31 @@
# 部署
## 生产基线
该仓库以 Web 前端体验为主,文档需要覆盖产品流程、界面边界与集成触点。
- 运行拓扑: `Caddy + Docker Compose`
- 目标主机: `root@jp-xhttp-contabo.svc.plus`
- 域名:
- `www.svc.plus`
- `console.svc.plus`
- 公开首选域名: `www.svc.plus`
- 前端独立发布流水线: `.github/workflows/pipeline.yaml`
本页用于统一部署前提、支持的拓扑、运维检查项与回滚注意事项。
## 运行方式
## 与当前代码对齐的说明
前端镜像在 GitHub Actions 中完成构建并推送到镜像仓库,目标主机只负责拉取镜像和启动容器,不在机器上本地构建。
- 文档目标仓库: `console.svc.plus`
- 仓库类型: `frontend`
- 构建与运行依据: package.json (`dashboard`)
- 主要实现与运维目录: `src/`, `scripts/`, `tests/`, `config/`, `public/`
- `package.json` 脚本快照: `dev`, `prebuild`, `build`, `build:static`, `start`, `lint`
`yarn prebuild` 现在只生成 console 自己的营销内容工件。`/docs` 与 `/blogs` 不再把 `knowledge/` 或 Markdown 文档打进前端镜像,而是在请求时通过服务端 `docsServiceClient``docs.svc.plus` 拉取渲染后的内容。
## 需要继续归并的现有文档
当前方案尽量以静态模式运行:
- Caddy 直接服务 `/_next/static/*``public/` 里的静态资源,并配合 `frontend_static` 共享卷。
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static`
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。
发布由 `.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` 承载更新后的环境。
这是针对弱 IO 单机主机 `root@jp-xhttp-contabo.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 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`
## 相关文档
- `usage/deployment.md`
- `governance/release-process.md`
- `development/dev-setup.md`
- `getting-started/installation.md`
- `getting-started/quickstart.md`
- `governance/release-process.md`
- `operations/runbooks/README.md`
- `operations/runbooks/rag-server.md`
- `usage/deployment.md`
- `zh/development/dev-setup.md`
## 本页下一步应补充的内容
- 先描述当前已落地实现,再补充未来规划,避免只写愿景不写现状。
- 术语需要与仓库根 README、构建清单和实际目录保持一致。
- 将上方列出的历史 runbook、spec、子系统说明逐步链接并归并到本页。
- 每次发布前依据当前脚本、清单、CI/CD 流程和环境契约重新核对部署步骤。

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

@ -1,23 +0,0 @@
#!/usr/bin/env bash
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}"
fi
GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}"
GHCR_REGISTRY="${GHCR_REGISTRY:-ghcr.io}"
if [[ -z "${GITHUB_OUTPUT-}" ]]; then
echo "GITHUB_OUTPUT is not set" >&2
exit 1
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}"
} >> "${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

@ -1,227 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
DEPLOY_SOURCE_DIR="${REPO_ROOT}/deploy/single-node"
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
require_env DEPLOY_HOST
require_env DEPLOY_USER
require_env DEPLOY_DIR
require_env SINGLE_NODE_VPS_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
}
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "${WORK_DIR}"' EXIT
RUNTIME_ENV_FILE="${WORK_DIR}/.env.runtime"
RELEASE_ARCHIVE="${WORK_DIR}/console-svc-plus-release.tgz"
REMOTE_ARCHIVE="/tmp/console-svc-plus-release-${GITHUB_SHA:-manual}.tgz"
SSH_KEY_FILE="${WORK_DIR}/deploy.key"
KNOWN_HOSTS_FILE="${WORK_DIR}/known_hosts"
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}"
chmod 600 "${SSH_KEY_FILE}"
ssh-keyscan -H "${DEPLOY_HOST}" > "${KNOWN_HOSTS_FILE}"
SSH_BASE=(
ssh
-i "${SSH_KEY_FILE}"
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="${KNOWN_HOSTS_FILE}"
"${DEPLOY_USER}@${DEPLOY_HOST}"
)
SCP_BASE=(
scp
-i "${SSH_KEY_FILE}"
-o StrictHostKeyChecking=yes
-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
"${SCP_BASE[@]}" "${RELEASE_ARCHIVE}" "${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_ARCHIVE}"
"${SSH_BASE[@]}" \
"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}"
install -m 0644 "${tmp_dir}/docker-compose.yml" "${DEPLOY_DIR}/docker-compose.yml"
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
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

@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTPUT_PATH="${1:?usage: render-frontend-runtime-env.sh <output-path>}"
mkdir -p "$(dirname "${OUTPUT_PATH}")"
: > "${OUTPUT_PATH}"
append_env() {
local key="$1"
local value="${2-}"
printf '%s=%s\n' "${key}" "${value}" >> "${OUTPUT_PATH}"
}
require_env() {
local key="$1"
local value="${!key-}"
if [[ -z "${value}" ]]; then
echo "Missing required environment variable: ${key}" >&2
exit 1
fi
}
require_env FRONTEND_IMAGE
require_env CANONICAL_DOMAIN
require_env SERVED_DOMAINS
append_env FRONTEND_IMAGE "${FRONTEND_IMAGE}"
append_env CANONICAL_DOMAIN "${CANONICAL_DOMAIN}"
append_env SERVED_DOMAINS "${SERVED_DOMAINS}"
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 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 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}"
append_env NEXT_PUBLIC_ACCOUNT_SERVICE_URL "${NEXT_PUBLIC_ACCOUNT_SERVICE_URL:-${ACCOUNT_SERVICE_URL:-https://accounts.svc.plus}}"
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-}"
append_env VAULT_NAMESPACE "${VAULT_NAMESPACE-}"
append_env VAULT_TOKEN "${VAULT_TOKEN-}"
append_env APISIX_AI_GATEWAY_URL "${APISIX_AI_GATEWAY_URL-}"
append_env AI_GATEWAY_ACCESS_TOKEN "${AI_GATEWAY_ACCESS_TOKEN-}"
append_env INTERNAL_SERVICE_TOKEN "${INTERNAL_SERVICE_TOKEN-}"
append_env CLOUDFLARE_API_TOKEN "${CLOUDFLARE_API_TOKEN-}"
append_env CLOUDFLARE_ACCOUNT_ID "${CLOUDFLARE_ACCOUNT_ID-}"
append_env CLOUDFLARE_WEB_ANALYTICS_SITE_TAG "${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG-}"
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_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-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO-}"
append_env NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION-}"

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' : '' })
}

View File

@ -0,0 +1,56 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession } from '@server/account/session'
import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const canUseInternalToken = isServiceTokenConfigured()
if (!session.token && !canUseInternalToken) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
try {
const headers = session.token
? new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
: buildInternalServiceHeaders({
Accept: 'application/json',
})
const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, {
method: 'GET',
headers,
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 (public)', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -1,76 +0,0 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_BRIDGE_SERVER_URL = "https://xworkmate-bridge.svc.plus";
function bridgeServerUrl(): string {
return (
process.env.BRIDGE_SERVER_URL?.trim().replace(/\/+$/, "") ||
DEFAULT_BRIDGE_SERVER_URL
);
}
function bridgeAuthToken(): string {
return process.env.BRIDGE_AUTH_TOKEN?.trim() ?? "";
}
function jsonError(message: string, status = 500): Response {
return Response.json({ error: { message } }, { status });
}
function bridgeHeaders(): HeadersInit {
const token = bridgeAuthToken();
return {
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function GET(request: NextRequest): Promise<Response> {
const action = request.nextUrl.searchParams.get("action") ?? "ping";
if (action !== "ping") {
return jsonError("Unsupported xworkmate bridge action.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/api/ping`, {
cache: "no-store",
headers: bridgeHeaders(),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}
export async function POST(request: NextRequest): Promise<Response> {
let payload: unknown;
try {
payload = await request.json();
} catch {
return jsonError("Invalid JSON body.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/acp/rpc`, {
method: "POST",
cache: "no-store",
headers: bridgeHeaders(),
body: JSON.stringify(payload),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}

View File

@ -6,7 +6,8 @@ import { notFound } from "next/navigation";
import BrandCTA from "@components/BrandCTA";
import { PublicPageShell } from "@/components/public/PublicPageShell";
import { getBlogPost } from "@lib/docsServiceClient";
import { getBlogPostBySlug } from "@lib/blogContent";
import { renderMarkdownContent } from "@server/render-markdown";
type PageProps = {
params: Promise<{ slug: string | string[] }>;
@ -37,12 +38,7 @@ export async function generateMetadata({
const slugPath = Array.isArray(slugParam.slug)
? slugParam.slug.join("/")
: slugParam.slug;
let post;
try {
post = await getBlogPost(slugPath);
} catch {
post = undefined;
}
const post = await getBlogPostBySlug(slugPath);
if (!post) {
return { title: "Blog Post | Cloud-Neutral" };
@ -59,19 +55,15 @@ export default async function BlogPostPage({ params }: PageProps) {
const slugPath = Array.isArray(slugParam.slug)
? slugParam.slug.join("/")
: slugParam.slug;
let post;
try {
post = await getBlogPost(slugPath);
} catch {
post = undefined;
}
const post = await getBlogPostBySlug(slugPath);
if (!post) {
notFound();
}
const html = renderMarkdownContent(post.content);
const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(
`${post.title} ${post.excerpt} ${post.plaintext ?? ""}`,
`${post.title} ${post.content}`,
)
? "zh"
: "en";
@ -131,7 +123,7 @@ export default async function BlogPostPage({ params }: PageProps) {
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-8">
<article
className="public-doc-prose"
dangerouslySetInnerHTML={{ __html: post.html }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>

View File

@ -1,4 +1,4 @@
export const dynamic = "force-dynamic";
export const dynamic = "error";
export const revalidate = false;
import type { Metadata } from "next";
@ -6,7 +6,8 @@ import { Suspense } from "react";
import BlogList from "@components/blog/BlogList";
import { PublicPageShell } from "@/components/public/PublicPageShell";
import { getBlogList } from "@lib/docsServiceClient";
import type { BlogCategory, BlogPostSummary } from "@lib/blogContent";
import { getBlogCategories, getBlogPosts } from "@lib/blogContent";
export const metadata: Metadata = {
title: "Blog | Cloud-Neutral",
@ -15,16 +16,10 @@ export const metadata: Metadata = {
};
export default async function BlogPage() {
const listing = await getBlogList({ page: 1, pageSize: 200 });
const categories = listing.categories;
const postsWithoutContent = listing.posts.map(
({
html: _html,
plaintext: _plaintext,
sourcePath: _sourcePath,
language: _language,
...post
}) => post,
const posts = await getBlogPosts();
const categories: BlogCategory[] = await getBlogCategories();
const postsWithoutContent: BlogPostSummary[] = posts.map(
({ content: _content, ...post }) => post,
);
return (

View File

@ -1,4 +1,4 @@
export const dynamic = "force-dynamic";
export const dynamic = "error";
export const revalidate = false;
import type { Metadata } from "next";
@ -6,6 +6,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import DocArticle from "@/components/doc/DocArticle";
import DocMetaPanel from "@/components/doc/DocMetaPanel";
import { PublicPageIntro } from "@/components/public/PublicPageShell";
import { isFeatureEnabled } from "@lib/featureToggles";
@ -41,6 +42,14 @@ function DocsBreadcrumbs({
);
}
export const generateStaticParams = async () => {
if (!isFeatureEnabled("appModules", "/docs")) {
return [];
}
return getDocVersionParams();
};
export const dynamicParams = false;
export async function generateMetadata({
@ -100,10 +109,7 @@ export default async function DocVersionPage({
</section>
<section className="rounded-[1rem] border border-slate-900/8 bg-white/90 p-5 shadow-[var(--shadow-soft)] lg:p-6">
<article
className="public-doc-prose"
dangerouslySetInnerHTML={{ __html: version.html }}
/>
<DocArticle content={version.content} />
</section>
<Feedback />

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