Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61968a6460 | ||
|
|
4186f47a31 | ||
|
|
4ed6cf6c7e | ||
|
|
2bdbdabe55 | ||
|
|
1a589b6d7e | ||
|
|
bec350b24e | ||
|
|
0be9ea3657 | ||
|
|
a2a9e5b719 | ||
|
|
ceb9019095 | ||
|
|
82e141fd35 | ||
|
|
02228a642a | ||
|
|
130b685e06 | ||
|
|
6817988ac8 | ||
|
|
648779e021 | ||
|
|
3f017a3618 | ||
|
|
e4aa4f3549 | ||
|
|
0e569c24e5 | ||
|
|
3cb6ed21ad | ||
|
|
62ad8a4379 | ||
|
|
21e8f967d8 | ||
|
|
a9209b2566 | ||
|
|
09ce3041a5 | ||
|
|
9b8b3e6f2c | ||
|
|
06487f469e | ||
|
|
b5f8a68a4b | ||
|
|
e7052befbd | ||
|
|
13a95428b1 | ||
|
|
294ed0debb | ||
|
|
0a9055e745 | ||
|
|
37f0495ed7 | ||
|
|
a8a445ab0f | ||
|
|
6fe6e03ba2 | ||
|
|
ec6b87ee37 | ||
|
|
6ce8fe25ee | ||
|
|
92e3ce3b52 | ||
|
|
72c0e99b15 | ||
|
|
438b7d6576 | ||
|
|
8ed6439abb | ||
|
|
e24869c3fc | ||
|
|
ab5aa0550d | ||
|
|
1399fa825b | ||
|
|
cc53ef6d64 | ||
|
|
19daa14d3e | ||
|
|
bc59ea6b8b | ||
|
|
6802c5bb75 | ||
|
|
6ad804daa3 | ||
|
|
c06e27bc32 | ||
|
|
963e852e9d | ||
|
|
c5988b1100 | ||
|
|
976eab1f24 | ||
|
|
f154c27374 | ||
|
|
60779291e2 | ||
|
|
c0c961bc51 | ||
|
|
623e1941c1 | ||
|
|
2b2a1ae5a1 | ||
|
|
6313780dc6 | ||
|
|
778ad107d8 | ||
|
|
490c941819 | ||
|
|
c24268f88c | ||
|
|
db393cff4b | ||
|
|
88f5a18fdd | ||
|
|
197b3217da | ||
|
|
00b01f6960 | ||
|
|
be1a96f05c | ||
|
|
d11a1b7bb4 | ||
|
|
bdaa003fdf | ||
|
|
adafd97b61 | ||
|
|
680da6a452 | ||
|
|
fad2fb2073 | ||
|
|
6752ae435c | ||
|
|
5346d86df9 | ||
|
|
e33cb74416 | ||
|
|
e854502330 | ||
|
|
a33299f7d3 | ||
|
|
8a36bf793a | ||
|
|
bd8a816519 | ||
|
|
96721b78a1 | ||
|
|
57e61c9ec0 | ||
|
|
cccea4db87 | ||
|
|
f7ac350bf7 | ||
|
|
aa6483ae91 | ||
|
|
f38c9a4567 | ||
|
|
7e0fe859c9 | ||
|
|
108f025678 | ||
|
|
465affa237 | ||
|
|
7225f4274a | ||
|
|
98500bb012 | ||
|
|
c3f0694b84 | ||
|
|
5f24718b83 | ||
|
|
e760caf76a | ||
|
|
1e17f0383b | ||
|
|
013d25b519 | ||
|
|
5fdd8b7ad8 | ||
|
|
cb153b3b9c | ||
|
|
27dc1ff860 | ||
|
|
f440bdc859 | ||
|
|
b3e67226a8 | ||
|
|
4d8bec1154 | ||
|
|
260deb6d79 | ||
|
|
87d3d84fc6 | ||
|
|
1597f34870 | ||
|
|
b64b4b6b7f | ||
|
|
c9e72dd52a | ||
|
|
22ea663bf3 | ||
|
|
66abdb9d51 | ||
|
|
378ccbecd9 | ||
|
|
7dfe32fa0e | ||
|
|
bd6e583cb6 | ||
|
|
fbe44a937f | ||
|
|
c742e107e5 | ||
|
|
dd2c96fe14 | ||
|
|
2fdb6b87c6 | ||
|
|
b97e2ba263 | ||
|
|
b9f570db74 | ||
|
|
b8fb513a25 | ||
|
|
ae3a9418dc | ||
|
|
06d563b32e | ||
|
|
0468e5d5f3 | ||
|
|
84bea92dfa | ||
|
|
467605c2fd | ||
|
|
a132fd0c66 | ||
|
|
cce2646d30 | ||
|
|
6f5bced5b9 | ||
|
|
d8e898f0a6 | ||
|
|
fa88ea4c64 | ||
|
|
62cb43944d | ||
|
|
63c45abda0 | ||
|
|
dceeecbafb | ||
|
|
a571f9f4ae | ||
|
|
34c4161048 | ||
|
|
166f927c66 | ||
|
|
848f7e2f90 | ||
|
|
8e39b62167 | ||
|
|
f21cb0a5df | ||
|
|
5baeb5b114 | ||
|
|
7e5e065d93 | ||
|
|
6a401479ad | ||
|
|
267d512875 | ||
|
|
d34ff8eb1c | ||
|
|
cf30e199b9 | ||
|
|
8604da5eac | ||
|
|
8d13556517 | ||
|
|
c69b67835c | ||
|
|
6f3ff57ce8 | ||
|
|
c1c9df9a05 | ||
|
|
acb72dd6be | ||
|
|
5c7e476f61 | ||
|
|
8d5766062b | ||
|
|
09c481e0ac | ||
|
|
3923cf2fe0 | ||
|
|
f5b24fe295 | ||
|
|
cf653a87b2 | ||
|
|
e1e19059b7 | ||
|
|
52102a9abb | ||
|
|
414792c55e | ||
|
|
df0ce10be3 | ||
|
|
2dc4169a7f | ||
|
|
855a290c69 | ||
|
|
ed34d24c47 | ||
|
|
354c5b3f41 | ||
|
|
793a4f31d1 | ||
|
|
456e417589 | ||
|
|
0624f8055a | ||
|
|
c40e33e5c0 | ||
|
|
5eb511b896 | ||
|
|
09c2ab7ce9 | ||
|
|
b3c4376834 | ||
|
|
f8415b116b | ||
|
|
1127f85100 | ||
|
|
ede309ed2f | ||
|
|
1e1c6b37d8 | ||
|
|
1515df9802 | ||
|
|
0806575a96 | ||
|
|
d4526fb854 | ||
|
|
7bbe95038c | ||
|
|
643ad2e8c8 | ||
|
|
ac2faaaa82 | ||
|
|
1beb6fca9b | ||
|
|
d750e83aca | ||
|
|
db1648dff4 | ||
|
|
ecf06ae606 | ||
|
|
5486885b69 | ||
|
|
8f10ccb5ad | ||
|
|
ff2aa1eb87 | ||
|
|
a2265dd34e | ||
|
|
0db1e9db64 | ||
|
|
d25ddcc5e2 | ||
|
|
fdb45e6dc5 | ||
|
|
b96db9c320 | ||
|
|
eebb3b9560 | ||
|
|
adc0e9a55b | ||
|
|
f7880f1831 | ||
|
|
a3f194de5f | ||
|
|
becdc3c597 | ||
|
|
6518808537 | ||
|
|
0a26473b52 | ||
|
|
a98db5eb2e | ||
|
|
972a3e70b1 | ||
|
|
c15fdc48f4 | ||
|
|
aaecae8c8d | ||
|
|
821cb266ad | ||
|
|
9d3fa34cb2 | ||
|
|
c7241900a4 | ||
|
|
d3d1690d87 | ||
|
|
f122b5bdc9 | ||
|
|
26b4a02921 | ||
|
|
5d031fb55f | ||
|
|
afeed80c01 | ||
|
|
86aafd1fb5 | ||
|
|
9da0dbe492 | ||
|
|
6174ea4d73 | ||
|
|
066baf1977 | ||
|
|
ee980c8fba | ||
|
|
effcc7fc13 | ||
|
|
af33afa321 | ||
|
|
322ca05e40 | ||
|
|
ee2638c777 | ||
|
|
24260390c4 | ||
|
|
6275707e57 | ||
|
|
893f9a846e | ||
|
|
5ea2efc881 | ||
|
|
10030b9001 | ||
|
|
2c564cf698 | ||
|
|
9a1f0ba32f | ||
|
|
70b38c0b93 | ||
|
|
c5ca7fb1f2 | ||
|
|
8f96a115a9 | ||
|
|
fc4c48bc32 | ||
|
|
b8a088d797 | ||
|
|
9f916dd52e | ||
|
|
de14e749b2 | ||
|
|
b25b0005c1 | ||
|
|
4fb1c08fc1 | ||
|
|
f92fa2cb57 | ||
|
|
eacffd81f6 | ||
|
|
6eed04b972 | ||
|
|
ace1bf12db | ||
|
|
fdce8b3253 | ||
|
|
67e0bde60f | ||
|
|
08d3d233e6 | ||
|
|
659a0231aa | ||
|
|
b2bd4f5152 | ||
|
|
70a4053749 | ||
|
|
7fcedda84b | ||
|
|
2d777c7cd6 | ||
|
|
ef3a2d087c | ||
|
|
18269d908b | ||
|
|
2df1ebd052 | ||
|
|
d8c77eeb5c | ||
|
|
cb44d80dea | ||
|
|
0ccc964850 | ||
|
|
23ec09b338 | ||
|
|
48de8e18bd | ||
|
|
d0bd4390c4 | ||
|
|
5d68f27825 | ||
|
|
686b017474 | ||
|
|
abb87e5d0e | ||
|
|
0c822f9aa0 | ||
|
|
f16987b298 | ||
|
|
fd1fcb73ab | ||
|
|
a2d2d1fa9b | ||
|
|
909514e4cc | ||
|
|
d052760732 | ||
|
|
063c5700cb | ||
|
|
7afda87461 | ||
|
|
65c7bf6a3e | ||
|
|
c0eb0011b5 | ||
|
|
ef7ff6dd0f | ||
|
|
1f6dd3e3bc | ||
|
|
a7a69a2c65 | ||
|
|
dd09e7f4be | ||
|
|
bec70dbdd6 | ||
|
|
3a597f2dc9 | ||
|
|
84e131fecc | ||
|
|
70196abf76 | ||
|
|
82952d9a39 | ||
|
|
dff40364ce | ||
|
|
b6f3b4c731 | ||
|
|
323f565722 | ||
|
|
4f69460a44 | ||
|
|
dbf107e0a1 | ||
|
|
42d8d44ad5 | ||
|
|
492f6b48ed | ||
|
|
32e41fb3c3 | ||
|
|
a14fff2785 | ||
|
|
a3ed821eea | ||
|
|
72c6c2384e | ||
|
|
abbce51bdb | ||
|
|
bf88b1f253 | ||
|
|
6977258f3d | ||
|
|
c80f3719d0 | ||
|
|
119fb7abb2 | ||
|
|
b99c7506ad | ||
|
|
a6d2c2b812 | ||
|
|
cafa9c692e | ||
|
|
549730d0ac | ||
|
|
5051e16414 | ||
|
|
ddde6818c6 | ||
|
|
0b5fa4a205 | ||
|
|
a29e164323 | ||
|
|
a5fef9c37e | ||
|
|
17d3b048d9 | ||
|
|
d76975b3d7 | ||
|
|
3fa78b8c82 | ||
|
|
9f84043b18 | ||
|
|
8d31438a84 | ||
|
|
59f793f8ea | ||
|
|
dddab4a0b5 | ||
|
|
422b54d145 | ||
|
|
b94cafd099 | ||
|
|
1b1b529806 | ||
|
|
8ed465491a | ||
|
|
a8d07bfaa6 |
@@ -0,0 +1,48 @@
|
||||
# Native CI for the Hanzo Git act_runners (label hanzo-build-linux-amd64).
|
||||
# Self-contained — no reusable-workflow hub on Gitea. GitHub.com ignores
|
||||
# .gitea/workflows, so this never touches the GitHub image lane
|
||||
# (.github/workflows/build-image.yml); it runs the same checks the Dockerfile
|
||||
# does, on the runner.
|
||||
#
|
||||
# Secrets: NONE needed — every @hanzo/@zap-proto/@luxfi/@zooai scope resolves
|
||||
# from public npm (no .npmrc auth). GITHUB_TOKEN is auto-minted per job by Gitea.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
# Next 15 + @hanzo/gui (large react-native dep tree) overflows Node's
|
||||
# default heap during `next build` (exit 137); the Dockerfile caps it the
|
||||
# same way. SOURCE_COMMIT feeds next.config.mjs's deterministic build id.
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
SOURCE_COMMIT: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
# `npm install`, NOT `npm ci`: @hanzo/gui's react-native optional deps
|
||||
# resolve differently across npm versions, so a lockfile from one npm
|
||||
# fails `npm ci` under another — the Dockerfile installs the same way.
|
||||
- run: npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
|
||||
test:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
- run: npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
- run: npm run test
|
||||
@@ -4,8 +4,38 @@ name: Build Docker Image
|
||||
# from the request hostname (console.hanzo.ai → hanzo, console.lux.cloud → lux,
|
||||
# console.zoo.cloud → zoo; src/config/index.ts), and /v1 is same-origin per host.
|
||||
# So NO NEXT_PUBLIC_* are baked — baking them would pin the image to one brand.
|
||||
# Tags: SEMVER ONLY (no sha, no :latest) — a `v*` git tag publishes that exact
|
||||
# version; a main push publishes `v<package.json version>` (bump to release).
|
||||
#
|
||||
# TAGS ARE IMMUTABLE RECEIPTS (modeled on ghcr.io/hanzoai/cloud release.yml). The
|
||||
# invariant this workflow enforces:
|
||||
#
|
||||
# a git tag v<X.Y.Z> exists ⇔ an image ghcr.io/hanzoai/console:v<X.Y.Z> was
|
||||
# pushed by a proven build — and that :v<X.Y.Z> is never re-pushed to different
|
||||
# bytes.
|
||||
#
|
||||
# The OLD design tagged the image `:v<package.json version>` on every main push, so
|
||||
# a main push that did NOT bump package.json re-published DIFFERENT bytes under the
|
||||
# SAME :v tag — anything pinned to it silently drifted (a deploy got an image other
|
||||
# than the one the tag was cut for; the v8.4.118 re-push incident documented in
|
||||
# universe crs/console.yaml). The fix:
|
||||
#
|
||||
# 1. Every build pushes an IMMUTABLE, content-addressable primary tag
|
||||
# `sha-<short-git-sha>` — unique per commit, it can never collide or overwrite.
|
||||
# This is the tag deploys SHOULD pin.
|
||||
# 2. The release version is max(highest git tag, highest pushed container tag) + 1
|
||||
# (patch bump only — never a major/minor jump, never read from package.json), so
|
||||
# a re-run without a version bump lands on a NEW free number and never reuses or
|
||||
# overwrites an existing :v tag.
|
||||
# 3. Order: build → push image → tag as receipt. A failed build pushes nothing and
|
||||
# leaves no tag. The proven `sha-` image is retagged to :v<X.Y.Z> ATOMICALLY and
|
||||
# the git tag pushed as the receipt, so :v<X.Y.Z> exists iff its git tag exists.
|
||||
#
|
||||
# DO NOT push v* tags by hand anymore — this workflow OWNS them (a hand-cut tag has no
|
||||
# image behind it, and there is no `tags:` trigger to build one). Every merge to main
|
||||
# IS the release; skip one with the usual `[skip ci]` in the commit/merge message.
|
||||
#
|
||||
# The npm/package.json version is unchanged and still drives the in-app "Hanzo Cloud
|
||||
# X.Y" umbrella label (NEXT_PUBLIC_APP_VERSION, config.ts) — it just no longer decides
|
||||
# the image TAG. Both stay on the 8.4.x lineage, so major.minor is consistent.
|
||||
#
|
||||
# Build muscle: RAW `docker buildx build` on the host builder — the canonical
|
||||
# hanzoai/ci pattern. We deliberately do NOT use docker/setup-buildx-action +
|
||||
@@ -19,40 +49,61 @@ name: Build Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
# One serialized release lane. Never cancel in-flight: a run killed between "image
|
||||
# pushed" and "tag created" is exactly the drift this workflow prevents, and two main
|
||||
# pushes must never compute the same next version (the queued run starts only after the
|
||||
# running one tags, re-reads the tags, and lands on the next free patch — monotonic).
|
||||
concurrency:
|
||||
group: docker-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: release-console
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
contents: write # push the git-tag receipt
|
||||
packages: write # push the image
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: resolve semver tag
|
||||
- name: Checkout (full history + all tags — the version floor is read from tags)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve commit sha (the immutable primary tag)
|
||||
id: ver
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# node is NOT on the ARC runner PATH — resolve the version with grep/sed
|
||||
# (a missing `node` silently produced the tag `:v` and a 404 deploy).
|
||||
ver=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
[ -n "$ver" ] || { echo "could not resolve version from package.json"; exit 1; }
|
||||
echo "tag=v${ver}" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
git fetch --tags --force --quiet
|
||||
sha_short="$(git rev-parse --short "$GITHUB_SHA")"
|
||||
echo "sha_short=${sha_short}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Informational only — the AUTHORITATIVE version is assigned atomically in the
|
||||
# Tag step below (which recomputes + retries on collision). Just log the hint.
|
||||
git_max="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
cont_max=""
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
cont_max="$(GH_TOKEN="${GH_PAT:-$GH_TOKEN}" gh api \
|
||||
'/orgs/hanzoai/packages/container/console/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
fi
|
||||
echo "Immutable primary tag: sha-${sha_short} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Ensure base image (reuse host cache)
|
||||
# No-op on a warm runner (the host builder reuses the cached base). Only a
|
||||
# cold runner pulls. Use the same ECR Public Docker-library mirror as the
|
||||
@@ -68,11 +119,94 @@ jobs:
|
||||
echo "pull failed (attempt $i/5) — backing off"; sleep $((i * 30))
|
||||
done
|
||||
echo "could not pull $base after retries"; exit 1
|
||||
- name: Build & push (host builder — reuses base cache, no artifact upload)
|
||||
|
||||
- name: Build & push the immutable per-commit image (host builder — reuses base cache)
|
||||
# The Next production build the Dockerfile runs (strict tsc + compile of every
|
||||
# route) is the gate: a broken build fails HERE and pushes nothing, so no tag is
|
||||
# ever minted for it. Push ONLY the content-addressable sha- tag — always unique,
|
||||
# it can never race or overwrite. The v<X.Y.Z> version is assigned + retagged in
|
||||
# the Tag step, so :v exists iff its git tag exists.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg SOURCE_COMMIT=${{ github.sha }} \
|
||||
--push \
|
||||
-t ghcr.io/hanzoai/console:${{ steps.ver.outputs.tag }} \
|
||||
-t ghcr.io/hanzoai/console:sha-${{ steps.ver.outputs.sha_short }} \
|
||||
-f Dockerfile .
|
||||
|
||||
# THE RECEIPT + ATOMIC VERSION ASSIGNMENT (race-safe). Reached only because the
|
||||
# build + push succeeded, so a proven image exists under the unique sha- tag. Here
|
||||
# we assign the next FREE v<X.Y.Z> and retag that proven image to it — with the
|
||||
# git-tag push as the serialization point:
|
||||
# * Recompute the floor FRESH = max(highest git tag, highest pushed container
|
||||
# tag). Folding in container tags means a number that already has an image
|
||||
# (even from a run that died before tagging) is never reused.
|
||||
# * next = floor patch + 1. If that git tag already exists, bump and retry.
|
||||
# * Retag the proven sha-image → :v<X.Y.Z> via imagetools (metadata only, NO
|
||||
# rebuild — byte-identical to the pushed image).
|
||||
# * Push the git tag; the FIRST pusher of vX wins, a loser recomputes. So
|
||||
# concurrent releases each grab a distinct free number.
|
||||
- name: Tag the proven image (atomic free-version receipt — race-safe)
|
||||
id: tag
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "hanzo-dev"
|
||||
git config user.email "dev@hanzo.ai"
|
||||
SHA_IMG="ghcr.io/hanzoai/console:sha-${{ steps.ver.outputs.sha_short }}"
|
||||
TOKEN="${GH_PAT:-$GH_TOKEN}"
|
||||
PUSH_URL="https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
for attempt in $(seq 1 8); do
|
||||
git fetch --tags --force --quiet
|
||||
|
||||
# Highest semver git tag (vX.Y.Z), normalised without the leading v.
|
||||
git_max="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
|
||||
# Fail-CLOSED on the container-tag lookup: an ORPHANED container tag (image
|
||||
# pushed by a run that died after retag but before its git tag) MUST raise the
|
||||
# floor, or a later run reassigns that same number to different bytes (an
|
||||
# ambiguous mutable prod tag). If the lookup ERRORS (vs legitimately empty) we
|
||||
# retry the whole attempt rather than silently reuse a number with an image.
|
||||
cont_max=""
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
if cont_raw="$(GH_TOKEN="$TOKEN" gh api \
|
||||
'/orgs/hanzoai/packages/container/console/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null)"; then
|
||||
cont_max="$(printf '%s\n' "$cont_raw" \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
else
|
||||
echo " container-tag lookup failed — retry so an orphaned tag can't be reused (attempt $attempt)"; sleep 3; continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Floor = highest of the two; fall back to 8.4.0 only if the repo has no tags
|
||||
# at all (never, in practice). next = floor patch + 1 (patch bump only).
|
||||
max="$(printf '%s\n%s\n%s\n' "8.4.0" "$git_max" "$cont_max" \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
|
||||
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
|
||||
VER="${major}.${minor}.$((patch + 1))"; V="v${VER}"
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/$V" >/dev/null; then
|
||||
echo " $V already tagged — recomputing (attempt $attempt)"; sleep 3; continue
|
||||
fi
|
||||
|
||||
# Metadata-only retag of the PROVEN sha-image → the version tag (no rebuild).
|
||||
docker buildx imagetools create -t "ghcr.io/hanzoai/console:${V}" "$SHA_IMG"
|
||||
|
||||
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/console:$V (retagged from sha-${{ steps.ver.outputs.sha_short }}, ${GITHUB_SHA})"
|
||||
if git push "$PUSH_URL" "$V" 2>/dev/null; then
|
||||
echo "Tagged $V → ghcr.io/hanzoai/console:$V (immutable receipt for sha-${{ steps.ver.outputs.sha_short }})"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_v=${V}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo " push of $V lost the race — recomputing (attempt $attempt)"
|
||||
git tag -d "$V" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
done
|
||||
echo "::error::could not acquire a free version tag after 8 attempts"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# ~7-line canonical caller — all real config lives in /hanzo.yml.
|
||||
# Builds + pushes the console-embed artifact on OUR arc pool; auto-mirrors to
|
||||
# registry.hanzo.ai. The Next.js server image stays in build-image.yml.
|
||||
name: CI/CD
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
@@ -20,3 +20,6 @@ e2e-shots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.claude/
|
||||
|
||||
# blank-audit generated report
|
||||
e2e/blank-report.json
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
@@ -33,6 +38,8 @@ COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# hanzoai/console — the EMBED artifact.
|
||||
#
|
||||
# Builds the console SPA static export (`npm run build:embed` → out/) ONCE, as a
|
||||
# versioned immutable image whose rootfs is just the bundle at /dist. hanzoai/cloud
|
||||
# then does `FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console` +
|
||||
# `COPY --from=console /dist/ webui/dist/` instead of re-running npm+Next export on
|
||||
# EVERY cloud release (the ~15-min cache-busted long pole). Console changes far less
|
||||
# often than cloud ships, so this moves the build to console's own cadence and turns
|
||||
# a cloud rebuild into a registry pull.
|
||||
#
|
||||
# The Next.js SERVER image (standalone/admin hosts) stays in build-image.yml — this
|
||||
# is a separate, additional artifact, not a replacement.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# Bake the console.hanzo.ai analytics property (public per-site id) — the SAME default
|
||||
# cloud baked at build:embed time, so the embedded console keeps tracking identically.
|
||||
# GA4/Pixel stay unset. Public id, not a KMS secret.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# FAIL-HARD: the export MUST emit a real bundle (non-empty out/index.html + out/_next/),
|
||||
# never a placeholder shell — same invariant cloud's console stage enforced.
|
||||
RUN npm run build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> embedded REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
FROM scratch
|
||||
COPY --from=build /console/out/ /dist/
|
||||
@@ -1,76 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { resolveView, isAdminRoute } from '~/lib/products/match'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
|
||||
import { ProductSubpageModule } from '~/components/products/subpage/ProductSubpageModule'
|
||||
import { ProductInterstitial } from '~/components/products/ProductInterstitial'
|
||||
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
|
||||
import { ProductErrorBoundary } from '~/components/errors/ProductErrorBoundary'
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
|
||||
/**
|
||||
* Catch-all product route. Resolves the module + route from the registry and
|
||||
* renders its component. Adding a product anywhere in the registry makes its
|
||||
* routes live here — no per-product page files.
|
||||
* renders its component via the shared `ProductRoute` (the ONE renderer, also used
|
||||
* by the dashboard home for the static embed). Adding a product anywhere in the
|
||||
* registry makes its routes live here — no per-product page files.
|
||||
*
|
||||
* Two honest gates on top of the resolver:
|
||||
* - A known product SUB-PAGE (a declared specific or a uniform base sub-page:
|
||||
* Overview · Settings · Status · Logs · Metrics) with no backend route yet
|
||||
* renders a placeholder stub — never a 404, never a fabricated surface.
|
||||
* - A CUSTOMER (non-global-admin) reaching an admin-only surface (cross-tenant
|
||||
* IAM/KMS, provider + routing config) gets a graceful "managed by Hanzo" notice
|
||||
* instead of the module's hostile 403 red error. Access is enforced
|
||||
* server-side regardless.
|
||||
*
|
||||
* The resolved module renders inside `ProductErrorBoundary`: modules mount
|
||||
* client-only (the authed shell renders a loader during SSR), so a throw in one
|
||||
* module's first render had no boundary and white-screened the whole console
|
||||
* ("Application error: a client-side exception") on a direct load / refresh. The
|
||||
* boundary keeps the shell + nav and shows an honest, retryable card instead —
|
||||
* one place, every product route (DRY).
|
||||
* `ProductRoute` applies the two honest gates (sub-page stub, admin "managed by
|
||||
* Hanzo" notice), the external-product interstitial, and the per-route error
|
||||
* boundary. See that component.
|
||||
*/
|
||||
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = use(params)
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const view = resolveView(slug)
|
||||
|
||||
if (view.kind === 'notfound') notFound()
|
||||
|
||||
// A directly-navigated URL to an EXTERNAL product (its id, or a conventional
|
||||
// alias like /automation → auto) — render its in-console discover page (what it
|
||||
// is + an "Open" that launches its own domain), never a 404. The nav/launcher
|
||||
// launch it directly; this only catches a hand-typed/bookmarked URL.
|
||||
if (view.kind === 'external') return <ProductInterstitial id={view.entry.id} />
|
||||
|
||||
if (!showAdmin && isAdminRoute(slug)) {
|
||||
const entry = findEntry(slug[0])
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
|
||||
return <AdminManagedNotice entry={entry} subpage={subpage} />
|
||||
}
|
||||
}
|
||||
|
||||
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
|
||||
|
||||
// A uniform base sub-page (Status/Logs/Metrics/Settings) → the shared per-product
|
||||
// sub-page system (real feed or honest state), inside the same error boundary so
|
||||
// a data fetch that throws shows the retryable card, never a white screen.
|
||||
if (view.kind === 'subpage')
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<ProductSubpageModule entry={view.entry} subpage={view.subpage} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
|
||||
const Component = view.matched.route.component
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<Component params={view.matched.params} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
return <ProductRoute slug={slug} />
|
||||
}
|
||||
|
||||
@@ -14,10 +14,8 @@ import { useEffect } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { isChunkLoadError, shouldReloadForChunk } from '~/components/errors/boundary-logic'
|
||||
|
||||
/** Shared once-per-window guard key (same as ProductErrorBoundary — never double-reload). */
|
||||
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
|
||||
import { reportError } from '~/lib/event'
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
@@ -27,12 +25,17 @@ export default function DashboardError({ error, reset }: { error: Error & { dige
|
||||
// A chunk skew self-heals: reload ONCE per window to pull the fresh HTML +
|
||||
// current chunks (same recovery the product boundary does), so a stale-deploy
|
||||
// crash at the segment level auto-recovers instead of stranding a manual card.
|
||||
if (!chunk || typeof window === 'undefined') return
|
||||
// A chunk skew is not an app bug, so report only a genuine crash to the ONE stream.
|
||||
if (!chunk) {
|
||||
reportError(error, { digest: error.digest, boundary: 'dashboard' })
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
|
||||
+15
-32
@@ -1,39 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AuthGate } from '~/components/AuthGate'
|
||||
import { OrgGate } from '~/components/OrgGate'
|
||||
import { DashboardShell } from '~/components/DashboardShell'
|
||||
import { PreferencesProvider } from '~/lib/products/preferences'
|
||||
import { ScopeProvider } from '~/lib/scope-context'
|
||||
import { ToastProvider } from '~/components/ui/Toast'
|
||||
import { CommandPaletteProvider } from '~/components/CommandPalette'
|
||||
import { AppLauncherProvider } from '~/components/AppLauncher'
|
||||
import { DetailPaneProvider } from '~/components/DetailPane'
|
||||
import { FloatingChatProvider } from '~/components/FloatingChat'
|
||||
import { Preferences } from '~/lib/products/preferences'
|
||||
import { Toast } from '~/components/ui/Toast'
|
||||
import { Entry } from '~/entry/entry'
|
||||
|
||||
/**
|
||||
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
|
||||
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
|
||||
* onboard wizard + every module report through Toast — so they sit above the switch.
|
||||
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
|
||||
* (sign-in · waitlist · org · onboard · dashboard).
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthGate>
|
||||
<OrgGate>
|
||||
<ScopeProvider>
|
||||
<PreferencesProvider>
|
||||
<ToastProvider>
|
||||
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
|
||||
<AppLauncherProvider>
|
||||
<CommandPaletteProvider>
|
||||
{/* FloatingChat floats the assistant bubble over every page. */}
|
||||
<FloatingChatProvider>
|
||||
{/* DetailPane hosts the ONE right-side item detail/edit pane. */}
|
||||
<DetailPaneProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DetailPaneProvider>
|
||||
</FloatingChatProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppLauncherProvider>
|
||||
</ToastProvider>
|
||||
</PreferencesProvider>
|
||||
</ScopeProvider>
|
||||
</OrgGate>
|
||||
</AuthGate>
|
||||
<Preferences>
|
||||
<Toast>
|
||||
<Entry>{children}</Entry>
|
||||
</Toast>
|
||||
</Preferences>
|
||||
)
|
||||
}
|
||||
|
||||
+66
-11
@@ -8,20 +8,25 @@
|
||||
* no external bounce. Each card can be pinned to the sidebar (persisted to the
|
||||
* account). Rendered entirely from the catalog registry.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ArrowRight, BookOpen, KeyRound } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { shellFor } from '~/lib/products/shell'
|
||||
import { visibleCatalogByCategory, categorySlug, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { resolveView } from '~/lib/products/match'
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
|
||||
import { ProductObservability } from '~/components/products/observability/ProductObservability'
|
||||
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
@@ -107,7 +112,7 @@ function ProductCard({
|
||||
*/
|
||||
function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4">
|
||||
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4" data-tour="api-key">
|
||||
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
|
||||
<XStack items="center" gap="$3" flex={1} minW={240}>
|
||||
<YStack bg="$color5" rounded="$4" p="$2.5" items="center" justify="center">
|
||||
@@ -132,18 +137,42 @@ function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
|
||||
|
||||
export default function DashboardHome() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { toggle, isPinned } = useFavorites()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const push = (path: string) => router.push(path)
|
||||
const groups = visibleCatalogByCategory(showAdmin)
|
||||
|
||||
// Billing-only shell (billing.<brand> / NEXT_PUBLIC_BILLING_ONLY): the default
|
||||
// route IS the Billing Center — redirect the catalog home to the billing overview
|
||||
// so people who only ever see billing.hanzo.ai land straight on billing.
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
// Product-shell face (billing.<brand> / sentry.<brand> / an override): the default
|
||||
// route IS the face's home — redirect the catalog home there so people who only ever
|
||||
// see billing.hanzo.ai land on billing, and sentry.hanzo.ai on Issues. ONE redirect
|
||||
// for every face, driven by the shell descriptor.
|
||||
const shellHome = shellFor(config.shell).home
|
||||
useEffect(() => {
|
||||
if (config.billingOnly) router.replace('/billing')
|
||||
}, [router])
|
||||
if (config.billingOnly) {
|
||||
if (shellHome) router.replace(`/${shellHome}`)
|
||||
}, [router, shellHome])
|
||||
|
||||
// One-binary STATIC embed: cloud serves THIS page's index.html for EVERY deep
|
||||
// link (a static export can't pre-generate arbitrary product slugs), so a direct
|
||||
// load / refresh — or a client nav that hard-falls-back — of /models, /chat,
|
||||
// /tracker … would otherwise render the home instead of the module. Resolve the
|
||||
// LIVE path client-side and hand any real product route to the shared
|
||||
// ProductRoute. Gated on `mounted` so the first client render matches the
|
||||
// server-exported home ("/") — no hydration mismatch; it then swaps to the
|
||||
// resolved module. On a real Next server this page only renders for "/", so
|
||||
// `segments` is empty and the home always shows; an unknown/non-product deep path
|
||||
// (e.g. /category/*, /discover/*) resolves to notfound here and falls through to
|
||||
// the home rather than a hard 404 in the embed.
|
||||
const segments =
|
||||
mounted && pathname ? pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean) : []
|
||||
if (segments.length > 0 && resolveView(segments).kind !== 'notfound') {
|
||||
return <ProductRoute slug={segments} />
|
||||
}
|
||||
|
||||
if (shellHome) {
|
||||
return (
|
||||
<XStack flex={1} justify="center" items="center" p="$8">
|
||||
<Spinner size="large" color="$color11" />
|
||||
@@ -155,6 +184,32 @@ export default function DashboardHome() {
|
||||
<YStack gap="$7">
|
||||
<GetApiKeyCta onOpen={() => push('/api-keys')} />
|
||||
<OverviewDashboard params={{}} />
|
||||
|
||||
{/* Observability, front-and-center — the platform's live LLM signals (RED
|
||||
metrics · recent logs · recent traces) on the home, the way Langfuse put
|
||||
its metrics dashboard up top. Reuses the ONE shared ProductObservability
|
||||
panel over the `ai` inference service (honest-empty until o11y emits), and
|
||||
deep-links to the full Observe surface. `data-tour` anchors the first-run
|
||||
tour's Observability step. */}
|
||||
<YStack gap="$3" data-tour="metrics">
|
||||
<XStack
|
||||
self="flex-start"
|
||||
items="center"
|
||||
gap="$2"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.75 }}
|
||||
onPress={() => push('/o11y')}
|
||||
aria-label="Open Observability"
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
Observability
|
||||
</Text>
|
||||
<ArrowRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
<ProductObservability service="ai" label="AI inference" />
|
||||
</YStack>
|
||||
|
||||
<ResourceOverview />
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Explore products"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* /accept — the invitee's landing page for a team invite (PUBLIC, no session).
|
||||
*
|
||||
* The org admin shares this link (email/OTP delivery isn't wired on this
|
||||
* deployment). The invitee opens it, sees the org they've been invited to, sets a
|
||||
* password (IAM hashes it server-side — never plaintext), then signs in and lands
|
||||
* in that org with the role the admin assigned. Honest states throughout: an
|
||||
* invalid/expired link, an already-accepted link, and IAM errors are all truthful,
|
||||
* never a fake success.
|
||||
*/
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { CheckCircle2, ArrowRight, ShieldAlert, UserPlus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
type Info =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; message: string }
|
||||
| { phase: 'accepted'; org: string }
|
||||
| { phase: 'form'; org: string; email: string; displayName: string; role: string }
|
||||
|
||||
function Center({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function AcceptFlow() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const token = params?.get('t') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<Info>({ phase: 'loading' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [done, setDone] = useState<false | string>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setInfo({ phase: 'error', message: 'This invitation link is missing its token.' })
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
;(async () => {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/console/accept?t=${encodeURIComponent(token)}`, { credentials: 'include' })
|
||||
} catch {
|
||||
if (live) setInfo({ phase: 'error', message: 'Network error — please try again.' })
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as
|
||||
| { org?: string; email?: string; displayName?: string; role?: string; accepted?: boolean; error?: string }
|
||||
| null
|
||||
if (!live) return
|
||||
if (!res.ok || !j?.org) {
|
||||
setInfo({ phase: 'error', message: j?.error || 'This invitation link is invalid or has expired.' })
|
||||
return
|
||||
}
|
||||
if (j.accepted) {
|
||||
setInfo({ phase: 'accepted', org: j.org })
|
||||
return
|
||||
}
|
||||
setInfo({ phase: 'form', org: j.org, email: j.email || '', displayName: j.displayName || '', role: j.role || 'member' })
|
||||
setName(j.displayName || '')
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setErr(`Use a password of at least ${MIN_PASSWORD} characters.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/console/accept', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ t: token, password, displayName: name.trim() || undefined }),
|
||||
})
|
||||
} catch {
|
||||
setErr('Network error — please try again.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as { ok?: boolean; org?: string; error?: string } | null
|
||||
if (!res.ok || !j?.ok) {
|
||||
setErr(j?.error || `Could not activate your account (HTTP ${res.status}).`)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setDone(j.org || (info.phase === 'form' ? info.org : ''))
|
||||
}, [password, name, token, info])
|
||||
|
||||
if (done !== false) {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={40} color="$green10" />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$7" fontWeight="800">You're in</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
Your account for <Text color="$color12" fontWeight="700">{done}</Text> is ready. Sign in to continue.
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
width="100%"
|
||||
iconAfter={<ArrowRight size={16} />}
|
||||
onPress={() => router.push('/signin')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'loading') {
|
||||
return (
|
||||
<Center>
|
||||
<Spinner size="large" color="$color11" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'error') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<ShieldAlert size={36} color="$red10" />
|
||||
<Text fontSize="$6" fontWeight="800">Invitation unavailable</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">{info.message}</Text>
|
||||
<Button size="$3" onPress={() => router.push('/signin')}>Go to sign in</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'accepted') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={36} color="$green10" />
|
||||
<Text fontSize="$6" fontWeight="800">Already accepted</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This invitation to <Text color="$color12" fontWeight="700">{info.org}</Text> was already used. Sign in to continue.
|
||||
</Text>
|
||||
<Button size="$4" theme="light" iconAfter={<ArrowRight size={16} />} onPress={() => router.push('/signin')}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$2" items="center">
|
||||
<UserPlus size={20} />
|
||||
<Text fontSize="$7" fontWeight="800">Join {info.org}</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
You've been invited to <Text color="$color12" fontWeight="700">{info.org}</Text> as a{' '}
|
||||
<Text color="$color12" fontWeight="700">{info.role}</Text>. Set a password to activate{' '}
|
||||
<Text color="$color12">{info.email}</Text> and sign in.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Your name</Text>
|
||||
<Input value={name} onChangeText={setName} placeholder="Your name" autoCapitalize="words" />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Password</Text>
|
||||
<Input
|
||||
value={password}
|
||||
onChangeText={(v) => {
|
||||
setPassword(v)
|
||||
if (err) setErr(null)
|
||||
}}
|
||||
placeholder={`At least ${MIN_PASSWORD} characters`}
|
||||
// secureTextEntry alone does not mask in this @hanzo/gui build; set the
|
||||
// web input type explicitly (RNW passthrough) — same as SignInForm.
|
||||
secureTextEntry
|
||||
{...{ type: 'password' }}
|
||||
autoComplete="new-password"
|
||||
onSubmitEditing={() => void submit()}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
{err ? <Text fontSize="$2" color="$red10">{err}</Text> : null}
|
||||
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
disabled={busy || password.length < MIN_PASSWORD}
|
||||
iconAfter={busy ? <Spinner color="$color1" /> : <ArrowRight size={16} />}
|
||||
onPress={() => void submit()}
|
||||
>
|
||||
{busy ? 'Activating…' : 'Set password & join'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcceptPage() {
|
||||
return (
|
||||
<Suspense fallback={<Center><Spinner size="large" color="$color11" /></Center>}>
|
||||
<AcceptFlow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* routing that affects every org).
|
||||
*
|
||||
* The admin business board is an ALL-ORGS god view (`?org=all`) over IAM + commerce
|
||||
* + o11y. So — unlike the per-tenant `/cloud` proxy, which authorizes on the bearer
|
||||
* + o11y. So — unlike the per-tenant `/v1` proxy, which authorizes on the bearer
|
||||
* `owner` claim and is safe for any authenticated user — this MUST be gated to a
|
||||
* GLOBAL admin BEFORE anything is forwarded: a tenant customer (even one who is
|
||||
* `isAdmin` of their own org) must NOT read another org's revenue/spend/customers,
|
||||
@@ -37,6 +37,7 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { cloudAudience } from '~/config'
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowAdminSurface } from '~/lib/server/admin-aggregate'
|
||||
@@ -73,6 +74,14 @@ async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowAdminSurface,
|
||||
// Scope the minted user bearer to the brand's cloud audience (`<brand>-cloud`).
|
||||
// The operator is a member of the reserved `admin` org, whose OWN app is
|
||||
// `admin-console` — NOT in cloud's audience allowlist — so a default-audience
|
||||
// bearer is rejected (anonymous → 403 on every /v1/admin/*). With the cloud
|
||||
// audience, cloud validates the token and, seeing owner=admin + isAdmin=true,
|
||||
// sets X-User-IsAdmin=true. Host-aware so a lux/zoo admin host scopes to its own
|
||||
// brand cloud audience. (Tenant proxies are unchanged — they omit this.)
|
||||
audience: cloudAudience(req.headers.get('host')),
|
||||
// The AdminApi client unwraps the casibase `{status,msg,data}` envelope, so this
|
||||
// proxy's own 401/404 must speak the same shape (an honest state, never a throw).
|
||||
errorShape: 'casibase',
|
||||
@@ -86,21 +95,40 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
|
||||
/**
|
||||
* POST — the GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`/v1/admin/providers/{toggle,primary}`). Identical path through `getAdminGate`
|
||||
* (fail-closed 403) → `forwardWithUserBearer`, which applies the same-origin CSRF
|
||||
* check to this mutating method BEFORE resolving the user, streams the JSON body
|
||||
* through, and re-validates the path against `allowAdminSurface` (so a POST can only
|
||||
* ever reach an allowed head — never `iam`/`kms`, never a traversal).
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
|
||||
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
|
||||
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
|
||||
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
|
||||
* can only ever reach an allowed head — never `iam`/`kms`, never a traversal).
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — the GLOBAL-admin enablement set (`PUT /v1/admin/enablement`, flip an item
|
||||
* off|beta|ga + grant orgs). Same gate + same CSRF/traversal hardening as POST;
|
||||
* `allowAdminSurface` admits only `v1/admin/enablement`, nothing else.
|
||||
* PUT — the GLOBAL-admin upserts on the same god-view gate (`PUT /v1/admin/enablement`
|
||||
* flip an item off|beta|ga + grant orgs; `PUT /v1/admin/promos` upsert the single
|
||||
* platform plan promo). Same gate + same CSRF/traversal hardening as POST;
|
||||
* `allowAdminSurface` admits only the declared heads, nothing else.
|
||||
*/
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
|
||||
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
|
||||
* mutating verbs; only an allow-listed head/sub-path is ever reached.
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ const GET_SEGMENTS = new Set([
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
// Waitlist approval queue (iam#104) — the Pending-Users board reads this. The
|
||||
// /admin/iam gate is already global-admin-only, matching IAM's own
|
||||
// GetPendingUsers auth (global admin or org admin). REUSED, not rebuilt.
|
||||
'get-pending-users',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -61,6 +65,10 @@ const POST_SEGMENTS = new Set([
|
||||
'add-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
// Waitlist approval actions (iam#104) — approve/reject a pending user. Body is
|
||||
// `{id:"owner/name"}`; the global-admin gate + forwardIam's owner scoping apply.
|
||||
'approve-user',
|
||||
'reject-user',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -96,7 +104,7 @@ async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'):
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(
|
||||
req,
|
||||
{ user: gate.user, isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ user: gate.user, isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
{
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
|
||||
@@ -37,11 +37,11 @@ function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a global admin passes ?org=
|
||||
/** Org the operator acts on — the brand org, unless a SuperAdmin passes ?org=
|
||||
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
return policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.nextUrl.searchParams.get('org'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Server-gated GLOBAL-admin proxy for the commerce SaaS-operations god-view
|
||||
* (`GET /v1/metrics/saas`) — the cross-tenant revenue / subscription / customer
|
||||
* snapshot computed IN commerce (the money system of record). This is the exact
|
||||
* console→commerce pattern commerce's own `api/costs` gate documents: the console's
|
||||
* OWN global-admin gate runs FIRST, then it forwards with the `COMMERCE_SERVICE_TOKEN`
|
||||
* and NO user identity — commerce's `RequirePlatformAdmin` admits that trusted M2M
|
||||
* token (Admin bit, empty Subject) for the fleet god-view.
|
||||
*
|
||||
* Gated fail-closed BEFORE any cross-tenant row is read: `getAdminGate` requires a
|
||||
* VERIFIED `@<brand.adminDomain>` email AND an IAM global-admin flag (the SAME gate
|
||||
* the IAM/KMS/aggregate admin proxies use), → 403 on any miss. A tenant customer —
|
||||
* even one who is `isAdmin` of their OWN org — can never read another org's revenue.
|
||||
* The client-side `admin: true` nav gate + module `OperatorAccessRequired` are
|
||||
* UI-only defense-in-depth; this server gate is the boundary.
|
||||
*
|
||||
* The path is FIXED (`/v1/metrics/saas`) — there is no client-controlled path
|
||||
* segment, so no traversal surface. Only the allow-listed `window`/`limit` query
|
||||
* params are forwarded (validated here), never the raw query string. The commerce
|
||||
* SERVICE token comes from server-only env (never `NEXT_PUBLIC_`, never the browser
|
||||
* bundle); unset → honest 501 (the board shows "not configured", never a fabricated
|
||||
* MRR). The commerce raw JSON is wrapped in the casibase `{status,msg,data}`
|
||||
* envelope the admin client (`originGet`) unwraps.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { commerceBaseUrl, commerceServiceToken } from '~/lib/server/billing-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** The commerce SaaS god-view — the internal `/v1` bundle path (cloud/costs siblings
|
||||
* live here too), reached directly at the in-cluster commerce address. */
|
||||
const METRICS_PATH = '/v1/metrics/saas'
|
||||
|
||||
/** The windows commerce accepts; anything else is dropped (commerce defaults 30d). */
|
||||
const WINDOWS = new Set(['7d', '30d', '90d', 'mtd', 'all'])
|
||||
|
||||
const NO_STORE = 'no-store, must-revalidate'
|
||||
const envelope = (msg: string, status: number) =>
|
||||
NextResponse.json({ status: 'error', msg, data: null }, { status, headers: { 'Cache-Control': NO_STORE } })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin never
|
||||
// triggers the cross-tenant commerce walk.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return envelope('forbidden', 403)
|
||||
|
||||
const token = commerceServiceToken()
|
||||
if (!token) return envelope('SaaS metrics are not configured (COMMERCE_TOKEN missing).', 501)
|
||||
|
||||
// Forward ONLY the allow-listed, validated params — never the raw query string.
|
||||
const q = new URLSearchParams()
|
||||
const window = (req.nextUrl.searchParams.get('window') ?? '').trim()
|
||||
if (WINDOWS.has(window)) q.set('window', window)
|
||||
const limit = Number(req.nextUrl.searchParams.get('limit'))
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 200) q.set('limit', String(limit))
|
||||
|
||||
const url = `${commerceBaseUrl()}${METRICS_PATH}${q.toString() ? `?${q}` : ''}`
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Forward commerce's status class as an honest error; the board shows the
|
||||
// failure state (never a fabricated snapshot).
|
||||
return envelope(`SaaS metrics upstream returned ${res.status}.`, res.status === 403 ? 403 : 502)
|
||||
}
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ status: 'ok', msg: '', data }, { headers: { 'Cache-Control': NO_STORE } })
|
||||
} catch (e) {
|
||||
// Redact the exception (it carries the internal commerce host/port) — log
|
||||
// server-side only; return a generic client message.
|
||||
console.error('saas-metrics proxy: upstream unreachable:', commerceBaseUrl(), e instanceof Error ? e.message : String(e))
|
||||
return envelope('SaaS metrics upstream is unavailable.', 502)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin (`/ai/v1/...`) with just
|
||||
* the session cookie; `forwardWithUserBearer` resolves the user, mints a SHORT-LIVED,
|
||||
* user-bound IAM token (shared per-user cache in identity.ts), and forwards to the
|
||||
* gateway with that token. No key in the browser, no rotation on a chat turn, and
|
||||
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
* identity.ts), and forwards to the gateway with that token. No key in the browser, and
|
||||
* every call is billed to the user's own org. The response STREAMS through, so
|
||||
* `chat/completions` SSE (and the multi-model TTFT measurement) is preserved.
|
||||
*
|
||||
@@ -37,18 +38,91 @@ const ALLOWED = new Set([
|
||||
'v1/rerank',
|
||||
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
|
||||
'v1/images/generations', // text-to-image (JSON in → image url/b64 out) for the Playground Image tab
|
||||
'v1/videos/generations', // text-to-video (JSON in → base64 MP4 out) for the Playground Video tab
|
||||
'v1/videos/generations', // text-to-video CREATE — async: JSON in → a queued job object out (Sora-style)
|
||||
'v1/ai/connections', // AI Login Manager (ai#79/#80): GET list + POST link a BYO provider key (KMS-sealed server-side)
|
||||
'v1/training/clients', // Interactive Training: GET list clients + POST create a LoRA training client (engine plane)
|
||||
'v1/router/policy', // Router: GET the caller's org policy + PUT upsert it (org-admin gated upstream, self-scoped)
|
||||
'v1/router/stats', // Router: the caller org's routing observability aggregate (RequirePrincipal upstream, self-scoped)
|
||||
'v1/get-training-contribution', // Router: the caller org's training opt-in flag (org-admin gated upstream)
|
||||
'v1/update-training-contribution', // Router: set the caller org's training opt-in flag (org-admin gated upstream)
|
||||
'v1/org/settings', // Routing admin: one org's settings row — GET read, PUT upsert (PATCH-merge), DELETE revert (super-admin gated upstream)
|
||||
'v1/org/settings/list', // Routing admin: per-org settings rows (super-admin gated upstream)
|
||||
])
|
||||
|
||||
/**
|
||||
* Async video poll/download sub-paths: GET `/v1/videos/{id}` and
|
||||
* `/v1/videos/{id}/content`. Video generation is async (create returns a job id
|
||||
* immediately; the client polls the job and then downloads the finished MP4), so
|
||||
* the Playground must reach these two dynamic paths in addition to the exact
|
||||
* CREATE above. The job id is an opaque `video_<uuid>`; the charset is kept
|
||||
* conservative and the pattern is anchored to `v1/videos/`, so this stays a
|
||||
* narrow allow-list (the create POST is still only the exact
|
||||
* `v1/videos/generations`), never a general gateway tunnel. Method is enforced
|
||||
* by the backend (these are GET-only there).
|
||||
*/
|
||||
const VIDEO_JOB_PATH = /^v1\/videos\/[A-Za-z0-9._-]+(?:\/content)?$/
|
||||
|
||||
/**
|
||||
* Per-provider AI-connection sub-path: `/v1/ai/connections/<provider>` — the
|
||||
* disconnect (the AI router maps POST here to the delete). Anchored to the
|
||||
* connections head with a conservative provider charset, so it stays a narrow
|
||||
* allow-list, never a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Provider-login OAuth start (ai#85): `/v1/ai/connections/<provider>/authorize`.
|
||||
* GET returns the provider consent URL (`?format=json` → `{ authorizeUrl }`) that
|
||||
* the console redirects the browser to; the OAuth callback is handled server-side
|
||||
* by the backend (KMS-sealed), never through this proxy. Anchored to the
|
||||
* connections head with a conservative provider charset — a narrow allow-list, not
|
||||
* a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_AUTHORIZE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/authorize$/
|
||||
|
||||
/**
|
||||
* Import a connected account's usage: `/v1/ai/connections/<provider>/usage`. GET only —
|
||||
* the org's key is unsealed SERVER-SIDE and the provider's usage/cost API is called there;
|
||||
* the browser only reads the normalized ProviderUsage. Anchored to the connections head
|
||||
* with a conservative provider charset — a narrow allow-list, not a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_USAGE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/usage$/
|
||||
|
||||
/**
|
||||
* Interactive-training per-client sub-path: `/v1/training/clients/<id>` and its four
|
||||
* drive actions — `/forward_backward`, `/optim_step`, `/sample`, `/save_weights`. GET
|
||||
* reads a client, DELETE drops it, POST drives the actions. Anchored to the clients
|
||||
* head with a conservative id charset (opaque `client_<...>`) and an exact action set,
|
||||
* so it stays a narrow allow-list, never a general tunnel. The bare `v1/training/clients`
|
||||
* (list/create) is the exact entry above.
|
||||
*/
|
||||
const TRAINING_CLIENT_PATH = /^v1\/training\/clients\/[A-Za-z0-9._-]+(?:\/(?:forward_backward|optim_step|sample|save_weights))?$/
|
||||
|
||||
/** Whether a resolved `/v1/<...>` path is reachable through this proxy. */
|
||||
function isAllowedAiPath(p: string): boolean {
|
||||
return (
|
||||
ALLOWED.has(p) ||
|
||||
VIDEO_JOB_PATH.test(p) ||
|
||||
AI_CONNECTION_PATH.test(p) ||
|
||||
AI_CONNECTION_AUTHORIZE_PATH.test(p) ||
|
||||
AI_CONNECTION_USAGE_PATH.test(p) ||
|
||||
TRAINING_CLIENT_PATH.test(p)
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// The client builds a clean `/v1/<aihead>` and `next.config.mjs` dispatches it here
|
||||
// WITHOUT a nested version (destination `/ai/<aihead>`), so the catch-all captures the
|
||||
// sub-path after `/ai/`. Re-root the upstream at `v1/` — the exact path `isAllowedAiPath`
|
||||
// and the gateway see (`v1/chat/completions`, `v1/images/generations`, `v1/ai/connections`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: AI_GATEWAY_URL,
|
||||
path,
|
||||
allow: (p) => ALLOWED.has(p),
|
||||
allow: isAllowedAiPath,
|
||||
// Forward the RAG retrieval switch when present; the store's org owner is still
|
||||
// resolved server-side from the session (the bearer), never the browser.
|
||||
extraHeaders: retrievalHeaders((h) => req.headers.get(h)),
|
||||
@@ -64,3 +138,8 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
// DELETE drops an interactive-training client (`/v1/training/clients/<id>`); the
|
||||
// same-origin CSRF guard in the bearer proxy gates it like every mutating verb.
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
@@ -1,55 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
|
||||
* for a backend session (`/v1/iam/signin`) and land on the dashboard. On failure we
|
||||
* surface the error and offer a retry.
|
||||
* IAM OAuth callback route. The exchange logic lives in <AuthCallback/> — the SPA fallback
|
||||
* also routes `/auth/callback` through <Auth/> (which renders the same component), so
|
||||
* both entry points share the ONE handler rather than duplicating the code→token flow.
|
||||
*/
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
|
||||
function Callback() {
|
||||
const params = useSearchParams() ?? new URLSearchParams()
|
||||
const router = useRouter()
|
||||
const { completeSignIn } = useSession()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
if (!code || !state) {
|
||||
setError('Missing authorization code.')
|
||||
return
|
||||
}
|
||||
completeSignIn(code, state)
|
||||
// Land the user back where a mid-task expiry interrupted them (default home).
|
||||
.then(() => router.replace(takeReturnTo()))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
|
||||
}, [params, completeSignIn, router])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
return <Loader label="Completing sign-in…" />
|
||||
}
|
||||
import { AuthCallback } from '~/components/AuthCallback'
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Callback />
|
||||
<AuthCallback />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* /auth/refresh — silently renew the console session (server-side, BFF).
|
||||
*
|
||||
* The browser calls this with just its httpOnly cookies (no token in the body, none
|
||||
* in the URL). The route reassembles + reads the sealed refresh token, calls IAM with
|
||||
* `grant_type=refresh_token`, and re-seals the ROTATED token set (IAM issues a new
|
||||
* one-time-use refresh token every refresh — we always persist the NEW one). It
|
||||
* returns only the new lifetime, never a token.
|
||||
*
|
||||
* Called two ways, both single-flight on the client (`lib/auth/refresh`): proactively
|
||||
* on a timer at ~80% of the access lifetime, and reactively on a 401 from any cloud/BFF
|
||||
* call. On failure (the refresh token is truly expired/revoked, or a replay of a
|
||||
* rotated one) it 401s WITHOUT clearing the cookies (multi-tab rotating-token race
|
||||
* safety — a lost-race 401 must not nuke another tab's freshly-rotated cookie); the
|
||||
* client then re-reads the session (seeing a winner's fresh cookie if any) or falls
|
||||
* through to graceful re-auth. Only explicit sign-out clears the cookies.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { readRefreshToken, refreshGrant, sealSession, setCookies, SessionError, type CookieDirective } from '~/lib/server/session'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** 401 WITHOUT clearing the cookies (see the multi-tab note above). */
|
||||
const fail = () => NextResponse.json({ error: 'refresh failed' }, { status: 401 })
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: uniform same-origin gate on every mutating BFF route (the hz_rt cookie is
|
||||
// already SameSite=lax + Path=/auth, so this is belt-and-suspenders).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (!rt) return fail()
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await refreshGrant(rt)
|
||||
} catch (e) {
|
||||
// A 502 (endpoint unreachable) is transient — surface it distinctly so the client
|
||||
// can retry, and never touch the cookies.
|
||||
if (e instanceof SessionError && e.status === 502) {
|
||||
return NextResponse.json({ error: 'refresh unavailable' }, { status: 502 })
|
||||
}
|
||||
return fail()
|
||||
}
|
||||
// A rotated set with no new refresh token would strand us next cycle — require it
|
||||
// (fail-closed: never persist a session we cannot refresh again).
|
||||
if (!tokens.refreshToken) return fail()
|
||||
|
||||
const sealed = sealSession(tokens)
|
||||
if (!sealed) return fail()
|
||||
const res = NextResponse.json({ expiresIn: Math.floor(sealed.expiresInMs / 1000) })
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* POST establish the console session for the SIGNED-IN user (first-party
|
||||
* confidential-client password grant WITH offline_access → access +
|
||||
* rotating refresh token, sealed into the httpOnly cookies).
|
||||
* GET the current account resolved from that session (what the AuthGate reads
|
||||
* GET the current account resolved from that session (what the Auth reads
|
||||
* FIRST — durable + silently refreshed, so it survives the casibase
|
||||
* session's own lifetime and never bounces the user mid-task).
|
||||
* DELETE sign out — best-effort revoke the refresh token + clear the cookies.
|
||||
@@ -18,10 +18,11 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { type Account } from '~/lib/api/types'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import {
|
||||
accountOf,
|
||||
applyCookies,
|
||||
clearCookies,
|
||||
consoleSession,
|
||||
passwordGrant,
|
||||
@@ -32,44 +33,10 @@ import {
|
||||
sessionConfigured,
|
||||
setCookies,
|
||||
SessionError,
|
||||
type ConsoleClaims,
|
||||
type CookieDirective,
|
||||
} from '~/lib/server/session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Build the client-facing Account from console claims (display + admin fields only;
|
||||
* never the secret material Casdoor also packs into the token). `isGlobalAdmin` is
|
||||
* carried so the client nav/org gates (`isGlobalAdminAccount`) match the casibase
|
||||
* path; `owner === 'admin'` also implies it. */
|
||||
function accountOf(c: ConsoleClaims): Account {
|
||||
return {
|
||||
owner: c.owner ?? '',
|
||||
name: c.name ?? '',
|
||||
type: c.type,
|
||||
displayName: c.displayName,
|
||||
email: c.email,
|
||||
avatar: c.avatar,
|
||||
isAdmin: c.isAdmin,
|
||||
isGlobalAdmin: c.isGlobalAdmin || c.owner === 'admin',
|
||||
properties: c.properties,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply cookie directives to a NextResponse. */
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** GET — the account + remaining access lifetime from the live console session, or
|
||||
* 401 when there is none (the client then falls back to the casibase session). */
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
@@ -134,7 +101,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
account: accountOf(sealed.claims),
|
||||
expiresIn: Math.floor(sealed.expiresInMs / 1000),
|
||||
})
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
|
||||
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
|
||||
@@ -145,5 +112,5 @@ export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (rt) await revokeRefreshToken(rt)
|
||||
return withCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
return applyCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Email self-serve signup (HIP-0111) — create a brand-new account + its own org.
|
||||
*
|
||||
* This is the ONE unauthenticated BFF route (the caller has no account yet). It
|
||||
* acts as the confidential `hanzo-console` client to mint, in one shot:
|
||||
* 1. a personal organization (owner=`admin`, password/locale cloned from the
|
||||
* brand org so the account hashes with the brand's argon2id policy), and
|
||||
* 2. the user as that org's ADMIN (IAM hashes the password server-side).
|
||||
* The client then signs in with the same credentials and lands as admin — no
|
||||
* separate onboarding step (IAM users always belong to an org, so "create then
|
||||
* onboard" is not possible against casibase; the org is minted here).
|
||||
*
|
||||
* Email uniqueness without a global user-lookup endpoint: the org slug is a
|
||||
* DETERMINISTIC, injective function of the email (`personalOrgFromEmail`), so a
|
||||
* repeat signup with the same email resolves to the same slug and is caught by
|
||||
* `getOrganization` (409) — two different emails never false-collide.
|
||||
*
|
||||
* Honest states: 501 when the IAM client is unwired, 400 on bad input, 409 when
|
||||
* the account already exists, 502 on an IAM failure.
|
||||
*
|
||||
* NOTE (hardening, flagged not done here): this endpoint creates accounts from the
|
||||
* open internet. It validates input but has NO captcha / rate-limit / email-
|
||||
* verification gate yet — those are follow-ups (email verification especially
|
||||
* would add friction the go-live conversion goal explicitly avoids).
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS } from '~/lib/branding/brands'
|
||||
import { createOrganization, createUser, getOrganization, mintConfigured } from '~/lib/server/identity'
|
||||
import {
|
||||
deriveUsername,
|
||||
displayNameFromEmail,
|
||||
personalOrgFromEmail,
|
||||
validateSignup,
|
||||
} from '~/lib/server/onboarding'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Same-origin gate: this route creates accounts from the open internet with no
|
||||
// captcha/rate-limit yet, so refuse cross-origin scripted signups (a mild anti-abuse
|
||||
// measure; the console's own signup form is same-origin).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account creation is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { email?: string; password?: string }
|
||||
const v = validateSignup(body.email ?? '', body.password ?? '')
|
||||
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const brandOrg = brand.id // hanzo/lux/zoo/pars — cloned for password/locale policy
|
||||
const signupApplication = `${brand.id}-cloud` // hanzo-cloud, lux-cloud, …
|
||||
|
||||
const digest = createHash('sha256').update(v.email).digest('hex')
|
||||
const orgSlug = personalOrgFromEmail(v.email, digest)
|
||||
|
||||
if (await getOrganization(orgSlug)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An account with this email already exists. Sign in instead.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const displayName = displayNameFromEmail(v.email)
|
||||
try {
|
||||
await createOrganization({ name: orgSlug, displayName, personal: true, sourceOwner: brandOrg })
|
||||
await createUser({
|
||||
org: orgSlug,
|
||||
username: deriveUsername(v.email),
|
||||
email: v.email,
|
||||
password: v.password,
|
||||
displayName,
|
||||
signupApplication,
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not create your account: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, org: orgSlug })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* GET /auth/waitlist — the signed-in user's WAITLIST ACCESS + position (BFF).
|
||||
*
|
||||
* THE shared product-access check. The console shell (Waitlist) reads this to
|
||||
* decide whether to render the product or the waitlist status page; hanzo.chat and
|
||||
* hanzo.app gate on the SAME underlying `/v1/waitlist/status` for the same user, so
|
||||
* a user's access + position are identical across every surface.
|
||||
*
|
||||
* Resolves the caller's email from their established session (never trusts a
|
||||
* client-supplied email), then asks the waitlist plugin. FAIL-OPEN: when the waitlist
|
||||
* is unconfigured or unreachable, `waitlistAccess` grants access — the gate is
|
||||
* additive and never locks a signed-in user out of a paid product on a blip.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { waitlistAccess } from '~/lib/server/waitlist'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
// No email on the identity → cannot key a waitlist entry; fail OPEN (don't strand
|
||||
// a valid session behind a gate it can never satisfy).
|
||||
if (!user.email) return NextResponse.json({ hasAccess: true, status: null })
|
||||
|
||||
const { hasAccess, status } = await waitlistAccess(user.email, req.headers.get('host'))
|
||||
return NextResponse.json({ hasAccess, status })
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. The browser calls console2's OWN origin
|
||||
* (`/billing/v1/...`); this server handler forwards to commerce's `/v1/billing/...`,
|
||||
* injecting the commerce SERVICE token from server-only env (never `NEXT_PUBLIC_`,
|
||||
* never in the browser bundle) AND scoping every request to the caller's OWN org.
|
||||
*
|
||||
* Namespaced under `/billing/v1/` (NOT bare `/billing/`) so the data plane never
|
||||
* shadows the billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …): a
|
||||
* route handler always wins over the catch-all page for a matching path segment, so
|
||||
* the tab slugs and the data endpoints must live in disjoint path space. The tab
|
||||
* URLs now fall through to the SPA (`app/(dashboard)/[...slug]`).
|
||||
*
|
||||
* Same trust boundary as the `/admin/iam` + `/admin/kms` proxies, but the authz is
|
||||
* PER-TENANT, not admin: any authenticated session may read/act on ITS OWN billing
|
||||
* (balance / usage / invoices / credit-grants / subscriptions / payment-methods).
|
||||
* The org is resolved server-side from the validated session (`resolveUser`) and
|
||||
* stamped as `X-Org-Id` (the header commerce's service-token path actually reads —
|
||||
* `commerce/middleware/accesstoken.go`), and the server-resolved billing subject is
|
||||
* pinned onto the FULL commerce subject-key set (`user`/`userId`/`customerId`, via
|
||||
* `scopedBillingSearch`) while `?org=` is dropped. The client CANNOT widen scope: a
|
||||
* forged `?userId=`/`?customerId=`/`?org=` is overwritten, and because EVERY subject
|
||||
* param is pinned, no billing endpoint is left unfiltered regardless of which one it
|
||||
* reads (subscriptions filter `userId`, payment-methods `customerId`). So commerce's
|
||||
* per-tenant isolation can never be crossed from the browser. No session → 401.
|
||||
*
|
||||
* Billing-subject mirrors `object.BillingSubject` (hanzoai/ai) + chat's
|
||||
* `billingSubject`: a member of a PERSONAL-billing org (default the shared `hanzo`
|
||||
* catch-all) bills per-user as `<org>/<name>`; a dedicated org (maxpower, …) bills
|
||||
* per-org as `<org>`. The SAME subject the gateway debits — so the console shows
|
||||
* the exact balance/usage that gets charged.
|
||||
*
|
||||
* `COMMERCE_TOKEN` unset → honest 501 (the UI shows a truthful "not configured"
|
||||
* state; it never fabricates a balance).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { billingSubject, scopedBillingSearch, scopedBillingBody } from '~/lib/server/billing-scope'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const isSafeSegment = (s: string): boolean =>
|
||||
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
|
||||
|
||||
function commerceBaseUrl(): string {
|
||||
return (process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// CSRF: a mutating billing write (spend-alert/budget) authenticates from the
|
||||
// auto-sent cookie, so refuse a cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
// Per-tenant authz: any valid session may see ITS OWN billing (no admin gate).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view billing.' }, { status: 401 })
|
||||
}
|
||||
if (!path.every(isSafeSegment)) {
|
||||
return NextResponse.json({ error: 'Invalid billing path.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = process.env.COMMERCE_TOKEN ?? process.env.COMMERCE_SERVICE_TOKEN ?? ''
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billing is not configured (COMMERCE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
// Scope to the caller's OWN org — server-resolved, never client-supplied.
|
||||
const org = user.owner.trim()
|
||||
const subject = billingSubject(org, user.name)
|
||||
|
||||
// Pin the FULL billing-subject key set to the server-resolved subject, and
|
||||
// strip `org`, so the browser can never read another tenant's ledger. Commerce
|
||||
// filters each endpoint on a DIFFERENT param — subscriptions on `userId`,
|
||||
// payment-methods on `customerId` (or `user`), usage on `user` — so pinning
|
||||
// only ONE param leaves the others unfiltered (a cross-tenant read). This
|
||||
// mirrors commerce's own edge-auth `billingSubjectKeys`
|
||||
// (commerce/middleware/edgeauth.go: {"user","userId","customerId"}) exactly, so
|
||||
// every billing endpoint is scoped no matter which param it reads.
|
||||
const qs = scopedBillingSearch(req.nextUrl.search, subject)
|
||||
const url = `${commerceBaseUrl()}/v1/billing/${path.join('/')}${qs ? `?${qs}` : ''}`
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Commerce resolves the tenant namespace from `X-Org-Id` on the service-token
|
||||
// path (commerce/middleware/accesstoken.go). It does NOT read `X-Hanzo-Org`,
|
||||
// so sending that alone silently falls back to the service org — every tenant
|
||||
// sharing one namespace. Send `X-Org-Id`, matching the `/ai` proxy.
|
||||
'X-Org-Id': org,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
// Scope the WRITE body to the caller's OWN subject too (not just the query):
|
||||
// commerce reads the subject from the JSON body on writes like create-spend-alert
|
||||
// (`userId`), so pin it server-side — the browser needn't know its subject and a
|
||||
// forged body subject cannot widen scope. Mirrors `scopedBillingSearch`.
|
||||
init.body = scopedBillingBody(await req.text(), subject)
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
// A per-tenant money response (balance/usage/invoices) must NEVER be cached
|
||||
// by the browser or any intermediary — otherwise the wallet shows a stale
|
||||
// number after a completion or a top-up. The live-balance store still polls,
|
||||
// but this guarantees each fetch hits commerce, not a cache.
|
||||
'Cache-Control': 'no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Billing upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to the unified cloud-api `/v1/*` — the ONE path the
|
||||
* browser uses to reach the cloud surfaces that authorize on a Bearer JWT.
|
||||
*
|
||||
* The managed data resources (vector/sql/kv/s3/docdb/datastore/search) and the
|
||||
* serverless / prompt / agent surfaces resolve the org from the token's `owner`
|
||||
* claim and 403 a cookie-only call ("X-Org-Id required"). So — exactly like the
|
||||
* `/ai` proxy — the browser calls this OWN-origin route with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
|
||||
* IAM token (shared per-user cache), and forwards to cloud-api with that Bearer. No
|
||||
* credential reaches the browser, org is server-authoritative (never the browser's
|
||||
* claim), and every read/write is billed + scoped to the user's own org.
|
||||
*
|
||||
* Least privilege: only the data + serverless HEADS are reachable (`allowCloudSurface`);
|
||||
* `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a general cloud-api tunnel.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* /console/accept — the invitee's side of the team-invite flow (UNAUTHENTICATED).
|
||||
*
|
||||
* GET ?t=<token> → validate the sealed invite; report whether the member is
|
||||
* still PENDING (no password) or already ACTIVATED, plus the
|
||||
* org + email to show. Never leaks anything a token-holder
|
||||
* shouldn't already know (the admin put them in the org).
|
||||
* POST { t, password, displayName? } → set the pending member's INITIAL password
|
||||
* (IAM hashes it — never plaintext) and mark them activated.
|
||||
*
|
||||
* The sealed token IS the authorization (it names exactly one `org/name`), so this
|
||||
* needs no session — the invitee has none yet. It refuses once the member already
|
||||
* has a password, so a link can never reset an active member's credential.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS } from '~/lib/branding/brands'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { getMember, memberHasPassword, activateMember, mintConfigured } from '~/lib/server/identity'
|
||||
import { readInvite, inviteUserId } from '~/lib/server/invite'
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (error: string, status: number) => NextResponse.json({ error }, { status })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const inv = readInvite(req.nextUrl.searchParams.get('t'))
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const member = await getMember(inviteUserId(inv))
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
org: inv.org,
|
||||
email: member.email || inv.email,
|
||||
displayName: member.displayName || member.name,
|
||||
role: member.isAdmin ? 'admin' : 'member',
|
||||
accepted: memberHasPassword(member),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return bad('Invite acceptance is not configured on this deployment.', 501)
|
||||
}
|
||||
|
||||
let body: { t?: unknown; password?: unknown; displayName?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const inv = readInvite(typeof body.t === 'string' ? body.t : null)
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return bad(`Use a password of at least ${MIN_PASSWORD} characters.`, 400)
|
||||
}
|
||||
if (/\s/.test(password)) return bad('Password cannot contain spaces.', 400)
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''
|
||||
|
||||
const id = inviteUserId(inv)
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
// Single-use for activation: refuse if the member already has a credential, so a
|
||||
// stale/re-shared link can never reset an active member's password.
|
||||
if (memberHasPassword(member)) {
|
||||
return bad('This invitation was already accepted. Please sign in.', 409)
|
||||
}
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const signupApplication = `${brand.id}-cloud`
|
||||
|
||||
try {
|
||||
await activateMember(id, { password, displayName: displayName || undefined, signupApplication })
|
||||
} catch (e) {
|
||||
return bad(`Could not activate the account: ${e instanceof Error ? e.message : String(e)}`, 502)
|
||||
}
|
||||
return NextResponse.json({ ok: true, org: inv.org, email: member.email || inv.email })
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /console/invite-link — mint a shareable ACCEPT LINK for a pending member.
|
||||
*
|
||||
* The Team module creates the member row via the `/org/iam` proxy (Dave's own
|
||||
* user bearer, Casbin-scoped to his org) — that path is unchanged. This route then
|
||||
* mints the sealed, TTL-bound invite token so the invitee can set a password and
|
||||
* sign in, WITHOUT any email/OTP (delivery is a link hand-off; IAM `send-invitation`
|
||||
* is a documented stub on this deployment).
|
||||
*
|
||||
* Gate: any authenticated ORG ADMIN, pinned to a member of their OWN org (a global
|
||||
* admin may target any org — same policy as the `/org/iam` proxy). The member must
|
||||
* actually EXIST in that org (verified via the confidential client) — so an admin
|
||||
* can never mint an activation link for someone else's tenant or a phantom user.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate, getMember } from '~/lib/server/identity'
|
||||
import { ownerAllowed, orgWriteAllowed } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { signInvite, acceptLink, type Invite } from '~/lib/server/invite'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (msg: string, status: number) => NextResponse.json({ error: msg }, { status })
|
||||
|
||||
/** The public origin the invitee will open — from the ingress-set Host header. */
|
||||
function publicOrigin(req: NextRequest): string {
|
||||
const host = req.headers.get('host') ?? req.nextUrl.host
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return bad('forbidden', 403)
|
||||
// Writes (an invite is one) require org admin — a member can view the roster only.
|
||||
if (!orgWriteAllowed({ isSuperAdmin: gate.isSuperAdmin, isAdmin: gate.user.isAdmin })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
let body: { org?: unknown; name?: unknown; email?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
// The org defaults to the caller's own scope; a SuperAdmin may pass another.
|
||||
const reqOrg = typeof body.org === 'string' && body.org.trim() ? body.org.trim() : gate.orgScope
|
||||
if (!name) return bad('missing member name', 400)
|
||||
|
||||
// Pin the org to the caller's scope (a non-SuperAdmin can only ever mint a link
|
||||
// for their OWN org) — the SAME guard as the /org/iam proxy.
|
||||
if (!ownerAllowed(reqOrg, { isSuperAdmin: gate.isSuperAdmin, orgScope: gate.orgScope, orgMetadataOk: false })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
const id = `${reqOrg}/${name}`
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== reqOrg) return bad('member not found', 404)
|
||||
|
||||
const inv: Invite = { org: reqOrg, name, email: email || member.email || '' }
|
||||
const token = signInvite(inv)
|
||||
return NextResponse.json({ ok: true, org: reqOrg, name, email: inv.email, link: acceptLink(publicOrigin(req), token) })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
|
||||
*
|
||||
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
|
||||
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
|
||||
* user who signed in through it lands on an account page that can't manage MFA
|
||||
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
|
||||
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
|
||||
* (the authz filter authenticates the JWT and Casbin authorizes self-service MFA),
|
||||
* with owner/name PINNED to the resolved session user — so a caller can only ever
|
||||
* manage THEIR OWN 2FA, never another account's.
|
||||
*
|
||||
* Actions (POST): initiate · verify · enable · disable — the standard TOTP flow.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, adminBearer, iamBaseUrl } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const TOTP = 'app' // Casdoor TotpType
|
||||
|
||||
/**
|
||||
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
|
||||
* pinned to the resolved session user for TWO reasons: (1) the handler targets that
|
||||
* user, and (2) the IAM authz filter derives the request OBJECT from `owner`/`name`
|
||||
* (query) and grants self-access when it equals the bearer subject — the SAME rule
|
||||
* that lets `get-users?owner=<me>` through. We send these as the QUERY STRING with an
|
||||
* EMPTY body: the authz filter's object-derivation reads a form body as JSON, so a
|
||||
* form-encoded body yields an empty object (→ no self-match → denied); with the
|
||||
* params in the query and no body it reads owner/name and the self grant applies.
|
||||
*/
|
||||
const ACTIONS: Record<string, { path: string; params: (u: { owner: string; name: string }, b: Body) => Record<string, string> }> = {
|
||||
initiate: {
|
||||
path: '/v1/iam/mfa/setup/initiate',
|
||||
params: (u) => ({ owner: u.owner, name: u.name, mfaType: TOTP }),
|
||||
},
|
||||
verify: {
|
||||
path: '/v1/iam/mfa/setup/verify',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, passcode: b.passcode ?? '', secret: b.secret ?? '' }),
|
||||
},
|
||||
enable: {
|
||||
path: '/v1/iam/mfa/setup/enable',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, secret: b.secret ?? '', recoveryCodes: b.recoveryCodes ?? '' }),
|
||||
},
|
||||
disable: {
|
||||
path: '/v1/iam/delete-mfa',
|
||||
params: (u) => ({ owner: u.owner, name: u.name }),
|
||||
},
|
||||
}
|
||||
|
||||
type Body = { passcode?: string; secret?: string; recoveryCodes?: string }
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ action: string }> }): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const { action } = await ctx.params
|
||||
const spec = ACTIONS[action]
|
||||
if (!spec) return NextResponse.json({ error: 'unknown action' }, { status: 404 })
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Body
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Could not authorize the request.' }, { status: 502 })
|
||||
}
|
||||
|
||||
// Params ride the QUERY STRING (see ACTIONS doc) with an EMPTY body so the IAM
|
||||
// authz filter derives owner/name for the self-access grant.
|
||||
const qs = new URLSearchParams(spec.params({ owner: user.owner, name: user.name }, body)).toString()
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${iamBaseUrl()}${spec.path}?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Identity service is unavailable.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-level recovery boundary (Next App Router `global-error`).
|
||||
*
|
||||
* This REPLACES Next's built-in root fallback — the one that renders the bare,
|
||||
* dead-ended "Application error: a client-side exception has occurred" and leaves
|
||||
* the SPA wedged (no router, so a later in-app nav back to `/` stays dead until a
|
||||
* full reload). It is the OUTERMOST boundary: it catches throws in the root layout
|
||||
* and anything that bubbles past the segment boundaries — including a chunk-load
|
||||
* failure during the very first hydration, which is exactly the "deep-link /
|
||||
* refresh a sub-route → crash" the audit hit (a stale-deploy chunk 404s, falls
|
||||
* through to the app-shell HTML, and the browser throws parsing HTML as JS).
|
||||
*
|
||||
* On a chunk skew it SELF-HEALS: one full reload per window pulls the fresh HTML +
|
||||
* current chunks. The reload is bounded by the SAME sessionStorage key every other
|
||||
* recovery site uses (`CHUNK_RELOAD_AT_KEY`), so a skew that trips several
|
||||
* boundaries at once reloads ONCE, never in a loop. For a genuine (non-chunk)
|
||||
* crash it shows a minimal, self-contained recovery card — it runs with the root
|
||||
* layout torn down, so it owns its own `<html>`/`<body>` and uses inline styles
|
||||
* (no GUI provider is mounted here).
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { reportError } from '~/lib/event'
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[console] global error:', error)
|
||||
// The root layout (and its AnalyticsProvider) is torn down here, so this boundary
|
||||
// reports through the module-singleton `eventClient` — the reason it is shared. A
|
||||
// chunk skew self-heals below and is not reported; only a genuine crash is.
|
||||
if (!chunk) {
|
||||
reportError(error, { digest: error.digest, boundary: 'global' })
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* sessionStorage blocked (private mode) — fall through to the manual card */
|
||||
}
|
||||
}, [error, chunk])
|
||||
|
||||
return (
|
||||
<html lang="en" style={{ backgroundColor: '#000', colorScheme: 'dark' }}>
|
||||
<body style={{ margin: 0, fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif', color: '#fff', backgroundColor: '#000' }}>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div style={{ maxWidth: 440, width: '100%', border: '1px solid #262626', borderRadius: 12, padding: 24, backgroundColor: '#0a0a0a' }}>
|
||||
<h1 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700 }}>
|
||||
{chunk ? 'Updating to the latest version' : 'Something went wrong'}
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 14, lineHeight: 1.5, color: '#a3a3a3' }}>
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reloading to load the latest…'
|
||||
: 'The console hit an unexpected error. Reload to recover, or return home.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!chunk ? (
|
||||
<button type="button" onClick={() => reset()} style={btn(true)}>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.reload() }}
|
||||
style={btn(chunk)}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.assign('/') }}
|
||||
style={btn(false)}
|
||||
>
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline button style — primary (filled) vs chromeless (bordered). */
|
||||
function btn(primary: boolean): React.CSSProperties {
|
||||
return {
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
border: primary ? '1px solid #fff' : '1px solid #333',
|
||||
backgroundColor: primary ? '#fff' : 'transparent',
|
||||
color: primary ? '#000' : '#e5e5e5',
|
||||
}
|
||||
}
|
||||
+244
-61
@@ -1,26 +1,9 @@
|
||||
/* Geist Mono — canonical Hanzo mono face (code/data). */
|
||||
/* Canonical Hanzo faces — Geist Sans (UI/body/headings) + Geist Mono (code/data),
|
||||
loaded from the same CDN package so both faces resolve one way. Geist ships a full
|
||||
real weight range, so headings render a true heavier cut, never a synthesized face. */
|
||||
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/style.css');
|
||||
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css');
|
||||
|
||||
/* Basel Grotesk — canonical Hanzo UI/body/display/heading face (self-hosted). */
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Medium.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
@@ -31,17 +14,15 @@ body {
|
||||
margin: 0;
|
||||
background-color: var(--background, #000000);
|
||||
color: var(--color, #ededf1);
|
||||
font-family:
|
||||
'Basel', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
/* Basel ships only Book (400) + Medium (500). NEVER let the browser fabricate a
|
||||
heavier/oblique face — a requested 600/700/800 falls to the real Medium (500)
|
||||
via CSS font-matching, so headings are crisp Medium, never a smeared faux-bold.
|
||||
Inherited by every element; the ONE place the whole product bans synthesis. */
|
||||
/* Geist ships a full real weight range, so a requested 500/600/700 resolves to a
|
||||
genuine cut — never a browser-fabricated faux-bold/oblique. This bans synthesis
|
||||
outright as a floor. Inherited by every element; the ONE place the product sets it. */
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@@ -62,7 +43,7 @@ samp {
|
||||
/* Data/numeric face — Geist Mono + tabular figures for metric values, prices, IDs,
|
||||
counts and code-like tokens. The dashboard-grade "numbers are typeset" detail
|
||||
(Linear/Stripe): stat tiles, table numeric cells and monospace identifiers read
|
||||
as precise, column-aligned data — distinct from Basel prose. One class, whole
|
||||
as precise, column-aligned data — distinct from Geist prose. One class, whole
|
||||
product. `className` forwards to the DOM node on web, so a Gui <Text> can wear it. */
|
||||
.hz-mono {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
@@ -75,9 +56,9 @@ samp {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ban faux-bold/oblique EVERYWHERE. Basel ships only Book (400) + Medium (500);
|
||||
a requested 600/700/800/900 must fall to the real Medium face, never a
|
||||
browser-synthesized smear. Tamagui/RNW inject runtime styles that reset the
|
||||
/* Ban faux-bold/oblique EVERYWHERE. Geist ships real weight cuts, so a requested
|
||||
600/700/800/900 must map to a genuine face, never a browser-synthesized smear.
|
||||
Tamagui/RNW inject runtime styles that reset the
|
||||
inherited `font-synthesis` on Text nodes, so a body-level declaration loses —
|
||||
this universal rule (with !important, a true global invariant) wins on every
|
||||
element regardless of insertion order. One place, whole product. */
|
||||
@@ -104,7 +85,7 @@ samp {
|
||||
html:root.t_dark {
|
||||
--background: #000000;
|
||||
--backgroundStrong: #000000;
|
||||
--backgroundHover: #171717;
|
||||
--backgroundHover: #101010;
|
||||
--backgroundPress: #050505;
|
||||
--backgroundFocus: #171717;
|
||||
|
||||
@@ -114,40 +95,64 @@ html:root.t_dark {
|
||||
--color2: #0a0a0a;
|
||||
--color3: #171717;
|
||||
--color4: #1f1f1f;
|
||||
--color5: hsl(220 6% 16%);
|
||||
--color6: hsl(220 6% 22%);
|
||||
--color7: hsl(220 6% 30%);
|
||||
--color8: hsl(219 6% 42%);
|
||||
--color9: hsl(220 6% 55%);
|
||||
--color10: hsl(219 7% 68%);
|
||||
--color11: hsl(214 9% 83%);
|
||||
--color12: hsl(210 12% 95%);
|
||||
--color: hsl(210 12% 95%);
|
||||
--color5: hsl(0 0% 16%);
|
||||
--color6: hsl(0 0% 22%);
|
||||
--color7: hsl(0 0% 30%);
|
||||
--color8: hsl(0 0% 42%);
|
||||
--color9: hsl(0 0% 55%);
|
||||
--color10: hsl(0 0% 68%);
|
||||
--color11: hsl(0 0% 83%);
|
||||
--color12: hsl(0 0% 95%);
|
||||
--color: #ededed;
|
||||
|
||||
/* Gentle hairlines — present enough to define, quiet enough to disappear on black. */
|
||||
--borderColor: hsl(220 8% 15%);
|
||||
--borderColorHover: hsl(220 7% 23%);
|
||||
--borderColorPress: hsl(220 8% 13%);
|
||||
--borderColorFocus: hsl(220 7% 23%);
|
||||
--borderColor: #1f1f1f;
|
||||
--borderColorHover: #333333;
|
||||
--borderColorPress: hsl(0 0% 13%);
|
||||
--borderColorFocus: hsl(0 0% 23%);
|
||||
|
||||
/* Elevation ladder (Material-inspired: ambient + key light). On the true-black
|
||||
canvas a cast shadow alone is nearly invisible, so each level pairs a deep
|
||||
shadow with a faint top highlight + a hairline ring (set on .hz-paper) so a
|
||||
sheet lifts cleanly off black. Brand-neutral — color stays token-driven. */
|
||||
--hz-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.6), 0 1px 1px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-2: 0 3px 8px rgba(0, 0, 0, 0.62), 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-3: 0 8px 24px rgba(0, 0, 0, 0.64), 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-4: 0 16px 40px rgba(0, 0, 0, 0.68), 0 6px 14px rgba(0, 0, 0, 0.55);
|
||||
--hz-elevation-5: 0 28px 64px rgba(0, 0, 0, 0.72), 0 12px 24px rgba(0, 0, 0, 0.6);
|
||||
--hz-ring: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Light theme — the calm parallel: a warm off-white base (not stark #fff), soft
|
||||
ink text (not pure black), and quiet hairlines. Lighter touch than dark, since
|
||||
the console defaults to dark, but kept consistent for the theme toggle. */
|
||||
/* Light theme — the calm parallel: a neutral off-white base (not stark #fff), soft
|
||||
ink text (not pure black), and quiet hairlines. MONOCHROME by construction — every
|
||||
token is a zero-saturation gray (hue-agnostic), the light twin of the dark ladder,
|
||||
so no surface ever reads a blue/cool tint. Lighter touch than dark, since the
|
||||
console defaults to dark, but kept consistent for the theme toggle. */
|
||||
html:root.t_light {
|
||||
--background: hsl(220 20% 99%);
|
||||
--color1: hsl(220 24% 100%);
|
||||
--color2: hsl(220 20% 98%);
|
||||
--color3: hsl(220 18% 95.5%);
|
||||
--color4: hsl(220 16% 92.5%);
|
||||
--color5: hsl(220 15% 89%);
|
||||
--color9: hsl(220 9% 46%);
|
||||
--color10: hsl(220 10% 38%);
|
||||
--color11: hsl(220 14% 22%);
|
||||
--color12: hsl(220 22% 12%);
|
||||
--color: hsl(220 22% 12%);
|
||||
--borderColor: hsl(220 16% 90%);
|
||||
--borderColorHover: hsl(220 14% 82%);
|
||||
--background: hsl(0 0% 99%);
|
||||
--color1: hsl(0 0% 100%);
|
||||
--color2: hsl(0 0% 98%);
|
||||
--color3: hsl(0 0% 95.5%);
|
||||
--color4: hsl(0 0% 92.5%);
|
||||
--color5: hsl(0 0% 89%);
|
||||
--color9: hsl(0 0% 46%);
|
||||
--color10: hsl(0 0% 38%);
|
||||
--color11: hsl(0 0% 22%);
|
||||
--color12: hsl(0 0% 12%);
|
||||
--color: hsl(0 0% 12%);
|
||||
--borderColor: hsl(0 0% 90%);
|
||||
--borderColorHover: hsl(0 0% 82%);
|
||||
|
||||
/* Elevation ladder — light theme: soft NEUTRAL-grey Material shadows (pure black
|
||||
alpha, zero hue) on the off-white base — the calm parallel of the dark ladder. */
|
||||
--hz-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--hz-elevation-2: 0 3px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--hz-elevation-3: 0 10px 24px rgba(0, 0, 0, 0.1), 0 3px 8px rgba(0, 0, 0, 0.07);
|
||||
--hz-elevation-4: 0 18px 40px rgba(0, 0, 0, 0.13), 0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
--hz-elevation-5: 0 28px 60px rgba(0, 0, 0, 0.16), 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
--hz-ring: 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
|
||||
@@ -376,3 +381,181 @@ html:root.t_light {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Touch targets — WCAG 2.5.5 (AAA) / Apple HIG ≥44px ──────────────────────
|
||||
On phones/tablets (<lg) every control inside the mobile nav drawer must be at
|
||||
least 44px tall to tap reliably. Scoped to `.hz-touch-target` (set on the drawer
|
||||
root only), so the dense DESKTOP sidebar — a separate mount at lg+ that never
|
||||
wears this class — keeps its Linear-grade density. A Gui <Button> renders a real
|
||||
<button>, so this one rule reaches every nav row / control within the drawer.
|
||||
One class, every touch surface (DRY). */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-touch-target button,
|
||||
.hz-touch-target [role='button'] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chat composer dock — pinned to the viewport bottom on phones/tablets ─────
|
||||
The full-page chat scrolls inside the shell's content scroller; without this the
|
||||
composer sits at the end of a tall welcome/thread and first paints BELOW the fold.
|
||||
Made sticky it rides the bottom edge of the scrollport (the conversation scrolls
|
||||
under it), so the input is always reachable. From lg up the capped, centered
|
||||
column already keeps it in view, so it stays in normal flow. The element carries
|
||||
an opaque background so content scrolls cleanly beneath. */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-chat-dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
/* Clear the iOS home indicator when Safari's bottom bar hides (viewport-fit=cover
|
||||
exposes the inset; 0 on devices without one, so no effect elsewhere). */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Material paper / 3D elevation ─────────────────────────────────────────────
|
||||
A real depth system for the console's overlay surfaces (drawer, command palette,
|
||||
menus, dialog, support bubble). Layered box-shadow (ambient + key light) read
|
||||
from the per-theme --hz-elevation-* tokens (light AND dark aware). Brand-neutral:
|
||||
the shadow is monochrome and color stays token-driven, so lux/zoo/pars theme
|
||||
cleanly. One place defines the ladder; an overlay wears a class. `className`
|
||||
forwards to the DOM node on web, so a Gui surface can wear these. */
|
||||
.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
|
||||
.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
|
||||
.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
|
||||
.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
|
||||
.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
|
||||
|
||||
/* Paper = an elevated sheet: hairline ring + top highlight + a mid cast shadow, so
|
||||
a menu/palette/dialog reads as a physical sheet floating above the page. */
|
||||
.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
|
||||
.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
|
||||
.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
|
||||
|
||||
/* Overlay entrance — a fast, physical scale-fade from the origin (menus, palette,
|
||||
dialog, support sheet). 180ms ease-out enter; the overlay's own unmount handles
|
||||
exit. Reduced-motion → snap (no transform). */
|
||||
@keyframes hz-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.hz-pop-in {
|
||||
animation: hz-pop-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transform-origin: var(--hz-pop-origin, center);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Popover MENU entrance — OPACITY-ONLY (never transform). floating-ui positions an
|
||||
anchored menu with an inline `transform: translate(x,y)`, and a CSS-animation that
|
||||
also drives `transform` (like hz-pop-in) OVERRIDES that inline value for the
|
||||
animation's duration — detaching the menu from its trigger. So anchored menus
|
||||
(SelectMenu / ComboBox Popover.Content) fade in with NO transform, keeping the
|
||||
floating-ui anchor exact. The transform-based hz-pop-in stays for the centered
|
||||
Dialog surfaces (CommandPalette / AppLauncher / FloatingChat), which are NOT
|
||||
floating-ui-positioned. Reduced-motion → snap. */
|
||||
@keyframes hz-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.hz-menu-in {
|
||||
animation: hz-menu-in 140ms ease-out both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Scrim fade — the dimmed backdrop behind a dialog/palette eases in (Tamagui mounts
|
||||
the overlay instantly otherwise). */
|
||||
@keyframes hz-scrim-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.hz-scrim-in {
|
||||
animation: hz-scrim-in 160ms ease-out both;
|
||||
}
|
||||
|
||||
/* Support bubble — a gentle hover lift on the elevated brand-H bubble. */
|
||||
.hz-lift {
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 160ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.hz-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Hover-paper — a subtle elevation lift on hover for a small affordance (the sidebar
|
||||
brand-H container). Only the H wears this, never the whole row. */
|
||||
.hz-hover-paper {
|
||||
transition:
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 140ms ease;
|
||||
}
|
||||
.hz-hover-paper:hover {
|
||||
box-shadow: var(--hz-elevation-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-pop-in,
|
||||
.hz-menu-in,
|
||||
.hz-scrim-in {
|
||||
animation: none;
|
||||
}
|
||||
.hz-lift,
|
||||
.hz-hover-paper {
|
||||
transition: none;
|
||||
}
|
||||
.hz-lift:hover,
|
||||
.hz-hover-paper:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A11y + responsive hardening ─────────────────────────────────────────────
|
||||
Global floors that hold across every product surface. One place, whole app. */
|
||||
|
||||
/* 1. The page body is a hard NO-horizontal-scroll surface. A stray fixed/overwide
|
||||
child (an off-screen drawer mid-transition, a wide table) must clip, never
|
||||
scroll the whole document sideways. `clip` (not `hidden`) does not create a
|
||||
scroll container, so sticky/fixed descendants keep working. */
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* 2. Visible keyboard focus, everywhere. `:focus-visible` fires ONLY for keyboard
|
||||
navigation (never a mouse/touch press), so this paints a crisp ring for
|
||||
tab-through without touching pointer interactions. Tamagui focusStyle handles
|
||||
some controls; this is the global floor so nothing is ever focus-invisible.
|
||||
Colour reads from the theme scale, so it adapts in light and dark. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color9, #6c6c6c);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 3. Touch tap targets ≥44px (WCAG 2.5.5 / Apple HIG). On a COARSE pointer
|
||||
(phone/tablet) every top-bar control meets the 44×44 minimum; the desktop
|
||||
mouse density is deliberately left unchanged. Scoped to the top bar so table
|
||||
row-actions and inline chips are untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hz-topbar button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* API-keys "sign in to manage API keys" / CORS crack).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/keys`) with just its first-party
|
||||
* session cookie. This handler resolves the signed-in user from that cookie
|
||||
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
|
||||
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
|
||||
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
|
||||
* verified live). No credential ever reaches the browser; the `hk-` secret is
|
||||
* returned ONLY by POST (show once).
|
||||
*
|
||||
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
|
||||
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
|
||||
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
|
||||
* deployment anyway. The IAM confidential-client mint the console already uses for
|
||||
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
|
||||
* exact primitives from `identity.ts`, no new IAM plumbing).
|
||||
*
|
||||
* GET → { hasKey, keyPrefix, createdAt } (no secret)
|
||||
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
|
||||
* DELETE → { ok: true } (revoke; the old key stops working)
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, mintUserKey, getUserKey, revokeUserKey, mintConfigured } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** 401 (not signed in) — the honest state the UI shows to sign in. */
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
|
||||
}
|
||||
|
||||
/** GET — the user's current key state (existence + public prefix, NEVER the secret). */
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
// Honest, non-leaking: the confidential client isn't wired on this deployment.
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const { accessKey, updatedAt } = await getUserKey(user)
|
||||
return NextResponse.json({
|
||||
hasKey: Boolean(accessKey),
|
||||
keyPrefix: accessKey ? accessKey.slice(0, 11) : '',
|
||||
createdAt: updatedAt || '',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('keys: could not read key state:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not read the API key state.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
|
||||
// refuse a cross-origin request before any work.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const accessKey = await mintUserKey(user)
|
||||
return NextResponse.json({ accessKey })
|
||||
} catch (e) {
|
||||
console.error('keys: could not mint key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not create the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE — revoke the key (the old key stops working; gateway cache ~5m). */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
await revokeUserKey(user)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('keys: could not revoke key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not revoke the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { headers } from 'next/headers'
|
||||
|
||||
import { Provider } from '~/components/Provider'
|
||||
import { ChunkGuard } from '~/components/ChunkGuard'
|
||||
import { BrandTitle } from '~/components/BrandTitle'
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
|
||||
@@ -25,6 +26,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#000000',
|
||||
// Extend the layout into the display cutout / home-indicator area so the
|
||||
// `env(safe-area-inset-*)` values become non-zero on notched devices — the mobile
|
||||
// drawers + chat composer read them to keep content clear of the notch/indicator.
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
@@ -32,6 +37,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<body style={{ margin: 0 }}>
|
||||
<ChunkGuard />
|
||||
<BrandTitle />
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -55,19 +55,25 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a global
|
||||
// admin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a
|
||||
// SuperAdmin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// a brand admin to their own — so we forward the resolved org, never the raw
|
||||
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
|
||||
const org = policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.headers.get('X-Org-Id'),
|
||||
)
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
const search = req.nextUrl.search
|
||||
const url = `${PLATFORM_URL}/v1/${path.join('/')}${search}`
|
||||
// `/paas/<x>` → `/v1/paas/<x>`. The control plane mounts under `/v1/paas`; this
|
||||
// route prefixed only `/v1`, so every call landed on a path that does not exist
|
||||
// (`/paas/apps` → `/v1/apps` → 404) and the board rendered nothing. The name is
|
||||
// 1:1 on both sides: this proxy is the PaaS plane, so it forwards to the PaaS
|
||||
// plane. It aimed at `/v1/<x>` because that IS where the standalone Node platform
|
||||
// served apps; the plane moved into cloud under `/v1/paas` and the path did not.
|
||||
const url = `${PLATFORM_URL}/v1/paas/${path.join('/')}${search}`
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Design-reference route — renders the ProductLanding kit + the RailwayDeploy pipeline
|
||||
* in its lifecycle states OFFLINE (static status props, no backend, no auth), so the
|
||||
* landing/pipeline design can be reviewed and screenshotted from `next dev` without a
|
||||
* live session. Data-free by construction; not linked from the product nav.
|
||||
*/
|
||||
import { Boxes, DollarSign, FileText, Gauge, Layers, Plus, Search, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ProductLanding, apiBaseFromDocs, type LandingMetric, type ProductLandingConfig } from '~/components/products/landing'
|
||||
import { RailwayDeploy } from '~/components/products/paas/RailwayDeploy'
|
||||
import { embeddingsCodeSamples } from '~/components/products/embeddings/logic'
|
||||
|
||||
const metrics: LandingMetric[] = [
|
||||
{ key: 'collections', label: 'Collections', value: 12, format: (n) => Math.round(n).toLocaleString(), icon: <Boxes size={14} opacity={0.6} /> },
|
||||
{ key: 'documents', label: 'Documents indexed', value: 3420, format: (n) => Math.round(n).toLocaleString(), icon: <FileText size={14} opacity={0.6} /> },
|
||||
{ key: 'vectors', label: 'Total vectors', value: 184213, format: (n) => Math.round(n).toLocaleString(), series: [120, 138, 150, 171, 184], deltaPct: 12, icon: <Layers size={14} opacity={0.6} /> },
|
||||
{ key: 'queries', label: 'Queries (7D)', value: 8241, format: (n) => Math.round(n).toLocaleString(), series: [900, 1100, 1050, 1300, 1450], deltaPct: 8, icon: <Search size={14} opacity={0.6} /> },
|
||||
{ key: 'latency', label: 'Avg latency', value: 42, format: (n) => `${Math.round(n)} ms`, series: [55, 50, 47, 44, 42], deltaPct: -6, icon: <Gauge size={14} opacity={0.6} /> },
|
||||
{ key: 'cost', label: 'Cost (7D)', value: null, format: (n) => `$${(n / 100).toFixed(2)}`, icon: <DollarSign size={14} opacity={0.6} />, hint: 'Awaiting metering' },
|
||||
]
|
||||
|
||||
const landingConfig: ProductLandingConfig = {
|
||||
productId: 'embeddings',
|
||||
title: 'Vector embeddings & semantic search',
|
||||
tagline: 'Generate, store, and search embeddings at scale — one API for semantic search and RAG, powered by Zen embedding models.',
|
||||
icon: Boxes,
|
||||
docsProduct: 'embeddings',
|
||||
primary: { label: 'Create collection', icon: <Plus size={16} />, onPress: () => {} },
|
||||
secondary: { label: 'Try search', icon: <Search size={15} />, onPress: () => {} },
|
||||
metrics,
|
||||
samples: embeddingsCodeSamples(apiBaseFromDocs('https://docs.hanzo.ai'), 'zen-embedding'),
|
||||
run: { label: 'Generate in console', icon: <Sparkles size={14} />, onPress: () => {} },
|
||||
actions: [
|
||||
{ label: 'Create collection', icon: <Plus size={15} />, onPress: () => {} },
|
||||
{ label: 'Explore search', icon: <Search size={15} />, onPress: () => {} },
|
||||
{ label: 'Generate embeddings', icon: <Sparkles size={15} />, onPress: () => {} },
|
||||
],
|
||||
}
|
||||
|
||||
function RailCard({ title, status }: { title: string; status: string }) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={320}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<RailwayDeploy status={status} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RailwayDemoPage() {
|
||||
return (
|
||||
<YStack gap="$6" p="$5" maxW={1180} self="center" width="100%">
|
||||
<Text fontSize="$9" fontWeight="900">RailwayDeploy pipeline</Text>
|
||||
<XStack gap="$4" flexWrap="wrap">
|
||||
<RailCard title="Building (in progress)" status="building" />
|
||||
<RailCard title="Deploying (in progress)" status="deploying" />
|
||||
<RailCard title="Live" status="live" />
|
||||
<RailCard title="Failed" status="error" />
|
||||
</XStack>
|
||||
|
||||
<Text fontSize="$9" fontWeight="900">Embeddings landing (ProductLanding kit)</Text>
|
||||
<ProductLanding config={landingConfig} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+8
-13
@@ -1,18 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { SignInForm } from '~/components/SignInForm'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
/**
|
||||
* Sign-in route. The whole experience (tenant credential form / admin silent SSO)
|
||||
* lives in the shared `<SignIn/>` component, which `Auth` also renders — so a
|
||||
* direct `/signin` load resolves to the form whether it mounts this route or the
|
||||
* dashboard shell (the deploy serves the SPA shell for every path).
|
||||
*/
|
||||
import { SignIn } from '~/components/SignIn'
|
||||
|
||||
export default function SignInPage() {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && account) router.replace('/')
|
||||
}, [loading, account, router])
|
||||
|
||||
return <SignInForm />
|
||||
return <SignIn />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* /system-status — same-origin BFF for the global status badge.
|
||||
*
|
||||
* status.<brand> (Gatus) serves its JSON at `/api/v1/endpoints/statuses` with NO
|
||||
* CORS header, so the browser can't read it cross-origin. This route fetches it
|
||||
* SERVER-SIDE (no CORS) and returns a small overall summary the badge renders
|
||||
* natively — the console's established BFF pattern (no iframe, no third-party
|
||||
* script). Public health data only; no auth, no secrets.
|
||||
*
|
||||
* Fail-soft by construction: any upstream error (down/slow/garbage) returns
|
||||
* `overall: 'unknown'` with HTTP 200, so the badge shows a neutral state and the
|
||||
* shell never breaks.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import { summarizeStatuses, type StatusSummary } from '~/lib/status/summary'
|
||||
|
||||
// Health changes minute-to-minute — always evaluate fresh (short CDN cache below).
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UNKNOWN: StatusSummary = { overall: 'unknown', total: 0, up: 0, down: [] }
|
||||
|
||||
export async function GET() {
|
||||
const statusUrl = config.statusUrl
|
||||
let summary = UNKNOWN
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${statusUrl}/api/v1/endpoints/statuses`,
|
||||
{ headers: { accept: 'application/json' }, cache: 'no-store' },
|
||||
{ timeoutMs: 4000 },
|
||||
)
|
||||
if (res.ok) summary = summarizeStatuses(await res.json())
|
||||
} catch {
|
||||
// fail-soft → UNKNOWN
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...summary, statusUrl, checkedAt: new Date().toISOString() },
|
||||
{ headers: { 'Cache-Control': 'public, max-age=30' } },
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Same-origin READ-ONLY proxy to VictoriaMetrics — the live platform telemetry
|
||||
* store (Prometheus-compatible TSDB, `vmsingle-victoria-metrics-single-server`).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/telemetry/api/v1/query?query=up`) with
|
||||
* just its first-party session cookie; this handler resolves the caller
|
||||
* (`resolveUser`) and — only for a signed-in user — forwards the READ query to
|
||||
* VictoriaMetrics. It powers Status (real `up{}` service health) and Metrics (real
|
||||
* infra time-series), replacing the empty `/paas/apps` board and the unwired
|
||||
* Metrics overview. VictoriaMetrics has no per-request auth of its own (it is an
|
||||
* internal ClusterIP service), so this route IS the access boundary — hence three
|
||||
* hard limits, all fail-closed:
|
||||
*
|
||||
* 1. Authenticated only — `resolveUser` (401 otherwise). Platform status/metrics
|
||||
* is not tenant-customer data; it is the health of the Hanzo Cloud platform the
|
||||
* user is signed into (a status-page concern), appropriate for any signed-in
|
||||
* console user and strictly READ-ONLY — no service token, far weaker than the
|
||||
* admin `/paas` control plane.
|
||||
* 2. READ-only — GET only, and only the allow-listed VictoriaMetrics query
|
||||
* endpoints (`allowTelemetrySurface`): `/api/v1/query`, `/query_range`,
|
||||
* `/series`, `/labels`, `/label/<name>/values`, `/status/tsdb`, `/metadata`.
|
||||
* Never `/api/v1/write`, `/import`, `/-/reload`, or any admin/mutating path.
|
||||
* 3. Traversal-hardened — `pathIsClean` rejects `.`/`..`/`%XX`/`;` segments and the
|
||||
* forward re-validates the WHATWG-normalized path, exactly like the bearer
|
||||
* proxies.
|
||||
*
|
||||
* Honest 501 when `VM_URL` is unset, so the UI shows a truthful "telemetry not
|
||||
* configured" state — never fabricated metrics.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { pathIsClean } from '~/lib/server/bearer-proxy'
|
||||
import { allowTelemetrySurface } from '~/lib/server/proxy-allow'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trimR = (s: string) => s.replace(/\/+$/, '')
|
||||
const trimL = (s: string) => s.replace(/^\/+/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** VictoriaMetrics single-node read API. In-cluster ClusterIP :8428 (headless).
|
||||
* Override with VM_URL. `|| default` (not `??`) so a blank/whitespace env still
|
||||
* resolves the in-cluster service (the same env-drift guard the /vm proxy uses). */
|
||||
const VM_URL = trimR(
|
||||
process.env.VM_URL?.trim() || 'http://vmsingle-victoria-metrics-single-server.hanzo.svc:8428',
|
||||
)
|
||||
|
||||
const json = (body: unknown, status: number) => NextResponse.json(body, { status })
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
const rawPath = trimL((await ctx.params).path.join('/')).replace(/\/+$/, '')
|
||||
|
||||
// Traversal + least-privilege on the RAW path (literal `.`/`..`/`%XX`/`;` rejected).
|
||||
if (!pathIsClean(rawPath) || !allowTelemetrySurface(rawPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
if (!process.env.VM_URL?.trim() && !VM_URL) {
|
||||
return json(
|
||||
{ status: 'error', errorType: 'not_configured', error: 'Telemetry store is not configured (VM_URL missing).' },
|
||||
501,
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated only — the query surface is the access boundary (VM has no auth).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return json({ status: 'error', errorType: 'unauthenticated', error: 'Sign in to view platform telemetry.' }, 401)
|
||||
}
|
||||
|
||||
// Re-validate the WHATWG-normalized destination (undici resolves %2e/double-encoded
|
||||
// dot-segments a raw check can't see) — validate AND fetch the exact same URL.
|
||||
let dest: URL
|
||||
try {
|
||||
dest = new URL(`${VM_URL}/${rawPath}${req.nextUrl.search}`)
|
||||
} catch {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Bad telemetry path.' }, 404)
|
||||
}
|
||||
const normPath = trimL(dest.pathname).replace(/\/+$/, '')
|
||||
if (!pathIsClean(normPath) || !allowTelemetrySurface(normPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(dest, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
return new NextResponse(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('telemetry-proxy: VictoriaMetrics unreachable:', msgOf(e))
|
||||
return json({ status: 'error', errorType: 'upstream_error', error: 'Telemetry store is unavailable.' }, 502)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,24 @@
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
* that cookie and forwards to the cloud backend's `/v1/...` surface, passing the
|
||||
* cookie through (the proven `get-account` server-to-server pattern in
|
||||
* lib/server/identity.ts) plus the active `X-Org-Id`. Training is a TENANT action —
|
||||
* any signed-in org user may run it — so this is user-scoped (resolveUser), NOT the
|
||||
* control-plane admin gate the `/paas` proxy uses. The cloud backend scopes by org
|
||||
* (GetEffectiveOrg / the X-Org-Id the plain-REST train sub-service requires), so a
|
||||
* caller can only ever touch their own org's jobs. `POST /v1/train/jobs` is
|
||||
* billing-gated by the live ResourceMeter and returns 402 on an unfunded org — that
|
||||
* status flows straight back so the UI can surface it honestly.
|
||||
* that cookie, mints a SHORT-LIVED, user-bound IAM Bearer (`adminBearer` — the ONE
|
||||
* per-user cache shared with the `/v1` bearer proxy), and forwards to the cloud
|
||||
* backend's `/v1/...` surface with `Authorization: Bearer <token>` + the active
|
||||
* `X-Org-Id`. Training is a TENANT action — any signed-in org user may run it — so
|
||||
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
|
||||
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
|
||||
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
|
||||
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
|
||||
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
|
||||
* so the UI can surface it honestly.
|
||||
*
|
||||
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
|
||||
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
|
||||
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
|
||||
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
|
||||
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
|
||||
* authenticate, and a cookie + JWT together risks the public-gateway 431).
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
|
||||
@@ -21,7 +30,7 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { resolveUser, adminBearer } from '~/lib/server/identity'
|
||||
import { orgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
@@ -29,6 +38,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
|
||||
|
||||
@@ -71,17 +81,35 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
)
|
||||
}
|
||||
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
|
||||
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
|
||||
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
|
||||
// the token can't be minted — never fall through to an unauthenticated forward.
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch (e) {
|
||||
// Redact — the exception carries the internal IAM host/port. Log server-side only.
|
||||
console.error('training-proxy: could not mint user bearer:', msgOf(e))
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Could not authorize the request.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
// Org is SERVER-RESOLVED, not the raw browser header: a global admin's switched
|
||||
// org (?/X-Org-Id) is honored, a non-global caller is PINNED to their own — so a
|
||||
// Org is SERVER-RESOLVED, not the raw browser header: a SuperAdmin's switched
|
||||
// org (?/X-Org-Id) is honored, a non-SuperAdmin caller is PINNED to their own — so a
|
||||
// brand admin can't drive another tenant's training jobs even if the backend
|
||||
// trusted the forwarded header. Matches the /paas + /admin/kms orgFor pin.
|
||||
'X-Org-Id': orgFor({ isGlobalAdmin: user.isGlobalAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
|
||||
// trusted the forwarded header. For a non-SuperAdmin caller this equals the token
|
||||
// owner (the Bearer's own claim), so header and token agree. Matches the /paas +
|
||||
// /admin/kms orgFor pin. The raw session cookie is NOT forwarded (cloud-api can't
|
||||
// validate it as a principal, and cookie + JWT together risks the gateway 431).
|
||||
'X-Org-Id': orgFor({ isSuperAdmin: user.isSuperAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
|
||||
}
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy at the console's OWN `/v1/*` — the ONE prefix-free
|
||||
* path the browser uses to reach the unified cloud-api surfaces that authorize on a
|
||||
* Bearer JWT. CTO contract: every cloud API path is `/v1/`-rooted, ZERO prefix (no
|
||||
* `/cloud/`, no `/api/`).
|
||||
*
|
||||
* The browser holds NO credential: it calls `<origin>/v1/<head>/...` with just its
|
||||
* first-party session cookie. This catch-all resolves WHO the caller is from that
|
||||
* cookie (`resolveUser`), mints a SHORT-LIVED, user-bound IAM token (shared per-user
|
||||
* cache in identity.ts — ONE cache across every proxy), and forwards to cloud-api's
|
||||
* `/v1/*` with `Authorization: Bearer <token>`. The backend resolves the ORG from the
|
||||
* token's `owner` claim, so tenancy is server-authoritative — a browser can never
|
||||
* supply its own org — and the raw session cookie NEVER reaches cloud-api (no
|
||||
* cookie-CSRF surface upstream). This is the EXACT transport the `/ai` proxy proved
|
||||
* live; every service proxy shares the ONE `forwardWithUserBearer` implementation.
|
||||
*
|
||||
* DISPATCH: the AI (`models`/`chat`/…), admin-aggregate (`/v1/admin/*`), visor
|
||||
* (`regions`/`sizes`/`gpu-sizes`), billing (`/v1/billing/*`) and commerce
|
||||
* (`/v1/commerce/*`) heads are routed to their OWN backends by `next.config.mjs`
|
||||
* `beforeFiles` rewrites BEFORE they reach this catch-all — so this handler owns
|
||||
* exactly the cloud-api `/v1/<head>` surface.
|
||||
*
|
||||
* Least privilege: only the allow-listed cloud HEADS are reachable
|
||||
* (`allowCloudSurface`); `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a
|
||||
* general cloud-api tunnel. The mutating same-origin (CSRF) guard, the path-traversal
|
||||
* rejection, and the bearer mint all live in `forwardWithUserBearer`.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// The `[...path]` catch-all sits UNDER `/v1`, so it captures the segments AFTER
|
||||
// `/v1`. Re-prepend the `v1/` root so the allow-list (matches `v1/<head>`) and the
|
||||
// upstream URL (`CLOUD_API_URL/v1/<head>/...`) both see the cloud-api contract path.
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* AI-account credential store — the per-user connect/list/disconnect route.
|
||||
*
|
||||
* GET v1/accounts → { providers: masked[] } (existence + mode, NO secret)
|
||||
* POST v1/accounts/:providerId → seal a pasted API key / OAuth token / cookie header
|
||||
* DELETE v1/accounts/:providerId → drop the sealed credential
|
||||
*
|
||||
* The secret is sealed into an httpOnly cookie server-side (`lib/server/ai-accounts`)
|
||||
* and NEVER echoed back or logged. Every request is session-gated (`resolveUser`); the
|
||||
* two mutating verbs are CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* Namespaced under `/v1/ai-accounts/` so the data plane never shadows the UI tab URLs
|
||||
* (`/ai-accounts`, `/ai-accounts/accounts`) — a route handler always wins over the
|
||||
* catch-all page, so the two live in disjoint path space (same rule as `/v1/billing/`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import {
|
||||
readAccounts,
|
||||
accountsCookie,
|
||||
maskAccounts,
|
||||
type AiAccountsStore,
|
||||
type StoredCredential,
|
||||
} from '~/lib/server/ai-accounts'
|
||||
import { isAiProvider, type ConnectMode } from '~/lib/products/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
const MODES: ConnectMode[] = ['api', 'oauth', 'web']
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI accounts.' }, { status: 401 })
|
||||
const notFound = () => NextResponse.json({ error: 'Not found.' }, { status: 404 })
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
const seg = (await ctx.params).path
|
||||
if (seg[0] !== 'accounts' || seg.length !== 1) return notFound()
|
||||
return NextResponse.json({ providers: maskAccounts(readAccounts(req)) })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
if (!isAiProvider(id)) return NextResponse.json({ error: 'Unknown provider.' }, { status: 400 })
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { mode?: string; secret?: string; baseUrl?: string } | null
|
||||
const mode = body?.mode as ConnectMode
|
||||
const secret = typeof body?.secret === 'string' ? body.secret.trim() : ''
|
||||
if (!MODES.includes(mode) || !secret) {
|
||||
return NextResponse.json({ error: 'A link mode and a non-empty credential are required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cred: StoredCredential = {
|
||||
mode,
|
||||
secret, // sealed at rest by accountsCookie; never logged.
|
||||
baseUrl: body?.baseUrl?.trim() || undefined,
|
||||
connectedAt: new Date().toISOString(),
|
||||
}
|
||||
const next: AiAccountsStore = { ...readAccounts(req), [id]: cred }
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(next) }), [accountsCookie(next)])
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
|
||||
const store = readAccounts(req)
|
||||
delete store[id]
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(store) }), [accountsCookie(store)])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AI-accounts ORG routing defaults (READ-ONLY) — the server-driven default the
|
||||
* admin set for the whole org, surfaced so the Routing tab can show
|
||||
* "Organization default: On/Off" and fall back to it when the user has no explicit
|
||||
* override.
|
||||
*
|
||||
* GET v1/routing-defaults → cloud-api `{ status, data: { auto_routing_active,
|
||||
* default_session_routing } }` (streamed through verbatim)
|
||||
*
|
||||
* This is a pure READ. It forwards to cloud-api's org-scoped
|
||||
* `GET /v1/router/defaults` with the caller's short-lived user bearer (org is
|
||||
* the token owner — never browser-supplied), the EXACT same auth pattern as the
|
||||
* `/v1` proxy. It deliberately does NOT touch the org-settings WRITE path: a
|
||||
* customer surface has no clean authenticated path to mint the global-admin write,
|
||||
* and forging one is a confused-deputy escalation (see the long note in
|
||||
* `settings/route.ts`). Reads are fine; writes stay out.
|
||||
*
|
||||
* FAIL-SOFT: an older cloud-api with no such endpoint 404s, which streams straight
|
||||
* through as a 404 the client treats as "no org default" — the tab then honors the
|
||||
* cookie preference alone, exactly as before this endpoint existed.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage` and `/v1/ai-accounts/settings`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud) — same in-cluster target as the `/v1` proxy. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
const UPSTREAM_PATH = 'v1/router/defaults'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path: UPSTREAM_PATH,
|
||||
allow: (p) => p === UPSTREAM_PATH,
|
||||
errorShape: 'casibase',
|
||||
unauthorizedMessage: 'Sign in to read organization routing defaults.',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* AI-accounts NON-SECRET preferences — the org/user settings route.
|
||||
*
|
||||
* GET v1/settings → { settings: { routingEnabled } }
|
||||
* PUT v1/settings → persist { routingEnabled } (sealed), returns the new settings
|
||||
*
|
||||
* The one preference today is `routingEnabled` — the org's `model: "auto"` smart-
|
||||
* routing default that Hanzo surfaces read. Persisted with the SAME sealed-cookie
|
||||
* store as the credential blob (`lib/server/ai-accounts`); there is no secret here,
|
||||
* so the seal is for integrity, not confidentiality. Session-gated; the mutating
|
||||
* verb is CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import { readSettings, settingsCookie, normalizeSettings } from '~/lib/server/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI settings.' }, { status: 401 })
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
return NextResponse.json({ settings: readSettings(req) })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { routingEnabled?: unknown } | null
|
||||
if (typeof body?.routingEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'routingEnabled (boolean) is required.' }, { status: 400 })
|
||||
}
|
||||
const settings = normalizeSettings(body)
|
||||
|
||||
// Cookie-only, deliberately. cloud-api now enforces per-org auto-routing via
|
||||
// `OrgSettings.AutoRouting` (hanzoai/ai), toggled through
|
||||
// `PUT /v1/org/settings`. But that endpoint is `RequireGlobalAdmin`-gated
|
||||
// (like every /v1/*-model-route admin route) and is NOT gateway-exposed — it is
|
||||
// reachable only on the direct api.cloud.hanzo.ai ingress with a global-admin
|
||||
// session. This Routing tab is a CUSTOMER surface: `resolveUser` here is a tenant
|
||||
// user whose minted `hanzo-console` bearer is NOT global-admin, and the console's
|
||||
// only admin proxy (`/admin/aggregate`) fail-closed-403s a non-global-admin. So
|
||||
// there is NO clean authenticated path for a customer to write cloud-side
|
||||
// OrgSettings, and forging one (a console service token asserting admin authority
|
||||
// for a client-supplied org) would be a confused-deputy privilege escalation —
|
||||
// refused per "do not bodge auth". The toggle therefore stays the sealed-cookie
|
||||
// org preference the Hanzo surfaces read; API `model:"auto"` still honors the
|
||||
// GLOBAL router flag. To make this write real, a global-admin must set the org's
|
||||
// AutoRouting via the admin console (the OrgSettings CRUD), OR cloud-api must add a
|
||||
// self-serve, org-scoped (owner-from-JWT, non-global-admin) auto-routing toggle the
|
||||
// `/ai` proxy can reach — at which point wire that call in here.
|
||||
return applyCookies(NextResponse.json({ settings }), [settingsCookie(settings)])
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unified AI-account usage — the Overview data plane.
|
||||
*
|
||||
* For each CONNECTED provider it runs the headless `@hanzo/usage` pipeline
|
||||
* server-side over the Node host, decrypting the sealed credential into the
|
||||
* usage-engine settings (`settingsFor`) only in memory for the fetch. It ALSO
|
||||
* merges the org's own Hanzo lane — the REAL commerce usage ledger overview,
|
||||
* fetched through the tested `/billing` proxy (the SAME source the Billing/Overview
|
||||
* dashboards read), so `/ai-accounts` shows Hanzo + every linked provider side by side.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path. Session-gated; a secret is never logged or returned.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { nodeHost } from '@hanzo/usage/node'
|
||||
import { runPipeline } from '@hanzo/usage'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { readAccounts, descriptorFor, settingsFor } from '~/lib/server/ai-accounts'
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
import { normalizeUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { buildCloudUsageOverview } from '~/lib/api/usage-adapter'
|
||||
import type { CloudUsageOverview } from '~/lib/api/usage'
|
||||
import type { ProviderUsage } from '~/lib/api/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const msgOf = (e: unknown): string => (e instanceof Error ? e.message : 'Fetch failed.')
|
||||
|
||||
/** The org's own Hanzo Cloud lane: the real commerce ledger overview, null on any miss. */
|
||||
async function hanzoLane(req: NextRequest): Promise<CloudUsageOverview | null> {
|
||||
try {
|
||||
const res = await forwardBilling(req, ['usage'])
|
||||
if (!res.ok) return null
|
||||
const records = normalizeUsageRecords(await res.json())
|
||||
return buildCloudUsageOverview(records, {
|
||||
range: '30d',
|
||||
topModels: 6,
|
||||
activityType: 'all',
|
||||
activityLimit: 8,
|
||||
activityOffset: 0,
|
||||
now: Date.now(),
|
||||
product: null,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the usage pipeline for one connected provider. */
|
||||
async function providerUsage(id: string, cred: ReturnType<typeof readAccounts>[string]): Promise<ProviderUsage> {
|
||||
const descriptor = descriptorFor(id)
|
||||
if (!descriptor) return { id, ok: false, error: 'Unknown provider.' }
|
||||
const { mode, settings } = settingsFor(cred)
|
||||
try {
|
||||
const outcome = await runPipeline(descriptor, { host: nodeHost, sourceMode: mode, settings })
|
||||
if (outcome.result) return { id, ok: true, usage: outcome.result.usage }
|
||||
return { id, ok: false, error: msgOf(outcome.error) }
|
||||
} catch (e) {
|
||||
return { id, ok: false, error: msgOf(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to view usage.' }, { status: 401 })
|
||||
|
||||
const store = readAccounts(req)
|
||||
const [providers, hanzo] = await Promise.all([
|
||||
Promise.all(Object.entries(store).map(([id, cred]) => providerUsage(id, cred))),
|
||||
hanzoLane(req),
|
||||
])
|
||||
|
||||
return NextResponse.json({ providers, hanzo })
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. Thin route wrapper: the trust boundary,
|
||||
* tenant scoping, CSRF guard, and binary (PDF) passthrough all live in the tested
|
||||
* `~/lib/server/billing-proxy` (`forwardBilling`) — this file only maps the HTTP verbs.
|
||||
*
|
||||
* Rooted at `/v1/billing/` (the /v1-first law) — this handler lives at
|
||||
* `app/v1/billing/[...path]`, MORE SPECIFIC than the cloud BFF catch-all
|
||||
* `app/v1/[...path]`, so `/v1/billing/*` (data) resolves here while `/v1/<other>/*`
|
||||
* falls through to the catch-all. And `/v1/billing/*` (data) never collides with the
|
||||
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce for the PLATFORM CATALOG admin surface
|
||||
* (`/v1/catalog/entries` + `/v1/catalog/seed`) — the SuperAdmin CMS for the product
|
||||
* + pricing catalog (the 17 infra tiers increment 1 seeded, plus every product
|
||||
* surface docs/pricing/the console read from).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/v1/catalog/...`) with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
|
||||
* IAM token, and forwards to commerce with that Bearer. Commerce's `requireSuperAdmin`
|
||||
* (owner=="admin", the `IsSuperAdmin()` home-org predicate) is the AUTHORITATIVE gate:
|
||||
* the platform catalog is cross-tenant `system`-namespace data, so an org-level admin
|
||||
* is refused 403 — a tenant can never read cost/margin or edit the catalog. The org is
|
||||
* server-authoritative (the Bearer owner), never browser-supplied.
|
||||
*
|
||||
* This is the ADMIN twin of the tenant `/v1/commerce/*` store proxy: a DISTINCT
|
||||
* least-privilege boundary (`allowCatalogSurface`) that admits ONLY the catalog
|
||||
* entries + seed paths, so it can never tunnel commerce's `/v1/billing`, `/v1/checkout`,
|
||||
* `/_/commerce/tenants`, or the merchant store models. It lives at
|
||||
* `app/v1/catalog/[...path]` — MORE SPECIFIC than the `app/v1/[...path]` cloud BFF
|
||||
* catch-all, so Next resolves `/v1/catalog/*` here (the same precedence as
|
||||
* `app/v1/commerce/[...path]`). The path is `/v1/catalog/*` (the REAL commerce mount),
|
||||
* so the go:embed console (where the BFF is pruned) reaches the SAME path on the cloud
|
||||
* binary's embedded commerce directly.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCatalogSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR wires
|
||||
* `COMMERCE_URL` (public egress is CF-gated). Override per-deploy / for local dev. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/catalog/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/catalog/` (e.g. `entries`, `entries/cloud-dev`,
|
||||
// `seed`). Commerce serves the catalog admin CRUD at `/v1/catalog/*`, so re-root
|
||||
// the upstream path at `v1/catalog/` — the same path `allowCatalogSurface` and
|
||||
// `forwardWithUserBearer` see (`v1/catalog/entries`).
|
||||
const path = `v1/catalog/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowCatalogSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the catalog is platform-global and commerce gates on the
|
||||
// SuperAdmin home-org from the token.
|
||||
unauthorizedMessage: 'Sign in as an administrator to edit the catalog.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Same-origin user-bearer proxy to commerce (`commerce.hanzo.svc`) — the store /
|
||||
* merchant admin surface (products / orders / customers / collections / variants /
|
||||
* discounts / store settings). The browser calls this OWN-origin route
|
||||
* (`/commerce/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* (`/v1/commerce/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* the user, mints a short-lived user-bound IAM token, and forwards to commerce with
|
||||
* that Bearer. Commerce's EdgeAuth validates the JWT and resolves the org from its
|
||||
* `owner` claim (`middleware.TokenRequired` fast-paths IAM auth), so the store is
|
||||
@@ -33,7 +33,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/commerce/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/commerce/` (e.g. `product`). Commerce serves its REST
|
||||
// models under `/v1/<model>`, so re-root the upstream path at `v1/` — the same path
|
||||
// `allowCommerceSurface` (v1Head) and `forwardWithUserBearer` see (`v1/product`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Per-user proxy to the Lux DEX indexer's `dex` subgraph — the Lux Economy /
|
||||
* Markets board's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/economy/v1/overview`) with just the session cookie; this handler resolves the
|
||||
* (`/v1/economy/overview`) with just the session cookie; this handler resolves the
|
||||
* caller, resolves the BRAND from the request host, and POSTs a FIXED, allowlisted
|
||||
* GraphQL query to the in-cluster graphd per brand-scoped network, returning the
|
||||
* NORMALIZED markets + fills + day-data. No graph host or GraphQL query ever reaches
|
||||
@@ -11,7 +11,7 @@
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY path is `v1/overview`, and the ONLY GraphQL query is
|
||||
* - Least privilege: the ONLY path is `overview`, and the ONLY GraphQL query is
|
||||
* the fixed markets+fills+dayData read below — this is not a general GraphQL
|
||||
* tunnel (no client-supplied query, no mutations, no arbitrary entity).
|
||||
*
|
||||
@@ -142,7 +142,9 @@ async function probe(net: NodeNetworkId): Promise<EconomySnapshot> {
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
if (path.join('/') !== 'v1/overview') {
|
||||
// This handler lives at `app/v1/economy/[...path]`, so the catch-all captures the
|
||||
// sub-path after `/v1/economy/`.
|
||||
if (path.join('/') !== 'overview') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
|
||||
* The browser calls console2's OWN origin (`/nodes/v1/inventory`) with just the
|
||||
* The browser calls console2's OWN origin (`/v1/nodes/inventory`) with just the
|
||||
* session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and fetches the allowlisted luxd RPC methods server-side for each
|
||||
* network that brand may see, returning NORMALIZED per-node rows. No RPC host or
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — so cloud.lux.cloud sees only Lux networks,
|
||||
* console.hanzo.ai (hanzo) sees all.
|
||||
* - Least privilege: the ONLY path is `v1/inventory`, and the ONLY luxd methods
|
||||
* - Least privilege: the ONLY path is `inventory`, and the ONLY luxd methods
|
||||
* called are the four read methods below — this is not a general RPC tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -139,8 +139,9 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through.
|
||||
if (path.join('/') !== 'v1/inventory') {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through. This handler lives at
|
||||
// `app/v1/nodes/[...path]`, so the catch-all captures the sub-path after `/v1/nodes/`.
|
||||
if (path.join('/') !== 'inventory') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce for the PLATFORM PLAN admin surface
|
||||
* (`/v1/plans/entries` + `/v1/plans/seed`) — the SuperAdmin CMS for the subscription/DNS
|
||||
* plan authority (`models/plan`, the source of truth `GET /v1/billing/plans` and the
|
||||
* internal-ledger renewal charge derive from).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/v1/plans/...`) with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound IAM
|
||||
* token, and forwards to commerce with that Bearer. Commerce's `requireSuperAdmin`
|
||||
* (owner=="admin") is the AUTHORITATIVE gate: the plan authority is cross-tenant
|
||||
* `system`-namespace PRICING data — a plan's price is the real renewal charge — so an
|
||||
* org-level admin is refused 403. The org is server-authoritative (the Bearer owner).
|
||||
*
|
||||
* The ADMIN twin of the tenant `/v1/commerce/*` store proxy and the sibling
|
||||
* `/v1/catalog/*` proxy: a DISTINCT least-privilege boundary (`allowPlansSurface`) that
|
||||
* admits ONLY the plan entries + seed paths, so it can never tunnel commerce's
|
||||
* `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants`, or the merchant store models. It
|
||||
* lives at `app/v1/plans/[...path]` — MORE SPECIFIC than the `app/v1/[...path]` cloud BFF
|
||||
* catch-all, so Next resolves `/v1/plans/*` here. The path is `/v1/plans/*` (the REAL
|
||||
* commerce mount), so the go:embed console (BFF pruned) reaches the SAME path on the
|
||||
* cloud binary's embedded commerce directly.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowPlansSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR wires
|
||||
* `COMMERCE_URL` (public egress is CF-gated). Override per-deploy / for local dev. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/plans/[...path]`, so the catch-all captures ONLY
|
||||
// the sub-path after `/v1/plans/` (e.g. `entries`, `entries/pro`, `seed`). Commerce
|
||||
// serves the plan admin CRUD at `/v1/plans/*`, so re-root the upstream path at
|
||||
// `v1/plans/` — the same path `allowPlansSurface` and `forwardWithUserBearer` see.
|
||||
const path = `v1/plans/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowPlansSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the plan authority is platform-global and commerce gates on the
|
||||
// SuperAdmin home-org from the token.
|
||||
unauthorizedMessage: 'Sign in as an administrator to edit plans.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
|
||||
* Base module's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/superbase/v1/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* (`/v1/superbase/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* resolves the user, mints a short-lived user-bound IAM token (shared per-user
|
||||
* cache), and forwards to base.hanzo.ai with that token. No token ever reaches the
|
||||
* browser, and the SAME @hanzo/superbase-dashboard screens render here and standalone.
|
||||
@@ -37,7 +37,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/superbase/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/superbase/` (e.g. `collections/...`). Base serves its
|
||||
// data plane under `/v1/collections`, so re-root the upstream path at `v1/` — the same
|
||||
// path `allowBaseSurface` and `forwardWithUserBearer` see (`v1/collections/...`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: BASE_URL,
|
||||
path,
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Per-user proxy to the LIVE trading-bot state — the Trading module's ONE
|
||||
* live-data transport (the DEPLOYED FLEET is read separately via the `/cloud`
|
||||
* PaaS proxy). The browser calls console2's OWN origin (`/trading/v1/*`) with just
|
||||
* live-data transport (the DEPLOYED FLEET is read separately via the `/v1`
|
||||
* PaaS proxy). The browser calls console2's OWN origin (`/v1/trading/*`) with just
|
||||
* the session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and reads the allowlisted upstreams server-side, per network that
|
||||
* brand may see. No cluster host or RPC method ever reaches the browser, and the
|
||||
@@ -11,7 +11,7 @@
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY paths are `v1/metrics` and `v1/orderbook`; the ONLY
|
||||
* - Least privilege: the ONLY paths are `metrics` and `orderbook`; the ONLY
|
||||
* upstreams are the maker's :2112 /metrics scrape and the DEX read endpoint —
|
||||
* this is not a general RPC/HTTP tunnel.
|
||||
*
|
||||
@@ -155,7 +155,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
return NextResponse.json({ error: 'Sign in to view trading bots.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (route === 'v1/metrics') {
|
||||
if (route === 'metrics') {
|
||||
const networks = scopedNetworks(req)
|
||||
// A single-network scope is the common case (per-bot status); return the first
|
||||
// (the network the caller asked for), or the brand's first if none specified.
|
||||
@@ -165,7 +165,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
return NextResponse.json(status)
|
||||
}
|
||||
|
||||
if (route === 'v1/orderbook') {
|
||||
if (route === 'orderbook') {
|
||||
const networks = scopedNetworks(req)
|
||||
const net = networks[0]
|
||||
if (!net) {
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to Visor (vm.hanzo.ai) — the compute control plane
|
||||
* (regions / gpus / machines / instances). The browser calls this OWN-origin route
|
||||
* (`/vm/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* (`/v1/vm/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* user, mints a short-lived user-bound IAM token, and forwards to visor with that
|
||||
* Bearer. Visor mints org + user from the JWT claims, so compute is org-scoped
|
||||
* server-side — a caller only ever sees their own org's machines. No token reaches
|
||||
@@ -34,7 +34,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/vm/[...path]`, so the catch-all captures ONLY the
|
||||
// sub-path after `/v1/vm/` (e.g. `regions`). Visor serves its compute surface under
|
||||
// `/v1/<x>`, so re-root the upstream path at `v1/` — the same path `allowVisorSurface`
|
||||
// and `forwardWithUserBearer` see (`v1/regions`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: VISOR_URL,
|
||||
path,
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Fixture-server gate for the render specs.
|
||||
*
|
||||
* Several render specs (ai-economics, budgets-responsive, gpus-*, provider-billing,
|
||||
* entitlement-sidebar, interactive-training, blank-audit, probe-o11y) assert data
|
||||
* that exists ONLY in a LOCAL fixture server — they default `BASE_URL` to
|
||||
* `http://localhost:4000` and seed exact numbers ("$26k credit / 62% margin /
|
||||
* fable-5 75%"). Run against live prod that server isn't there (ECONNREFUSED) and
|
||||
* the numbers are meaningless anyway, so the spec has nothing real to assert.
|
||||
*
|
||||
* This is NOT a blind skip: it's a reachability gate. Point `BASE_URL` at a running
|
||||
* fixture (`npm run dev` on :4000, or a prod origin that actually serves the seeded
|
||||
* surface) and the spec runs for real. Call `requireFixtureServer()` once at module
|
||||
* top level in a fixture spec; its `beforeAll` probes the target and skips the whole
|
||||
* file only when it's genuinely unreachable.
|
||||
*/
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
/** The origin a fixture render spec targets (its own default is the local dev server). */
|
||||
export const FIXTURE_BASE = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
/** Skip the whole spec file when its fixture server can't be reached. */
|
||||
export function requireFixtureServer(base: string = FIXTURE_BASE): void {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const reachable = await request
|
||||
.get(base, { timeout: 4000 })
|
||||
.then((r) => r.status() < 500)
|
||||
.catch(() => false)
|
||||
test.skip(
|
||||
!reachable,
|
||||
`fixture server ${base} not reachable — point BASE_URL at a running fixture to exercise these render specs`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Session priming for render specs — the ONE recipe for the IAM-PKCE auth model.
|
||||
*
|
||||
* Identity is a client-held @hanzo/iam token now (there is NO /auth/session
|
||||
* endpoint): `AccountApi.session()` reads the sessionStorage access token and
|
||||
* projects the OIDC userinfo claims. So a spec authenticates by (1) seeding a
|
||||
* forged unsigned JWT + expiry into sessionStorage (the client only
|
||||
* base64-decodes the payload — no signature check in the browser), and
|
||||
* (2) serving the claims from a mocked userinfo endpoint (discovery is left to
|
||||
* 404 — the SDK synthesizes its endpoints). Registered AFTER a spec's own
|
||||
* catch-all route, these handlers win (Playwright matches routes in reverse
|
||||
* registration order), so legacy `/auth/session` mock branches are simply dead.
|
||||
*
|
||||
* Also seeds the first-run gates that otherwise block interaction: the guided
|
||||
* TOUR overlays the whole page at z=100000 (clicks hang on actionability), the
|
||||
* onboarding wizard is a takeover, and Scope parks on the picker.
|
||||
*
|
||||
* Usage (after the spec registers its own catch-all page.route):
|
||||
* await primeSession(page) // hanzo/z admin (default)
|
||||
* await primeSession(page, { owner: 'maxpower', name: 'dave', isAdmin: false })
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export type SessionClaims = {
|
||||
owner: string
|
||||
name: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
|
||||
|
||||
/** The default identity render specs run as — a hanzo-org admin. */
|
||||
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
|
||||
export function forgeToken(claims: Required<SessionClaims>): string {
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
|
||||
}
|
||||
|
||||
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
|
||||
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
|
||||
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
await page.addInitScript(
|
||||
({ org, token }: { org: string; token: string }) => {
|
||||
try {
|
||||
sessionStorage.setItem('hanzo_iam_access_token', token)
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
localStorage.setItem(`hz_tour_seen:v1:${org}`, '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
},
|
||||
{ org: claims.owner, token: forgeToken(claims) },
|
||||
)
|
||||
// Registered after the spec's catch-all → these win for the IAM endpoints.
|
||||
await page.route('**/.well-known/**', (route: Route) => route.fulfill({ status: 404, body: '' }))
|
||||
await page.route('**/userinfo*', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...claims, sub: `${claims.owner}/${claims.name}` }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Catalog & Pricing admin editor — render + edit-persists proof (increment 2).
|
||||
*
|
||||
* Drives the REAL CatalogModule (client + form + metadata editor) against a
|
||||
* mock of commerce's `/v1/catalog/*` CRUD, seeded with the REAL 17 infra tiers
|
||||
* increment 1 seeds (11 cloud + 3 gpu + 3 datastore). The mock is a live
|
||||
* in-memory store: a PUT mutates it, so a save → re-fetch shows the NEW price —
|
||||
* the exact "edit persists" loop the module drives against commerce (whose CRUD
|
||||
* contract is itself proven by commerce's own passing api/catalog handler tests).
|
||||
*
|
||||
* Proves: the table renders every real tier with its price + spec; opening a
|
||||
* cloud tier shows the editable form (name/price/published/category/metadata);
|
||||
* changing the price + Save issues `PUT /v1/catalog/entries/<slug>` with the new
|
||||
* priceCents; and the table then reflects the persisted price. Screenshots the
|
||||
* table + the open edit form (admin-catalog-editor.png).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-catalog-editor
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** The REAL 17 infra tiers commerce seeds (models/catalogentry/seed/infra-tiers.json),
|
||||
* in the raw `catalog-entry` shape the admin GET /v1/catalog/entries returns. */
|
||||
function seedEntries(): Record<string, unknown>[] {
|
||||
const cloud = (
|
||||
[
|
||||
['cloud-starter', 'Starter', 'Get started for free. Perfect for side projects, bots, and learning.', 500, { id: 'starter', vcpus: 1, memoryGB: 1, diskGB: 20, cpuType: 'shared', maxVMs: 1, priceMonthly: 5, features: ['1 VM', '1 vCPU', '1 GB RAM', '20 GB SSD'], freeTier: true }],
|
||||
['cloud-builder', 'Builder', 'For developers shipping real products.', 1000, { id: 'builder', vcpus: 2, memoryGB: 2, diskGB: 40, cpuType: 'shared', maxVMs: 5, priceMonthly: 10, features: ['Up to 5 VMs', '2 vCPU'] }],
|
||||
['cloud-dev', 'Dev', 'The sweet spot. Full dev environment with room to grow.', 1500, { id: 'dev', vcpus: 2, memoryGB: 8, diskGB: 25, cpuType: 'shared', maxVMs: 25, priceMonthly: 15, features: ['Up to 25 VMs', '2 vCPU', '8 GB RAM'], popular: true }],
|
||||
['cloud-pro', 'Pro', 'Dedicated CPU. Zero noisy neighbors.', 2500, { id: 'pro', vcpus: 2, memoryGB: 8, diskGB: 80, cpuType: 'dedicated', maxVMs: 25, priceMonthly: 25, features: ['2 dedicated vCPU'] }],
|
||||
['cloud-turbo', 'Turbo', '4x the power. Browser automation, CI/CD, and heavy workloads.', 3900, { id: 'turbo', vcpus: 4, memoryGB: 16, diskGB: 160, cpuType: 'shared', maxVMs: 25, priceMonthly: 39, features: ['4 vCPU', '16 GB RAM'] }],
|
||||
['cloud-turbo-dedicated', 'Turbo Dedicated', 'All the power of Turbo with dedicated CPU cores.', 4900, { id: 'turbo-dedicated', vcpus: 4, memoryGB: 16, diskGB: 160, cpuType: 'dedicated', maxVMs: 25, priceMonthly: 49, features: ['4 dedicated vCPU'] }],
|
||||
['cloud-business', 'Business', 'Team-scale compute.', 21900, { id: 'business', vcpus: 8, memoryGB: 32, diskGB: 240, cpuType: 'dedicated', maxVMs: 50, priceMonthly: 219, features: ['8 dedicated vCPU'] }],
|
||||
['cloud-enterprise', 'Enterprise', 'Mission-critical infrastructure.', 42900, { id: 'enterprise', vcpus: 16, memoryGB: 64, diskGB: 360, cpuType: 'dedicated', maxVMs: 100, priceMonthly: 429, features: ['16 dedicated vCPU'] }],
|
||||
['cloud-scale', 'Scale', 'Platform-scale compute.', 84900, { id: 'scale', vcpus: 32, memoryGB: 128, diskGB: 600, cpuType: 'dedicated', maxVMs: 250, priceMonthly: 849, features: ['32 dedicated vCPU'] }],
|
||||
['cloud-mega', 'Mega', 'Maximum single-node power.', 129900, { id: 'mega', vcpus: 48, memoryGB: 192, diskGB: 960, cpuType: 'dedicated', maxVMs: 500, priceMonthly: 1299, features: ['48 dedicated vCPU'] }],
|
||||
['cloud-ultra', 'Ultra', 'Extreme compute. Multi-node clusters.', 399900, { id: 'ultra', vcpus: 96, memoryGB: 384, diskGB: 1920, cpuType: 'dedicated', maxVMs: 1000, priceMonthly: 3999, features: ['96 dedicated vCPU'] }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'cloud', description, priceCents, currency: 'usd', order: i, published: true, metadata }))
|
||||
|
||||
const gpu = (
|
||||
[
|
||||
['gpu-standard', 'GPU Standard', '1x H100 · 80 GB VRAM', 348, { gpu: '1x H100', vram: '80 GB', price: 3.48 }],
|
||||
['gpu-pro', 'GPU Pro', '2x H100 · 160 GB VRAM', 696, { gpu: '2x H100', vram: '160 GB', price: 6.96 }],
|
||||
['gpu-ultra', 'GPU Ultra', '4x H100 · 320 GB VRAM', 1392, { gpu: '4x H100', vram: '320 GB', price: 13.92 }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'gpu', description, priceCents, currency: 'usd', order: 11 + i, published: true, metadata }))
|
||||
|
||||
const datastore = (
|
||||
[
|
||||
['datastore-basic', 'Basic', 'For teams getting started with analytics', 6652, { id: 'basic', replicas: 1, ramGiB: 8, vcpu: 2, storageGB: 1000, priceMonthly: 66.52, priceHourly: 0.0922, support: { level: 'standard' }, features: ['async_inserts', 'http_api'] }],
|
||||
['datastore-scale', 'Scale', 'For production workloads with high availability', 49938, { id: 'scale', replicas: 2, ramGiB: 8, vcpu: 2, storageGB: null, priceMonthly: 499.38, priceHourly: 0.6936, support: { level: 'priority' }, popular: true }],
|
||||
['datastore-enterprise', 'Enterprise', 'For mission-critical deployments at scale', 266940, { id: 'enterprise', replicas: 2, ramGiB: 32, vcpu: 8, storageGB: 5000, priceMonthly: 2669.4, priceHourly: 3.7075, support: { level: 'enterprise', sla: true }, contactSales: true }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'datastore', description, priceCents, currency: 'usd', order: 14 + i, published: true, metadata }))
|
||||
|
||||
return [...cloud, ...gpu, ...datastore]
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
test('catalog editor renders the infra tiers, edits a price, and persists', async ({ page }) => {
|
||||
// A live in-memory catalog — GET returns it, PUT mutates it (the persistence loop).
|
||||
const store = new Map(seedEntries().map((e) => [e.slug as string, e]))
|
||||
// A holder (not a bare `let`) so TS keeps the union type across the route closure.
|
||||
const cap: { put: { slug: string; body: Record<string, unknown> } | null } = { put: null }
|
||||
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
// Catalog admin CRUD (bare JSON, not the casibase envelope).
|
||||
if (path === '/v1/catalog/entries' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([...store.values()]) })
|
||||
}
|
||||
const m = path.match(/^\/v1\/catalog\/entries\/(.+)$/)
|
||||
if (m && req.method() === 'PUT') {
|
||||
const slug = decodeURIComponent(m[1])
|
||||
const body = JSON.parse(req.postData() || '{}') as Record<string, unknown>
|
||||
cap.put = { slug, body }
|
||||
const updated = { ...(store.get(slug) ?? {}), ...body, slug }
|
||||
store.set(slug, updated)
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(updated) })
|
||||
}
|
||||
|
||||
// Everything else same-origin API → an honest empty envelope (the shell's
|
||||
// non-critical calls); let real assets/documents through.
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
|
||||
// A global admin (reserved `admin` org) — the catalog module is admin-gated.
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
|
||||
await page.goto(`${BASE_URL}/catalog`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The table renders every real tier.
|
||||
await expect(page.getByText('Catalog & Pricing').first()).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.getByText('cloud-dev').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('gpu-standard').first()).toBeVisible()
|
||||
await expect(page.getByText('datastore-enterprise').first()).toBeVisible()
|
||||
// The cloud-dev price is the seeded $15.00 before the edit.
|
||||
await expect(page.getByText('$15.00').first()).toBeVisible()
|
||||
|
||||
// Open the cloud-dev row → the edit form.
|
||||
await page.getByText('cloud-dev').first().click()
|
||||
await expect(page.getByText('Edit Dev').first()).toBeVisible({ timeout: 10_000 })
|
||||
// The spec (metadata) editor shows the real cloud scalars.
|
||||
await expect(page.getByText('Spec (metadata)').first()).toBeVisible()
|
||||
|
||||
// Screenshot the editor (table behind + the open edit form).
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-catalog-editor.png'), fullPage: false })
|
||||
|
||||
// Edit the price: $15 → $18. The price field is uniquely identified by its
|
||||
// placeholder "15" (the metadata priceMonthly value input shows placeholder "value").
|
||||
const priceBox = page.locator('input[placeholder="15"]')
|
||||
await expect(priceBox).toBeVisible({ timeout: 8_000 })
|
||||
await expect(priceBox).toHaveValue('15')
|
||||
await priceBox.fill('18')
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click()
|
||||
|
||||
// The PUT was issued to the correct endpoint with the new priceCents (1800).
|
||||
await expect.poll(() => cap.put?.slug, { timeout: 10_000 }).toBe('cloud-dev')
|
||||
expect(cap.put?.body.priceCents).toBe(1800)
|
||||
// Name/category/metadata survived the round-trip (the form sends the whole entry).
|
||||
expect(cap.put?.body.name).toBe('Dev')
|
||||
expect(cap.put?.body.category).toBe('cloud')
|
||||
expect((cap.put?.body.metadata as Record<string, unknown>)?.vcpus).toBe(2)
|
||||
|
||||
// The store persisted it, so the reloaded table shows the NEW price.
|
||||
await expect(page.getByText('$18.00').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-catalog-editor-persisted.png'), fullPage: false })
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Subscription Plans admin editor — render + edit-persists proof (increment 3a-console).
|
||||
*
|
||||
* Drives the REAL PlansCatalogModule (client + form + metadata editor) against a mock
|
||||
* of commerce's `/v1/plans/*` CRUD, seeded with real-shaped subscription/DNS plans. The
|
||||
* mock is a live in-memory store: a PUT mutates it, so a save → re-fetch shows the NEW
|
||||
* price — the exact "edit persists" loop the module drives against commerce (whose CRUD
|
||||
* + slug-immutable guard is proven by commerce's own api/plan handler tests).
|
||||
*
|
||||
* Proves: the table renders every plan with its monthly/annual price + custom/per-seat
|
||||
* flags; opening a plan shows the editable form (slug locked, name/price/category/
|
||||
* contactSales/popular/metadata) with the LIVE-BILLING warning; changing the price + Save
|
||||
* issues `PUT /v1/plans/entries/<slug>` with the new cents; and the table reflects it.
|
||||
* Screenshots the table + the open edit form (admin-plans-editor.png).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-plans-editor
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** Real-shaped platform plans (the raw `plan` shape the admin GET /v1/plans/entries returns). */
|
||||
function seedPlans(): Record<string, unknown>[] {
|
||||
const base = { sku: '', currency: 'usd', interval: 'month', intervalCount: 1 }
|
||||
return [
|
||||
{ ...base, slug: 'personal-free', name: 'Personal', description: 'For personal projects.', category: 'personal', price: 0, priceAnnual: 0, trialPeriodDays: 0, perSeat: false, contactSales: false, popular: false, metadata: { limits: { requests: 1000 }, features: ['1 project'] } },
|
||||
{ ...base, slug: 'pro', name: 'Pro', description: 'For professionals shipping real products.', category: 'personal', price: 2000, priceAnnual: 1600, trialPeriodDays: 14, perSeat: false, contactSales: false, popular: true, metadata: { limits: { requests: 100000 }, features: ['Unlimited projects', 'Priority support'] } },
|
||||
{ ...base, slug: 'team', name: 'Team', description: 'For teams, billed per seat.', category: 'team', price: 9900, priceAnnual: 7900, trialPeriodDays: 14, perSeat: true, contactSales: false, popular: false, metadata: { seats: 'unlimited' } },
|
||||
{ ...base, slug: 'enterprise', name: 'Enterprise', description: 'Custom deployment at scale.', category: 'enterprise', price: 0, priceAnnual: 0, trialPeriodDays: 0, perSeat: false, contactSales: true, popular: false, metadata: { sla: true } },
|
||||
{ ...base, slug: 'dns-basic', name: 'DNS Basic', description: 'Managed DNS for a domain.', category: 'dns', price: 500, priceAnnual: 400, trialPeriodDays: 0, perSeat: false, contactSales: false, popular: false, metadata: { zones: 1 } },
|
||||
]
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
test('plans editor renders the plans, edits a price, and persists', async ({ page }) => {
|
||||
const store = new Map(seedPlans().map((p) => [p.slug as string, p]))
|
||||
const cap: { put: { slug: string; body: Record<string, unknown> } | null } = { put: null }
|
||||
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/v1/plans/entries' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([...store.values()]) })
|
||||
}
|
||||
const m = path.match(/^\/v1\/plans\/entries\/(.+)$/)
|
||||
if (m && req.method() === 'PUT') {
|
||||
const slug = decodeURIComponent(m[1])
|
||||
const body = JSON.parse(req.postData() || '{}') as Record<string, unknown>
|
||||
cap.put = { slug, body }
|
||||
// Commerce pins the path slug (immutable) — mirror that here.
|
||||
const updated = { ...(store.get(slug) ?? {}), ...body, slug }
|
||||
store.set(slug, updated)
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(updated) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
|
||||
await page.goto(`${BASE_URL}/plan-catalog`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The table renders every plan.
|
||||
await expect(page.getByText('Subscription Plans').first()).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.getByText('pro').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('enterprise').first()).toBeVisible()
|
||||
await expect(page.getByText('dns-basic').first()).toBeVisible()
|
||||
// Pro is $20.00/mo before the edit; Enterprise shows the custom price.
|
||||
await expect(page.getByText('$20.00/mo').first()).toBeVisible()
|
||||
await expect(page.getByText('Contact sales').first()).toBeVisible()
|
||||
|
||||
// Open the Pro row → the edit form (with the live-billing warning).
|
||||
await page.getByText('pro', { exact: true }).first().click()
|
||||
await expect(page.getByText('Edit Pro').first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('Editing the price changes the real renewal charge').first()).toBeVisible()
|
||||
// The slug field is disabled (immutable on edit).
|
||||
await expect(page.locator('input[value="pro"]')).toBeDisabled()
|
||||
|
||||
// Screenshot the editor (table behind + the open edit form).
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-plans-editor.png'), fullPage: false })
|
||||
|
||||
// Edit the monthly price: $20 → $25 (the price field is uniquely identified by
|
||||
// its placeholder "20"; the annual + metadata inputs carry different placeholders).
|
||||
const priceBox = page.locator('input[placeholder="20"]')
|
||||
await expect(priceBox).toBeVisible({ timeout: 8_000 })
|
||||
await expect(priceBox).toHaveValue('20')
|
||||
await priceBox.fill('25')
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click()
|
||||
|
||||
// The PUT was issued to the correct endpoint with the new price (2500 cents), the
|
||||
// immutable slug preserved, and the metadata round-tripped type-exactly.
|
||||
await expect.poll(() => cap.put?.slug, { timeout: 10_000 }).toBe('pro')
|
||||
expect(cap.put?.body.price).toBe(2500)
|
||||
expect(cap.put?.body.slug).toBe('pro')
|
||||
expect(cap.put?.body.name).toBe('Pro')
|
||||
expect(cap.put?.body.popular).toBe(true)
|
||||
expect((cap.put?.body.metadata as Record<string, unknown>)?.limits).toEqual({ requests: 100000 })
|
||||
|
||||
// The store persisted it, so the reloaded table shows the NEW price.
|
||||
await expect(page.getByText('$25.00/mo').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-plans-editor-persisted.png'), fullPage: false })
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai super-admin view audit — monochrome + not-broken + org search.
|
||||
*
|
||||
* Renders every admin-only view as a super-admin (primeSession owner:'admin') against
|
||||
* a LOCAL fixture server with the network mocked, and asserts three things the CTO asked
|
||||
* for: (1) MONOCHROME — no surface has a blue/cool color cast (the hue-220 light-theme
|
||||
* bug); (2) NOT BROKEN — every admin route renders its shell without an error-boundary
|
||||
* crash, and page errors are collected per route; (3) org SEARCH is reachable. One
|
||||
* screenshot per view so breakage is visible.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-views-audit
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots', 'admin-audit')
|
||||
|
||||
/** The super-admin identity (a@hanzo.ai in the reserved `admin` org). */
|
||||
const ADMIN = { owner: 'admin', name: 'a', email: 'a@hanzo.ai', displayName: 'Admin', isAdmin: true }
|
||||
|
||||
/** Every admin-only view (registry `admin:true`) + the two catalog editors. */
|
||||
const ADMIN_VIEWS = [
|
||||
'finance-center', 'provider-billing', 'provider-admin', 'ai-economics', 'iam', 'kms',
|
||||
'audit', 'secrets', 'authz', 'hsm', 'mpc', 'treasury', 'tenants', 'entitlements',
|
||||
'cluster-fleet', 'function-fleet', 'service-mesh', 'gitops', 'status', 'tracker',
|
||||
'routing', 'models', 'platform', 'authors-admin', 'affiliates-admin', 'referrals-admin',
|
||||
'catalog', 'plans',
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
// Honest-empty for every API — the audit is about RENDER + THEME, not data.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
/** Parse `rgb(r, g, b[, a])` → [r,g,b] or null. */
|
||||
function rgb(v: string): [number, number, number] | null {
|
||||
const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
||||
}
|
||||
|
||||
/** A color is monochrome when R≈G≈B. A blue cast = B meaningfully above R and G. */
|
||||
function blueCast([r, g, b]: [number, number, number]): number {
|
||||
return b - Math.max(r, g)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('every admin view is monochrome — no blue cast in the rendered surfaces', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.goto(`${BASE_URL}/finance-center`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2500) // let the SPA hydrate + the module mount
|
||||
|
||||
// Sample the computed background/border/text colors of every rendered element and
|
||||
// assert none carries a blue cast beyond a small tolerance (anti-aliasing / semantics
|
||||
// like a green "live" dot are allowed — we only flag a systemic BLUE tint).
|
||||
const offenders = await page.evaluate(() => {
|
||||
const bad: { sel: string; prop: string; color: string }[] = []
|
||||
const rgbOf = (v: string) => { const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); return m ? [+m[1], +m[2], +m[3]] as [number, number, number] : null }
|
||||
const els = Array.from(document.querySelectorAll('*')).slice(0, 4000)
|
||||
for (const el of els) {
|
||||
const cs = getComputedStyle(el as Element)
|
||||
for (const prop of ['backgroundColor', 'borderTopColor', 'color'] as const) {
|
||||
const c = rgbOf(cs[prop]); if (!c) continue
|
||||
const [r, g, bl] = c
|
||||
// Ignore near-black/near-white/transparent grays; flag a real blue tint only.
|
||||
if (bl - Math.max(r, g) >= 18 && bl > 60) bad.push({ sel: (el as Element).tagName.toLowerCase(), prop, color: cs[prop] })
|
||||
}
|
||||
}
|
||||
return bad.slice(0, 20)
|
||||
})
|
||||
await page.screenshot({ path: join(SHOTS, 'finance-center.png') })
|
||||
if (offenders.length) console.log('BLUE-CAST offenders:', JSON.stringify(offenders, null, 2))
|
||||
expect(offenders, `blue-cast surfaces found: ${JSON.stringify(offenders)}`).toHaveLength(0)
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the LIGHT theme is monochrome — the hue-220 blue-tinge fix', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.addInitScript(() => { try { localStorage.setItem('theme', 'light') } catch { /* private */ } })
|
||||
await page.goto(`${BASE_URL}/finance-center`, { waitUntil: 'domcontentloaded' })
|
||||
// Force the light-theme class regardless of the next-themes storage key — this is the
|
||||
// surface (html:root.t_light) that used to build its scale on hsl(220 …) = blue.
|
||||
await page.evaluate(() => { document.documentElement.classList.add('t_light'); document.documentElement.classList.remove('t_dark') })
|
||||
await page.waitForTimeout(1500)
|
||||
const offenders = await page.evaluate(() => {
|
||||
const bad: { sel: string; prop: string; color: string }[] = []
|
||||
const rgbOf = (v: string) => { const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); return m ? [+m[1], +m[2], +m[3]] as [number, number, number] : null }
|
||||
for (const el of Array.from(document.querySelectorAll('*')).slice(0, 4000)) {
|
||||
const cs = getComputedStyle(el as Element)
|
||||
for (const prop of ['backgroundColor', 'borderTopColor', 'color'] as const) {
|
||||
const c = rgbOf(cs[prop]); if (!c) continue
|
||||
const [r, g, bl] = c
|
||||
if (bl - Math.max(r, g) >= 18 && bl > 60) bad.push({ sel: (el as Element).tagName.toLowerCase(), prop, color: cs[prop] })
|
||||
}
|
||||
}
|
||||
return bad.slice(0, 20)
|
||||
})
|
||||
await page.screenshot({ path: join(SHOTS, 'finance-center-light.png') })
|
||||
if (offenders.length) console.log('LIGHT-MODE BLUE-CAST offenders:', JSON.stringify(offenders, null, 2))
|
||||
expect(offenders, `light-mode blue-cast surfaces: ${JSON.stringify(offenders)}`).toHaveLength(0)
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('org search is reachable for a super-admin', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2000)
|
||||
// The org switcher/picker must expose a filter input for a super-admin (many orgs).
|
||||
const filter = page.locator('input[placeholder*="rganization" i], input[placeholder*="ilter" i], input[placeholder*="earch" i]')
|
||||
await expect(filter.first(), 'no org search/filter input found for super-admin').toBeVisible({ timeout: 10_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('no admin view crashes — each renders its shell (screenshot per view)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
|
||||
const broken: { view: string; reason: string }[] = []
|
||||
for (const view of ADMIN_VIEWS) {
|
||||
const errors: string[] = []
|
||||
const onErr = (e: Error) => errors.push(e.message)
|
||||
page.on('pageerror', onErr)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}/${view}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: join(SHOTS, `${view}.png`) })
|
||||
// A hard crash = the shared error boundary card, or a JS pageerror.
|
||||
const crashed = await page.locator('text=/Something went wrong|Application error|Unhandled|Cannot read prop/i').first().isVisible().catch(() => false)
|
||||
if (crashed) broken.push({ view, reason: 'error-boundary/crash card' })
|
||||
else if (errors.length) broken.push({ view, reason: `pageerror: ${errors[0]}` })
|
||||
} catch (e) {
|
||||
broken.push({ view, reason: `navigation: ${(e as Error).message}` })
|
||||
} finally {
|
||||
page.off('pageerror', onErr)
|
||||
}
|
||||
}
|
||||
if (broken.length) console.log('BROKEN ADMIN VIEWS:', JSON.stringify(broken, null, 2))
|
||||
expect(broken, `broken admin views: ${JSON.stringify(broken)}`).toHaveLength(0)
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai AI Economics board (feat/ai-economics).
|
||||
*
|
||||
* TWO layers, mirroring provider-billing.spec:
|
||||
* (A) FIXTURE render — runs against a LOCAL server (BASE_URL=http://localhost:4000)
|
||||
* with the network mocked: `/auth/session` → a global admin so the admin shell
|
||||
* mounts, and the reads (`/v1/admin/usage/funding`, `/v1/admin/finance`,
|
||||
* `/v1/admin/providers/credit`, `/v1/evals/{datasets,runs,evaluators}`) → a
|
||||
* fixture where fable-5 is exactly 75% of requests and gross margin is 62%.
|
||||
* Proves: the page renders, the model-mix table shows the mocked rows WITH the
|
||||
* request-share %, the margin card shows the mocked grossMarginPct, and the
|
||||
* honest "no traffic is harvested" training-data card renders. Desktop + mobile.
|
||||
* (B) LIVE — the fail-closed gate proof (`/v1/admin/*` → >=401 unauthenticated)
|
||||
* against the same origin; needs no credentials, always runs.
|
||||
*
|
||||
* Run fixture: BASE_URL=http://localhost:4000 npx playwright test ai-economics
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A SuperAdmin via the isGlobalAdmin/isSuperAdmin CLAIM (what the `admin: true` module
|
||||
// gates on). owner is a normal org so Scope resolves locally instead of demanding a
|
||||
// pick from the (mocked-empty) org list.
|
||||
// owner === the reserved `admin` org IS the SuperAdmin signal the client gate reads
|
||||
// (`isSuperAdminOwner` / IAM `User.IsSuperAdmin` — the isGlobalAdmin/isSuperAdmin claim
|
||||
// fields are NOT read), so the `admin: true` module renders instead of the managed notice.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isGlobalAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** GET /v1/admin/usage/funding — the model mix: fable-5 = 750/1000 requests (75%),
|
||||
* gpt-5.6 = 200 (20%), ds4-flash = 30, ds4-pro = 20. One row per (provider,model,funding). */
|
||||
const FUNDING = [
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'credit', tokens: 4_800_000, cost_cents: 18_200, requests: 600 },
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'paid', tokens: 1_200_000, cost_cents: 6_100, requests: 150 },
|
||||
{ provider: 'openrouter', model: 'gpt-5.6', funding: 'paid', tokens: 900_000, cost_cents: 44_000, requests: 200 },
|
||||
{ provider: 'openrouter', model: 'ds4-flash', funding: 'paid', tokens: 120_000, cost_cents: 900, requests: 30 },
|
||||
{ provider: 'openrouter', model: 'ds4-pro', funding: 'paid', tokens: 80_000, cost_cents: 3_100, requests: 20 },
|
||||
]
|
||||
|
||||
/** GET /v1/admin/finance — the casibase-enveloped finance aggregate; grossMarginPct 62. */
|
||||
const FINANCE = {
|
||||
status: 'ok',
|
||||
msg: '',
|
||||
data: {
|
||||
cost: { configured: true, error: '', period: '2026-07', totalCents: 3_800_000, vendors: [], digitalocean: { configured: true, error: '', creditRemainingCents: 2_418_000, monthToDateSpendCents: 41_200, avgDailyBurnCents: 20_100, accountBalanceCents: -2_418_000, generatedAt: '', history: [] } },
|
||||
revenue: { configured: true, totalRevenueCents: 10_000_000, mrrCents: 820_000, creditsConsumedCents: 120_000 },
|
||||
derived: { grossMarginCents: 6_200_000, grossMarginPct: 62, runwayDays: 120, profitable: true },
|
||||
generatedAt: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
/** GET /v1/admin/providers/credit — the DO grant + a paid-only provider. */
|
||||
const CREDIT = [
|
||||
{ provider: 'do-ai', grant_cents: 2_600_000, burn_cents: 41_200, remaining_cents: 2_418_000, runway_days: 58, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openrouter', grant_cents: 100_000, burn_cents: 21_000, remaining_cents: 62_500, runway_days: 3, has_credit: true, is_paid_only: false },
|
||||
]
|
||||
|
||||
/** GET /v1/evals/datasets — user-curated registry: 2 datasets, 150 items. */
|
||||
const DATASETS = { data: [
|
||||
{ name: 'router-quality', description: 'router routing quality', items: 120, createdAt: '2026-07-08T00:00:00Z' },
|
||||
{ name: 'safety-redteam', description: 'safety judgments', items: 30, createdAt: '2026-07-02T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/runs — recent LLM-as-judge runs with an average score. */
|
||||
const RUNS = { data: [
|
||||
{ dataset: 'router-quality', runName: 'rq-2026-07-10', model: 'fable-5', judgeModel: 'claude-opus-4.6', items: 120, scored: 120, avgScore: 0.87, createdAt: '2026-07-10T00:00:00Z' },
|
||||
{ dataset: 'safety-redteam', runName: 'sr-2026-07-04', model: 'gpt-5.6', judgeModel: 'claude-opus-4.6', items: 30, scored: 30, avgScore: 0.93, createdAt: '2026-07-04T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/evaluators. */
|
||||
const EVALUATORS = { data: [{ name: 'quality-judge', model: 'claude-opus-4.6', criteria: 'routing quality', scoreName: 'quality' }] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The economics reads. funding/credit are bare arrays (restGet + pluckList); finance
|
||||
// is the casibase envelope (originGet unwraps `data`); evals are `{data:[...]}`.
|
||||
const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (path === '/v1/admin/usage/funding') return json(FUNDING)
|
||||
if (path === '/v1/admin/finance') return json(FINANCE)
|
||||
if (path === '/v1/admin/providers/credit') return json(CREDIT)
|
||||
if (path === '/v1/evals/datasets') return json(DATASETS)
|
||||
if (path === '/v1/evals/runs') return json(RUNS)
|
||||
if (path === '/v1/evals/evaluators') return json(EVALUATORS)
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBoard(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — Scope → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/ai-economics`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(content.getByTestId('ai-economics')).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
// ─── (A) fixture render ───────────────────────────────────────────────────────
|
||||
test.describe('(A) fixture render — model mix (fable-5 75%) + 62% margin + honest training card', () => {
|
||||
test('renders the model mix, share %, margin, and the honest training-data card (desktop)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
// Page rendered (not the operator gate).
|
||||
await expect(page.getByText('AI Economics').first()).toBeVisible()
|
||||
await expect(page.locator('text=/Operator access required|not authorized|access denied/i')).toHaveCount(0)
|
||||
|
||||
// (a) model mix — the mocked rows WITH request-share %.
|
||||
const modelMix = page.getByTestId('model-mix')
|
||||
await expect(modelMix.getByText('Model mix').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('fable-5').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('gpt-5.6').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('75%').first()).toBeVisible() // fable-5 = 750/1000 requests
|
||||
await expect(modelMix.getByText('20%').first()).toBeVisible() // gpt-5.6 = 200/1000
|
||||
|
||||
// (b) profitability — the mocked grossMarginPct.
|
||||
const margin = page.getByTestId('margin-card')
|
||||
await expect(margin.getByText('+62% margin').first()).toBeVisible()
|
||||
|
||||
// (c) training data — the honest "no traffic harvested" collection card + real counts.
|
||||
const training = page.getByTestId('training-collection-card')
|
||||
await expect(training).toBeVisible()
|
||||
await expect(training.getByText(/No traffic is harvested for training/i)).toBeVisible()
|
||||
await expect(page.getByText('Eval datasets').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
await expect(page.getByTestId('model-mix').getByText('fable-5').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (B) fail-closed gate — always runs, no credentials ───────────────────────
|
||||
test.describe('(B) admin gate fail-closed', () => {
|
||||
test('/v1/admin/{usage/funding,finance,providers/credit} → fail-closed unauthenticated', async ({ request }) => {
|
||||
for (const p of ['usage/funding', 'finance', 'providers/credit']) {
|
||||
const res = await request.get(`${BASE_URL}/v1/admin/${p}`)
|
||||
// A raw request (no page mocks, no session) NEVER gets data: the console's
|
||||
// getAdminGate is fail-closed. Post-deploy this is the 403 global-admin gate;
|
||||
// before a sibling route deploys it may 404 — both are "not open". Never 200.
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must be fail-closed (>=401)`).toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must not 5xx`).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ async function billing(page: Page, path: string): Promise<{ status: number; ids:
|
||||
const body = await res.json()
|
||||
const rows = Array.isArray(body)
|
||||
? body
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.data ?? [])
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.invoices ?? body?.data ?? [])
|
||||
ids = (Array.isArray(rows) ? rows : [])
|
||||
.map((r: { id?: unknown }) => (typeof r?.id === 'string' ? r.id : ''))
|
||||
.filter(Boolean)
|
||||
@@ -72,7 +72,10 @@ test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
await signIn(pageA, A.email, A.password)
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
for (const path of ['subscriptions', 'payment-methods']) {
|
||||
// `invoices` is included because its row ids drive the per-invoice PDF URL
|
||||
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// proves a user can only ever build a PDF URL for their OWN org's invoices.
|
||||
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* COMPREHENSIVE SMOKE — the whole MONEY / USAGE / OBSERVABILITY surface of the console,
|
||||
* end to end, honest by construction. This is the repeatable proof that every billing,
|
||||
* settings, usage-metrics and o11y page RENDERS (real data OR a graceful/honest state)
|
||||
* and never a dead "Could not load" card — the exact regression the platform owner
|
||||
* asked to lock down.
|
||||
*
|
||||
* Two layers, so the spec is ALWAYS runnable and honest:
|
||||
*
|
||||
* A. UNAUTHENTICATED fail-closed proof (ALWAYS runs, no creds — green in CI). Proves
|
||||
* the harness reaches the deployment AND the security invariant that matters most:
|
||||
* an anonymous caller NEVER gets 2xx billing/usage/o11y DATA. Each read is gated
|
||||
* (401/403) when the backend is up, or 5xx/redirect while it rolls (single-replica
|
||||
* Recreate) — but never a 200 leaking a tenant's money/usage. Resilient to a roll:
|
||||
* it asserts only "anonymous is refused", and LOGS the live status matrix.
|
||||
*
|
||||
* B. AUTHENTICATED render smoke (runs when HANZO_PASSWORD is provided). Signs in with
|
||||
* the ESTABLISHED console form pattern and walks every surface the owner listed:
|
||||
* - Billing: Overview · Reports · Budgets · Invoices · Subscriptions ·
|
||||
* Payment methods · Credits (+ the Finance ledger board). Invoices gets a deep
|
||||
* test: the list/table renders, the DOWNLOAD control exists + the PDF endpoint
|
||||
* responds, the print/statement path (window.print + the PDF) is available, and
|
||||
* a RELOAD re-renders cleanly (no flash-of-error).
|
||||
* - Settings: General · Branding (every tab renders).
|
||||
* - Usage metrics: Usage · Metrics · AI Metrics (charts or an honest empty state;
|
||||
* the time-range control works).
|
||||
* - o11y: Traces · Observations · Service Map · Logs · Dashboards · Alerts ·
|
||||
* Fleet Observability (real data OR an honest RuntimeNotice/empty — NOT a dead
|
||||
* card).
|
||||
* Each surface: navigate, assert it RENDERS (a real marker OR an honest state),
|
||||
* screenshot into e2e-shots/, and COLLECT any dead "Could not load" card. A final
|
||||
* aggregate test FLAGS the collected dead-card list (the 402-as-crash bug the owner
|
||||
* is fixing separately): green for a funded org, and a precise per-surface bug list
|
||||
* for an unfunded one (maxpower).
|
||||
*
|
||||
* Run:
|
||||
* # unauthenticated fail-closed proof (works today, no creds):
|
||||
* BASE_URL=https://console.hanzo.ai npx playwright test billing-usage-o11y --reporter=line
|
||||
* # full authenticated render smoke (needs a real password — NEVER hardcode it):
|
||||
* HANZO_EMAIL='z@hanzo.ai' HANZO_PASSWORD='…' npx playwright test billing-usage-o11y --reporter=line
|
||||
*
|
||||
* With no HANZO_PASSWORD the authenticated smoke SKIPS (so the suite is green in CI
|
||||
* without secrets) while the fail-closed proof still runs.
|
||||
*/
|
||||
import { test, expect, type Page, type Browser, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const CONSOLE = process.env.BASE_URL ?? process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
|
||||
|
||||
// ── the dead-card signal ────────────────────────────────────────────────────────
|
||||
// The generic fallback the owner wants eliminated: a 402/500 rendered as a dead
|
||||
// "Could not load" (states-logic.ts) / "Could not reach the backend" (BackendState)
|
||||
// instead of an HONEST top-up / empty / access / initializing state. Matched EXACTLY
|
||||
// (exact:true) so the legitimate "Could not load the card form." (a Square-iframe
|
||||
// honest state) and "Could not load more." (pager) are NOT false-flagged.
|
||||
const DEAD_HEADINGS = ['Could not load', 'Could not reach the backend'] as const
|
||||
// A hard crash / error boundary / static 404 — always a failure, never honest.
|
||||
const CRASH_RE = /something went wrong|application error|unexpected token|this page could not be found|client-side exception/i
|
||||
|
||||
/** Accumulates "<surface> → <dead heading>" across the serial run; the final test asserts it empty. */
|
||||
const deadCards: string[] = []
|
||||
|
||||
/** Sign in via the console app sign-in form — the ESTABLISHED pattern (never hardcode the password). */
|
||||
async function signIn(page: Page): Promise<void> {
|
||||
await page.goto(`${CONSOLE}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 }).catch(() => {})
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Body text of an APIResponse, best-effort (first 200 chars). */
|
||||
async function bodyText(res: { text(): Promise<string> }): Promise<string> {
|
||||
return (await res.text().catch(() => '')).slice(0, 200)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a surface and audit it honestly. Asserts liveness (not bounced to
|
||||
* sign-in, no hard crash, a real marker OR an honest state is visible), screenshots
|
||||
* it, and COLLECTS any exact dead "Could not load" heading (surfaced by the final
|
||||
* aggregate test — a dead card never silently passes, but it also doesn't mask the
|
||||
* liveness signal of the other surfaces).
|
||||
*/
|
||||
async function auditSurface(page: Page, slug: string, name: string, marker: RegExp): Promise<void> {
|
||||
await page.goto(`${CONSOLE}/${slug}`, { waitUntil: 'domcontentloaded' })
|
||||
// Give the RNW/Tamagui SPA + its data fetch time to settle; networkidle is best-effort.
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(2500)
|
||||
|
||||
// 1) A signed-in user must never be bounced to /signin on a money/usage/o11y page.
|
||||
await expect(page, `${name} bounced to sign-in`).not.toHaveURL(/\/signin/, { timeout: 10_000 })
|
||||
|
||||
// 2) No hard crash / error boundary / static 404.
|
||||
await expect(page.locator(`text=${CRASH_RE}`), `${name} hard-crashed`).toHaveCount(0)
|
||||
|
||||
// 3) Screenshot every surface (repeatable visual smoke of the whole surface).
|
||||
const shot = `${SHOTS}/surface-${slug.replace(/[^a-z0-9]+/gi, '-')}.png`
|
||||
await page.screenshot({ path: shot, fullPage: true }).catch(() => {})
|
||||
|
||||
// 4) Collect any EXACT dead-card heading (flagged by the aggregate test).
|
||||
for (const h of DEAD_HEADINGS) {
|
||||
const n = await page.getByText(h, { exact: true }).count().catch(() => 0)
|
||||
if (n > 0) {
|
||||
deadCards.push(`${name} (/${slug}) → dead "${h}"`)
|
||||
console.warn(`⚠ ${name} (/${slug}) shows a dead "${h}" — expected an honest top-up/empty/access state (402-as-crash bug).`)
|
||||
}
|
||||
}
|
||||
|
||||
// 5) The surface rendered SOMETHING truthful — its real marker OR a recognized honest state.
|
||||
await expect(page.getByText(marker).first(), `${name} rendered neither real content nor an honest state`).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
console.log(`✓ ${name} (/${slug}) rendered — screenshot ${shot}`)
|
||||
}
|
||||
|
||||
// Honest states that count as a truthful render for ANY surface (real content is added
|
||||
// per-surface). Kept in ONE place so every marker is consistent.
|
||||
const HONEST =
|
||||
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|Operator access|No .* yet|not connected|not configured|Sign in'
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// A. UNAUTHENTICATED fail-closed proof — ALWAYS runs (no credentials required).
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenticated)', () => {
|
||||
// The tenant-scoped reads that must NEVER return data to an anonymous caller.
|
||||
const READS = [
|
||||
'/v1/billing/balance',
|
||||
'/v1/billing/invoices',
|
||||
'/v1/billing/usage',
|
||||
'/v1/billing/payment-methods',
|
||||
'/v1/billing/spend-alerts',
|
||||
'/v1/usage/summary',
|
||||
'/v1/get-cloud-usages',
|
||||
'/v1/o11y/observations',
|
||||
]
|
||||
|
||||
test('anonymous never receives 2xx billing/usage/o11y DATA (gated when up, refused while rolling)', async ({
|
||||
request,
|
||||
}: {
|
||||
request: APIRequestContext
|
||||
}) => {
|
||||
const matrix: string[] = []
|
||||
let gatedCount = 0
|
||||
for (const path of READS) {
|
||||
const res = await request.get(`${CONSOLE}${path}`, { failOnStatusCode: false })
|
||||
const status = res.status()
|
||||
const body = await bodyText(res)
|
||||
matrix.push(`${status} ${path}`)
|
||||
|
||||
// THE invariant: an anonymous caller must not get a 2xx with a data payload.
|
||||
// A JSON body carrying data|balance|invoices|records for status 2xx is a leak.
|
||||
const twoxxData = status >= 200 && status < 300 && /"(data|balance|invoices|records|usage|amount|cents)"/i.test(body)
|
||||
expect(twoxxData, `anonymous ${path} leaked a 2xx data payload: ${body}`).toBe(false)
|
||||
|
||||
// When the backend is UP (not a 5xx roll), a sensitive read should be
|
||||
// specifically GATED (401/403) or unrouted (404) — never an open 2xx.
|
||||
if (status < 500) {
|
||||
expect(status, `${path} is up but not gated (expected 401/403/404, got ${status})`).toBeGreaterThanOrEqual(400)
|
||||
if (status === 401 || status === 403) gatedCount++
|
||||
}
|
||||
}
|
||||
console.log(`✓ anonymous fail-closed matrix:\n ${matrix.join('\n ')}`)
|
||||
if (gatedCount === 0) {
|
||||
console.warn(
|
||||
'⚠ no endpoint returned a clean 401/403 — the console backend appears to be mid-roll (5xx). The fail-closed invariant still held (no 2xx data leaked).',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// B. AUTHENTICATED render smoke — runs when HANZO_PASSWORD is provided.
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe.serial('Billing / Settings / Usage / o11y render smoke (authenticated)', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — the authenticated render smoke is staged')
|
||||
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }: { browser: Browser }) => {
|
||||
// ONE authenticated context reused across the whole surface walk (fast + realistic:
|
||||
// the same session hits every page, exactly like a real user clicking the nav).
|
||||
page = await browser.newPage()
|
||||
await signIn(page)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page?.close()
|
||||
})
|
||||
|
||||
// ── 1) BILLING — every page (Overview · Reports · Budgets · Invoices · Subscriptions ·
|
||||
// Payment methods · Credits) + the Finance ledger board. ─────────────────────
|
||||
test('Billing · Overview renders (balance / spend / add-credits, never dead)', async () => {
|
||||
await auditSurface(page, 'billing', 'Billing · Overview', new RegExp(`Balance|Spend|Month-to-date|Overview|Credits|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Billing · Reports renders (cost breakdown by service, never dead)', async () => {
|
||||
await auditSurface(page, 'billing/reports', 'Billing · Reports', new RegExp(`Report|Cost|Spend|Model|Provider|breakdown|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Billing · Budgets renders (spend caps / limits, never dead)', async () => {
|
||||
await auditSurface(page, 'billing/budgets', 'Billing · Budgets', new RegExp(`Budget|cap|limit|alert|Spend|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Billing · Subscriptions renders (plans / renewal, never dead)', async () => {
|
||||
await auditSurface(page, 'billing/subscriptions', 'Billing · Subscriptions', new RegExp(`Subscription|Plan|renew|status|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Billing · Payment methods renders (masked cards, never dead)', async () => {
|
||||
await auditSurface(page, 'billing/payment-methods', 'Billing · Payment methods', new RegExp(`Payment|Card|method|Add a card|•••|ending|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Billing · Credits / recharge renders (top-up, never dead)', async () => {
|
||||
await auditSurface(page, 'billing/credits', 'Billing · Credits', new RegExp(`Credit|Add credits|balance|top.?up|recharge|HUSD|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Finance ledger board renders (balance / ledger, never dead)', async () => {
|
||||
await auditSurface(page, 'finance-center', 'Finance ledger', new RegExp(`Finance|Ledger|Balance|spend|credit|invoice|${HONEST}`, 'i'))
|
||||
})
|
||||
|
||||
// ── Invoices — the deep test: list renders · view/download control · PDF endpoint ·
|
||||
// print/statement path · clean reload. ───────────────────────────────────────────
|
||||
test('Billing · Invoices — list renders, download+statement work, reload is clean', async () => {
|
||||
await auditSurface(page, 'billing/invoices', 'Billing · Invoices', new RegExp(`Invoice|billing history|Download|No invoices|${HONEST}`, 'i'))
|
||||
|
||||
// The invoice LIST/table (or its honest empty/error) is present.
|
||||
const hasTable = (await page.locator('table, [role="table"]').count()) > 0
|
||||
const hasEmpty = (await page.getByText(/No invoices yet|billing period closes/i).count()) > 0
|
||||
const hasHonest = (await page.getByText(new RegExp(HONEST, 'i')).count()) > 0
|
||||
expect(hasTable || hasEmpty || hasHonest, 'Invoices showed neither a table, an empty state, nor an honest state').toBe(true)
|
||||
|
||||
// DOWNLOAD / VIEW — the per-row "Download" control (opens the hosted invoice PDF).
|
||||
// When the org has ≥1 invoice the control exists; when empty, the download path is
|
||||
// still proven at the endpoint level below. Never a hard requirement on data existing.
|
||||
const downloadCtl = page.getByRole('button', { name: /download/i })
|
||||
const downloadCount = await downloadCtl.count()
|
||||
if (downloadCount > 0) {
|
||||
await expect(downloadCtl.first(), 'invoice Download control not visible').toBeVisible()
|
||||
console.log(`✓ Invoices: ${downloadCount} Download control(s) present (view/open the hosted PDF)`)
|
||||
} else {
|
||||
console.log('ℹ Invoices: no rows for this org — the Download control appears once a period closes')
|
||||
}
|
||||
|
||||
// The PDF/statement ENDPOINT responds (download triggers a request that resolves,
|
||||
// never a crash). Probe it through the SAME authenticated session (page.request).
|
||||
// A real id → the PDF/redirect; a probe id → an honest 404/402/401 — but never 5xx-crash.
|
||||
const probe = await page.request.get(`${CONSOLE}/v1/billing/invoices/e2e-probe/pdf`, { failOnStatusCode: false })
|
||||
expect(probe.status(), 'invoice PDF endpoint hard-crashed (5xx)').toBeLessThan(500)
|
||||
console.log(`✓ Invoices: PDF/statement endpoint responds honestly (status ${probe.status()}, no crash)`)
|
||||
|
||||
// PRINT A STATEMENT — the print hook exists (window.print), and the hosted PDF IS
|
||||
// the downloadable statement. (No dedicated "Print" button today — reported.)
|
||||
const canPrint = await page.evaluate(() => typeof window.print === 'function')
|
||||
expect(canPrint, 'window.print (statement print hook) is unavailable').toBe(true)
|
||||
console.log('✓ Invoices: statement path present — window.print hook + downloadable hosted PDF')
|
||||
|
||||
// RELOAD re-renders cleanly — no blank / flash-of-error after a hard reload.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(page.locator(`text=${CRASH_RE}`), 'Invoices crashed after reload').toHaveCount(0)
|
||||
const deadAfterReload = await page.getByText('Could not load', { exact: true }).count()
|
||||
if (deadAfterReload > 0) deadCards.push('Billing · Invoices (reload) → dead "Could not load"')
|
||||
await expect(
|
||||
page.getByText(new RegExp(`Invoice|billing history|No invoices|${HONEST}`, 'i')).first(),
|
||||
'Invoices did not re-render after reload',
|
||||
).toBeVisible({ timeout: 20_000 })
|
||||
console.log('✓ Invoices: reload re-rendered cleanly (no flash-of-error)')
|
||||
})
|
||||
|
||||
// ── 2) SETTINGS — every tab. ──────────────────────────────────────────────────────
|
||||
test('Settings · General renders (org + account)', async () => {
|
||||
await auditSurface(page, 'settings', 'Settings · General', new RegExp(`Settings|Organization|Your account|Name|Email|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Settings · Branding renders (branding + runtime)', async () => {
|
||||
await auditSurface(page, 'settings/branding', 'Settings · Branding', new RegExp(`Branding|Display name|Primary color|Runtime|Brand|${HONEST}`, 'i'))
|
||||
})
|
||||
|
||||
// ── 3) USAGE METRICS — Usage · Metrics · AI Metrics + the time-range control. ───────
|
||||
test('Usage renders (spend by category / LLM / compute, charts or honest empty)', async () => {
|
||||
await auditSurface(page, 'usage', 'Usage', new RegExp(`Usage|Spend|LLM|Machines|category|footprint|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Metrics renders (per-org usage board or infra health)', async () => {
|
||||
await auditSurface(page, 'metrics', 'Metrics', new RegExp(`Metrics|Requests|Tokens|Spend|Services|Uptime|Healthy|${HONEST}`, 'i'))
|
||||
})
|
||||
test('AI Metrics renders + the time-range control works', async () => {
|
||||
await auditSurface(page, 'ai-metrics', 'AI Metrics', new RegExp(`Requests|Tokens|Spend|model|balance|usage|${HONEST}`, 'i'))
|
||||
// The 24h/7d/30d range toggle is a real control — clicking it must not crash the board.
|
||||
const range = page.getByRole('button', { name: /^(7d|30d|24h)$/i })
|
||||
if ((await range.count()) > 0) {
|
||||
await range.first().click().catch(() => {})
|
||||
await page.waitForTimeout(1500)
|
||||
await expect(page.locator(`text=${CRASH_RE}`), 'AI Metrics crashed after a range change').toHaveCount(0)
|
||||
console.log('✓ AI Metrics: time-range control works (no crash on toggle)')
|
||||
} else {
|
||||
console.log('ℹ AI Metrics: range control not found (honest-empty board) — skipped the toggle')
|
||||
}
|
||||
})
|
||||
|
||||
// ── 4) o11y / OBSERVABILITY — Traces · Observations · Service Map · Logs · Dashboards ·
|
||||
// Alerts · Fleet Observability. Real data OR an honest RuntimeNotice/empty. ─────
|
||||
test('Traces (o11y) renders (real spans or honest runtime notice)', async () => {
|
||||
await auditSurface(page, 'o11y', 'Traces', new RegExp(`Trace|Latency|Tokens|Cost|Observ|No traces|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Observations renders (real observations or honest runtime notice)', async () => {
|
||||
await auditSurface(page, 'observations', 'Observations', new RegExp(`Observation|Spans|generations|Model|Tokens|No observations|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Service Map renders (RED metrics / dependency graph or honest state)', async () => {
|
||||
await auditSurface(page, 'service-map', 'Service Map', new RegExp(`Service Map|Rate|Errors|Duration|p99|dependency|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Logs renders (application logs or honest state)', async () => {
|
||||
await auditSurface(page, 'logs', 'Logs', new RegExp(`Logs|Application logs|Request activity|Severity|Message|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Dashboards renders (analytics dashboards or honest state)', async () => {
|
||||
await auditSurface(page, 'dashboards', 'Dashboards', new RegExp(`Dashboard|analytics|LLM|Overview|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Alerts renders (alerting rules or honest state)', async () => {
|
||||
await auditSurface(page, 'alerts', 'Alerts', new RegExp(`Alert|rule|notification|${HONEST}`, 'i'))
|
||||
})
|
||||
test('Fleet Observability renders (global-admin board or honest operator-access state)', async () => {
|
||||
// For a non-global-admin this is honestly `OperatorAccessRequired` — that IS a pass.
|
||||
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|Operator access|${HONEST}`, 'i'))
|
||||
})
|
||||
|
||||
// ── AGGREGATE — the dead-card audit. FLAGS every surface that showed a dead
|
||||
// "Could not load" (the 402-as-crash bug being fixed separately). Green for a
|
||||
// funded org; a precise per-surface bug list for an unfunded one. ────────────────
|
||||
test('DEAD-CARD AUDIT — no money/usage/o11y surface shows a dead "Could not load"', () => {
|
||||
if (deadCards.length > 0) {
|
||||
console.error(`✗ dead "Could not load" cards (402-as-crash bug) on:\n - ${deadCards.join('\n - ')}`)
|
||||
}
|
||||
expect(deadCards, `surfaces showing a dead card instead of an honest top-up/empty/access state:\n - ${deadCards.join('\n - ')}`).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* e2e blank audit — mocked-network render proof for EVERY in-console product route.
|
||||
*
|
||||
* Runs against a LOCAL `next dev` (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked, so it needs NO real backend and NO password:
|
||||
* - `/auth/session` → a global-admin account (sees every product), so the Auth
|
||||
* and Scope pass and the full console shell mounts.
|
||||
* - every data endpoint (`/v1`, `/v1`, `/ai`, `/billing`, `/commerce`,
|
||||
* `/telemetry`, `/vm`, `/superbase`, `/admin`, cross-origin platform) → a chosen
|
||||
* failure mode (AUDIT_MODE): `notrouted` (404, the "backend not wired on this
|
||||
* deployment" reality), `down` (502), or `empty` (200 empty payload).
|
||||
*
|
||||
* For each route it navigates `/<id>`, waits for the shell's `product-content`
|
||||
* region, and classifies what rendered there:
|
||||
* - `blank` → the content region mounted but is EMPTY (the bug we hunt),
|
||||
* - `content` → real data OR an honest error/empty card (the goal),
|
||||
* - `notfound` → Next 404 (an unrouted/external id — expected for a few),
|
||||
* - `no-shell` → the shell itself failed to mount (worse than blank).
|
||||
*
|
||||
* It writes e2e/blank-report.json and fails iff any route is `blank`/`no-shell`.
|
||||
*
|
||||
* Run: AUDIT_MODE=notrouted BASE_URL=http://localhost:4000 npx playwright test blank-audit
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const MODE = (process.env.AUDIT_MODE ?? 'notrouted') as 'notrouted' | 'down' | 'empty'
|
||||
const ROLE = (process.env.AUDIT_ROLE ?? 'admin') as 'admin' | 'customer'
|
||||
|
||||
const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e', 'route-ids.json'), 'utf8'))
|
||||
/**
|
||||
* The HUMAN slugs the console nav / docs / bookmarks / the CTO's e2e list use that
|
||||
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
|
||||
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
|
||||
*/
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
|
||||
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
|
||||
|
||||
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
|
||||
const ACCOUNT =
|
||||
ROLE === 'admin'
|
||||
? { owner: 'admin', name: 'z', type: 'normal-user', email: 'z@hanzo.ai', displayName: 'Z Admin', isGlobalAdmin: true, isAdmin: true, signupApplication: 'hanzo-cloud' }
|
||||
: { owner: 'maxpower', name: 'dave', type: 'normal-user', email: 'dave@maxpower.com', displayName: 'Dave', isGlobalAdmin: false, isAdmin: true, signupApplication: 'hanzo-cloud' }
|
||||
|
||||
/** casibase envelope + REST payloads for the three failure modes. */
|
||||
function payloadFor(mode: typeof MODE): { status: number; body: string } {
|
||||
if (mode === 'down') return { status: 502, body: 'Bad Gateway' }
|
||||
if (mode === 'notrouted') return { status: 404, body: JSON.stringify({ status: 'error', msg: 'not found', data: null }) }
|
||||
// empty-ok: a well-formed empty envelope; REST readers also tolerate [] / {}.
|
||||
return { status: 200, body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) }
|
||||
}
|
||||
|
||||
/** Path prefixes that are DATA calls (mock them); everything else is a Next asset/page. */
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
// NEVER mock a top-level page navigation (the app HTML). A product route id can
|
||||
// collide with an API head (e.g. `/integrations`, `/billing`), so keying off the
|
||||
// path alone would serve the mock JSON AS the page. Only data calls (xhr/fetch)
|
||||
// are mocked; documents/assets always load the real app.
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
// Session → authed account (always ok, regardless of MODE).
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
|
||||
// Same-origin Next asset or the app HTML → let it through.
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
const isApi = API_RE.test(path)
|
||||
if (sameOrigin && !isApi) return route.continue()
|
||||
|
||||
// Cross-origin (platform.hanzo.ai, api.hanzo.ai, cloud.hanzo.ai, …) OR a
|
||||
// same-origin data path → the chosen failure mode.
|
||||
const { status, body } = payloadFor(MODE)
|
||||
const contentType = body.startsWith('{') || body.startsWith('[') ? 'application/json' : 'text/plain'
|
||||
return route.fulfill({ status, contentType, body })
|
||||
}
|
||||
|
||||
type Outcome = 'content' | 'blank' | 'notfound' | 'no-shell'
|
||||
const results: Record<string, { outcome: Outcome; chars: number; sample: string }> = {}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe(`blank audit [mode=${MODE} role=${ROLE}]`, () => {
|
||||
let page: import('@playwright/test').Page
|
||||
let ctx: import('@playwright/test').BrowserContext
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
page = await ctx.newPage()
|
||||
// Seed the active org to the account's own org so Scope doesn't hard-pin +
|
||||
// reload a customer (currentOrg !== owner) mid-audit, and dismiss the admin
|
||||
// banner so the shell is stable. Runs before every navigation (survives reloads).
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
writeFileSync(join(process.cwd(), 'e2e', 'blank-report.json'), JSON.stringify({ mode: MODE, role: ROLE, results }, null, 2))
|
||||
const blanks = Object.entries(results).filter(([, r]) => r.outcome === 'blank' || r.outcome === 'no-shell')
|
||||
const nf = Object.entries(results).filter(([, r]) => r.outcome === 'notfound').map(([id]) => id)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n=== blank audit [${MODE}/${ROLE}] ===\nroutes: ${Object.keys(results).length} blank/no-shell: ${blanks.length} notfound: ${nf.length}`)
|
||||
if (blanks.length) console.log('BLANK:', blanks.map(([id, r]) => `${id}(${r.outcome})`).join(', '))
|
||||
if (nf.length) console.log('NOTFOUND:', nf.join(', '))
|
||||
await ctx?.close()
|
||||
})
|
||||
|
||||
for (const id of IDS) {
|
||||
test(`/${id}`, async () => {
|
||||
await page.goto(`${BASE_URL}/${id}`, { waitUntil: 'domcontentloaded' })
|
||||
// Let the client mount + the module's first data attempt settle.
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
const appeared = await content.waitFor({ state: 'attached', timeout: 15_000 }).then(() => true).catch(() => false)
|
||||
|
||||
let outcome: Outcome
|
||||
let chars = 0
|
||||
let sample = ''
|
||||
if (!appeared) {
|
||||
// No shell content region — either a Next 404 (notfound) or a shell failure.
|
||||
const is404 = await page.locator('text=/404|not be found|This page could not/i').count().then((c) => c > 0).catch(() => false)
|
||||
outcome = is404 ? 'notfound' : 'no-shell'
|
||||
} else {
|
||||
// Give async data one more settle beat, then read the region's text.
|
||||
await page.waitForTimeout(1200)
|
||||
const txt = (await content.innerText().catch(() => '')) || ''
|
||||
chars = txt.trim().length
|
||||
sample = txt.trim().slice(0, 80).replace(/\s+/g, ' ')
|
||||
outcome = chars > 0 ? 'content' : 'blank'
|
||||
}
|
||||
results[id] = { outcome, chars, sample }
|
||||
// Record only — the final `no blank routes` test asserts over ALL results, so
|
||||
// one blank never fail-fasts the serial group and hides the rest.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${outcome === 'content' ? '✓' : outcome === 'notfound' ? '·' : '✗'} /${id} [${outcome}] ${chars}c ${sample}`)
|
||||
})
|
||||
}
|
||||
|
||||
test('no route renders blank', async () => {
|
||||
const bad = Object.entries(results).filter(([, r]) => r.outcome === 'blank' || r.outcome === 'no-shell')
|
||||
expect(bad.map(([id, r]) => `${id}(${r.outcome})`), 'every product route must render real data or an honest state').toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* e2e: Budgets & limits page — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
|
||||
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
|
||||
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
|
||||
* envelope.
|
||||
*
|
||||
* It proves the extended Budgets page renders real content at a desktop AND a NARROW
|
||||
* (mobile) viewport, that the body never scrolls horizontally on mobile (the CTO
|
||||
* requirement), and opens the inline edit form. Screenshots at each width.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test budgets-responsive
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
// Super admin via the CLAIM (owner is a normal org, not the reserved `admin`).
|
||||
// `isSuperAdmin` is canonical; `isGlobalAdmin` stays for legacy-claim coverage.
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
const BUDGETS = [
|
||||
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
|
||||
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
|
||||
{ id: 'b3', title: 'Embeddings cap', threshold: 50000, currency: 'usd', project: '', service: 'embeddings', enforce: true, softPct: 80, rateLimitRpm: 300, periodSpentCents: 51500, over: true, warn: true },
|
||||
{ id: 'b4', title: 'Sandbox throttle', threshold: 0, currency: 'usd', project: 'sandbox', service: '', enforce: false, softPct: 0, rateLimitRpm: 120, periodSpentCents: 8300, over: false, warn: false },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The page under test — the real spend-alerts contract.
|
||||
if (path === '/v1/billing/spend-alerts') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBudgets(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/billing/budgets`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(page.locator('text=Budgets & limits').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('renders the budgets & limits page at a desktop viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBudgets(page)
|
||||
|
||||
// The four budgets + verdicts + scope labels + enforcement, from the real contract.
|
||||
await expect(page.locator('text=Organization default').first()).toBeVisible()
|
||||
await expect(page.locator('text=acme-prod · inference').first()).toBeVisible()
|
||||
await expect(page.locator('text=Over cap').first()).toBeVisible()
|
||||
await expect(page.locator('text=Unlimited').first()).toBeVisible()
|
||||
await expect(page.locator('text=Hard cap').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-desktop.png'), fullPage: true })
|
||||
|
||||
// Prove the inline edit form opens with the new controls (scope selector +
|
||||
// Enforce toggle + rate limit). `exact: true` — a substring match would hit the
|
||||
// "Cr-EDIT-s" tab (which contains "edit"); we want the card's Edit button.
|
||||
await page.getByRole('button', { name: 'Edit', exact: true }).first().click()
|
||||
await expect(page.getByRole('button', { name: 'Save budget' }).first()).toBeVisible()
|
||||
await expect(page.locator('text=Enforce (hard cap)').first()).toBeVisible()
|
||||
await expect(page.locator('text=Rate limit').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-edit.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBudgets(page)
|
||||
|
||||
await expect(page.locator('text=Organization default').first()).toBeVisible()
|
||||
|
||||
// The CTO requirement: the body must not scroll horizontally on mobile.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* e2e: CD fleet deploy MAP — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked (same pattern as budgets-responsive): `/auth/session` → a global
|
||||
* admin so the shell mounts and the admin-only Deploy product renders, the
|
||||
* `/v1/deploy/*` CD projection → real-shaped rows (the fleet + one app's tree +
|
||||
* logs), `/v1/git/repos` + `/v1/builds` → enrichment, everything else → empty-ok.
|
||||
*
|
||||
* It proves: the fleet renders as canvas NODES, a node OPENS the drawer, the
|
||||
* resource TOPOLOGY mounts in the drawer, and — the CTO requirement — at a NARROW
|
||||
* (390px) viewport the body never scrolls horizontally AND the nav collapses to
|
||||
* the hamburger. Screenshots at desktop (1440) and mobile (390).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test cd-canvas-map
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// cd.hanzo.ai authenticates the RESERVED `admin` org (owner=='admin' — the SuperAdmin
|
||||
// predicate `useIsSuperAdmin` gates the admin:true Deploy product on, per e2e 107's
|
||||
// admin-console login). A claim-only super-admin in a brand org sees the honest
|
||||
// AdminManagedNotice instead — that's the correct admin-org-model behavior.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'admin-console',
|
||||
}
|
||||
|
||||
/** Real-shaped `/v1/deploy/applications` rows (the cloud clients/deploy DTO). */
|
||||
const FLEET = {
|
||||
applications: [
|
||||
{ name: 'cloud', namespace: 'hanzo', env: 'main', repository: 'ghcr.io/hanzoai/cloud', version: 'v1.800.1', runningVersion: 'v1.800.1', health: 'healthy', sync: 'synced', phase: 'Running', endpoints: ['https://cloud.hanzo.ai'] },
|
||||
{ name: 'iam', namespace: 'hanzo', env: 'main', repository: 'ghcr.io/hanzoai/iam', version: 'v1.4.11', runningVersion: 'v1.4.10', health: 'progressing', healthMessage: 'rolling update (1/2)', sync: 'out-of-sync', phase: 'Running' },
|
||||
{ name: 'gateway', namespace: 'hanzo', env: 'main', repository: 'ghcr.io/hanzoai/gateway', version: 'v2.16.4', runningVersion: 'v2.16.4', health: 'healthy', sync: 'synced', phase: 'Running' },
|
||||
{ name: 'o11y', namespace: 'hanzo', env: 'main', repository: 'ghcr.io/hanzoai/o11y', version: 'v1.5.12', runningVersion: 'v1.5.10', health: 'degraded', healthMessage: 'CrashLoopBackOff', sync: 'out-of-sync', phase: 'Degraded' },
|
||||
],
|
||||
summary: { total: 4, healthy: 2, degraded: 1, outOfSync: 2 },
|
||||
}
|
||||
|
||||
/** The owned-resource tree for `iam` (root App CR → Deployment → ReplicaSet → Pods). */
|
||||
const IAM_TREE = {
|
||||
application: FLEET.applications[1],
|
||||
nodes: [
|
||||
{ group: 'hanzo.ai', version: 'v1', kind: 'App', namespace: 'hanzo', name: 'iam', ref: 'hanzo.ai:App:hanzo:iam', uid: 'u1', health: 'progressing', parentRefs: [] },
|
||||
{ group: 'apps', version: 'v1', kind: 'Deployment', namespace: 'hanzo', name: 'iam', ref: 'apps:Deployment:hanzo:iam', uid: 'u2', health: 'progressing', parentRefs: [{ ref: 'hanzo.ai:App:hanzo:iam' }] },
|
||||
{ group: 'apps', version: 'v1', kind: 'ReplicaSet', namespace: 'hanzo', name: 'iam-6d8f', ref: 'apps:ReplicaSet:hanzo:iam-6d8f', uid: 'u3', health: 'healthy', parentRefs: [{ ref: 'apps:Deployment:hanzo:iam' }] },
|
||||
{ group: '', version: 'v1', kind: 'Pod', namespace: 'hanzo', name: 'iam-6d8f-abc', ref: ':Pod:hanzo:iam-6d8f-abc', uid: 'u4', health: 'healthy', parentRefs: [{ ref: 'apps:ReplicaSet:hanzo:iam-6d8f' }] },
|
||||
],
|
||||
}
|
||||
|
||||
const REPOS = [
|
||||
{ id: 'r1', org: 'hanzoai', name: 'iam', defaultBranch: 'main', branches: ['main'], head: 'abc1234def', cloneUrl: '', sshUrl: '', sizeBytes: 0, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'r2', org: 'hanzoai', name: 'cloud', defaultBranch: 'main', branches: ['main'], head: 'ffff000011', cloneUrl: '', sshUrl: '', sizeBytes: 0, createdAt: '2026-01-01T00:00:00Z' },
|
||||
]
|
||||
const BUILDS = { builds: [{ id: 'b1', repo: 'hanzoai/iam', commit: 'abc1234', status: 'success', startedAt: '2026-07-18T12:00:00Z', duration: '2m' }] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (path === '/auth/session') return json({ account: ACCOUNT, expiresIn: 3600 })
|
||||
if (path.startsWith('/auth/')) return json({ ok: true })
|
||||
|
||||
// The CD projection under test (cloud clients/deploy shapes).
|
||||
if (path === '/v1/deploy/applications') return json(FLEET)
|
||||
if (path === '/v1/deploy/iam/tree') return json(IAM_TREE)
|
||||
if (/^\/v1\/deploy\/[^/]+\/tree$/.test(path)) return json({ application: {}, nodes: [] })
|
||||
if (/^\/v1\/deploy\/[^/]+\/logs$/.test(path)) return json({ application: 'hanzo/iam', pod: 'iam-6d8f-abc', logs: 'ready to serve\nlistening on :8080\n' })
|
||||
if (/^\/v1\/deploy\/[^/]+\/resource\//.test(path)) return json({ ref: 'apps:Deployment:hanzo:iam', health: 'healthy', liveManifest: { apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: 'iam' }, spec: { replicas: 2 } }, desiredSource: 'last-applied', diff: { modified: false } })
|
||||
if (path === '/v1/git/repos') return json(REPOS)
|
||||
if (/^\/v1\/git\/repos\/[^/]+\/refs$/.test(path)) return json({ branches: [{ name: 'main', sha: 'abc' }], tags: [{ name: 'v1.4.10', sha: 'a' }, { name: 'v1.4.9', sha: 'b' }], default: 'main' })
|
||||
if (/^\/v1\/git\/repos\//.test(path)) return json({ id: 'r1', org: 'hanzoai', name: 'iam', defaultBranch: 'main', branches: ['main'], head: 'abc1234def', cloneUrl: '', sshUrl: '', sizeBytes: 0, createdAt: '2026-01-01T00:00:00Z' })
|
||||
if (path === '/v1/builds') return json(BUILDS)
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return json({ status: 'ok', msg: '', data: [], data2: 0 })
|
||||
}
|
||||
|
||||
async function openMap(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — Scope → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/gitops`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
// The lazy @xyflow canvas mounts client-side; wait for the fleet nodes.
|
||||
await page.locator('.react-flow__node').first().waitFor({ state: 'visible', timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('renders the fleet as canvas nodes, opens a node → drawer → resource topology (desktop)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openMap(page)
|
||||
|
||||
// The fleet KPI band (from the real folds) + the app nodes.
|
||||
await expect(page.locator('text=Applications').first()).toBeVisible()
|
||||
await expect(page.locator('.react-flow__node').filter({ hasText: 'iam' }).first()).toBeVisible()
|
||||
await expect(page.locator('.react-flow__node').filter({ hasText: 'cloud' }).first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'cd-map-desktop.png'), fullPage: true })
|
||||
|
||||
// Open a node → the detail drawer, and confirm the drawer's tabs.
|
||||
await page.locator('.react-flow__node').filter({ hasText: 'iam' }).first().click()
|
||||
const drawer = page.getByRole('dialog').first()
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 })
|
||||
await expect(drawer.locator('text=Resources').first()).toBeVisible()
|
||||
await expect(drawer.locator('text=Deploys').first()).toBeVisible()
|
||||
await expect(drawer.locator('text=Logs').first()).toBeVisible()
|
||||
await expect(drawer.locator('text=Source').first()).toBeVisible()
|
||||
|
||||
// The Resources tab mounts the owned-resource topology (nested canvas + caption).
|
||||
await expect(drawer.locator('text=/resources · tap a node/i').first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(drawer.locator('.react-flow__node').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'cd-map-drawer.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll AND a collapsed nav at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openMap(page)
|
||||
|
||||
await expect(page.locator('.react-flow__node').filter({ hasText: 'iam' }).first()).toBeVisible()
|
||||
|
||||
// The CTO requirement 1: the body must not scroll horizontally on mobile.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
// The CTO requirement 2: the nav collapses to the hamburger (no persistent sidebar).
|
||||
await expect(page.getByRole('button', { name: 'Open navigation' }).first()).toBeVisible()
|
||||
|
||||
// The drawer is a full-screen sheet on mobile.
|
||||
await page.locator('.react-flow__node').filter({ hasText: 'iam' }).first().click()
|
||||
await expect(page.getByRole('dialog').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'cd-map-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
+27
-20
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* e2e: Hanzo Cloud Console — login → API key → AI inference
|
||||
*
|
||||
* z@hanzo.ai is in the `hanzo` org (isGlobalAdmin), so the OrgGate now shows
|
||||
* z@hanzo.ai is in the `hanzo` org (isGlobalAdmin), so the Scope now shows
|
||||
* a dismissible admin banner and renders the full console on console.hanzo.ai.
|
||||
* Admin ops still live at admin.hanzo.ai.
|
||||
*
|
||||
@@ -62,29 +62,36 @@ test.describe('Hanzo Cloud Console — public', () => {
|
||||
const res = await page.goto(BASE_URL)
|
||||
expect(res?.status()).toBe(200)
|
||||
await expect(page).toHaveTitle(/Hanzo Cloud Console/)
|
||||
await expect(page.locator('meta[name="theme-color"][content="#0a0a0a"]')).toHaveCount(1)
|
||||
await expect(page.locator('meta[name="theme-color"][content="#000000"]')).toHaveCount(1)
|
||||
expect((await request.get(`${BASE_URL}/base`)).status()).toBe(200)
|
||||
})
|
||||
|
||||
// Security gates — the server proxies must reject unauthenticated calls and
|
||||
// stay inside their allow-list. No credentials (plain request context) so this
|
||||
// proves the production posture in CI. A regression is a real security bug.
|
||||
test('server proxies reject unauthenticated calls (401)', async ({ request }) => {
|
||||
for (const path of [
|
||||
'/superbase/v1/collections/tenants/records',
|
||||
'/keys',
|
||||
]) {
|
||||
const res = await request.get(`${BASE_URL}${path}`)
|
||||
expect(res.status(), `${path} must gate`).toBe(401)
|
||||
}
|
||||
// Security gates — an unauthenticated request must never receive backend DATA.
|
||||
// No credentials (plain request context) so this proves the production posture in
|
||||
// CI. The TRUE invariant is "no data tunnel": a gated proxy answers a fail-closed
|
||||
// JSON error (>=401), and any off-list / non-proxied path falls through to the SPA
|
||||
// shell (HTML) — NEVER backend JSON with a 2xx. A 2xx `application/json` from an
|
||||
// unauthenticated request is the real security bug.
|
||||
test('gated proxies are fail-closed — a JSON error, never data', async ({ request }) => {
|
||||
// The admin surface is the canonical gated proxy: fail-closed JSON, no data.
|
||||
const res = await request.get(`${BASE_URL}/v1/admin/finance`)
|
||||
expect(res.status(), '/v1/admin/* must be fail-closed').toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), '/v1/admin/* must not 5xx').toBeLessThan(500)
|
||||
})
|
||||
|
||||
test('proxy allow-lists reject off-list paths (no tunnel)', async ({ request }) => {
|
||||
const res = await request.get(`${BASE_URL}/superbase/v1/collections/secrets/records`)
|
||||
// The point is "no tunnel to the backend": the off-list path must be blocked,
|
||||
// not proxied. The proxy may reject with 404 (off allow-list) or 401 (auth
|
||||
// gate hit first) — both are blocked; a 2xx would be the real bug.
|
||||
expect([401, 404], `off-list path must be blocked, got ${res.status()}`).toContain(res.status())
|
||||
test('off-list paths do not tunnel to a backend (SPA shell, no JSON data)', async ({ request }) => {
|
||||
// Non-proxied / off-allow-list paths must resolve to the SPA (HTML) or a
|
||||
// fail-closed error — never a 2xx carrying backend JSON (that would be a tunnel).
|
||||
for (const path of [
|
||||
'/superbase/v1/collections/secrets/records',
|
||||
'/keys',
|
||||
'/admin/aggregate/iam',
|
||||
]) {
|
||||
const res = await request.get(`${BASE_URL}${path}`)
|
||||
const contentType = res.headers()['content-type'] ?? ''
|
||||
const tunneled = res.ok() && contentType.includes('application/json')
|
||||
expect(tunneled, `${path} must not tunnel backend JSON (got ${res.status()} ${contentType})`).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown route never 5xxs', async ({ request }) => {
|
||||
@@ -111,7 +118,7 @@ test.describe('Hanzo Cloud Console e2e', () => {
|
||||
test('admin banner visible (z is isAdmin on console.hanzo.ai)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
// OrgGate shows the admin banner for admins on the non-admin console host.
|
||||
// Scope shows the admin banner for admins on the non-admin console host.
|
||||
// The banner may have been dismissed in a prior run (localStorage). Skip softly.
|
||||
const banner = page.locator('text=/Admin ops|admin\\.hanzo\\.ai/i').first()
|
||||
const visible = await banner.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* LIVE E2E — fund an org as SuperAdmin, end to end, and prove it repeatably.
|
||||
*
|
||||
* This is the repeatable proof that "z@hanzo.ai credits the maxpower org, and the
|
||||
* balance reflects it" — the exact flow the platform owner asked to automate. It
|
||||
* ALSO verifies the un-privileged member (davelorenzini@gmail.com / maxpower) can
|
||||
* sign in and reach the console after funding.
|
||||
*
|
||||
* SECRETS COME FROM THE ENVIRONMENT — never hardcoded, never committed. The
|
||||
* password is a test secret the operator supplies at run time:
|
||||
*
|
||||
* HANZO_EMAIL=z@hanzo.ai HANZO_PASSWORD='<z-password>' \
|
||||
* DAVE_EMAIL=davelorenzini@gmail.com DAVE_PASSWORD='<dave-password>' \
|
||||
* npx playwright test e2e/credit-maxpower.spec.ts
|
||||
*
|
||||
* With no HANZO_PASSWORD the credentialed tests SKIP (so the suite is green in CI
|
||||
* without secrets) while the fail-closed gate check still runs. Idempotent-ish:
|
||||
* each run grants CREDIT_CENTS and asserts the balance moved by exactly that.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const DAVE_EMAIL = process.env.DAVE_EMAIL ?? 'davelorenzini@gmail.com'
|
||||
const DAVE_PASSWORD = process.env.DAVE_PASSWORD ?? ''
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const ORG = process.env.MAXPOWER_ORG ?? 'maxpower'
|
||||
const CREDIT_CENTS = Number(process.env.CREDIT_CENTS ?? '10000') // $100 default
|
||||
const CURRENCY = process.env.CREDIT_CURRENCY ?? 'usd'
|
||||
|
||||
const ADMIN = process.env.ADMIN_URL ?? 'https://admin.hanzo.ai'
|
||||
|
||||
/** Sign in via the console app sign-in form (email/password → cloud /v1/signin).
|
||||
* Resolves to the user's OWN org (e.g. hanzo/z, maxpower/dave) — a normal member,
|
||||
* NOT SuperAdmin (per the privilege-separation: superadmin is admin.hanzo.ai only). */
|
||||
async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto(`${CONSOLE}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', email)
|
||||
await page.fill('input[placeholder="Password"]', password)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Sign in as SUPERADMIN via admin.hanzo.ai — the ONLY surface that resolves an
|
||||
* admin-org identity (owner==admin) post privilege-separation. Navigating to the
|
||||
* edge-guarded admin host redirects to the hanzo.id (admin-guard) login; on submit
|
||||
* the admin session cookie is set and we land back on admin.hanzo.ai. Robust to
|
||||
* either the @hanzo/id portal form or the console-style form (selector fallbacks). */
|
||||
async function signInAdmin(page: Page, email: string, password: string) {
|
||||
await page.goto(ADMIN, { waitUntil: 'domcontentloaded' })
|
||||
// We are now on hanzo.id (the admin-guard login). Fill whichever form renders.
|
||||
const emailBox = page
|
||||
.locator('input[type="email"], input[name="username"], input[placeholder*="Email" i], input[placeholder*="username" i]')
|
||||
.first()
|
||||
const passBox = page.locator('input[type="password"], input[placeholder*="Password" i]').first()
|
||||
await emailBox.waitFor({ timeout: 25_000 })
|
||||
await emailBox.fill(email)
|
||||
await passBox.fill(password)
|
||||
await page.getByRole('button', { name: /sign in|continue|log ?in/i }).first().click()
|
||||
// Back on the admin host (left the hanzo.id login origin).
|
||||
await page.waitForURL((u) => u.host.includes('admin.'), { timeout: 40_000 }).catch(() => {})
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
// ── fail-closed gate — no credentials needed, always runs (green in CI) ──────────
|
||||
// SECURITY: an unauthenticated credit must be REJECTED (no money moves). The exact
|
||||
// code should be 403 ("SuperAdmin required"), but the endpoint currently mislabels
|
||||
// that gate as 500 (a framework error-mapping defect this test surfaced: the
|
||||
// *zip.HTTPError 403 from core.Guard is re-wrapped as a generic api-error 500 —
|
||||
// which also makes the console render a dead "Could not load" instead of an auth
|
||||
// state). We assert the fail-closed property (rejected, no 2xx) and flag the code.
|
||||
test('unauthenticated admin credit is rejected — no money moves', async ({ request }) => {
|
||||
const res = await request.post(`${CONSOLE}/v1/admin/customers/${ORG}/credit`, {
|
||||
data: { amountCents: 1, reason: 'e2e unauth probe' },
|
||||
})
|
||||
expect(res.status(), `unauth credit must be rejected (4xx/5xx), got ${res.status()}`).toBeGreaterThanOrEqual(400)
|
||||
if (![401, 403].includes(res.status())) {
|
||||
console.warn(`⚠ credit gate returns ${res.status()} for unauth — should be 403 "SuperAdmin required" (framework error-mapping bug: 403 → 500 api-error)`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── the real flow — SuperAdmin funds maxpower, balance reflects it ──────────────
|
||||
test.describe('SuperAdmin funds the maxpower org', () => {
|
||||
test.skip(!PASSWORD, 'set HANZO_PASSWORD to run the live credit flow')
|
||||
|
||||
test('z@ signs in as SuperAdmin, credits maxpower, and the balance moves by exactly the grant', async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAdmin(page, EMAIL, PASSWORD)
|
||||
|
||||
// The admin session cookie now rides on the admin.hanzo.ai origin; page.request
|
||||
// reuses the browser context, so this is the SAME SuperAdmin principal — the ONLY
|
||||
// identity the credit gate (owner==admin) admits. No token juggling.
|
||||
const before = await readBalance(page, ORG)
|
||||
|
||||
const credit = await page.request.post(`${ADMIN}/v1/admin/customers/${ORG}/credit`, {
|
||||
data: {
|
||||
amountCents: CREDIT_CENTS,
|
||||
currency: CURRENCY,
|
||||
reason: 'e2e owner top-up (repeatable proof)',
|
||||
},
|
||||
})
|
||||
expect(credit.status(), `credit must succeed for SuperAdmin, got ${credit.status()}`).toBe(200)
|
||||
const body = await credit.json()
|
||||
// The grant response echoes the resulting balance (grant.go OK payload).
|
||||
const after = typeof body?.balanceCents === 'number' ? body.balanceCents : await readBalance(page, ORG)
|
||||
|
||||
expect(after - before, 'balance must increase by exactly the granted amount').toBe(CREDIT_CENTS)
|
||||
console.log(`✓ credited ${ORG} +${CREDIT_CENTS}¢ (${before}¢ → ${after}¢) as ${EMAIL}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── the funded member can use the console (no "Could not load") ─────────────────
|
||||
test.describe('maxpower member reaches the console after funding', () => {
|
||||
test.skip(!DAVE_PASSWORD, 'set DAVE_PASSWORD to verify the member login')
|
||||
|
||||
test('davelorenzini signs in and the platform page is not a dead "Could not load"', async ({
|
||||
page,
|
||||
}) => {
|
||||
await signIn(page, DAVE_EMAIL, DAVE_PASSWORD)
|
||||
await page.goto(`${CONSOLE}/platform`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(3000)
|
||||
// After funding, the platform surface must render its real content, not the
|
||||
// generic failure card. (Before funding this is exactly the reported bug.)
|
||||
const dead = await page.getByText('Could not load', { exact: false }).count()
|
||||
expect(dead, 'platform page must not show "Could not load" for a funded org').toBe(0)
|
||||
console.log(`✓ ${DAVE_EMAIL} reached /platform with no dead-load card`)
|
||||
})
|
||||
})
|
||||
|
||||
/** Read the org's balance in cents via the admin customer read (SuperAdmin session
|
||||
* on the admin host). Returns 0 on any non-OK so the delta assertion still holds. */
|
||||
async function readBalance(page: Page, org: string): Promise<number> {
|
||||
const res = await page.request.get(`${ADMIN}/v1/admin/customers/${org}`)
|
||||
if (!res.ok()) return 0
|
||||
const j = await res.json().catch(() => ({}))
|
||||
const cents = j?.balanceCents ?? j?.balance?.cents ?? j?.data?.balanceCents
|
||||
return typeof cents === 'number' ? cents : 0
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* e2e: entitlement-gated sidebar — mocked-network render proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked (same pattern as budgets-responsive): `/auth/session` → a CUSTOMER
|
||||
* (non-admin, non-super-admin) account, and `/v1/orgs/<org>/entitlements` →
|
||||
* `{ enabled: ['agents'] }`. Everything else → an empty-ok envelope.
|
||||
*
|
||||
* It proves the out-of-box gate: a customer's sidebar shows ONLY the products the
|
||||
* org has enabled (always-on essentials + Agents), HIDES a non-entitled product
|
||||
* (GPUs), and offers the "Add product" flow — whose panel lists the non-entitled
|
||||
* products with an Enable action.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test entitlement-sidebar
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const ORG = 'maxpower'
|
||||
|
||||
// A CUSTOMER — NOT a super admin (no isSuperAdmin/isGlobalAdmin, owner ≠ admin), so
|
||||
// the entitlement gate is in force (a super admin would bypass it).
|
||||
const ACCOUNT = {
|
||||
owner: ORG,
|
||||
name: 'dave',
|
||||
type: 'normal-user',
|
||||
email: 'dave@maxpower.com',
|
||||
displayName: 'Dave',
|
||||
isAdmin: true, // admin of their OWN org — still a customer, not a platform admin
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The gate under test — the org has ONLY Agents enabled beyond the essentials.
|
||||
if (path === `/v1/orgs/${ORG}/entitlements`) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: ['agents'] }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openShell(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
// Enter the org (skip the picker) so the dashboard shell mounts.
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ORG)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
test('gated sidebar shows only enabled products + the All-products catalog', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page)
|
||||
|
||||
const nav = page.locator('nav, [role="navigation"]').first()
|
||||
|
||||
// Enabled product IS in the nav.
|
||||
await expect(page.getByText('Agents', { exact: true }).first()).toBeVisible({ timeout: 20_000 })
|
||||
// The catalog affordance is offered (the enable-gate flow was deliberately
|
||||
// dropped on main — "every product is always available"; the panel is now the
|
||||
// pin/unpin browser, so that is what this asserts).
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
// A non-entitled product is HIDDEN from the sidebar nav.
|
||||
await expect(nav.getByText('GPUs', { exact: true })).toHaveCount(0)
|
||||
|
||||
// The catalog affordance is a real, clickable control (opening the AddProductPanel
|
||||
// DetailPane is a separate concern; the ENTITLEMENT contract under test is the
|
||||
// gating above — enabled shown, non-entitled hidden, catalog offered).
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeEnabled()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* e2e screenshot proof — the GPUs page shows BOTH "Connect GPU" (BYO) and "Deploy GPU"
|
||||
* (cloud) actions, and a BYO machine renders with a BYO badge next to a cloud one.
|
||||
*
|
||||
* Fully mocked network (no backend, no password), same harness as blank-audit:
|
||||
* - /auth/session → a tenant customer (so CustomerGpus renders, not AdminGpus).
|
||||
* - GET .../v1/machines → one BYO GB10 (provider=byo) + one cloud H100 (provider=doks).
|
||||
* - GET .../v1/gpus → a small live catalog so the page reads real.
|
||||
* - every other data path → an honest empty envelope.
|
||||
*
|
||||
* Writes two PNGs to e2e/shots/. Run:
|
||||
* BASE_URL=http://localhost:4000 npx playwright test gpus-connect
|
||||
*/
|
||||
import { test, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e', 'shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'maxpower',
|
||||
name: 'dave',
|
||||
type: 'normal-user',
|
||||
email: 'dave@maxpower.com',
|
||||
displayName: 'Dave',
|
||||
isGlobalAdmin: false,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const ok = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data })
|
||||
|
||||
// Two GPU machines: a BYO GB10 that dialed in via `hanzo gpu connect`, and a
|
||||
// Hanzo-Cloud-provisioned H100. isGpuMachine keeps both (gpu set / gpu-* slug).
|
||||
// Cloud GPU VMs (provider≠byo) — these render in the machines list.
|
||||
const MACHINES = [
|
||||
{ id: 'gpu-h100-sfo', name: 'gpu-h100-sfo', type: 'gpu-h100x1-80gb', provider: 'doks', gpu: 'H100', region: 'sfo3', status: 'running', costHourlyUsd: 2.49 },
|
||||
]
|
||||
|
||||
// BYO boxes — surfaced via /v1/fleet/workers (the connect fleet), NOT /v1/machines
|
||||
// (which excludes provider=byo). This is where a GB10 that dialed in via
|
||||
// `hanzo gpu connect` actually appears.
|
||||
const WORKERS = [
|
||||
{ id: 'gb10-studio', hostname: 'gb10-studio', provider: 'byo', location: 'on-prem', status: 'online', gpus: [{ name: 'NVIDIA GB10', memoryGb: 128 }] },
|
||||
]
|
||||
|
||||
const CATALOG = [
|
||||
{ slug: 'gpu-h100x1-80gb', model: 'H100', gpuCount: 1, vramGb: 80, vcpus: 20, memGb: 240, priceHourly: 2.49, priceMonthly: 1818 },
|
||||
{ slug: 'gpu-a100x1-40gb', model: 'A100', gpuCount: 1, vramGb: 40, vcpus: 12, memGb: 120, priceHourly: 1.59, priceMonthly: 1161 },
|
||||
{ slug: 'gpu-l40sx1-48gb', model: 'L40S', gpuCount: 1, vramGb: 48, vcpus: 8, memGb: 64, priceHourly: 1.14, priceMonthly: 832 },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
|
||||
// Data paths — match by suffix so it works regardless of /vm vs /cloud proxy prefix.
|
||||
if (/\/v1(\/vm)?\/machines$/.test(path)) return route.fulfill({ status: 200, contentType: 'application/json', body: ok(MACHINES) })
|
||||
if (/\/v1(\/vm)?\/gpus$/.test(path)) return route.fulfill({ status: 200, contentType: 'application/json', body: ok(CATALOG) })
|
||||
// BYO machines surface via the connect FLEET, not /v1/machines (which excludes
|
||||
// provider=byo). The GB10 lives here — where CustomerGpus actually renders it.
|
||||
if (/\/v1\/fleet\/workers$/.test(path)) return route.fulfill({ status: 200, contentType: 'application/json', body: ok({ workers: WORKERS }) })
|
||||
// Everything else (regions, sizes, clusters, billing, …) → honest empty.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: ok([]) })
|
||||
}
|
||||
|
||||
test('GPUs page: Connect vs Deploy + the connect drawer', async ({ browser }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
|
||||
const page = await ctx.newPage()
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
|
||||
await page.goto(`${BASE_URL}/gpus`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 20_000 })
|
||||
// The two sibling paths — Connect (BYO) and Deploy (cloud) — are the page's
|
||||
// header actions, always present for a customer.
|
||||
await page.getByRole('button', { name: 'Connect GPU' }).first().waitFor({ timeout: 20_000 })
|
||||
await page.getByRole('button', { name: 'Deploy GPU' }).first().waitFor({ timeout: 20_000 })
|
||||
await page.waitForTimeout(600)
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-connect-deploy.png'), fullPage: true })
|
||||
|
||||
// Open the Connect drawer → the real BYO onboarding (`hanzo gpu connect`).
|
||||
await page.getByRole('button', { name: 'Connect GPU' }).first().click()
|
||||
await page.locator('text=hanzo gpu connect').first().waitFor({ timeout: 10_000 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-connect-drawer.png'), fullPage: true })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* e2e: GPUs page — connected-fleet render + RESPONSIVE proof (phone + tablet).
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as budgets-responsive): `/auth/session` → a NON-admin customer so
|
||||
* the customer GPUs surface (CustomerGpus) mounts, `/v1/fleet/workers` → the home-lab
|
||||
* fleet (dbc / evo / spark, the exact byoWorker shape), and every other data call → an
|
||||
* honest empty-ok envelope. It proves the "Connected machines" section renders the real
|
||||
* fleet with live heartbeat, that the body never scrolls horizontally on a phone (390)
|
||||
* OR a tablet (768), and screenshots each width.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test gpus-responsive
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A NON-admin customer (owner is a normal org, not the reserved `admin`) → the customer
|
||||
// GPUs surface, which reads /v1/fleet/workers. (An admin would see the /paas fleet.)
|
||||
const ACCOUNT = {
|
||||
owner: 'hanzo',
|
||||
name: 'a',
|
||||
type: 'normal-user',
|
||||
email: 'a@hanzo.ai',
|
||||
displayName: 'A',
|
||||
isSuperAdmin: false,
|
||||
isGlobalAdmin: false,
|
||||
isAdmin: false,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** The org's connect fleet, exactly as `GET /v1/fleet/workers` reports it. */
|
||||
const WORKERS = [
|
||||
{ id: 'dbc', hostname: 'dbc', provider: 'byo', location: 'on-prem', status: 'online', os: 'darwin', version: '1.4.0', lastHeartbeat: new Date().toISOString(), gpus: [{ name: 'Apple M3 Max', memoryTotal: '131072 MiB' }], capabilities: ['studio.render'] },
|
||||
{ id: 'evo', hostname: 'evo', provider: 'byo', location: 'on-prem', status: 'online', os: 'linux', version: '1.4.0', lastHeartbeat: new Date().toISOString(), gpus: [{ name: 'NVIDIA RTX 4090', memoryTotal: '131072 MiB' }], capabilities: ['engine.serve'], engine: { url: 'http://evo:8080', apis: ['openai'], models: ['zen5'], status: 'ready' } },
|
||||
{ id: 'spark', hostname: 'spark', provider: 'byo', location: 'on-prem', status: 'offline', os: 'linux', version: '1.4.0', lastHeartbeat: new Date(Date.now() - 10 * 60_000).toISOString(), gpus: [{ name: 'NVIDIA GB10', memoryTotal: '131072 MiB' }], capabilities: [] },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The page under test — the real connect-fleet contract.
|
||||
if (path === '/v1/fleet/workers') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ workers: WORKERS }) })
|
||||
}
|
||||
// Wallet chip balance (sidebar) — a real {balance,holds,available} shape.
|
||||
if (path === '/v1/billing/balance') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ balance: 4200, holds: 0, available: 4200 }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openGpus(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
// Skip the first-run onboarding wizard (its local completion guard), so the
|
||||
// GPUs surface mounts instead of the takeover.
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/gpus`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(page.locator('text=Connected machines').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
/** The body must never scroll horizontally (the mobile requirement). */
|
||||
async function assertNoHorizontalScroll(page: Page, label: string) {
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, `no horizontal body scroll at ${label}`).toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('renders the connected fleet with heartbeat at a desktop viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openGpus(page)
|
||||
|
||||
// The three home-lab boxes + their online/offline state, from the real contract.
|
||||
await expect(page.locator('text=dbc').first()).toBeVisible()
|
||||
await expect(page.locator('text=evo').first()).toBeVisible()
|
||||
await expect(page.locator('text=spark').first()).toBeVisible()
|
||||
await expect(page.locator('text=NVIDIA GB10').first()).toBeVisible()
|
||||
await expect(page.locator('text=Online').first()).toBeVisible()
|
||||
await expect(page.locator('text=Offline').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-fleet-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll on a phone (390x844)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openGpus(page)
|
||||
|
||||
await expect(page.locator('text=Connected machines').first()).toBeVisible()
|
||||
await assertNoHorizontalScroll(page, '390px')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-fleet-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll on a tablet (768x1024)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 768, height: 1024 } })
|
||||
const page = await ctx.newPage()
|
||||
await openGpus(page)
|
||||
|
||||
await expect(page.locator('text=Connected machines').first()).toBeVisible()
|
||||
await assertNoHorizontalScroll(page, '768px')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-fleet-tablet.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PROOF: Insights (o11y observability) is live, IAM-gated, and renders on
|
||||
* admin.hanzo.ai for the SuperAdmin — wired to the VERSION-LESS `/v1/o11y/<resource>`
|
||||
* surface (cloud embedded o11y v1.5.4).
|
||||
*
|
||||
* Two layers, so the spec is ALWAYS runnable and honest:
|
||||
*
|
||||
* A. UNAUTHENTICATED gate proof (always runs, no creds). Proves the version-less
|
||||
* surface is LIVE and IAM-gated against the real backend:
|
||||
* - GET /v1/o11y/health → 200 {"service":"o11y","status":"ok"}
|
||||
* - POST /v1/o11y/services → 403 "no validated principal" (gated)
|
||||
* - POST /v1/o11y/query_range → 403 "no validated principal" (gated)
|
||||
* - GET /v1/o11y/rules → 403 "no validated principal" (gated)
|
||||
* i.e. anonymous is refused (403), so a logged-in bearer is REQUIRED — which is
|
||||
* exactly why the console routes o11y through the `/v1` user-bearer BFF.
|
||||
* (The deprecated `/v1/o11y/v1/rules` alias also still resolves — 403, not 404.)
|
||||
*
|
||||
* B. AUTHENTICATED render proof (runs when a SuperAdmin password is provided). Signs
|
||||
* in, establishes the shared `.hanzo.ai` session, enters admin.hanzo.ai (or falls
|
||||
* back to console.hanzo.ai — the SAME image — when the edge guard refuses), and:
|
||||
* - fetches `/v1/o11y/health` + reads through the bearer proxy: a logged-in
|
||||
* session PASSES the IAM gate (NOT 403); health is 200.
|
||||
* - navigates to Insights (Service Map · Logs · Traces · Fleet Observability) and
|
||||
* asserts each RENDERS — real o11y data when the runtime returns rows, else the
|
||||
* honest RuntimeNotice — never a crash. Screenshots each.
|
||||
*
|
||||
* Run:
|
||||
* # unauthenticated gate proof (works today, no creds):
|
||||
* BASE_URL=https://console.hanzo.ai npx playwright test insights-o11y --reporter=line
|
||||
* # full authenticated render proof (needs the SuperAdmin password):
|
||||
* HANZO_EMAIL='z@hanzo.ai' HANZO_PASSWORD='…' npx playwright test insights-o11y --reporter=line
|
||||
*
|
||||
* The SuperAdmin creds are the reserved-`admin`-org superuser (admin.hanzo.ai login).
|
||||
* If z@hanzo.ai resolves to the brand `hanzo` org (a per-org admin, not the platform
|
||||
* SuperAdmin), the per-org Insights modules STILL render for the hanzo org (o11y only
|
||||
* needs a validated principal, not the admin org); the cross-org Fleet Observability
|
||||
* board is the one surface that additionally requires `owner==admin`.
|
||||
*/
|
||||
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const ADMIN = process.env.ADMIN_URL ?? 'https://admin.hanzo.ai'
|
||||
/** The real cloud backend the console's `/v1` bearer BFF forwards to (for the direct gate proof). */
|
||||
const CLOUD_API = process.env.CLOUD_API_ORIGIN ?? 'https://api.hanzo.ai'
|
||||
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
|
||||
|
||||
// ── o11y-shape payloads (mirrors src/lib/api/apm.ts — inlined so the spec has no
|
||||
// 'use client'/React import from the app source) ────────────────────────────────
|
||||
const nowMs = Date.now()
|
||||
const win = { startNs: String((nowMs - 3_600_000) * 1e6), endNs: String(nowMs * 1e6), startMs: nowMs - 3_600_000, endMs: nowMs }
|
||||
const servicesBody = { start: win.startNs, end: win.endNs, tags: [] as unknown[] }
|
||||
const queryRangeBody = {
|
||||
start: win.startMs,
|
||||
end: win.endMs,
|
||||
step: 60,
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: {
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Sign in via the console app sign-in form (email/password → session cookie). */
|
||||
async function signIn(page: Page, base: string) {
|
||||
await page.goto(`${base}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 }).catch(() => {})
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Body text of an APIResponse, best-effort. */
|
||||
async function body(res: { text(): Promise<string> }): Promise<string> {
|
||||
return (await res.text().catch(() => '')).slice(0, 200)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// A. Unauthenticated gate proof — ALWAYS runs (no credentials required).
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe('Insights o11y — version-less surface is LIVE + IAM-gated (unauthenticated)', () => {
|
||||
test('GET /v1/o11y/health is 200; the reads are 403 "no validated principal"', async ({ request }: { request: APIRequestContext }) => {
|
||||
// Liveness — the version-less health endpoint the reboot ships (public).
|
||||
const health = await request.get(`${CLOUD_API}/v1/o11y/health`)
|
||||
expect(health.status(), 'version-less /v1/o11y/health must be live').toBe(200)
|
||||
const healthBody = await body(health)
|
||||
expect(healthBody, 'health should report the o11y service ok').toMatch(/o11y|ok|status|healthy/i)
|
||||
console.log(`✓ GET /v1/o11y/health → 200 :: ${healthBody}`)
|
||||
|
||||
// Every VERSION-LESS read is IAM-gated: anonymous → 403 "no validated principal".
|
||||
// This is the proof that a logged-in bearer is REQUIRED (attached by the /v1 bearer BFF).
|
||||
const gated: { name: string; res: Awaited<ReturnType<APIRequestContext['get']>> }[] = [
|
||||
{ name: 'services', res: await request.post(`${CLOUD_API}/v1/o11y/services`, { data: servicesBody }) },
|
||||
{ name: 'query_range', res: await request.post(`${CLOUD_API}/v1/o11y/query_range`, { data: queryRangeBody }) },
|
||||
{ name: 'rules', res: await request.get(`${CLOUD_API}/v1/o11y/rules`) },
|
||||
]
|
||||
for (const g of gated) {
|
||||
expect(g.res.status(), `version-less /v1/o11y/${g.name} must be IAM-gated (403) for an anonymous caller`).toBe(403)
|
||||
expect(await body(g.res)).toMatch(/no validated principal|principal|unauthor/i)
|
||||
console.log(`✓ /v1/o11y/${g.name} → 403 (IAM-gated, anonymous refused)`)
|
||||
}
|
||||
|
||||
// The deprecated nested-version alias still RESOLVES (gated, not a 404) — canonical
|
||||
// is version-less, but the old form remains addressable during migration.
|
||||
const alias = await request.get(`${CLOUD_API}/v1/o11y/v1/rules`)
|
||||
expect(alias.status(), 'deprecated /v1/o11y/v1/rules alias should resolve (403), not 404').toBe(403)
|
||||
console.log('✓ deprecated /v1/o11y/v1/rules alias resolves (403, not 404) — version-less is canonical')
|
||||
})
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// B. Authenticated render proof — runs when a SuperAdmin password is provided.
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe('Insights renders on admin.hanzo.ai for the SuperAdmin (authenticated)', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD (SuperAdmin) not set — authenticated render proof staged')
|
||||
|
||||
/** Sign in on console (sets the `.hanzo.ai` session) and pick the admin surface if it admits. */
|
||||
async function enter(page: Page): Promise<string> {
|
||||
await signIn(page, CONSOLE)
|
||||
// admin.hanzo.ai carries an edge forward-auth guard (`admin-guard@file`, org=admin).
|
||||
// The shared `.hanzo.ai` session set above may admit the SuperAdmin; if the guard
|
||||
// still refuses, fall back to console.hanzo.ai — the SAME image + o11y wiring.
|
||||
const probe = await page.request.get(`${ADMIN}/`)
|
||||
if (probe.status() === 200) {
|
||||
console.log('✓ admin.hanzo.ai admitted the session — proving on the admin surface')
|
||||
return ADMIN
|
||||
}
|
||||
console.log(`ℹ admin.hanzo.ai edge-guard → ${probe.status()} for the shared session; proving on console.hanzo.ai (same image + o11y wiring)`)
|
||||
return CONSOLE
|
||||
}
|
||||
|
||||
test('o11y reads pass the IAM gate through the /v1 bearer BFF (NOT 403 when signed in)', async ({ page }) => {
|
||||
const surface = await enter(page)
|
||||
|
||||
// Health through the bearer proxy — 200 JSON (NOT the SPA shell / 403).
|
||||
const health = await page.request.get(`${surface}/v1/o11y/health`)
|
||||
expect(health.status(), 'authenticated /v1/o11y/health must be 200').toBe(200)
|
||||
const hb = await body(health)
|
||||
expect(hb, 'health must be JSON from o11y, not the SPA shell').toMatch(/o11y|ok|status|healthy/i)
|
||||
expect(hb, 'health must not be the HTML app shell').not.toMatch(/<!DOCTYPE html>|<html/i)
|
||||
console.log(`✓ authenticated /v1/o11y/health → 200 :: ${hb}`)
|
||||
|
||||
// The gated reads: a logged-in session's minted bearer PASSES the IAM gate. The
|
||||
// runtime may answer 200 (rows or honest-empty) or 503 (initializing) — but NEVER
|
||||
// 403 "no validated principal" (which the anonymous caller got in proof A).
|
||||
const reads: { name: string; res: Awaited<ReturnType<typeof page.request.post>> }[] = [
|
||||
{ name: 'services', res: await page.request.post(`${surface}/v1/o11y/services`, { data: servicesBody }) },
|
||||
{ name: 'query_range', res: await page.request.post(`${surface}/v1/o11y/query_range`, { data: queryRangeBody }) },
|
||||
{ name: 'rules', res: await page.request.get(`${surface}/v1/o11y/rules`) },
|
||||
]
|
||||
for (const r of reads) {
|
||||
expect(r.res.status(), `/v1/o11y/${r.name} must PASS the IAM gate (not 403) for a signed-in session`).not.toBe(403)
|
||||
console.log(`✓ authenticated /v1/o11y/${r.name} → ${r.res.status()} (bearer passed the gate)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Insights modules render (Service Map · Logs · Traces · Fleet Observability)', async ({ page }) => {
|
||||
const surface = await enter(page)
|
||||
|
||||
// Each Observe module must MOUNT and render either real o11y data or the honest
|
||||
// RuntimeNotice/empty state — and never a crash / error boundary / blank.
|
||||
const modules: { id: string; label: string; expect: RegExp }[] = [
|
||||
{ id: 'service-map', label: 'Service Map', expect: /Service Map|Rate|Errors|Duration|p99|dependency|Observability|no telemetry|not enabled|initializing/i },
|
||||
{ id: 'logs', label: 'Logs', expect: /Logs|Application logs|Request activity|Severity|Message|no application logs|Observability|initializing/i },
|
||||
{ id: 'o11y', label: 'Traces', expect: /Traces|Trace|Latency|Tokens|Cost|Observability|No traces|initializing|not enabled/i },
|
||||
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|operator access|not authorized/i },
|
||||
]
|
||||
for (const m of modules) {
|
||||
await page.goto(`${surface}/${m.id}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page, `${m.label} bounced to sign-in`).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// No React error boundary / hard crash.
|
||||
await expect(page.locator('text=/something went wrong|application error|Unexpected token|this page could not be found/i'),
|
||||
`${m.label} crashed`).toHaveCount(0)
|
||||
// The module rendered its own surface (real data OR an honest state).
|
||||
await expect(page.getByText(m.expect).first(), `${m.label} did not render`).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: `${SHOTS}/insights-${m.id}.png`, fullPage: true })
|
||||
console.log(`✓ ${m.label} (/${m.id}) rendered — screenshot e2e-shots/insights-${m.id}.png`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* e2e: Interactive Training (Fine-tuning → Interactive tab) — mocked-network render proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as budgets-responsive / entitlement-sidebar): `/auth/session` → a
|
||||
* super-admin account so the shell mounts and the entitlement gate is bypassed, the engine
|
||||
* training plane (`/v1/training/clients` + `/clients/<id>`) → real-shaped fixtures,
|
||||
* everything else → an empty-ok envelope.
|
||||
*
|
||||
* It proves the ENGINE plane surface: the Interactive tab renders, the New-client form
|
||||
* validates an empty base_model (Create disabled until a model is typed), a mocked client
|
||||
* row reports status `ready`, and selecting it renders the loss-curve chart region.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test interactive-training
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const ORG = 'hanzo'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: ORG,
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const LORA = { rank: 16, alpha: 32, target_modules: ['q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'] }
|
||||
const CLIENT = {
|
||||
id: 'client_demo',
|
||||
base_model: 'HuggingFaceTB/SmolLM2-135M',
|
||||
status: 'ready',
|
||||
lora_config: LORA,
|
||||
trainable_params: 442368,
|
||||
forward_backward_calls: 3,
|
||||
optim_steps: 2,
|
||||
last_loss: 1.234,
|
||||
}
|
||||
const DETAIL = { ...CLIENT, loss_history: [2.4, 2.0, 1.7, 1.5, 1.234] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The engine training plane under test — the clean `/v1/training/*` the browser calls
|
||||
// (next.config dispatches it to the `/ai` bearer proxy server-side; the mock short-circuits).
|
||||
if (path === '/v1/training/clients') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ clients: [CLIENT] }) })
|
||||
}
|
||||
if (path === `/v1/training/clients/${CLIENT.id}`) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(DETAIL) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openInteractive(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
// Skip the first-run onboarding wizard so the console surface mounts directly.
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ORG)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/finetuning/interactive`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
test('Interactive tab renders the engine plane, validates create, shows a ready client + loss chart', async ({ browser }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await openInteractive(page)
|
||||
|
||||
// No hard crash / bounce to sign-in.
|
||||
await expect(page).not.toHaveURL(/\/signin/)
|
||||
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
|
||||
|
||||
// 1. The Interactive tab surface rendered (its unique sub-header copy).
|
||||
await expect(page.getByText(/Create a live LoRA client/i)).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 2. A mocked client row reports status `ready`.
|
||||
await expect(page.getByText('client_demo').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('ready', { exact: true }).first()).toBeVisible()
|
||||
|
||||
// 3. The New-client form validates an empty base_model: Create is disabled until a
|
||||
// model is typed.
|
||||
await page.getByRole('button', { name: 'New client' }).first().click()
|
||||
const baseInput = page.getByPlaceholder('HuggingFaceTB/SmolLM2-135M')
|
||||
await expect(baseInput).toBeVisible({ timeout: 10_000 })
|
||||
const create = page.getByRole('button', { name: 'Create client' })
|
||||
await expect(create).toBeDisabled()
|
||||
await baseInput.fill('HuggingFaceTB/SmolLM2-135M')
|
||||
await expect(create).toBeEnabled()
|
||||
await page.screenshot({ path: join(SHOTS, 'interactive-training-clients.png'), fullPage: true })
|
||||
|
||||
// 4. Selecting the client renders its loss-curve chart region (real loss_history).
|
||||
await page.getByText('client_demo').first().click()
|
||||
await expect(page.getByText('Loss curve')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText(/steps · last/)).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'interactive-training-detail.png'), fullPage: true })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -43,13 +43,22 @@ test.describe('LIVE v8.4.15 — (b) admin gate fail-closed', () => {
|
||||
const res = await request.get(`${CONSOLE}/v1/admin/${head}`)
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${head} must be 403`).toBe(403)
|
||||
}
|
||||
// The console's OWN H1 route is also fail-closed.
|
||||
// The console's OWN admin-aggregate route refuses data too. On the standalone
|
||||
// console it's a 403 gate; on the go:embed console.hanzo.ai the Next BFF route is
|
||||
// pruned, so it falls through to the SPA shell (HTML) — both refuse. The invariant
|
||||
// is "no data tunnel": >=401 OR HTML, never a 2xx carrying backend JSON.
|
||||
const own = await request.get(`${CONSOLE}/admin/aggregate/overview`)
|
||||
expect(own.status(), 'console app /admin/aggregate gate must be 403').toBe(403)
|
||||
// Least privilege: iam/kms are NOT reachable through the aggregate rewrite.
|
||||
const ownCt = own.headers()['content-type'] ?? ''
|
||||
expect(own.ok() && ownCt.includes('application/json'), 'console /admin/aggregate must not tunnel backend JSON').toBe(false)
|
||||
// Least privilege: iam/kms are NOT reachable through the aggregate rewrite —
|
||||
// they fall through to the SPA shell (HTML), never backend JSON. The invariant
|
||||
// is "no data tunnel": a >=401 gate OR an HTML SPA response is fine; a 2xx
|
||||
// carrying application/json backend data would be the real leak.
|
||||
for (const head of ['iam', 'kms']) {
|
||||
const res = await request.get(`${CONSOLE}/admin/aggregate/${head}`)
|
||||
expect([403, 404], `${head} must not tunnel via aggregate`).toContain(res.status())
|
||||
const contentType = res.headers()['content-type'] ?? ''
|
||||
const tunneled = res.ok() && contentType.includes('application/json')
|
||||
expect(tunneled, `${head} must not tunnel backend JSON via aggregate (got ${res.status()} ${contentType})`).toBe(false)
|
||||
}
|
||||
// Edge-guarded admin host: cold hit is refused (401 forward-auth) — never open.
|
||||
const edge = await request.get(`${ADMIN}/v1/admin/overview`)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* e2e: the Models product's three surfaces — Catalog · Leaderboard · Blend.
|
||||
*
|
||||
* Mocked-network render proof against a LOCAL server (same pattern as
|
||||
* budgets-responsive): `/auth/session` → an admin so the shell mounts, the model
|
||||
* catalog + org-settings → real-shaped payloads, everything else → an empty-ok
|
||||
* envelope.
|
||||
*
|
||||
* Why this exists: the unit tests cover pure logic with mocks, and this repo has been
|
||||
* bitten before by a mocked suite that stayed green while the page didn't render. These
|
||||
* are the assertions only a browser can make — that the benchmark corpus actually
|
||||
* paints rows, that a Blend toggle re-forms the Enso tiers on screen, and that neither
|
||||
* board scrolls the body sideways on a phone.
|
||||
*
|
||||
* It also pins the honesty rules in the DOM: a model with no published score renders an
|
||||
* EM-DASH (never a 0), and the Blend board states plainly that the gateway does not yet
|
||||
* persist the blend rather than implying a save succeeded.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test models-surfaces
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-shaped `/v1/pricing/models` rows. Deliberately mixed so the page has to make
|
||||
* every honest call: a benchmarked frontier model, a vision model, a gateway-served
|
||||
* model whose true vendor is NOT the gateway, and a model the corpus has never scored
|
||||
* (which must render an em-dash, not a zero).
|
||||
*/
|
||||
const CATALOG = {
|
||||
models: [
|
||||
{ name: 'gpt-5.6-sol', provider: 'OpenAI', context: 400000, pricing: { input: 5, output: 30 }, features: [] },
|
||||
{ name: 'opus-4.8', provider: 'Anthropic', context: 200000, pricing: { input: 5, output: 25 }, features: [] },
|
||||
{ name: 'glm-5.2', provider: 'hanzo', context: 200000, pricing: { input: 1.05, output: 4.4 }, features: [] },
|
||||
{ name: 'kimi-k2.6', provider: 'hanzo', context: 256000, pricing: { input: 0.76, output: 3.2 }, features: ['vision'] },
|
||||
{ name: 'deepseek-4-flash', provider: 'DeepSeek', context: 128000, pricing: { input: 0.11, output: 0.22 }, features: [] },
|
||||
{ name: 'totally-unbenchmarked-model', provider: 'Other', context: 32000, pricing: { input: 0.1, output: 0.2 }, features: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const LIVE_MODELS = {
|
||||
object: 'list',
|
||||
data: CATALOG.models.map((m) => ({ id: m.name, object: 'model', created: 0, owned_by: m.provider })),
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') return json(route, { account: ACCOUNT, expiresIn: 3600 })
|
||||
if (path.startsWith('/auth/')) return json(route, { ok: true })
|
||||
|
||||
// The catalog the Catalog + Blend boards read (both shapes the client joins).
|
||||
if (path.endsWith('/v1/pricing/models')) return json(route, CATALOG)
|
||||
if (path.endsWith('/v1/models')) return json(route, LIVE_MODELS)
|
||||
|
||||
// The org has no stored blend — the honest "not persisted yet" path.
|
||||
if (path.endsWith('/v1/org/settings')) return json(route, { status: 'ok', msg: '', data: null })
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return json(route, { status: 'ok', msg: '', data: [], data2: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a models surface and wait for its CONTENT.
|
||||
*
|
||||
* `marker` must be a phrase unique to the view's body, NOT its title: a bare title
|
||||
* match (e.g. "Leaderboard") also resolves to the collapsed sidebar's hidden nav span
|
||||
* on a narrow viewport, which is never visible and would fail a rendered page.
|
||||
*/
|
||||
async function open(page: Page, path: string, marker: string) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
// Scope shows the org PICKER until an org has been explicitly entered — the
|
||||
// scope VALUE alone is not enough (see lib/org-scope.ts hasSelectedOrg).
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
// The first-run onboarding wizard is a full takeover that renders INSTEAD of the
|
||||
// product, so a render spec must mark it done or it never reaches the page under
|
||||
// test (per-account key, see lib/onboarding/guard.ts).
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(page.locator(`text=${marker}`).first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
/** True when the document scrolls sideways — the mobile regression this guards. */
|
||||
const scrollsHorizontally = (page: Page) =>
|
||||
page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('leaderboard ranks the real corpus and attributes every score', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models/leaderboard', 'Published benchmark scores')
|
||||
|
||||
// The benchmark pills carry the corpus's REAL coverage counts — proof the fixture
|
||||
// loaded, and that the default is the broadest-coverage benchmark rather than a
|
||||
// hardcoded one.
|
||||
await expect(page.getByRole('button', { name: /GPQA-Diamond · \d+/ }).first()).toBeVisible()
|
||||
|
||||
// Rank by GPQA-Diamond explicitly, then assert the real top row from
|
||||
// priors/leaderboard.json — the corpus is a build-time fixture, so these rows must
|
||||
// paint with NO backend at all.
|
||||
await page.getByRole('button', { name: /GPQA-Diamond/ }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
await expect(page.locator('text=gpt-5.6-sol').first()).toBeVisible()
|
||||
await expect(page.locator('text=90.4').first()).toBeVisible()
|
||||
// Provenance is rendered, not hidden — our own harness is badged.
|
||||
await expect(page.locator('text=Hanzo-measured').first()).toBeVisible()
|
||||
|
||||
// Switching benchmark re-ranks: MMLU-Pro is a different corpus slice with a
|
||||
// different leader.
|
||||
await page.getByRole('button', { name: /MMLU-Pro/ }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
await expect(page.locator('text=claude-opus-4.5').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'models-leaderboard.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('blend re-forms the Enso tiers when a model is toggled', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models/blend', 'price bands over this set')
|
||||
|
||||
// All three tier cards render, formed from the live catalog's prices.
|
||||
await expect(page.locator('text=Enso Flash').first()).toBeVisible()
|
||||
await expect(page.locator('text=Enso Blend').first()).toBeVisible()
|
||||
await expect(page.locator('text=Enso Ultra').first()).toBeVisible()
|
||||
|
||||
// Honest about persistence — never a confirmation for a write the backend drops.
|
||||
await expect(page.locator('text=Blend storage is not live yet').first()).toBeVisible()
|
||||
|
||||
// Every catalog model starts enabled (inherit-all).
|
||||
await expect(page.locator('text=6 of 6 enabled').first()).toBeVisible()
|
||||
|
||||
// Turning one off re-forms the tiers live: the two ultra-band models are
|
||||
// gpt-5.6-sol (25.0) and opus-4.8 (21.0); disabling one must drop Ultra to 1.
|
||||
await page.getByRole('button', { name: /Disable .*opus-4\.8/ }).first().click()
|
||||
await page.waitForTimeout(400)
|
||||
await expect(page.locator('text=5 of 6 enabled').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'models-blend.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('catalog shows vision capability and an em-dash for an unscored model', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models', 'Catalog') // the catalog tab bar + family list
|
||||
|
||||
// The vision-capable model is badged from the catalog's own features, and the
|
||||
// benchmarked models show their real corpus score.
|
||||
await expect(page.locator('text=Vision').first()).toBeVisible()
|
||||
await expect(page.locator('text=90.4').first()).toBeVisible()
|
||||
|
||||
// The honesty rule, asserted on the SPECIFIC unscored row (not just "an em-dash
|
||||
// exists somewhere on the page"): filter the catalog down to the model the corpus
|
||||
// has never scored, then read that row's own benchmark cell.
|
||||
await page.getByPlaceholder(/Search models/i).fill('totally-unbenchmarked')
|
||||
await page.waitForTimeout(600)
|
||||
const row = page.locator('text=totally-unbenchmarked-model').first()
|
||||
await expect(row).toBeVisible()
|
||||
await expect(page.locator('text=—').first()).toBeVisible()
|
||||
// …and it must NOT invent a zero for a model nobody has benchmarked.
|
||||
await expect(page.locator('text=0.0')).toHaveCount(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'models-catalog.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('both boards reflow with no horizontal body scroll on a phone', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
await open(page, '/models/leaderboard', 'Published benchmark scores')
|
||||
expect(await scrollsHorizontally(page)).toBe(false)
|
||||
await page.screenshot({ path: join(SHOTS, 'models-leaderboard-mobile.png'), fullPage: true })
|
||||
|
||||
await page.goto(`${BASE_URL}/models/blend`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('text=price bands over this set').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(800)
|
||||
expect(await scrollsHorizontally(page)).toBe(false)
|
||||
await page.screenshot({ path: join(SHOTS, 'models-blend-mobile.png'), fullPage: true })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* e2e: visual-polish + responsive + a11y regression guards (v8.4.112).
|
||||
*
|
||||
* Locks the fixes from the state-of-the-art QA pass so they can't silently
|
||||
* regress:
|
||||
* - the body is a hard no-horizontal-scroll surface (overflow-x guard),
|
||||
* - a global :focus-visible keyboard ring exists,
|
||||
* - the overview loads REAL data (KPI numbers, not skeletons),
|
||||
* - the per-product quick-links band navigates to the right destination,
|
||||
* - the GPU Launch drawer shows the prepay/card gate (never credit-fundable),
|
||||
* - the model catalog renders DISTINCT per-family brand icons,
|
||||
* - the sidebar collapses to a hamburger drawer on mobile,
|
||||
* - top-bar tap targets are ≥44px on a touch (coarse) pointer.
|
||||
*
|
||||
* The PUBLIC block runs with no credentials (it exercises /signin + the shipped
|
||||
* CSS floor) so it always runs in CI. The AUTHENTICATED block gates on
|
||||
* HANZO_PASSWORD (the repo convention) — it needs the Dave/maxpower-class session.
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai
|
||||
* HANZO_PASSWORD required for the authenticated block (skips when unset)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: pnpm e2e polish-qa.spec.ts
|
||||
* HANZO_PASSWORD=xxx pnpm e2e polish-qa.spec.ts
|
||||
*/
|
||||
import { test, expect, devices, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Widest element beyond the viewport right edge (a real horizontal-overflow culprit
|
||||
* in NORMAL flow — excludes off-screen transform:translateX drawers, which clip). */
|
||||
async function horizontalOverflow(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const de = document.documentElement
|
||||
return { scrollW: de.scrollWidth, clientW: de.clientWidth, overflow: de.scrollWidth > de.clientWidth + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
// ── PUBLIC — always runs (no credentials) ────────────────────────────────────
|
||||
|
||||
test.describe('console polish — public (CSS floor + responsive)', () => {
|
||||
for (const [name, width, height] of [
|
||||
['mobile', 390, 844],
|
||||
['tablet', 768, 1024],
|
||||
['desktop', 1440, 900],
|
||||
] as const) {
|
||||
test(`no horizontal body scroll on /signin — ${name} ${width}×${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height })
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const { overflow, scrollW, clientW } = await horizontalOverflow(page)
|
||||
expect(overflow, `document scrolls sideways (${scrollW} > ${clientW})`).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
test('body carries the overflow-x guard (never a sideways-scrolling document)', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const overflowX = await page.evaluate(() => getComputedStyle(document.body).overflowX)
|
||||
// `clip` (preferred) or `hidden` — either bans a horizontal scroll container.
|
||||
expect(['clip', 'hidden']).toContain(overflowX)
|
||||
})
|
||||
|
||||
test('a global :focus-visible keyboard ring is defined', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
// The rule is compiled into a same-origin stylesheet — scan for it (proves the
|
||||
// a11y floor shipped, independent of any element's own focus style).
|
||||
const hasRule = await page.evaluate(() => {
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
let rules: CSSRuleList
|
||||
try {
|
||||
rules = sheet.cssRules
|
||||
} catch {
|
||||
continue // cross-origin sheet — skip
|
||||
}
|
||||
for (const rule of Array.from(rules)) {
|
||||
const t = (rule as CSSStyleRule).selectorText
|
||||
if (t && t.includes(':focus-visible') && (rule as CSSStyleRule).style?.outlineStyle) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasRule, ':focus-visible outline rule not found in any stylesheet').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── AUTHENTICATED — the console shell (gates on HANZO_PASSWORD) ───────────────
|
||||
|
||||
test.describe('console polish — authenticated shell', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated polish checks')
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('overview loads REAL data (KPI numbers, not skeletons)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
// The living overview renders count-up KPI tiles with real figures.
|
||||
await expect(page.locator('text=/Inference tokens|Spend|Requests|Active models/i').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
// At least one KPI shows a concrete numeric value (k/M/$/%, not just "—").
|
||||
const body = (await page.locator('body').innerText()) || ''
|
||||
expect(/\$\s?\d|[\d.]+\s?[kKmM]\b|\d+%/.test(body), 'no real KPI figure on the overview').toBe(true)
|
||||
})
|
||||
|
||||
test('per-product quick-links band navigates to the scoped destination', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// The band shows BILLING / USAGE / METRICS with → links.
|
||||
const cost = page.locator('text=/Cost reports/i').first()
|
||||
await expect(cost).toBeVisible({ timeout: 20_000 })
|
||||
await cost.click()
|
||||
// Lands on a billing/cost surface (never a 404 / access-required).
|
||||
await expect(page).toHaveURL(/billing|cost/i, { timeout: 15_000 })
|
||||
await expect(page.locator('text=/404|could not be found|Access required/i')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('GPU Launch drawer shows the prepay/CARD gate (credits never fund GPUs)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/gpus`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('button:has-text("Launch GPU")').first().click()
|
||||
// The drawer's gate copy is the exact prepay/card contract.
|
||||
await expect(page.locator('text=/Prepay only/i').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator("text=/Granted credits can.?t be used for GPUs/i").first()).toBeVisible()
|
||||
await expect(page.locator('text=/Add a payment card/i').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Machines launch is CREDIT-funded (distinct from the GPU card gate)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/machines`, { waitUntil: 'domcontentloaded' })
|
||||
// CPU machines fund from the Hanzo credit balance — no card required.
|
||||
await expect(page.locator('text=/Hanzo credit|charged to credits|no card required/i').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('model catalog renders DISTINCT per-family brand icons', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('text=/Search models across every family/i')).toBeVisible({ timeout: 20_000 })
|
||||
// Each family header icon carries its own brand background colour (Zen light,
|
||||
// Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE, Mistral #FA520F, Google #1A73E8,
|
||||
// OpenAI black). Collect the distinct colours behind the family marks.
|
||||
const distinct = await page.evaluate(() => {
|
||||
const colours = new Set<string>()
|
||||
document.querySelectorAll('[style*="background"]').forEach((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width >= 24 && r.width <= 56 && Math.abs(r.width - r.height) <= 8) {
|
||||
const bg = getComputedStyle(el as HTMLElement).backgroundColor
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') colours.add(bg)
|
||||
}
|
||||
})
|
||||
return colours.size
|
||||
})
|
||||
// At least 3 distinct brand colours ⇒ icons are NOT one generic circle.
|
||||
expect(distinct, 'family icons are not visibly distinct').toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('sidebar collapses to a hamburger drawer on mobile', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['iPhone 13'] })
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// Persistent sidebar (the "Filter products…" search) is hidden below lg.
|
||||
const sidebarFilter = page.locator('input[placeholder="Filter products…"]')
|
||||
await expect(sidebarFilter).toBeHidden({ timeout: 15_000 }).catch(() => {})
|
||||
// The hamburger opens the SAME nav as a drawer.
|
||||
await page.locator('button[aria-label="Open navigation"]').click()
|
||||
await expect(page.locator('text=/Overview/i').first()).toBeVisible({ timeout: 10_000 })
|
||||
// No horizontal body scroll on mobile.
|
||||
const { overflow } = await horizontalOverflow(page)
|
||||
expect(overflow, 'mobile document scrolls sideways').toBe(false)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('top-bar tap targets are ≥44px on a touch pointer', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['iPhone 13'] })
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// Wait for the shell top bar to render.
|
||||
await page.locator('button[aria-label="Open navigation"]').waitFor({ state: 'visible', timeout: 15_000 })
|
||||
const small = await page.evaluate(() => {
|
||||
const bad: { label: string; w: number; h: number }[] = []
|
||||
document.querySelectorAll('.hz-topbar button').forEach((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && (r.width < 44 || r.height < 44)) {
|
||||
bad.push({ label: el.getAttribute('aria-label') || '(icon)', w: Math.round(r.width), h: Math.round(r.height) })
|
||||
}
|
||||
})
|
||||
return bad
|
||||
})
|
||||
expect(small, `top-bar controls under 44px: ${JSON.stringify(small)}`).toEqual([])
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
+43
-74
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* LIVE probe of the o11y (SigNoz) backend through the console's own /cloud bearer
|
||||
* proxy. Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches
|
||||
* each candidate endpoint exactly as the new Observe modules will — same-origin
|
||||
* `<origin>/v1/o11y/*` (rewritten to `/cloud/v1/o11y/*`, cloud rewrites to the o11y
|
||||
* runtime's `/api/*`). Prints the HTTP status + a body snippet per endpoint so we
|
||||
* know what returns real data vs 404 (which must be flagged) before we build.
|
||||
* LIVE probe of the o11y (O11y) backend through the console's own /v1 bearer BFF.
|
||||
* Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches each
|
||||
* candidate endpoint exactly as the Observe modules now do — same-origin
|
||||
* `<origin>/v1/o11y/<resource>` (the VERSION-LESS canonical surface; the `/v1`
|
||||
* catch-all mints the caller's IAM bearer). Prints the HTTP status + a body
|
||||
* snippet per endpoint so we know what returns real data vs 404/403 before we build.
|
||||
*
|
||||
* Not a pass/fail test — a discovery harness. Run:
|
||||
* BASE_URL=https://console.hanzo.ai HANZO_PASSWORD='…' npx playwright test probe-o11y --reporter=line
|
||||
@@ -33,7 +33,7 @@ async function signIn(page: Page) {
|
||||
await page.waitForTimeout(2500)
|
||||
}
|
||||
|
||||
/** now() epoch — SigNoz wants ns for services/errors, ms for infra. */
|
||||
/** now() epoch — O11y wants ns for services/errors, ms for infra. */
|
||||
const nowMs = Date.now()
|
||||
const endNs = String(nowMs * 1_000_000)
|
||||
const startNs = String((nowMs - 60 * 60 * 1000) * 1_000_000) // 1h window
|
||||
@@ -43,83 +43,52 @@ const startMs = nowMs - 60 * 60 * 1000
|
||||
type Probe = { name: string; path: string; method: 'GET' | 'POST'; body?: unknown }
|
||||
|
||||
const PROBES: Probe[] = [
|
||||
// ── Dashboards (SigNoz) ──
|
||||
{ name: 'dashboards.list', path: 'o11y/v1/dashboards', method: 'GET' },
|
||||
{ name: 'dashboards.v2', path: 'o11y/v2/dashboards', method: 'GET' },
|
||||
// ── Health (sanity: proves the runtime is reachable) ──
|
||||
{ name: 'health', path: 'o11y/health', method: 'GET' },
|
||||
{ name: 'version', path: 'o11y/version', method: 'GET' },
|
||||
// ── Dashboards ──
|
||||
{ name: 'dashboards.list', path: 'o11y/dashboards', method: 'GET' },
|
||||
// ── Service map / APM ──
|
||||
{ name: 'services.list', path: 'o11y/v1/services/list', method: 'GET' },
|
||||
{ name: 'services.list', path: 'o11y/services/list', method: 'GET' },
|
||||
{ name: 'services', path: 'o11y/services', method: 'POST', body: { start: startNs, end: endNs, tags: [] } },
|
||||
{ name: 'dependency_graph', path: 'o11y/dependency_graph', method: 'POST', body: { start: startNs, end: endNs, tags: [] } },
|
||||
{ name: 'service.top_operations', path: 'o11y/service/top_operations', method: 'POST', body: { start: startNs, end: endNs, service: '' } },
|
||||
// ── Logs / traces (the one true read — composite query_range) ──
|
||||
{
|
||||
name: 'services',
|
||||
path: 'o11y/v1/services',
|
||||
name: 'query_range',
|
||||
path: 'o11y/query_range',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'dependency_graph',
|
||||
path: 'o11y/v1/dependency_graph',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'service.top_operations',
|
||||
path: 'o11y/v1/service/top_operations',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, service: '' },
|
||||
body: {
|
||||
start: startMs,
|
||||
end: endMs,
|
||||
step: 60,
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: { queryName: 'A', dataSource: 'logs', aggregateOperator: 'noop', aggregateAttribute: {}, expression: 'A', disabled: false, stepInterval: 60, filters: { items: [], op: 'AND' }, groupBy: [], having: [], orderBy: [{ columnName: 'timestamp', order: 'desc' }], limit: null, offset: 0, pageSize: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Infra ──
|
||||
{
|
||||
name: 'hosts.list',
|
||||
path: 'o11y/v1/hosts/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'pods.list',
|
||||
path: 'o11y/v1/pods/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'nodes.list',
|
||||
path: 'o11y/v1/nodes/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'namespaces.list',
|
||||
path: 'o11y/v1/namespaces/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'clusters.list',
|
||||
path: 'o11y/v1/clusters/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{ name: 'hosts.list', path: 'o11y/hosts/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'pods.list', path: 'o11y/pods/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'nodes.list', path: 'o11y/nodes/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'namespaces.list', path: 'o11y/namespaces/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'clusters.list', path: 'o11y/clusters/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
// ── Exceptions ──
|
||||
{
|
||||
name: 'listErrors',
|
||||
path: 'o11y/v1/listErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' },
|
||||
},
|
||||
{
|
||||
name: 'countErrors',
|
||||
path: 'o11y/v1/countErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs },
|
||||
},
|
||||
// ── Health (sanity: proves the runtime is reachable) ──
|
||||
{ name: 'health', path: 'o11y/v1/health', method: 'GET' },
|
||||
{ name: 'version', path: 'o11y/v1/version', method: 'GET' },
|
||||
// ── Alerts (known-good baseline — AlertsModule already uses this) ──
|
||||
{ name: 'rules', path: 'o11y/v1/rules', method: 'GET' },
|
||||
{ name: 'listErrors', path: 'o11y/listErrors', method: 'POST', body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' } },
|
||||
{ name: 'countErrors', path: 'o11y/countErrors', method: 'POST', body: { start: startNs, end: endNs } },
|
||||
// ── Alerts ──
|
||||
{ name: 'rules', path: 'o11y/rules', method: 'GET' },
|
||||
]
|
||||
|
||||
test('probe o11y endpoints (live, authenticated)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
if (!PASSWORD) throw new Error('HANZO_PASSWORD required for the live probe')
|
||||
// Credentialed discovery harness — skip cleanly without a password (never a hard
|
||||
// fail); set HANZO_PASSWORD to run the live authenticated probe.
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD required for the live o11y probe')
|
||||
await signIn(page)
|
||||
|
||||
const results = await page.evaluate(
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai Provider Billing board (feat/admin-provider-billing).
|
||||
*
|
||||
* TWO layers, mirroring insights-o11y.spec:
|
||||
* (A) FIXTURE render — runs against a LOCAL server (BASE_URL=http://localhost:4000)
|
||||
* with the network mocked (budgets-responsive pattern): `/auth/session` → a
|
||||
* global admin so the admin shell mounts, and the two contract endpoints
|
||||
* (`/v1/admin/providers/credit`, `/v1/admin/usage/funding`) → the DIGITALOCEAN
|
||||
* $26k credit + glm-5.2 funding-split fixture. Proves the credit card + the
|
||||
* credit-vs-paid split RENDER, at desktop AND mobile (no horizontal scroll),
|
||||
* with screenshots. This is the develop-against-the-contract proof.
|
||||
* (B) LIVE — the fail-closed gate proof (`/v1/admin/providers/credit` +
|
||||
* `/v1/admin/usage/funding` → 403 unauthenticated) ALWAYS runs; the
|
||||
* authenticated render against REAL DO data is STAGED behind HANZO_PASSWORD
|
||||
* (the reserved-admin SuperAdmin secret) + a deployed image.
|
||||
*
|
||||
* Run fixture: BASE_URL=http://localhost:4000 npx playwright test provider-billing
|
||||
* Run live: HANZO_PASSWORD=… CONSOLE_URL=https://admin.hanzo.ai npx playwright test provider-billing
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A SuperAdmin via the isGlobalAdmin/isSuperAdmin CLAIM (what the admin:true module
|
||||
// gates on — `useIsSuperAdmin`). owner is a normal org so the Scope resolves the
|
||||
// current org locally instead of demanding a pick from the (mocked-empty) org list;
|
||||
// the real reserved-`admin`-org SuperAdmin is exercised by the LIVE (B) test.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isGlobalAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** GET /v1/admin/providers/credit — the DO $26k grant (the acceptance headline),
|
||||
* plus a paid-only + a second credit provider to exercise every badge. */
|
||||
const CREDIT = [
|
||||
{ provider: 'do-ai', grant_cents: 2_600_000, burn_cents: 41_200, remaining_cents: 2_418_000, runway_days: 58, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openrouter', grant_cents: 100_000, burn_cents: 21_000, remaining_cents: 62_500, runway_days: 3, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openai-direct', grant_cents: 0, burn_cents: 8_500, remaining_cents: 0, runway_days: null, has_credit: false, is_paid_only: true },
|
||||
]
|
||||
|
||||
/** GET /v1/admin/usage/funding — glm-5.2 on DO drawn from OUR CREDIT (the live
|
||||
* usage), plus paid / paid-only / BYO rows so the split shows all four classes. */
|
||||
const FUNDING = [
|
||||
{ provider: 'do-ai', model: 'glm-5.2', funding: 'credit', tokens: 1_284_000, cost_cents: 18_200, requests: 3_120 },
|
||||
{ provider: 'openrouter', model: 'gpt-5', funding: 'paid', tokens: 92_000, cost_cents: 44_000, requests: 210 },
|
||||
{ provider: 'openai-direct', model: 'gpt-5-mini', funding: 'paid_only', tokens: 30_000, cost_cents: 9_000, requests: 140 },
|
||||
{ provider: 'anthropic', model: 'claude-opus-4.6', funding: 'byo', tokens: 50_000, cost_cents: 0, requests: 80 },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The two contract endpoints — bare arrays, exactly as documented.
|
||||
if (path === '/v1/admin/providers/credit') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CREDIT) })
|
||||
}
|
||||
if (path === '/v1/admin/usage/funding') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FUNDING) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBoard(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — Scope → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard (auto-skipped on admin.* + embed)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/provider-billing`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
// Scope the readiness wait to the product CONTENT (the "Provider Billing" nav
|
||||
// label is hidden behind the off-canvas sidebar on a narrow viewport).
|
||||
await expect(content.getByText('Provider credit').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
// ─── (A) fixture render ───────────────────────────────────────────────────────
|
||||
test.describe('(A) fixture render — DO $26k credit + glm-5.2 funding split', () => {
|
||||
// Asserts LOCAL fixture data (the seeded $26k/glm-5.2 board); skip when the
|
||||
// fixture server is down. The (B) LIVE gate below hits prod and always runs.
|
||||
requireFixtureServer()
|
||||
|
||||
test('renders the credit card + funding split at a desktop viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
// Section 1: the DO credit card — the $26k grant + remaining + runway + badge.
|
||||
await expect(page.locator('text=Provider credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=do-ai').first()).toBeVisible()
|
||||
await expect(page.locator('text=$26,000.00').first()).toBeVisible() // the $26k DO grant
|
||||
await expect(page.locator('text=$24,180.00').first()).toBeVisible() // remaining
|
||||
await expect(page.locator('text=58 days').first()).toBeVisible() // runway_days
|
||||
await expect(page.locator('text=Has credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=Paid-only').first()).toBeVisible() // openai-direct badge
|
||||
|
||||
// Section 2: the credit-vs-paid split — all four funding classes + the glm-5.2 row.
|
||||
await expect(page.locator('text=Credit vs paid usage').first()).toBeVisible()
|
||||
await expect(page.locator('text=Our credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=glm-5.2').first()).toBeVisible()
|
||||
await expect(page.locator('text=BYO key').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
await expect(page.locator('text=do-ai').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (B) live — fail-closed gate always runs; real-DO render staged ───────────
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
|
||||
test.describe('(B) LIVE — admin gate + real DO data', () => {
|
||||
test('fail-closed: /v1/admin/{providers/credit,usage/funding} → 403 unauthenticated', async ({ request }) => {
|
||||
for (const p of ['providers/credit', 'usage/funding']) {
|
||||
const res = await request.get(`${CONSOLE}/v1/admin/${p}`)
|
||||
// Fail-closed: an unauthenticated caller NEVER gets data. Post-deploy this is
|
||||
// the 403 global-admin gate (like every other /v1/admin/* head); before the
|
||||
// sibling endpoint deploys the route may 404 — both are "not open". Never 200.
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${p} must be fail-closed (>=401)`).toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${p} must not 5xx`).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated: the board renders REAL DO credit + funding for the SuperAdmin', async ({ page }) => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD (reserved-admin SuperAdmin secret) not set — staged for the live deploy')
|
||||
await page.goto(`${CONSOLE}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 })
|
||||
|
||||
await page.goto(`${CONSOLE}/provider-billing`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// The board (not the operator-access gate) rendered for the SuperAdmin.
|
||||
await expect(page.locator('text=/Provider credit|Credit vs paid/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('text=/Operator access required/i')).toHaveCount(0)
|
||||
// Real DO data: the $26k grant / a do-ai card / glm-5.2 usage.
|
||||
await expect(page.locator('text=/do-ai|digitalocean/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-live-do.png'), fullPage: true })
|
||||
console.log('✓ (B) Provider Billing rendered REAL DO credit + funding for the SuperAdmin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
[
|
||||
"overview",
|
||||
"overlord",
|
||||
"business",
|
||||
"finance",
|
||||
"fleet-customers",
|
||||
"fleet-revenue",
|
||||
"retention",
|
||||
"enablement",
|
||||
"beta-features",
|
||||
"bots",
|
||||
"vms",
|
||||
"cluster-fleet",
|
||||
"function-fleet",
|
||||
"models",
|
||||
"providers",
|
||||
"provider-admin",
|
||||
"agents",
|
||||
"inference",
|
||||
"finetuning",
|
||||
"ml-pipelines",
|
||||
"embeddings",
|
||||
"evals",
|
||||
"gpus",
|
||||
"machines",
|
||||
"containers",
|
||||
"functions",
|
||||
"edge",
|
||||
"applications",
|
||||
"app-platform",
|
||||
"vector",
|
||||
"sql",
|
||||
"kv",
|
||||
"s3",
|
||||
"datastore",
|
||||
"base",
|
||||
"records",
|
||||
"docdb",
|
||||
"gateway",
|
||||
"nodes",
|
||||
"vpc",
|
||||
"dns",
|
||||
"cdn",
|
||||
"load-balancer",
|
||||
"service-mesh",
|
||||
"iam",
|
||||
"authz",
|
||||
"kms",
|
||||
"hsm",
|
||||
"secrets",
|
||||
"mpc",
|
||||
"audit",
|
||||
"zero-trust",
|
||||
"cli",
|
||||
"sdks",
|
||||
"api",
|
||||
"integrations",
|
||||
"playground",
|
||||
"ide",
|
||||
"desktop",
|
||||
"projects",
|
||||
"tracker",
|
||||
"tenants",
|
||||
"apps",
|
||||
"environments",
|
||||
"builds",
|
||||
"registry",
|
||||
"releases",
|
||||
"pipelines",
|
||||
"clusters",
|
||||
"kubernetes",
|
||||
"logs",
|
||||
"metrics",
|
||||
"o11y",
|
||||
"service-map",
|
||||
"ai-metrics",
|
||||
"open-edition",
|
||||
"analytics",
|
||||
"dashboards",
|
||||
"alerts",
|
||||
"billing",
|
||||
"status",
|
||||
"plans",
|
||||
"trading",
|
||||
"markets",
|
||||
"settlement",
|
||||
"wallet",
|
||||
"referrals",
|
||||
"tokens",
|
||||
"networks",
|
||||
"indexer",
|
||||
"oracles",
|
||||
"attestations",
|
||||
"chat",
|
||||
"bot",
|
||||
"crm",
|
||||
"cms",
|
||||
"erp",
|
||||
"helpdesk",
|
||||
"accessibility",
|
||||
"marketplace",
|
||||
"search",
|
||||
"websearch",
|
||||
"crawl",
|
||||
"studio",
|
||||
"templates",
|
||||
"console",
|
||||
"products",
|
||||
"orders",
|
||||
"customers",
|
||||
"inventory",
|
||||
"promotions",
|
||||
"storefront",
|
||||
"api-keys",
|
||||
"settings",
|
||||
"prompts",
|
||||
"datasets",
|
||||
"experiments",
|
||||
"sessions",
|
||||
"scores",
|
||||
"score-configs",
|
||||
"annotation-queues",
|
||||
"observations",
|
||||
"users",
|
||||
"memory",
|
||||
"tasks",
|
||||
"team",
|
||||
"profile"
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* e2e: the per-org Router configuration panel (Router › Policy tab).
|
||||
*
|
||||
* Mocked-network render proof against a LOCAL server (same pattern as
|
||||
* budgets-responsive / models-surfaces): `/auth/session` → an admin so the shell
|
||||
* mounts, `GET /v1/router/policy` → a real-shaped policy (allowlist + dial +
|
||||
* prefer + ceiling + the org's servable `available` set), everything else → an
|
||||
* empty-ok envelope.
|
||||
*
|
||||
* Why this exists: the panel is the ONE surface an org admin uses to (1) restrict
|
||||
* which models the auto-router may pick from, and (2) bias it between savings and
|
||||
* quality. A mocked unit suite can stay green while the page doesn't render, so this
|
||||
* asserts what only a browser can — that the allowlist chips + the savings↔quality
|
||||
* dial actually paint, that Select-all/Clear re-count live, and — the load-bearing
|
||||
* contract check — that Save PUTs `enabledModels` + `qualityBias` to
|
||||
* `/v1/router/policy`.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4010 npx playwright test router-config
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4010'
|
||||
|
||||
// Render spec asserts LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
const DESKTOP = join(homedir(), 'Desktop')
|
||||
|
||||
const ORG = 'hanzo'
|
||||
|
||||
/**
|
||||
* The OIDC userinfo claims the @hanzo/iam SDK resolves the account from (the console's
|
||||
* current auth model — a valid access token in sessionStorage + a userinfo fetch, NOT
|
||||
* a `/auth/session` BFF). `accountFromClaims` projects these onto the Account.
|
||||
*/
|
||||
const CLAIMS = {
|
||||
sub: `${ORG}/z`,
|
||||
owner: ORG,
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
type: 'normal-user',
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** A real-shaped router policy per the GET /v1/router/policy contract. */
|
||||
const POLICY = {
|
||||
prefer: { default: ['zen5'], code: ['zen5-coder'] },
|
||||
costCeiling: 0.003,
|
||||
enabledModels: ['gpt-4o-mini', 'enso'], // 2 of 4 → the allowlist is a restriction
|
||||
qualityBias: 0.75, // → "Favor quality" on the dial
|
||||
hasOverride: true,
|
||||
available: [
|
||||
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
|
||||
{ id: 'enso', name: 'Enso' },
|
||||
{ id: 'zen5-coder', name: 'Zen5 Coder' },
|
||||
{ id: 'claude-haiku', name: 'Claude Haiku' },
|
||||
],
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/** Captured PUT body for /v1/router/policy — the contract proof for the save path. */
|
||||
let lastSaveBody: Record<string, unknown> = {}
|
||||
let saved = false
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
// @hanzo/iam OIDC: discovery → userinfo → the account claims (the current auth model).
|
||||
// Discovery is a cross-origin GET (to the IAM host) → the fulfilled response needs
|
||||
// an ACAO header to be readable; point `userinfo_endpoint` at the SAME origin so the
|
||||
// Bearer userinfo fetch is same-origin (no CORS preflight).
|
||||
if (path.endsWith('/.well-known/openid-configuration'))
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' },
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://hanzo.id',
|
||||
authorization_endpoint: 'https://hanzo.id/v1/iam/oauth/authorize',
|
||||
token_endpoint: 'https://hanzo.id/v1/iam/oauth/token',
|
||||
userinfo_endpoint: `${BASE_URL}/v1/iam/oauth/userinfo`,
|
||||
jwks_uri: 'https://hanzo.id/v1/iam/oauth/jwks',
|
||||
}),
|
||||
})
|
||||
if (path.endsWith('/v1/iam/oauth/userinfo')) return json(route, CLAIMS)
|
||||
if (path.startsWith('/auth/')) return json(route, { ok: true })
|
||||
|
||||
// The page under test — the real router-policy contract (casibase envelope). ONE noun,
|
||||
// method-dispatched: GET /v1/router/policy reads the effective policy; PUT upserts it.
|
||||
if (path.endsWith('/v1/router/policy')) {
|
||||
if (req.method() === 'PUT') {
|
||||
try {
|
||||
lastSaveBody = JSON.parse(req.postData() ?? '{}')
|
||||
} catch {
|
||||
lastSaveBody = {}
|
||||
}
|
||||
saved = true
|
||||
// Echo the submitted policy back (merged with the read-only available set).
|
||||
return json(route, { status: 'ok', msg: '', data: { ...POLICY, ...lastSaveBody } })
|
||||
}
|
||||
return json(route, { status: 'ok', msg: '', data: POLICY })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return json(route, { status: 'ok', msg: '', data: [], data2: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Router › Policy tab and wait for the panel's CONTENT.
|
||||
*
|
||||
* `marker` must be a phrase unique to the panel body ("Enabled models"), not a bare
|
||||
* product title — a title also matches the hidden sidebar nav span on a narrow
|
||||
* viewport, which is never visible and would fail a rendered page.
|
||||
*/
|
||||
async function openPolicy(page: Page, marker = 'Enabled models') {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
|
||||
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
|
||||
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
|
||||
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
|
||||
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
|
||||
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
// Scope shows the org PICKER until an org is explicitly entered — the scope
|
||||
// VALUE alone is not enough (lib/org-scope.ts hasSelectedOrg).
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
// The first-run onboarding wizard is a full takeover that renders INSTEAD of the
|
||||
// product — mark it done or the page under test never mounts (lib/onboarding/guard.ts).
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ORG)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/router/policy`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(page.locator(`text=${marker}`).first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
mkdirSync(DESKTOP, { recursive: true })
|
||||
})
|
||||
|
||||
test('renders the router config panel — allowlist, savings↔quality dial, pools, ceiling', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openPolicy(page)
|
||||
|
||||
// The panel title + all four concerns render.
|
||||
await expect(page.locator('text=Router policy').first()).toBeVisible()
|
||||
await expect(page.locator('text=Enabled models').first()).toBeVisible()
|
||||
await expect(page.locator('text=Savings vs quality').first()).toBeVisible()
|
||||
await expect(page.locator('text=Task pools').first()).toBeVisible()
|
||||
await expect(page.locator('text=Cost ceiling').first()).toBeVisible()
|
||||
|
||||
// The allowlist is populated from `available` (names shown), and the dial reflects
|
||||
// the loaded qualityBias=0.75 → "Favor quality". (Text locators — a selected chip
|
||||
// carries a Check icon that can perturb the button's accessible name.)
|
||||
await expect(page.locator('text=GPT-4o mini').first()).toBeVisible()
|
||||
await expect(page.locator('text=Claude Haiku').first()).toBeVisible()
|
||||
await expect(page.locator('text=2 of 4 models selected').first()).toBeVisible()
|
||||
await expect(page.locator('text=Favor quality').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'router-config-desktop.png'), fullPage: true })
|
||||
await page.screenshot({ path: join(DESKTOP, 'router-config-panel.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('Select-all / Clear re-count live, and Save POSTs enabledModels + qualityBias', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
lastSaveBody = {}
|
||||
saved = false
|
||||
await openPolicy(page)
|
||||
|
||||
// "All models" honesty: Clear → empty selection labelled as no restriction.
|
||||
await page.getByRole('button', { name: 'Clear', exact: true }).first().click()
|
||||
await expect(page.locator('text=All models are allowed').first()).toBeVisible()
|
||||
|
||||
// Select all → the count reflects every servable model.
|
||||
await page.getByRole('button', { name: 'Select all', exact: true }).first().click()
|
||||
await expect(page.locator('text=4 of 4 models selected').first()).toBeVisible()
|
||||
|
||||
// Save round-trips the FULL body — the load-bearing contract check.
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
expect(saved, 'Save PUT a body to /v1/router/policy').toBe(true)
|
||||
expect(Array.isArray(lastSaveBody.enabledModels), 'enabledModels is an array').toBe(true)
|
||||
expect((lastSaveBody.enabledModels as string[]).slice().sort()).toEqual(
|
||||
['claude-haiku', 'enso', 'gpt-4o-mini', 'zen5-coder'].sort(),
|
||||
)
|
||||
expect(typeof lastSaveBody.qualityBias, 'qualityBias is a number').toBe('number')
|
||||
expect(lastSaveBody.qualityBias).toBe(0.75)
|
||||
// prefer + costCeiling are preserved (round-tripped), not clobbered.
|
||||
expect(lastSaveBody.costCeiling).toBe(0.003)
|
||||
expect((lastSaveBody.prefer as Record<string, string[]>).code).toEqual(['zen5-coder'])
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'router-config-saved.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openPolicy(page)
|
||||
|
||||
await expect(page.locator('text=Enabled models').first()).toBeVisible()
|
||||
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'router-config-mobile.png'), fullPage: true })
|
||||
await page.screenshot({ path: join(DESKTOP, 'router-config-panel-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* e2e regression — a DIRECT load of /signin renders the sign-in form.
|
||||
*
|
||||
* ROOT CAUSE this guards: the deploy (the go:embed'd static console in hanzoai/cloud)
|
||||
* serves the SPA shell (the `/` route's index.html) for EVERY path — verified live:
|
||||
* GET / and GET /signin return byte-identical HTML. So a direct /signin load mounts the
|
||||
* dashboard tree (Auth), NOT the /signin route. Before the fix, Auth saw no
|
||||
* account and called `router.replace('/signin')`, a NO-OP at /signin, and spun on the
|
||||
* loader forever (inputs=0, buttons=0). Reaching /signin as a REDIRECT target (from
|
||||
* `/`, `/projects`, …) worked because the URL changed. This asserts the direct entry
|
||||
* now resolves to the form. The fix: Auth + the /signin route both render the ONE
|
||||
* `<SignIn/>` component, so /signin resolves to the form without depending on a nav.
|
||||
*
|
||||
* Runs LOGGED OUT (a fresh context): the live get-account is anonymous → not signed in.
|
||||
* Works against the live console (BASE_URL default) OR a local SPA-fallback server
|
||||
* (BASE_URL=http://localhost:4173 serving out/ with index.html as the catch-all).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
test.describe('direct /signin renders the sign-in form (SPA-fallback regression)', () => {
|
||||
test('a hard load of /signin shows inputs + buttons, not an infinite spinner', async ({ browser }) => {
|
||||
// Fresh, cookie-less context → a logged-out visitor (anonymous get-account).
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
await page.goto(`${BASE_URL}/signin`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The credential form: email + password inputs and at least one button. A few
|
||||
// seconds is ample — a spinner that never resolves is the bug.
|
||||
const emailInput = page.getByPlaceholder('Email')
|
||||
await expect(emailInput).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByPlaceholder('Password')).toBeVisible()
|
||||
expect(await page.locator('input').count()).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.locator('button').count()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/signin-direct.png' })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai Block Storage — the realtime DO block-storage fleet board.
|
||||
*
|
||||
* Renders `/block-storage` as a super-admin (primeSession owner:'admin') against a
|
||||
* LOCAL fixture server with the network mocked, and asserts the board is REAL + HONEST:
|
||||
* (1) the analytics datastore is highlighted with its fill (200 GiB · 7%); (2) the fleet
|
||||
* KPIs show the real volume count + monthly cost; (3) a near-full volume raises an alert;
|
||||
* (4) a volume with NO fill reported renders an honest "—", never a fabricated number;
|
||||
* (5) nothing crashes. One screenshot so the board is visible.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test storage-fleet
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** The super-admin identity (a@hanzo.ai in the reserved `admin` org). */
|
||||
const ADMIN = { owner: 'admin', name: 'a', email: 'a@hanzo.ai', displayName: 'Admin', isAdmin: true }
|
||||
|
||||
/** A realistic fleet snapshot — the live shape: 295 volumes, the datastore at 7%, a
|
||||
* near-full sibling, and one volume DO reports with no fill (honest "—"). */
|
||||
const SNAPSHOT = {
|
||||
fleet: { count: 295, totalGiB: 13000, usedGiB: null, pct: null, monthlyUsd: 1309 },
|
||||
datastore: { name: 'datastore-data-datastore-0', mount: '/var/lib/hanzo-datastore', sizeGiB: 200, usedGiB: 13.5, pct: 7 },
|
||||
volumes: [
|
||||
{ id: 'vol-1', name: 'pvc-datastore', region: 'sfo3', sizeGiB: 200, usedGiB: 13.5, pct: 7, attached: true, service: 'datastore-0' },
|
||||
{ id: 'vol-2', name: 'pvc-signoz', region: 'sfo3', sizeGiB: 100, usedGiB: 91, pct: 91, attached: true, service: 'signoz' },
|
||||
{ id: 'vol-3', name: 'pvc-detached', region: 'sfo3', sizeGiB: 50, usedGiB: null, pct: null, attached: false, service: null },
|
||||
],
|
||||
alerts: [{ volume: 'signoz', pct: 91, level: 'critical' }],
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
/** Serve the real snapshot for the storage read; honest-empty for every other API. */
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
|
||||
}
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('Block Storage renders the datastore, fleet KPIs, alerts, and honest "—"', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.goto(`${BASE_URL}/block-storage`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2500) // let the SPA hydrate + the module fetch/render
|
||||
|
||||
// (1) The analytics datastore is highlighted with its real fill. Scope the text
|
||||
// assertions to the card — "analytics datastore" also appears in the header subtitle.
|
||||
const card = page.getByTestId('datastore-card')
|
||||
await expect(card, 'the datastore card is missing').toBeVisible({ timeout: 10_000 })
|
||||
await expect(card.getByText('Analytics datastore')).toBeVisible()
|
||||
await expect(card.getByText('200 GiB')).toBeVisible() // the datastore capacity (14 GiB / 200 GiB)
|
||||
|
||||
// (2) Fleet KPIs — the real volume count + monthly cost (never a fabricated fill).
|
||||
await expect(page.getByText('295')).toBeVisible() // Volumes
|
||||
await expect(page.getByText('$1,309')).toBeVisible() // Fleet cost
|
||||
|
||||
// (3) A near-full volume raised an alert.
|
||||
await expect(page.getByText('Near-full volumes')).toBeVisible()
|
||||
await expect(page.getByText('91%').first()).toBeVisible()
|
||||
|
||||
// (4) The detached volume DO reports with no fill renders an honest "—".
|
||||
await expect(page.getByText('—').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'block-storage.png'), fullPage: true })
|
||||
|
||||
// (5) No crash.
|
||||
const crashed = await page.locator('text=/Something went wrong|Application error|Cannot read prop/i').first().isVisible().catch(() => false)
|
||||
expect(crashed, 'the board rendered an error boundary').toBe(false)
|
||||
expect(errors, `page errors: ${errors.join(' | ')}`).toHaveLength(0)
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* e2e: Developers workbench dock — mocked-network render proof.
|
||||
*
|
||||
* Same pattern as budgets-responsive: a LOCAL server with the network mocked
|
||||
* (primeSession seeds the IAM-PKCE identity; `/v1/billing/usage` → three
|
||||
* real-shaped ledger rows, `/v1/models` → a small catalog for the shell). Proves
|
||||
* the persistent Developers bar renders on every page, the drawer opens with real
|
||||
* Overview numbers + Logs rows, and the read-only shell runs a /v1 GET (and
|
||||
* refuses a mutation) — screenshots for each.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test workbench
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
|
||||
/** Real-shaped commerce ledger rows (the `/v1/billing/usage` contract). */
|
||||
const now = Date.now()
|
||||
const USAGE = {
|
||||
usage: [
|
||||
{
|
||||
transactionId: 't1',
|
||||
amount: 12,
|
||||
createdAt: new Date(now - 60_000).toISOString(),
|
||||
notes: 'API usage: zen5 (1200 tokens)',
|
||||
metadata: { model: 'zen5', provider: 'hanzo', status: 'success', promptTokens: 800, completionTokens: 400, totalTokens: 1200 },
|
||||
},
|
||||
{
|
||||
transactionId: 't2',
|
||||
amount: 3,
|
||||
createdAt: new Date(now - 120_000).toISOString(),
|
||||
notes: 'API usage: glm-5.2 (300 tokens)',
|
||||
metadata: { model: 'glm-5.2', provider: 'zhipu', status: 'success', promptTokens: 200, completionTokens: 100, totalTokens: 300 },
|
||||
},
|
||||
{
|
||||
transactionId: 't3',
|
||||
amount: 1,
|
||||
createdAt: new Date(now - 180_000).toISOString(),
|
||||
notes: 'API usage: zen5-mini (90 tokens)',
|
||||
metadata: { model: 'zen5-mini', provider: 'hanzo', status: 'error', promptTokens: 90, completionTokens: 0, totalTokens: 90 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const MODELS = { object: 'list', data: [{ id: 'zen5', owned_by: 'hanzo' }, { id: 'glm-5.2', owned_by: 'hanzo' }] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/v1/billing/usage') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(USAGE) })
|
||||
}
|
||||
if (path === '/v1/models') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MODELS) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openHome(page: Page) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Open the workbench' }).first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the Developers dock opens with real ledger numbers, logs, and a working read-only shell', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openHome(page)
|
||||
|
||||
// The always-there bar: Developers + the faux `$` prompt.
|
||||
await expect(page.locator('text=Developers').first()).toBeVisible()
|
||||
await expect(page.locator('text=$ Run a /v1 command…').first()).toBeVisible()
|
||||
|
||||
// Open → Overview shows the REAL mocked ledger roll-up (3 requests · 1 error ·
|
||||
// 1590 tokens · $0.16), never fabricated numbers.
|
||||
await page.getByRole('button', { name: 'Open the workbench' }).first().click()
|
||||
await expect(page.locator('text=Requests').first()).toBeVisible()
|
||||
await expect(page.locator('text=Last 24h · charged ledger').first()).toBeVisible()
|
||||
await expect(page.locator('text=1590').first()).toBeVisible()
|
||||
await expect(page.locator('text=$0.16').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'workbench-overview.png') })
|
||||
|
||||
// Logs tab — the same ledger rows, newest first.
|
||||
await page.getByRole('button', { name: 'Logs', exact: true }).first().click()
|
||||
await expect(page.locator('text=zen5-mini').first()).toBeVisible()
|
||||
await expect(page.locator('text=glm-5.2').first()).toBeVisible()
|
||||
|
||||
// Shell tab — a real same-origin /v1 GET renders the JSON; a mutation is refused.
|
||||
await page.getByRole('button', { name: 'Shell', exact: true }).first().click()
|
||||
const input = page.getByLabel('Workbench shell command')
|
||||
await input.fill('GET /v1/models')
|
||||
await input.press('Enter')
|
||||
await expect(page.locator('text=zen5').first()).toBeVisible()
|
||||
await input.fill('DELETE /v1/agents')
|
||||
await input.press('Enter')
|
||||
await expect(page.locator('text=read-only').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'workbench-shell.png') })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
+8
-8
@@ -1,20 +1,20 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
// Canonical Hanzo UI face: Basel Grotesk (self-hosted via app/globals.css @font-face),
|
||||
// paired with Geist Mono for code/data. Override the @hanzo/gui (Tamagui) v5 default
|
||||
// Canonical Hanzo UI face: Geist Sans (loaded via the CDN @import in app/globals.css,
|
||||
// parallel to Geist Mono for code/data). Override the @hanzo/gui (Tamagui) v5 default
|
||||
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
|
||||
// renders Basel — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps.
|
||||
const BASEL =
|
||||
"'Basel', -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
// renders Geist — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps. Falls back to system-ui
|
||||
// if the Geist face is unavailable, so the UI degrades gracefully.
|
||||
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
|
||||
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
fonts: {
|
||||
...defaultConfig.fonts,
|
||||
body: { ...defaultConfig.fonts.body, family: BASEL },
|
||||
heading: { ...defaultConfig.fonts.heading, family: BASEL },
|
||||
body: { ...defaultConfig.fonts.body, family: GEIST },
|
||||
heading: { ...defaultConfig.fonts.heading, family: GEIST },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
|
||||
# (.github/workflows/cicd.yml) and platform.hanzo.ai.
|
||||
#
|
||||
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
|
||||
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
|
||||
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
|
||||
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
|
||||
# automatically. The Next.js SERVER runner image stays in build-image.yml.
|
||||
images:
|
||||
- name: console-embed
|
||||
context: .
|
||||
dockerfile: Dockerfile.embed
|
||||
repo: ghcr.io/hanzoai/console-embed
|
||||
+90
-63
@@ -2,6 +2,8 @@ import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
@@ -37,54 +39,72 @@ function guiPackages() {
|
||||
}
|
||||
// @hanzo/dash and @hanzo/data ship their screens/components as ESM/TSX
|
||||
// source (no compiled dist), so the shared Base UI is transpiled here the same
|
||||
// way Gui is.
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', 'react-native-web', ...scoped]
|
||||
// way Gui is. @hanzo/usage ships its <UsagePanel> (`/react`) as TSX source for the
|
||||
// same reason (its headless core `.`/`/node` are compiled dist and pass through).
|
||||
// @hanzo/ui (v8, the shared shell: AppHeader/BrandMark/OrgSwitcher/orgScope)
|
||||
// also ships raw TS source.
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` for the AI product surface — ZERO client-visible prefix.
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
* `/v1/<head>/...`, never `/cloud/...`, `/ai/...` or `/api/...`.
|
||||
*
|
||||
* The CTO contract is "no prefix before /v1/ in any API call": the browser calls
|
||||
* its OWN origin at a clean `/v1/<head>/...`, never `/cloud/...` or `/ai/...`.
|
||||
* These rewrites map exactly the AI-surface heads to the console's already-hardened
|
||||
* server-side bearer proxies (`app/cloud`, `app/ai`) — so the URL the client builds
|
||||
* is `/v1/prompts` while the request still terminates at OUR Next origin, which
|
||||
* mints a short-lived user bearer and forwards it (the raw session cookie NEVER
|
||||
* reaches cloud-api, so cloud-api carries no cookie-CSRF surface). This gives the
|
||||
* one-endpoint-form goal WITHOUT weakening the bearer trust boundary.
|
||||
* The DEFAULT terminus for a `/v1/<head>` call is the console's `app/v1/[...path]`
|
||||
* catch-all bearer proxy (→ cloud-api `/v1/*`): it mints a short-lived user bearer
|
||||
* from the session cookie and forwards it, so the raw cookie NEVER reaches cloud-api
|
||||
* (no cookie-CSRF surface) and the org is server-authoritative. That handler needs
|
||||
* NO rewrite — a clean `/v1/agents` falls straight through to it.
|
||||
*
|
||||
* Scope is deliberately the CLOSED head list the AI clients use (prompts/agents/
|
||||
* evals via /cloud, models/chat/embeddings/rerank via /ai) — a blanket `/v1/:path*`
|
||||
* would shadow paths meant for other backends. Each destination handler still
|
||||
* enforces its own least-privilege allow-list (`proxy-allow.ts`), so a rewrite can
|
||||
* never widen what the proxy admits. `beforeFiles` so these win over any route.
|
||||
* These `beforeFiles` rewrites exist ONLY to DISPATCH the heads whose backend is NOT
|
||||
* cloud-api to their own hardened same-origin proxy, while keeping the client URL a
|
||||
* clean `/v1/...`:
|
||||
* - AI gateway heads (models/chat/embeddings/rerank/… + pricing/plans) → `/ai`.
|
||||
* - Admin AGGREGATE reads/writes (`/v1/admin/{overview,usage,…}`, the cross-tenant
|
||||
* god view) → the GLOBAL-ADMIN-GATED `/admin/aggregate` proxy, which runs
|
||||
* `getAdminGate` (fail-closed 403) BEFORE forwarding (RED H1). `admin/iam` +
|
||||
* `admin/kms` are deliberately NOT rewritten — they keep their own gated proxies,
|
||||
* reached by the client's explicit `/admin/*` origin path.
|
||||
* - Visor compute CATALOG (regions/sizes, and `gpu-sizes` → visor `gpus`) → `/v1/vm`.
|
||||
*
|
||||
* The admin AGGREGATE reads (`/v1/admin/{overview,usage,orgs,audit,products}` — the
|
||||
* cross-tenant business/platform board) map to the GLOBAL-ADMIN-GATED proxy
|
||||
* (`app/admin/aggregate`), which runs `getAdminGate` (fail-closed 403) BEFORE
|
||||
* forwarding. This is the console-side server gate for the all-orgs god view (RED
|
||||
* H1) — NOT the ungated `/cloud` proxy. `admin/iam` + `admin/kms` are deliberately
|
||||
* NOT rewritten here: they keep their own gated proxies with their own tenant
|
||||
* scoping, and are reached by the client's explicit `/admin/*` origin path.
|
||||
* (Per-tenant billing + commerce store DATA are NOT dispatched here — they are
|
||||
* FILESYSTEM routes `app/v1/{billing,commerce}/[...path]`, more specific than the
|
||||
* `/v1/[...path]` cloud BFF, so a clean `/v1/billing/*` · `/v1/commerce/*` resolves
|
||||
* straight to them with no rewrite.)
|
||||
*
|
||||
* `beforeFiles` so a dispatched head wins over the `/v1` catch-all; the scope is the
|
||||
* CLOSED head list each non-cloud client uses (a blanket `/v1/:path*` would shadow the
|
||||
* cloud surface). Each destination handler STILL enforces its own least-privilege
|
||||
* allow-list (`proxy-allow.ts`), so a rewrite can never widen what a proxy admits.
|
||||
*/
|
||||
const CLOUD_V1_HEADS = ['prompts', 'agents', 'evals', 'analytics', 'templates', 'projects', 'platform', 'crm', 'integrations', 'ml', 'vpcs', 'load-balancers', 'networks', 'mesh', 'edge', 'indexers', 'oracles', 'authz', 'o11y', 'websearch', 'enablement']
|
||||
// `pricing` (the rich model+provider CATALOG at `/v1/pricing/models`) and `plans` (the
|
||||
// subscription tiers/entitlements) are AI-gateway-served like models/chat and are in the
|
||||
// `/ai` proxy ALLOWED set (app/ai/[...path]), so they route to `/ai` too.
|
||||
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos', 'pricing', 'plans']
|
||||
// `ai` is the AI Login Manager connections head (`/v1/ai/connections[/*]`) — routed to the
|
||||
// `/ai` bearer proxy like the rest; it is NOT a cloud-api head (never shadows a cloud surface).
|
||||
// `training` is the interactive (Tinker-style) engine head (`/v1/training/clients[/*]`) — the
|
||||
// live LoRA client plane, allow-listed in the `/ai` proxy, likewise never a cloud-api head.
|
||||
// `router` is the router-config head — `/v1/router/policy` (GET read + PUT write),
|
||||
// `/v1/router/stats`, and `/v1/router/{defaults,ledger,rewards,artifact-meta}` — all served
|
||||
// by hanzoai/ai; `get-/update-training-contribution` are the org's opt-in flag. The super-admin
|
||||
// OrgSettings noun `/v1/org/settings` (GET/PUT/DELETE + `/v1/org/settings/list`) is routed by the
|
||||
// TARGETED rewrites below rather than an `org` head, because a broad `org` head would hijack the
|
||||
// platform `/v1/org/{org}/cluster` surface. All are in the `/ai` proxy ALLOWED set (app/ai/[...path]).
|
||||
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos', 'pricing', 'plans', 'ai', 'training', 'router', 'get-training-contribution', 'update-training-contribution']
|
||||
// The admin aggregate heads rewritten to the GLOBAL-ADMIN-GATED proxy. `providers`
|
||||
// is the AI-provider control board — its GET (the list) AND its POST mutations
|
||||
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
||||
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
||||
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'providers', 'customers', 'revenue', 'analytics', 'enablement']
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
|
||||
/**
|
||||
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
||||
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
|
||||
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
||||
* the built image), so production is unchanged — there the console host's edge routes
|
||||
* `/v1` to cloud-api. The request cookie is forwarded by the rewrite, so the local
|
||||
* dev session resolves against the real cloud.
|
||||
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
|
||||
* is forwarded by the rewrite, so the local dev session resolves against the real cloud.
|
||||
*/
|
||||
const DEV_CLOUD_ORIGIN = process.env.DEV_CLOUD_ORIGIN?.replace(/\/+$/, '')
|
||||
const devCloudRewrites = () =>
|
||||
@@ -95,44 +115,43 @@ const devCloudRewrites = () =>
|
||||
]
|
||||
: []
|
||||
|
||||
// Native cloud INFRA + managed-data heads the data-product clients call at a clean
|
||||
// `/v1/<head>` (nothing before /v1/); each is rewritten to the same-origin user-
|
||||
// bearer `/cloud` proxy (app/cloud) — which mints a per-user token and forwards to
|
||||
// cloud-api — and is allow-listed in proxy-allow.ts CLOUD_HEADS (defense in depth).
|
||||
const CLOUD_INFRA_V1_HEADS = ['machines', 'gpus', 'clusters', 'org', 'sql', 'vector', 'datastore', 'kv', 'search', 's3', 'docdb']
|
||||
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor `/vm` proxy
|
||||
// (app/vm). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes` so it
|
||||
// never collides with the cloud-api GPU INVENTORY at `/v1/gpus`.
|
||||
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor proxy
|
||||
// (`app/v1/vm/[...path]`). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes`
|
||||
// so it never collides with the cloud-api GPU INVENTORY at `/v1/gpus` (served by `/v1`).
|
||||
const VM_V1_HEADS = ['regions', 'sizes']
|
||||
// Serverless + build/deploy + framework product heads the data-product clients
|
||||
// (functions.ts, framework/client.ts) and the platform-aggregate modules
|
||||
// (Builds/Environments/Pipelines/Releases) call at a clean `/v1/<head>`; each routes to
|
||||
// the user-bearer `/cloud` proxy and is allow-listed in proxy-allow.ts CLOUD_HEADS.
|
||||
const CLOUD_PRODUCT_V1_HEADS = ['functions', 'framework', 'environments', 'pipelines', 'builds', 'releases']
|
||||
|
||||
const aiSurfaceRewrites = () => ({
|
||||
beforeFiles: [
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/v1/${h}` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/v1/${h}/:path*` })),
|
||||
// Cloud-api heads (prompts/agents/automations/functions/framework/s3/vector/…) are
|
||||
// NOT rewritten: a clean `/v1/<head>` falls through to the `app/v1/[...path]` bearer
|
||||
// proxy → cloud-api `/v1/*`. Only the NON-cloud backends are dispatched below.
|
||||
// Client builds a clean `/v1/<aihead>`; dispatch to the `/ai` bearer proxy WITHOUT a
|
||||
// nested version in the target — `app/ai/[...path]` re-roots the upstream at `v1/`
|
||||
// (`isAllowedAiPath`/the gateway see `v1/<aihead>`), so no nested version leaks anywhere.
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/${h}` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/${h}/:path*` })),
|
||||
// Super-admin OrgSettings noun → the `/ai` bearer proxy (hanzoai/ai). TARGETED (not an
|
||||
// `org` head) so it never shadows the platform `/v1/org/{org}/cluster|domain|…` surface,
|
||||
// which keeps falling through to the `/v1` cloud BFF. Covers `/v1/org/settings` and
|
||||
// `/v1/org/settings/list` (allow-listed in app/ai/[...path]).
|
||||
{ source: `/v1/org/settings`, destination: `/ai/org/settings` },
|
||||
{ source: `/v1/org/settings/:path*`, destination: `/ai/org/settings/:path*` },
|
||||
// The SaaS-operations god-view is served by COMMERCE (the money SOT), NOT the
|
||||
// cloud aggregate: route /v1/admin/saas to its OWN global-admin-gated commerce
|
||||
// proxy (`app/admin/saas`). Placed before the aggregate map so it wins; `saas` is
|
||||
// deliberately NOT in ADMIN_V1_HEADS (that list forwards to cloud /v1/admin/*).
|
||||
{ source: `/v1/admin/saas`, destination: `/admin/saas` },
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
|
||||
// Data-product clients (compute / visor / platform / provisioning / storage) —
|
||||
// clean `/v1/<head>` → the user-bearer `/cloud` proxy (org from the Bearer owner).
|
||||
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
...CLOUD_PRODUCT_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_PRODUCT_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
// Public compute catalog → the visor `/vm` proxy.
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/vm/v1/${h}` })),
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/vm/v1/${h}/:path*` })),
|
||||
{ source: `/v1/gpu-sizes`, destination: `/vm/v1/gpus` },
|
||||
// Per-tenant billing DATA → the service-token commerce proxy (app/billing/v1).
|
||||
{ source: `/v1/billing/:path*`, destination: `/billing/v1/:path*` },
|
||||
// Commerce store DATA → the user-bearer commerce proxy (app/commerce). Namespaced like
|
||||
// billing so the generic store heads (product/order/user/store/…) never collide at /v1.
|
||||
{ source: `/v1/commerce/:path*`, destination: `/commerce/v1/:path*` },
|
||||
// Public compute catalog → the visor `app/v1/vm/[...path]` proxy (a bare `/v1/regions`
|
||||
// dispatches to the /v1-first vm handler; the visor client also builds `/v1/vm/*` directly).
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/v1/vm/${h}` })),
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/v1/vm/${h}/:path*` })),
|
||||
{ source: `/v1/gpu-sizes`, destination: `/v1/vm/gpus` },
|
||||
// Per-tenant billing DATA + commerce store DATA need NO rewrite: they are FILESYSTEM
|
||||
// routes `app/v1/billing/[...path]` (service token) and `app/v1/commerce/[...path]`
|
||||
// (user bearer), each MORE SPECIFIC than the `app/v1/[...path]` cloud BFF, so a clean
|
||||
// `/v1/billing/*` · `/v1/commerce/*` resolves straight to them (the /v1-first law).
|
||||
...devCloudRewrites(),
|
||||
],
|
||||
})
|
||||
@@ -149,8 +168,8 @@ const aiSurfaceRewrites = () => ({
|
||||
* - NO `rewrites` — a static export cannot run rewrites, and it does not need
|
||||
* them: the clean `/v1/<head>` calls the SPA already builds now terminate
|
||||
* DIRECTLY at the embedded cloud's mounted subsystems (prompts/agents/evals/…,
|
||||
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites used
|
||||
* to forward to via the Next BFF. The BFF proxy routes (app/cloud, app/ai,
|
||||
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites/`app/v1`
|
||||
* proxy forward to via the Next BFF. The BFF proxy routes (app/v1, app/ai,
|
||||
* app/commerce, …) are the server, and in one-binary the cloud binary IS the
|
||||
* server — so they are simply absent from the export (see below).
|
||||
* - `images.unoptimized` — the export has no Image Optimization server.
|
||||
@@ -158,7 +177,7 @@ const aiSurfaceRewrites = () => ({
|
||||
* PRECONDITION for a clean `output:'export'`: the app/ tree must contain NO dynamic
|
||||
* server route handlers (a static export has no server runtime to run them).
|
||||
* Those handlers are the BFF proxies + the two standalone routes; the latter
|
||||
* (keys/onboard) are ported to cloud `/v1/console/*`, and the proxies collapse to
|
||||
* (keys/onboard) are ported to cloud `/v1/iam/{keys,onboard}`, and the proxies collapse to
|
||||
* the cloud `/v1/*` the SPA calls directly. The embed build therefore runs against
|
||||
* a tree with every app route handler removed (the build:embed script prunes the
|
||||
* "route" files into a scratch stash so the server build on `main` is untouched).
|
||||
@@ -171,7 +190,15 @@ const EMBED = process.env.CONSOLE_EMBED === '1'
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Deterministic per-commit build id (see src/config/build-id.mjs): pins ONE id
|
||||
// across every replica of a release so a rolling deploy never serves one build's
|
||||
// HTML against another build's /_next/static/<BUILD_ID>/ path — the chunk-404 the
|
||||
// live audit hit. SOURCE_COMMIT (CI build-arg) -> git HEAD -> package version.
|
||||
generateBuildId: () => resolveBuildId({ env: process.env, gitSha: readGitSha(__dirname), version: pkgVersion }),
|
||||
env: { NEXT_PUBLIC_APP_VERSION: pkgVersion },
|
||||
// @hanzo/usage is transpiled (see guiPackages) so its source <UsagePanel> (`/react`)
|
||||
// compiles in the client bundle; its headless `.` entry (used by the /ai-accounts
|
||||
// server routes) carries no node built-ins, so it needs no server-external treatment.
|
||||
transpilePackages: guiPackages(),
|
||||
...(EMBED
|
||||
? { output: 'export', images: { unoptimized: true } }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/z/work/hanzo/console2/node_modules
|
||||
Generated
+887
-17
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.4.77",
|
||||
"version": "8.4.154",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
@@ -13,14 +13,22 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed"
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0",
|
||||
"@hanzo/canvas": "^0.1.0",
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/event": "^0.3.1",
|
||||
"@hanzo/finance-ui": "0.1.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam": "^0.13.6",
|
||||
"@hanzo/iam-js-sdk": "0.19.1",
|
||||
"@hanzo/logo": "^1.0.7",
|
||||
"@hanzo/logo": "^1.0.13",
|
||||
"@hanzo/ui": "^8.0.6",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
@@ -33,6 +41,7 @@
|
||||
"@lexical/selection": "0.46.0",
|
||||
"@lexical/utils": "0.46.0",
|
||||
"@luxfi/logo": "^1.0.1",
|
||||
"@xyflow/react": "12.11.1",
|
||||
"@zap-proto/web": "1.0.0",
|
||||
"@zap-proto/zap": "1.6.0",
|
||||
"@zooai/logo": "^1.0.2",
|
||||
@@ -40,6 +49,7 @@
|
||||
"ethers": "6.17.0",
|
||||
"lexical": "0.46.0",
|
||||
"next": "15.5.19",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-web": "0.21.2",
|
||||
@@ -52,6 +62,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
"vitest": "3.2.4",
|
||||
"patch-package": "^8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
index fe3a04e..41c367e 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
|
||||
@@ -785,6 +785,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
|
||||
index 4228603..1b9db27 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/browser.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/browser.js
|
||||
@@ -783,6 +783,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
index d49c5d4..81cfb85 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.cjs
|
||||
@@ -1172,6 +1172,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
|
||||
index 48c5699..7a85d23 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/index.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/index.js
|
||||
@@ -1170,6 +1170,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
index 8642b04..66da7d3 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.cjs
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.cjs
|
||||
@@ -719,6 +719,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
|
||||
index 8f4927a..81bef42 100644
|
||||
--- a/node_modules/@hanzo/iam/dist/react.js
|
||||
+++ b/node_modules/@hanzo/iam/dist/react.js
|
||||
@@ -717,6 +717,17 @@ var IAM = class {
|
||||
if (tokens.expires_in) {
|
||||
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
||||
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
|
||||
+ } else {
|
||||
+ try {
|
||||
+ const _p = tokens.access_token.split(".");
|
||||
+ if (_p.length === 3) {
|
||||
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
+ while (_b.length % 4) _b += "=";
|
||||
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
|
||||
+ const _e = JSON.parse(_j).exp;
|
||||
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
|
||||
+ }
|
||||
+ } catch (_) {}
|
||||
}
|
||||
}
|
||||
/** Get the stored access token (may be expired). */
|
||||
@@ -9,7 +9,10 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
// Tamagui/RNW is a heavy client SPA — a hard page load occasionally hydrates slowly
|
||||
// under headless chromium (the React root mounts a beat late). Retries absorb that
|
||||
// inherent render flakiness (a real regression fails every attempt, so nothing is masked).
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 1) NO server route handlers. A static export has no runtime to run an
|
||||
* app/route.ts. This repo's route handlers are (a) BFF reverse-proxies that in
|
||||
* one-binary collapse to the cloud `/v1/*` the SPA calls directly, and (b) the
|
||||
* two standalone routes (keys/onboard) now ported to cloud `/v1/console/*`.
|
||||
* two standalone routes (keys/onboard) now ported to cloud `/v1/iam/{keys,onboard}`.
|
||||
* Either way they must be absent from the export → we STASH them.
|
||||
*
|
||||
* 2) Every dynamic page segment needs `generateStaticParams()`. The console's two
|
||||
@@ -193,7 +193,11 @@ try {
|
||||
execFileSync('npx', ['next', 'build'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CONSOLE_EMBED: '1' },
|
||||
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
|
||||
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
|
||||
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
|
||||
// don't exist in this static, server-less deployment.
|
||||
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
|
||||
})
|
||||
|
||||
const out = join(root, 'out')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-benchmarks — regenerate `src/lib/api/benchmarks.data.json` from the
|
||||
* enso-bench prior corpus.
|
||||
*
|
||||
* The benchmark corpus is a VERSIONED ARTEFACT, not live state: it changes when a
|
||||
* bench run lands in hanzoai/enso-bench, not per request. So it is checked in as a
|
||||
* fixture and imported at build time — the leaderboard renders with no network, no
|
||||
* endpoint, no loading state, and every number keeps the `source` string the corpus
|
||||
* carries. `src/lib/api/benchmarks.ts` is the ONE reader.
|
||||
*
|
||||
* Two inputs, both from the enso-bench checkout (ENSO_BENCH, default ../enso-bench):
|
||||
* • priors/leaderboard.json — the scored corpus (per model: vendor + per-benchmark
|
||||
* {value, source}). Already JSON, consumed as-is.
|
||||
* • harness/arms.py CATALOG — the (canonical, gateway_model_id) alias pairs, so a
|
||||
* LIVE gateway model id ("openai-gpt-5.2") can find its corpus row ("gpt-5.2").
|
||||
*
|
||||
* Keys are copied RAW. Normalizing/indexing is the reader's job (`normalizeModelKey`
|
||||
* in benchmarks.ts) so there is exactly one implementation of the join.
|
||||
*
|
||||
* Usage: node scripts/sync-benchmarks.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const bench = process.env.ENSO_BENCH ?? resolve(here, '../../enso-bench')
|
||||
const out = resolve(here, '../src/lib/api/benchmarks.data.json')
|
||||
|
||||
/** Fail loudly: a silently-empty fixture would render an empty leaderboard as if the
|
||||
* corpus were genuinely empty, which is exactly the fabrication we refuse. */
|
||||
const assert = (cond, msg) => {
|
||||
if (!cond) {
|
||||
console.error(`sync-benchmarks: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The scored corpus ────────────────────────────────────────────────────────
|
||||
const board = JSON.parse(readFileSync(resolve(bench, 'priors/leaderboard.json'), 'utf8'))
|
||||
assert(Array.isArray(board.models) && board.models.length > 0, 'leaderboard.json has no models')
|
||||
assert(board.benchmarks && Object.keys(board.benchmarks).length > 0, 'leaderboard.json has no benchmarks')
|
||||
|
||||
// ── The canonical → gateway-id alias pairs ───────────────────────────────────
|
||||
// A CATALOG row is ("canonical", "model_id", supports_temp, price_in, price_out[, provider]).
|
||||
// Only the first two fields matter here; the prices shown in the console come from the
|
||||
// LIVE gateway catalog, never from this table.
|
||||
const arms = readFileSync(resolve(bench, 'harness/arms.py'), 'utf8')
|
||||
const catalog = arms.slice(arms.indexOf('CATALOG = ['), arms.indexOf(']', arms.indexOf('CATALOG = [')))
|
||||
const aliases = []
|
||||
for (const m of catalog.matchAll(/\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,/g)) {
|
||||
const [, canonical, modelId] = m
|
||||
if (canonical !== modelId) aliases.push([modelId, canonical])
|
||||
}
|
||||
assert(aliases.length >= 20, `parsed only ${aliases.length} alias pairs from arms.py CATALOG — format changed?`)
|
||||
|
||||
const data = {
|
||||
source: 'hanzoai/enso-bench priors/leaderboard.json + harness/arms.py',
|
||||
benchmarks: board.benchmarks,
|
||||
taskBench: board.task_bench ?? {},
|
||||
models: board.models,
|
||||
aliases,
|
||||
}
|
||||
|
||||
writeFileSync(out, `${JSON.stringify(data, null, 2)}\n`)
|
||||
console.log(`sync-benchmarks: ${data.models.length} models · ${Object.keys(data.benchmarks).length} benchmarks · ${aliases.length} aliases → ${out}`)
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-models — regenerate `src/lib/api/catalog.data.json` from the enso-bench
|
||||
* `priors/openrouter_models.json` prior.
|
||||
*
|
||||
* WHY A CHECKED-IN FIXTURE (the data decision). The model catalog the console browses
|
||||
* must be complete and available even when the live gateway is unreachable — a browse
|
||||
* surface that blanks on a backend roll is a worse experience than a stale-but-honest
|
||||
* one. The openrouter prior is a VERSIONED ARTEFACT (it changes when a catalog sync
|
||||
* lands in hanzoai/enso-bench, not per request), so like `benchmarks.data.json` it is
|
||||
* checked in and imported at BUILD TIME. `aicatalog.fetchCatalog` uses it as the
|
||||
* guaranteed base and overlays the LIVE gateway (`/v1/models` availability + the
|
||||
* current Zen family, `/v1/pricing/models` fresh pricing) on top when reachable — so
|
||||
* the console always has a browsable ~400-model catalog, and live data always wins
|
||||
* where it exists. Nothing here is fabricated: every field is copied from the prior.
|
||||
*
|
||||
* Each catalog row is projected onto the console's `RichModel` shape (id · name ·
|
||||
* provider · contextWindow · pricing in/out/cache · isFree · capability features).
|
||||
* Capabilities are read from the prior's own flags (`accepts_image` → Vision, etc.),
|
||||
* never guessed. No description is emitted (the prior carries none — an em-dash beats a
|
||||
* fabricated blurb).
|
||||
*
|
||||
* Input: ENSO_BENCH/priors/openrouter_models.json (ENSO_BENCH default ../enso-bench).
|
||||
* Usage: node scripts/sync-models.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const bench = process.env.ENSO_BENCH ?? resolve(here, '../../enso-bench')
|
||||
const out = resolve(here, '../src/lib/api/catalog.data.json')
|
||||
|
||||
/** Fail loudly: a silently-empty fixture would render an empty catalog as if the prior
|
||||
* were genuinely empty, exactly the fabrication we refuse. */
|
||||
const assert = (cond, msg) => {
|
||||
if (!cond) {
|
||||
console.error(`sync-models: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const num = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined)
|
||||
|
||||
const src = JSON.parse(readFileSync(resolve(bench, 'priors/openrouter_models.json'), 'utf8'))
|
||||
assert(Array.isArray(src.models) && src.models.length > 0, 'openrouter_models.json has no models')
|
||||
|
||||
const models = src.models.map((m) => {
|
||||
// Capability tags from the prior's OWN flags — drives the Vision badge (supportsVision
|
||||
// reads `features`) and the detail-panel feature chips. Never inferred beyond the flag.
|
||||
const features = []
|
||||
if (m.accepts_image) features.push('Vision')
|
||||
if (m.accepts_audio) features.push('Audio')
|
||||
if (m.accepts_video) features.push('Video')
|
||||
if (m.supports_tools) features.push('Tools')
|
||||
if (m.supports_reasoning) features.push('Reasoning')
|
||||
if (m.supports_structured_outputs) features.push('Structured output')
|
||||
|
||||
const pricing = {}
|
||||
const pin = num(m.price_in_per_mtok)
|
||||
const pout = num(m.price_out_per_mtok)
|
||||
const cr = num(m.price_cache_read_per_mtok)
|
||||
const cw = num(m.price_cache_write_per_mtok)
|
||||
if (pin !== undefined) pricing.input = pin
|
||||
if (pout !== undefined) pricing.output = pout
|
||||
if (cr !== undefined) pricing.cacheRead = cr
|
||||
if (cw !== undefined) pricing.cacheWrite = cw
|
||||
|
||||
const row = {
|
||||
id: m.id,
|
||||
name: m.name ?? m.id,
|
||||
// The vendor slug (`openai`, `ai21`, …) — the id-first brand resolver keys off the
|
||||
// model id, so this is a fallback vendor tell for the family/logo + a display label.
|
||||
provider: m.vendor ?? undefined,
|
||||
contextWindow: num(m.context_length) ?? num(m.provider_context_length),
|
||||
}
|
||||
if (Object.keys(pricing).length) row.pricing = pricing
|
||||
if (features.length) row.features = features
|
||||
if (m.is_free === true) row.isFree = true
|
||||
return row
|
||||
})
|
||||
|
||||
const data = {
|
||||
source: 'hanzoai/enso-bench priors/openrouter_models.json',
|
||||
count: models.length,
|
||||
models,
|
||||
}
|
||||
|
||||
writeFileSync(out, `${JSON.stringify(data, null, 2)}\n`)
|
||||
console.log(`sync-models: ${models.length} models → ${out}`)
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* All products — the directory where you curate your sidebar. Every Hanzo product
|
||||
* is always available on demand; this panel lists the FULL catalog the viewer may
|
||||
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
|
||||
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
|
||||
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
|
||||
* "All products" row).
|
||||
*
|
||||
* Honest by construction: pinning is instant + optimistic (persisted through the
|
||||
* account preferences store — no async error state). Products with REAL org usage
|
||||
* carry an "In use" badge + an "In use" filter, derived from the charged usage
|
||||
* ledger (`fetchUsageRecords` → `inUseProductIds`); if that signal is unavailable
|
||||
* the badges/filter degrade away — never a fabricated "in use".
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { fetchUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { inUseProductIds } from '~/lib/products/product-usage'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
|
||||
/** The list narrowing controls at the top. */
|
||||
type Filter = 'all' | 'inuse' | 'pinned'
|
||||
|
||||
/** A small "In use" pill — real org usage, never fabricated. */
|
||||
function InUseBadge() {
|
||||
return (
|
||||
<XStack items="center" gap="$1.5" bg="$green3" px="$2" py="$1" rounded="$10">
|
||||
<Activity size={11} color="$green11" />
|
||||
<Text fontSize="$1" fontWeight="700" color="$green11">
|
||||
In use
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
|
||||
function ProductRow({
|
||||
entry,
|
||||
color,
|
||||
pinned,
|
||||
inUse,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
color: string
|
||||
pinned: boolean
|
||||
inUse: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
|
||||
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(color)} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={0} gap="$0.5">
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{entry.description}
|
||||
</Text>
|
||||
</YStack>
|
||||
{inUse ? <InUseBadge /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
|
||||
onPress={onToggle}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
aria-label={pinned ? `Remove ${entry.label} from sidebar` : `Pin ${entry.label} to sidebar`}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Segmented All | In use | Pinned filter (the "In use" tab only when the signal exists). */
|
||||
function FilterTabs({
|
||||
value,
|
||||
onChange,
|
||||
showInUse,
|
||||
}: {
|
||||
value: Filter
|
||||
onChange: (f: Filter) => void
|
||||
showInUse: boolean
|
||||
}) {
|
||||
const opts: { key: Filter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
...(showInUse ? [{ key: 'inuse' as const, label: 'In use' }] : []),
|
||||
{ key: 'pinned', label: 'Pinned' },
|
||||
]
|
||||
return (
|
||||
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden" self="flex-start">
|
||||
{opts.map((o) => (
|
||||
<Button
|
||||
key={o.key}
|
||||
size="$2"
|
||||
chromeless
|
||||
rounded="$0"
|
||||
bg={value === o.key ? '$color5' : 'transparent'}
|
||||
onPress={() => onChange(o.key)}
|
||||
aria-label={`Filter ${o.label}`}
|
||||
>
|
||||
{o.label}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { isPinned, toggle } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
|
||||
// answer. `usageFailed` distinguishes an errored fetch from still-loading so we can
|
||||
// note it honestly (never fabricate an "in use" state).
|
||||
const [inUse, setInUse] = useState<Set<string> | null>(null)
|
||||
const [usageFailed, setUsageFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
fetchUsageRecords()
|
||||
.then((recs) => {
|
||||
if (alive) setInUse(inUseProductIds(recs))
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setUsageFailed(true)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const usageReady = inUse !== null
|
||||
|
||||
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
|
||||
// appear), grouped by category.
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
|
||||
|
||||
// Literal, case-insensitive substring match over label/description/id — NOT a
|
||||
// compiled RegExp of user input.
|
||||
const q = query.trim().toLowerCase()
|
||||
const matches = (e: CatalogEntry): boolean => {
|
||||
if (filter === 'pinned' && !isPinned(e.id)) return false
|
||||
if (filter === 'inuse' && !inUse?.has(e.id)) return false
|
||||
if (q && ![e.label, e.description, e.id].some((f) => f.toLowerCase().includes(q))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const shown = groups
|
||||
.map((g) => ({ category: g.category, entries: g.entries.filter(matches) }))
|
||||
.filter((g) => g.entries.length > 0)
|
||||
|
||||
const empty: { icon: ProductIcon; title: string; description: string } = q
|
||||
? { icon: Search, title: 'No matches', description: `No products match “${query.trim()}”.` }
|
||||
: filter === 'inuse'
|
||||
? {
|
||||
icon: Activity,
|
||||
title: 'No products in use yet',
|
||||
description: 'When your organization uses a product, it appears here so you can pin it to your sidebar.',
|
||||
}
|
||||
: filter === 'pinned'
|
||||
? {
|
||||
icon: Star,
|
||||
title: 'No pinned products yet',
|
||||
description: 'Pin a product to add it to your sidebar’s quick-access section.',
|
||||
}
|
||||
: { icon: Search, title: 'No products', description: 'There are no products to show.' }
|
||||
|
||||
return (
|
||||
<YStack p="$3" gap="$4">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Every product is available on demand — pin the ones you use to your sidebar. You only pay for what you use.
|
||||
</Text>
|
||||
{usageFailed ? (
|
||||
<Text fontSize="$1" color="$color9">
|
||||
Usage signal unavailable — in-use products can’t be highlighted right now.
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{/* Controls: search + filter. Wrap on narrow viewports. */}
|
||||
<XStack gap="$2.5" flexWrap="wrap" items="center">
|
||||
<XStack
|
||||
flex={1}
|
||||
minW={200}
|
||||
items="center"
|
||||
gap="$2"
|
||||
height={40}
|
||||
px="$3"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Search size={16} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search products…"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</XStack>
|
||||
<FilterTabs value={filter} onChange={setFilter} showInUse={usageReady} />
|
||||
</XStack>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<EmptyState icon={empty.icon} title={empty.title} description={empty.description} />
|
||||
) : (
|
||||
shown.map((group) => (
|
||||
<YStack key={group.category} gap="$1">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
{group.entries.map((entry) => (
|
||||
<ProductRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
color={colorOf(entry.id)}
|
||||
pinned={isPinned(entry.id)}
|
||||
inUse={inUse?.has(entry.id) ?? false}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
|
||||
* the API client already uses (`setCurrentActor`), never the email — once the
|
||||
* session resolves. The org tenant is stamped server-side from the session, so
|
||||
* we send the user id only. Anonymous placeholder sessions are skipped.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/event/react'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
analytics.identify(personId)
|
||||
}, [account, analytics])
|
||||
|
||||
return null
|
||||
}
|
||||
+124
-31
@@ -17,16 +17,36 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Dialog, Input, ScrollView, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { Lock, Search } from '@hanzogui/lucide-icons-2'
|
||||
import { AppWindow, Bot, CreditCard, LayoutGrid, Lock, MessageCircle, Search, Sparkles, Users } from '@hanzogui/lucide-icons-2'
|
||||
import { otherSurfaces, type Surface, type SurfaceId } from '@hanzo/ui/product'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { findEntry, visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import { searchCatalog } from '~/lib/products/search'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
|
||||
/**
|
||||
* Cross-surface tiles — the shared app switcher's entries that live OUTSIDE this
|
||||
* console: every Hanzo surface but the console itself (this IS the console), from
|
||||
* the ONE canonical `SURFACES` list. Hanzo brand only (white-label law: a lux/zoo/
|
||||
* pars host never shows a Hanzo surface — gated by `getBrand().id` at render).
|
||||
*/
|
||||
const CROSS_SURFACES: Surface[] = otherSurfaces('console')
|
||||
const SURFACE_ICONS = {
|
||||
ai: Sparkles,
|
||||
console: LayoutGrid,
|
||||
app: AppWindow,
|
||||
chat: MessageCircle,
|
||||
bot: Bot,
|
||||
team: Users,
|
||||
billing: CreditCard,
|
||||
} as const satisfies Record<SurfaceId, unknown>
|
||||
|
||||
type LauncherApi = { isOpen: boolean; open: () => void; close: () => void }
|
||||
|
||||
@@ -34,11 +54,11 @@ const Ctx = createContext<LauncherApi | null>(null)
|
||||
|
||||
export function useAppLauncher(): LauncherApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAppLauncher must be used within <AppLauncherProvider>')
|
||||
if (!ctx) throw new Error('useAppLauncher must be used within <Launcher>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; onPress: () => void }) {
|
||||
function Tile({ entry, color, active, onPress }: { entry: CatalogEntry; color: string; active?: boolean; onPress: () => void }) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<YStack
|
||||
@@ -51,6 +71,9 @@ function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; o
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$6"
|
||||
bg={active ? '$color3' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor={active ? '$color6' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<XStack
|
||||
@@ -72,24 +95,73 @@ function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; o
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.status === 'soon' ? (
|
||||
<YStack px="$1.5" py={1} rounded="$10" bg="$color4" position="absolute" b="$2">
|
||||
<Text fontSize={8} fontWeight="800" letterSpacing={0.5} color="$color11">
|
||||
SOON
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A launcher tile for a cross-surface entry (opens in a new tab). */
|
||||
function SurfaceTile({ surface, onPress }: { surface: Surface; onPress: () => void }) {
|
||||
const Icon = SURFACE_ICONS[surface.id]
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
width={132}
|
||||
height={124}
|
||||
p="$3"
|
||||
gap="$2.5"
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$6"
|
||||
borderWidth={1}
|
||||
borderColor="transparent"
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<XStack width={56} height={56} items="center" justify="center" rounded="$7" bg="$color3">
|
||||
<Icon size={26} />
|
||||
</XStack>
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{surface.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{surface.hint}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const router = useRouter()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const pathname = usePathname() ?? ''
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { colorOf } = useProductColors()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin), [showAdmin])
|
||||
// The active product (from the current route) — pinned first + emphasized in the
|
||||
// browse grid, per directive #58 §2.2.
|
||||
const activeId = useMemo(() => {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
return seg ? (findEntry(seg)?.id ?? null) : null
|
||||
}, [pathname])
|
||||
|
||||
// Browse (no query): each category's apps are CONTINUOUS ALPHABETICAL with the
|
||||
// selected app pinned first — the SAME `orderEntries` rule the sidebar uses (DRY).
|
||||
// The launcher is the "browse ALL apps" surface — DISCOVERY, decoupled from
|
||||
// entitlement. It shows the WHOLE catalog (admin-gated only), NOT the org's enabled
|
||||
// scope: entitlement governs the SIDEBAR (your workspace nav = what you use) and is
|
||||
// enforced when you OPEN a product (its page shows the honest "enable for your org"
|
||||
// state), never by hiding a product from the directory. So a user always sees every
|
||||
// product Hanzo offers here — `enabled` is deliberately NOT passed.
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
visibleCatalogByCategory(showAdmin, null).map((g) => ({
|
||||
category: g.category,
|
||||
entries: orderEntries(g.entries, activeId),
|
||||
})),
|
||||
[showAdmin, activeId],
|
||||
)
|
||||
// While filtering, keep the relevance ranking (a search is not alphabetical). Gate
|
||||
// by admin ONLY — the full catalog is searchable (discovery, not entitlement scope).
|
||||
const filtered = useMemo(
|
||||
() => (query.trim() ? searchCatalog(query).filter((e) => showAdmin || !e.admin) : null),
|
||||
[query, showAdmin],
|
||||
@@ -106,11 +178,11 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="launcher-overlay" bg="rgba(0,0,0,0.6)" />
|
||||
<Dialog.Overlay key="launcher-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.6)" />
|
||||
<Dialog.Content
|
||||
key="launcher-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
bordered
|
||||
elevate
|
||||
width="92vw"
|
||||
height="88vh"
|
||||
maxW={1180}
|
||||
@@ -160,23 +232,44 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
) : (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{filtered.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} active={entry.id === activeId} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<YStack key={group.category} gap="$2">
|
||||
<Text fontSize="$2" color="$color10" fontWeight="800" textTransform="uppercase" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{group.entries.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
))
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<YStack key={group.category} gap="$2">
|
||||
<Text fontSize="$2" color="$color10" fontWeight="800" textTransform="uppercase" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{group.entries.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} active={entry.id === activeId} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
{getBrand().id === 'hanzo' ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color10" fontWeight="800" textTransform="uppercase" px="$2">
|
||||
Surfaces
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{CROSS_SURFACES.map((s) => (
|
||||
<SurfaceTile
|
||||
key={s.id}
|
||||
surface={s}
|
||||
onPress={() => {
|
||||
onOpenChange(false)
|
||||
if (typeof window !== 'undefined') window.open(s.href, '_blank', 'noopener')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
@@ -186,7 +279,7 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLauncherProvider({ children }: { children: ReactNode }) {
|
||||
export function Launcher({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM OAuth callback handler — the PKCE code→token exchange.
|
||||
*
|
||||
* IAM redirects to `/auth/callback?code&state`; `@hanzo/iam` completes the exchange
|
||||
* (`handleCallback` reads the code + state + the stored verifier and writes the tokens to
|
||||
* sessionStorage), then we hard-navigate back to where a mid-task expiry interrupted the
|
||||
* user (default home) so the SessionProvider re-resolves the account from the fresh token.
|
||||
* On failure we surface the error and offer a retry.
|
||||
*
|
||||
* This lives in its OWN component (not inline in the `/auth/callback` route) because the
|
||||
* deploy serves the SPA shell — the `/` route tree, guarded by `<Auth/>` — for EVERY
|
||||
* path (see Auth's SPA-fallback note). A hard nav to `/auth/callback` therefore mounts
|
||||
* Auth, not this route's file; Auth renders THIS component for `/auth/callback`
|
||||
* exactly as it renders `<SignIn/>` for `/signin`, so the exchange runs BEFORE the guard
|
||||
* can bounce the still-unauthenticated visitor to `/signin` (which would discard `?code`).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Button, Text, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
|
||||
export function AuthCallback() {
|
||||
const router = useRouter()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
handleCallback()
|
||||
.then(() => {
|
||||
if (cancelled) return
|
||||
// Hard navigate so the SessionProvider boots against the new token.
|
||||
window.location.assign(takeReturnTo())
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('Sign-in failed.')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
|
||||
<Text color="$color12" fontWeight="600">
|
||||
{error}
|
||||
</Text>
|
||||
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
return <Loader label="Completing sign-in…" />
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Auth gate — renders children only for a signed-in account.
|
||||
*
|
||||
* While the session loads, shows a spinner. With no account, redirects to
|
||||
* `/signin`. Used to wrap the authenticated dashboard.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !account) router.replace('/signin')
|
||||
}, [loading, account, router])
|
||||
|
||||
if (loading || !account) {
|
||||
return <Loader />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { branding } from '~/config'
|
||||
|
||||
/**
|
||||
* BrandTitle — keep the browser-tab document.title white-labeled to the request
|
||||
* host's brand, on the client.
|
||||
*
|
||||
* The console ships into the unified `hanzoai/cloud` binary as a Next.js STATIC
|
||||
* EXPORT (go:embed): `generateMetadata` runs at BUILD time with the default host,
|
||||
* so the exported <title> is baked to "Hanzo Cloud Console" for every host. The
|
||||
* cloud serving layer rewrites that <title> per Host on the first paint, but Next
|
||||
* re-applies the baked metadata title on hydration (and on client navigations) —
|
||||
* reverting a Lux/Zoo tab back to "Hanzo Cloud Console", a white-label violation.
|
||||
*
|
||||
* This client net resolves the brand from window.location (via `branding.name`,
|
||||
* the same source the visible shell uses) and enforces the correct title,
|
||||
* defeating the baked-metadata re-application. On the dynamic standalone app the
|
||||
* SSR title is already host-correct, so this only ever re-affirms it — a no-op.
|
||||
*/
|
||||
export function BrandTitle() {
|
||||
useEffect(() => {
|
||||
const desired = branding.name // "<Brand> Cloud Console", from window.location
|
||||
const apply = () => {
|
||||
if (document.title !== desired) document.title = desired
|
||||
}
|
||||
apply()
|
||||
// Next re-applies the baked metadata title after hydration; re-affirm the
|
||||
// brand title whenever the document head mutates. Writing document.title only
|
||||
// when it has drifted keeps the observer from looping on its own change.
|
||||
const observer = new MutationObserver(apply)
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,51 +1,74 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ChunkGuard — recover gracefully from a stale-deploy chunk error.
|
||||
* ChunkGuard — window-level net that recovers from a stale-deploy chunk 404.
|
||||
*
|
||||
* After a console deploy, an open tab still references the previous build's
|
||||
* hashed chunks. Those chunk URLs no longer exist, so the request falls through
|
||||
* to the app shell (HTML), and the browser throws `ChunkLoadError` /
|
||||
* "Unexpected token '<'" trying to parse HTML as JS — an unrecoverable blank
|
||||
* screen. This catches that exact failure and does ONE full reload, which pulls
|
||||
* the fresh HTML + current chunks. A sessionStorage flag prevents reload loops
|
||||
* if the failure is genuine (not a stale deploy); it clears on the next load.
|
||||
* On a rolling deploy an open tab (or a fresh deep-link that lands on the other
|
||||
* replica) requests a hashed chunk that no longer exists on the replica it hits.
|
||||
* The 404 falls through to the app-shell HTML, so the browser throws
|
||||
* `ChunkLoadError` / "Unexpected token '<'" trying to parse HTML as JS — a blank,
|
||||
* unrecoverable screen. This catches that at the WINDOW level and does one full
|
||||
* reload, which pulls the fresh HTML + current chunks.
|
||||
*
|
||||
* Two catch surfaces, because a chunk 404 surfaces two ways:
|
||||
* - CAPTURE-phase `error` on the failing `<script>`/`<link>` element — a resource
|
||||
* load error does NOT bubble, so only a capture listener sees it. This fires on
|
||||
* the RAW 404 during the initial deep-link load, before webpack's loader even
|
||||
* rejects — the earliest, most reliable signal for "refresh a sub-route 404s a
|
||||
* chunk".
|
||||
* - `unhandledrejection` / bubbled `error` carrying a `ChunkLoadError` message —
|
||||
* webpack's dynamic-import path.
|
||||
*
|
||||
* The React error boundaries (`global-error`, the dashboard segment,
|
||||
* `ProductErrorBoundary`) catch the same class at RENDER time; this complements
|
||||
* them for the async/resource paths a render boundary never sees. Every recovery
|
||||
* site — this net and all three boundaries — shares ONE loop-breaker
|
||||
* (`shouldReloadForChunk` bounded by `CHUNK_RELOAD_AT_KEY`), so a persistent skew
|
||||
* reloads at most once per window and never spins. Chunk detection is shared too
|
||||
* (`isChunkLoadError`) — one definition of "this is a chunk skew" for the whole app.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const FLAG = 'hz_chunk_reloaded'
|
||||
const PATTERN = /ChunkLoadError|Loading chunk [\d]+ failed|Loading CSS chunk|Importing a module script failed|Unexpected token '<'/i
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
/** True when a failed resource load targets a Next build asset (script/link). */
|
||||
function isNextAssetError(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== 'object') return false
|
||||
const el = target as Partial<HTMLScriptElement & HTMLLinkElement>
|
||||
const url = el.src || el.href
|
||||
return typeof url === 'string' && url.includes('/_next/static/')
|
||||
}
|
||||
|
||||
export function ChunkGuard() {
|
||||
useEffect(() => {
|
||||
// A clean load means any prior stale-chunk reload worked — reset the guard.
|
||||
try {
|
||||
sessionStorage.removeItem(FLAG)
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable (private mode) — best-effort only */
|
||||
}
|
||||
|
||||
const recover = (message: string) => {
|
||||
if (!PATTERN.test(message)) return
|
||||
// Reload at most once per window, coordinated with the render boundaries so a
|
||||
// skew that trips several detectors at once reloads ONCE (the timestamp ages
|
||||
// out, so a genuine later skew can still recover) — never a reload loop.
|
||||
const recover = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(FLAG)) return // already tried once — let the error surface
|
||||
sessionStorage.setItem(FLAG, '1')
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (!shouldReloadForChunk(Date.now(), last)) return
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* sessionStorage blocked (private mode) — let a render boundary show its card */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const onError = (e: ErrorEvent) => recover(e?.message ?? String(e?.error ?? ''))
|
||||
const onError = (e: ErrorEvent) => {
|
||||
if (isNextAssetError(e.target) || isChunkLoadError(e.error ?? e.message)) recover()
|
||||
}
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const r = e?.reason
|
||||
recover(typeof r === 'string' ? r : (r?.message ?? ''))
|
||||
if (isChunkLoadError(e.reason)) recover()
|
||||
}
|
||||
|
||||
window.addEventListener('error', onError)
|
||||
// `capture: true` so the non-bubbling resource-load error on a 404'd chunk
|
||||
// element reaches us.
|
||||
window.addEventListener('error', onError, true)
|
||||
window.addEventListener('unhandledrejection', onRejection)
|
||||
return () => {
|
||||
window.removeEventListener('error', onError)
|
||||
window.removeEventListener('error', onError, true)
|
||||
window.removeEventListener('unhandledrejection', onRejection)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -74,7 +74,7 @@ import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useAppLauncher } from '~/components/AppLauncher'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
|
||||
@@ -114,7 +114,7 @@ const Ctx = createContext<PaletteApi | null>(null)
|
||||
|
||||
export function useCommandPalette(): PaletteApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useCommandPalette must be used within <CommandPaletteProvider>')
|
||||
if (!ctx) throw new Error('useCommandPalette must be used within <Palette>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const launcher = useAppLauncher()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { colorOf } = useProductColors()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
@@ -357,10 +357,12 @@ function PaletteDialog({
|
||||
return [...verbs, ...orgVerbs]
|
||||
}, [isDark, orgs, router, launcher, signOut, setTheme, onOpenChange])
|
||||
|
||||
// Every jump target — products AND deep sub-pages ("queues" → Tasks › Queues) —
|
||||
// gated so a customer never sees an admin-only surface.
|
||||
// Every jump target — products AND deep sub-pages ("queues" → Tasks › Queues).
|
||||
// ⌘K is a DISCOVERY surface: it jumps to the WHOLE catalog (admin-gated only), NOT
|
||||
// the org's entitled scope — entitlement governs the sidebar + product use, never
|
||||
// what you can find/jump to. Admin-only operator surfaces stay gated by `showAdmin`.
|
||||
const destResults = useMemo(
|
||||
() => (mode === 'catalog' ? searchDestinations(query, showAdmin).slice(0, 50) : []),
|
||||
() => (mode === 'catalog' ? searchDestinations(query, showAdmin, null).slice(0, 50) : []),
|
||||
[mode, query, showAdmin],
|
||||
)
|
||||
|
||||
@@ -469,7 +471,7 @@ function PaletteDialog({
|
||||
}, [open, mode, items, sel, run, submit, activate, activateDest, onOpenChange])
|
||||
|
||||
// Keep the ↑/↓-selected row visible: as selection moves past the fold, scroll
|
||||
// the active row into view (the list can hold 50 results — well beyond 420px).
|
||||
// the active row into view (the list can hold 50 results — well beyond 560px).
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'catalog' || typeof document === 'undefined') return
|
||||
document.getElementById('cmdk-active')?.scrollIntoView({ block: 'nearest' })
|
||||
@@ -485,13 +487,13 @@ function PaletteDialog({
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="palette-overlay" bg="rgba(0,0,0,0.5)" />
|
||||
<Dialog.Overlay key="palette-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" />
|
||||
{/* Full-screen on mobile (fills the viewport, reachable from the mobile
|
||||
menu); a floating 640 box at lg+. */}
|
||||
menu); a floating 640 box at lg+ on Material paper (real depth). */}
|
||||
<Dialog.Content
|
||||
key="palette-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
bordered
|
||||
elevate
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
maxW="100vw"
|
||||
@@ -531,8 +533,10 @@ function PaletteDialog({
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{/* Body — fills the viewport on mobile, capped at lg+. */}
|
||||
<YStack flex={1} minH={0} overflow="hidden" $lg={{ flex: 0, minH: 120, maxH: 420 }}>
|
||||
{/* Body — fills the viewport on mobile; a stable, tall box at lg+ so
|
||||
the palette reads as a real command surface (Raycast/Linear-style)
|
||||
instead of collapsing to a two-row sliver when few results match. */}
|
||||
<YStack flex={1} minH={0} overflow="hidden" $lg={{ flex: 0, minH: 340, maxH: 560 }}>
|
||||
{mode === 'catalog' ? (
|
||||
items.length === 0 ? (
|
||||
<YStack p="$5" items="center">
|
||||
@@ -641,7 +645,7 @@ function Legend({ keys, label }: { keys: string; label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
export function Palette({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [seed, setSeed] = useState('')
|
||||
|
||||
@@ -696,7 +700,9 @@ export function CommandSearchBox() {
|
||||
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
|
||||
Search or jump to…
|
||||
</Text>
|
||||
<XStack items="center" gap="$1" opacity={0.6}>
|
||||
{/* ⌘K hint — hidden below lg: there is no keyboard shortcut on a phone, and
|
||||
the chip stole width from the placeholder (which truncated to “S…”). */}
|
||||
<XStack display="none" $lg={{ display: 'flex' }} items="center" gap="$1" opacity={0.6}>
|
||||
<Command size={12} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
K
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { Anchor, Text, XStack } from '@hanzo/gui'
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
|
||||
/**
|
||||
* Console footer — a quiet, brand-aware strip at the bottom of every page's content
|
||||
* column (docs / support / legal + copyright). Brand-derived URLs (getBrand) so the
|
||||
* white-labelled consoles point at their own site, not hanzo.ai. One place (DRY).
|
||||
*/
|
||||
export function ConsoleFooter() {
|
||||
const site = getBrand().websiteUrl
|
||||
const year = new Date().getFullYear()
|
||||
const links = [
|
||||
{ label: 'Docs', href: `${site}/docs` },
|
||||
{ label: 'Support', href: `${site}/support` },
|
||||
{ label: 'Privacy', href: `${site}/privacy` },
|
||||
{ label: 'Terms', href: `${site}/terms` },
|
||||
]
|
||||
return (
|
||||
<XStack
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
mt="$6"
|
||||
pt="$4"
|
||||
pb="$2"
|
||||
items="center"
|
||||
justify="space-between"
|
||||
gap="$3"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
© {year} {config.brandName}
|
||||
</Text>
|
||||
<XStack items="center" gap="$4" flexWrap="wrap">
|
||||
{links.map((l) => (
|
||||
<Anchor
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
fontSize="$2"
|
||||
color="$color10"
|
||||
hoverStyle={{ color: '$color12' }}
|
||||
>
|
||||
{l.label}
|
||||
</Anchor>
|
||||
))}
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user