Compare commits

..
2 Commits
Author SHA1 Message Date
Hanzo AI 345015b745 fix(security): gate /paas proxy, server-side authz, billing IDOR/idempotency, OIDC state+PKCE, strict CSP, drop client X-Org-Id, clear prefs on logout
Build Docker Image / docker (push) Successful in 2m38s
Seven hardening fixes, TDD (failing test → fix → green); +33 unit, +13 e2e.

1. [C1] app/paas/[...path] verifies the IAM session (same-origin /v1/get-account
   via lib/auth/server) AND requires admin BEFORE attaching PAAS_SERVICE_TOKEN —
   deny-by-default: unauth 401, non-admin 403, admin reaches upstream, honest 501
   when the token is unset. Re-add PAAS_SERVICE_TOKEN to the CR after re-review.
2. Function-level authz: catch-all [...slug] denies admin-only modules to
   non-admins (Access required, module never mounts). ADMIN_PRODUCT_IDS is the
   GUI-free source of truth, drift-guarded against the catalog admin:true entries.
3. Billing top-up: userId derived from the verified session (was body.userId =
   IDOR); txHash sent as Idempotency-Key so a replay never double-credits.
4. OIDC: callback validates state against the stored value before exchange (was
   unchecked) and surfaces ?error; PKCE S256 machinery added, gated off
   (NEXT_PUBLIC_IAM_PKCE) until casibase /v1/signin forwards code_verifier.
5. Strict security headers in next.config: CSP (frame-ancestors none, object-src
   none, base-uri, scoped form-action/connect-src), HSTS, nosniff, no-referrer,
   X-Frame-Options DENY, Permissions-Policy.
6. client.ts no longer sends X-Org-Id (spoofable, not a trust boundary); org is
   derived server-side from the session.
7. signOut clears the localStorage preferences cache (no cross-user leak).

136 unit + 111 e2e green; tsc --noEmit clean; production build green.
2026-06-28 07:54:33 -07:00
Hanzo AI a318a23065 test: add E2E + unit suite (200 tests) and fix 3 bugs found via TDD
Greenfield test infrastructure for console2 (had zero tests):

- vitest 3 unit layer (test/unit, 102 tests): routing, catalog/registry
  integrity, brand config, the /v1 client envelope + REST layer, account
  anonymous handling, honest-state mapping, and the provider/model/store/app
  domain logic. Heavy GUI deps aliased to hermetic stubs (test/stubs) so the
  registry graph imports without rendering Tamagui in Node.
- Playwright E2E layer (test/e2e, 98 tests): real clicks/forms/navigation over
  auth (IAM OIDC), the catalog home, the sidebar, every enabled module + honest
  empty/403/404/503 states, ALL admin flows (IAM tabs, Audit, Secrets/KMS,
  Clusters, Kubernetes, Settings, API Keys), negative authz, forms, mobile +
  desktop viewports, and clean-console checks. Hermetic: /v1 + /paas mocked via
  route interception — never touches real prod data.
- One-command CI: `npm run test:e2e` (webServer builds + serves), `test:unit`,
  `test:all`.

Bugs fixed (TDD red -> green, each with a regression test):
- favorites: DEFAULT_PINNED named a non-existent catalog id ('billing'); the
  shell silently drops unknown pins so new users lost a pin. -> ['chat','cost'].
- least privilege: the sidebar + home rendered admin-only surfaces (IAM/KMS/
  Secrets/Audit/Clusters/Kubernetes) to every user. Added visibleCatalog(isAdmin)
  and wired it into the shell + home; server-side 403 remains the authority.
- config: brandFromHost mapped every brand's .id host except pars.id. Added it.

Behavior-neutral: nav-sidebar/page-content/pinned-section testIDs on the shell
to scope E2E assertions; DEFAULT_PINNED exported for its regression test.
2026-06-28 07:06:43 -07:00
1186 changed files with 12593 additions and 212405 deletions
+3 -11
View File
@@ -2,21 +2,13 @@
# Copy to .env.local and adjust. All values are public (NEXT_PUBLIC_*) since the
# console is a browser app that talks to the unified /v1 backend with cookies.
# The ONE Hanzo API endpoint (the unified /v1 backend). There is no per-service
# API host — never llm./kms./platform./cloud./console.hanzo.ai. Leave UNSET in
# production: the browser then calls its own origin, so the session cookie stays
# first-party and the edge route forwards /v1 through the gateway.
# Unified Hanzo Cloud backend (the casibase /v1 API). Default: production.
# Local backend: http://localhost:14000
NEXT_PUBLIC_CLOUD_URL=https://api.hanzo.ai
NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
# Hanzo PaaS FRONTEND (deep-links only — the Clusters/PaaS *API* is /v1/paas on
# NEXT_PUBLIC_CLOUD_URL above, never a second API host).
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
# (fork a starter → customize by prompt in the builder). Default: production.
NEXT_PUBLIC_APP_URL=https://hanzo.app
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="console2">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">console2</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+51
View File
@@ -0,0 +1,51 @@
name: Build Docker Image
# Builds + pushes ghcr.io/hanzoai/console2 on the self-hosted ARC runner. ONE
# brand-agnostic image serves every brand: console2 resolves the brand at RUNTIME
# 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).
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: docker-image-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
- name: resolve semver tag
id: ver
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
echo "tag=v$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
fi
- uses: docker/setup-buildx-action@v3
- 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 }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
provenance: false
sbom: false
tags: |
ghcr.io/hanzoai/console2:${{ steps.ver.outputs.tag }}
+8 -16
View File
@@ -1,19 +1,19 @@
node_modules
node_modules/
.next/
out/
dist/
# pnpm is the one package manager here — pnpm-lock.yaml is the tracked lockfile and
# `packageManager` in package.json pins the version corepack installs. A lockfile
# from any other manager is a second source of truth that silently drifts.
package-lock.json
yarn.lock
bun.lockb
# build artifacts
*.tsbuildinfo
next-env.d.ts
# test artifacts (the suites under test/ ARE committed; only outputs are ignored)
/test-results/
/playwright-report/
/blob-report/
/coverage/
/.playwright/
# env
.env*.local
@@ -22,11 +22,3 @@ next-env.d.ts
.vscode/
.idea/
*.log
e2e/screenshots/
e2e-shots/
test-results/
playwright-report/
.claude/
# blank-audit generated report
e2e/blank-report.json
-25
View File
@@ -1,25 +0,0 @@
# ~7-line canonical caller — all real config lives in /hanzo.yml.
# Builds + pushes BOTH console images (the embed artifact cloud go:embeds, and
# the Next.js server image admin.hanzo.ai runs); auto-mirrors to registry.hanzo.ai.
#
# It replaces `.hanzo/workflows/deploy.yml`, which built the server image a
# SECOND time by hand and could not: `buildctl-daemonless.sh` is not in the image
# this fleet serves for `hanzo-build-linux-amd64` (every label in that pool maps
# to catthehacker/ubuntu:act-24.04 — universe:infra/k8s/git-runner/statefulset.yaml),
# and its `secrets.GIT_CLONE_TOKEN` exists on neither the repo nor the org. Its
# `kubectl patch app` was futile too: cd.hanzo.ai's selfHeal restores the CR from
# the universe pin on the next poll. Rollout is a reviewed tag pin in
# hanzoai/universe, never a CI side effect.
name: CI/CD
on:
workflow_dispatch:
push:
branches: [main]
# A hand-cut v* tag must produce its image, or the tag is a receipt for
# nothing — the exact drift the retired build-image.yml existed to prevent.
tags: ['v*']
pull_request:
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
-1
View File
@@ -1 +0,0 @@
LLM.md
+35
View File
@@ -0,0 +1,35 @@
# AGENTS — console2
Read [LLM.md](./LLM.md) first; it is the canonical design doc. Highlights for
agents working here:
- **Stack:** Next.js 15 (app router) + @hanzo/gui (npm), consumed at runtime via
`transpilePackages` (the `@hanzogui/next-plugin` is broken on npm). Next 15
(not 14) because @hanzo/gui needs React 19. Pin patch versions; never lazily
major-bump.
- **Gui style props:** the v5 config is `onlyShorthandStyleProps` — use
shorthands (`p`, `px`, `bg`, `items`, `justify`, `self`, `rounded`, `minH`),
never longhands (`padding`, `backgroundColor`, …). Keep `tsc` clean.
- **One way:** all backend calls go through `src/lib/api` (never raw `fetch`);
all selects/inputs through `src/components/ui/Field.tsx`; all nav/routing
through the registry in `src/lib/products`.
- **Extensibility:** add a cloud product by appending a `ProductModule` to
`src/lib/products/registry.tsx` and writing its module component — do not add
per-product routes or touch the shell.
- **Auth:** Hanzo IAM (OIDC) via `@hanzo/iam-js-sdk`; session cookie minted by
the backend at `/v1/signin`. Never store credentials client-side.
- **Boundaries:** frontend only. No DB. No Docker builds locally (CI/CD builds
images). No secrets in the repo — config is `NEXT_PUBLIC_*` only.
- **Verify:** `npm run typecheck` and `npm run build` must pass. Show output.
- **Tests (real, committed under `test/`):**
- `npm run test:unit` — vitest, pure client logic + catalog/data-integrity
(routing, registry, config, the `/v1` client envelope, domain `logic.ts`).
Heavy GUI deps are aliased to hermetic stubs (`test/stubs/`) so the registry
graph imports without rendering Tamagui in Node.
- `npm run test:e2e` — Playwright against the real Next server (builds + serves
via `webServer`). HERMETIC: the `/v1` + `/paas` backend is mocked with route
interception (`test/e2e/fixtures.ts`) — tests NEVER touch real prod data.
Fixtures: `ACCOUNTS.admin/member/anonymous`, `backend.account()/envelope()/
rest()/error()/paas()`, `baseline()`, `landAs()`, `trackConsoleErrors()`.
- `npm run test:all` — both. E2E scopes assertions with the `nav-sidebar`,
`page-content`, and `pinned-section` testIDs on the shell.
+21 -29
View File
@@ -1,38 +1,32 @@
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# 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
FROM node:22-alpine AS deps
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 the install in the same stage drops that RUN's freshly
# created node_modules (the 'next not found' cause — the install's own `test -f next`
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
# before install means node_modules is created by the LAST RUNs and nothing clobbers
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
COPY package.json package-lock.json* ./
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose
# platform/optional packages (e.g. react-native-worklets) resolve differently
# across npm versions, so a lockfile generated by one npm fails `npm ci` under
# another (EUSAGE "Missing: react-native-worklets@... from lock file"). install
# reconciles the tree deterministically for the build platform.
RUN npm install --no-audit --no-fund
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
RUN mkdir -p public
# corepack installs the exact pnpm from package.json's `packageManager`, so the
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
# across npm versions and a lockfile written by one npm failed under another. pnpm
# records every platform in the lockfile, so the build installs exactly what is
# committed and fails loudly instead of quietly resolving something else.
RUN corepack enable && pnpm install --frozen-lockfile
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
# (exit 137); cap the heap generously (chat uses 4096).
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that
# so nothing brand-specific is baked.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap on the
# build node → OOMKill (exit 137). Cap the heap generously, as every other Hanzo
# Next build does (chat uses 4096).
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
RUN pnpm build
RUN npm run build
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
RUN addgroup -S app && adduser -S app -G app
@@ -41,8 +35,6 @@ 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"]
CMD ["npm", "run", "start"]
-30
View File
@@ -1,30 +0,0 @@
# hanzoai/console — the EMBED artifact.
#
# Builds the console SPA static export (`pnpm 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 the install+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 corepack enable && pnpm install --frozen-lockfile
# 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 pnpm 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/
+38 -10
View File
@@ -1,14 +1,42 @@
Licensed under either of
BSD 3-Clause License
* Apache License, Version 2.0 (LICENSE-APACHE or
https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
Copyright (c) 2026-present, Hanzo AI, Inc.
at your option.
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
See HIP-0137 (hanzoai/hips) for the standard this follows.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-33
View File
@@ -1,33 +0,0 @@
MIT License
Copyright (c) 2026-present, Hanzo AI, Inc.
Portions of this software are derived from upstream code originally licensed
under the MIT License, with the following copyright notices retained per its
terms:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+59 -3580
View File
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
Hanzo Cloud Console (console2)
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
------------------------------------------------------------------------
Third-party attribution
------------------------------------------------------------------------
Observe surface — Langfuse (MIT License)
The console's Observe screens (Traces, Trace detail with the span-tree /
latency-waterfall, Observations, Sessions, Scores, Score Configs, Datasets,
Dataset Items, Dataset Runs / Experiments, and the observability Dashboards /
Metrics) reproduce the SCREEN LAYOUT AND USER FLOWS of Langfuse's observability
product.
This is a clean-room reimplementation in our own code (React + @hanzo/gui),
wired to the native Hanzo Cloud /v1/evals contract. No Langfuse source code is
copied. Only the MIT-licensed layout/flow concepts inform the design; the
Langfuse EE / commercial ("ee") code is neither used nor referenced.
Langfuse — https://github.com/langfuse/langfuse
Copyright (c) Langfuse GmbH
Licensed under the MIT License.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------
Vendored MIT-licensed code
------------------------------------------------------------------------
Portions of this software are derived from upstream MIT-licensed code. Those
copyright notices are retained here per the MIT License's terms; they were
previously carried in LICENSE, which is reserved for this project's own
BSD-3-Clause grant.
Copyright (c) 2020 Nate Wienert (Tamagui)
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
Copyright (c) 2021 Radix (Radix UI)
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V. (Framer Motion)
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -6
View File
@@ -1,5 +1,3 @@
<p align="center"><img src=".github/hero.svg" alt="console2" width="880"></p>
# Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all Hanzo cloud products. Built on
@@ -34,7 +32,7 @@ All config is `NEXT_PUBLIC_*` (browser app, cookie auth). See `.env.example`.
| Var | Default | Meaning |
| --- | --- | --- |
| `NEXT_PUBLIC_CLOUD_URL` | same origin, else `https://api.hanzo.ai` | The ONE Hanzo API endpoint (unified `/v1` backend). Never a per-service API host. |
| `NEXT_PUBLIC_CLOUD_URL` | `https://cloud.hanzo.ai` | Unified `/v1` backend base URL |
| `NEXT_PUBLIC_IAM_URL` | `https://iam.hanzo.ai` | Hanzo IAM OIDC authority |
| `NEXT_PUBLIC_IAM_APP_NAME` | `hanzo-console` | IAM application (`<org>-<app>`) |
| `NEXT_PUBLIC_IAM_ORG_NAME` | `hanzo` | IAM organization |
@@ -48,6 +46,4 @@ the product-module registry, and the Providers surface). Endpoint reference in
## License
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
+26 -8
View File
@@ -1,20 +1,38 @@
'use client'
import { use } from 'react'
import { notFound } from 'next/navigation'
import { ProductRoute } from '~/components/ProductRoute'
import { matchRoute } from '~/lib/products/match'
import { isAdminProductId } from '~/lib/auth/admin'
import { useSession } from '~/lib/auth/session'
import { ApiError } from '~/lib/api'
import { ErrorState } from '~/components/ui/States'
import { Loader } from '~/components/ui/Loader'
/**
* Catch-all product route. Resolves the module + route from the registry and
* 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.
* renders its component. Adding a product anywhere in the registry makes its
* routes live here — no per-product page files.
*
* `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.
* Function-level authz: an admin-only product (IAM/KMS/Secrets/Audit/Clusters/
* Kubernetes) NEVER renders for a non-admin, however the URL was reached — nav
* hiding is cosmetic, this is the gate. The backend `/v1` endpoints remain the
* server-side authority (defense in depth); this stops the admin UI from ever
* mounting (and firing those calls) for a non-admin.
*/
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = use(params)
return <ProductRoute slug={slug} />
const { account, loading } = useSession()
const matched = matchRoute(slug)
if (!matched) notFound()
if (isAdminProductId(matched.module.id) && !account?.isAdmin) {
if (loading) return <Loader />
return <ErrorState err={new ApiError('Admin access is required for this surface.', 403)} />
}
const Component = matched.route.component
return <Component params={matched.params} />
}
-78
View File
@@ -1,78 +0,0 @@
'use client'
/**
* Dashboard route error backstop (Next App Router).
*
* `ProductErrorBoundary` catches throws inside a resolved product module; this
* catches anything above it in the dashboard page tree (the resolver itself, a
* non-catch-all dashboard page). It renders in the layout's content slot, so the
* shell + nav stay mounted — never a white-screened "Application error". Next's
* `reset()` re-renders the segment; `notFound()`/`redirect()` are control flow and
* do not reach here.
*/
import { useEffect } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
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)
useEffect(() => {
console.error('[console] dashboard route error:', error)
// 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.
// 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(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 (
<YStack p="$4">
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{chunk
? 'A newer version of the console just shipped. Reload to load the latest.'
: 'The rest of the console still works. Try again, or reload the page.'}
</Text>
<XStack gap="$2">
{!chunk ? (
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => reset()}>
Try again
</Button>
) : null}
<Button
size="$2"
chromeless={!chunk}
icon={<RefreshCw size={14} />}
onPress={() => { if (typeof window !== 'undefined') window.location.reload() }}
>
Reload
</Button>
</XStack>
</Card>
</YStack>
)
}
+8 -20
View File
@@ -1,27 +1,15 @@
import type { ReactNode } from 'react'
import { Preferences } from '~/lib/products/preferences'
import { Toast } from '~/components/ui/Toast'
import { Entry } from '~/entry/entry'
import { Host } from '~/entry/host'
import { AuthGate } from '~/components/AuthGate'
import { DashboardShell } from '~/components/DashboardShell'
import { PreferencesProvider } from '~/lib/products/preferences'
/**
* 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.
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
* add credits), so every card below renders its affordance without being handed one.
* `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 (
<Preferences>
<Toast>
<Host>
<Entry>{children}</Entry>
</Host>
</Toast>
</Preferences>
<AuthGate>
<PreferencesProvider>
<DashboardShell>{children}</DashboardShell>
</PreferencesProvider>
</AuthGate>
)
}
+66 -235
View File
@@ -1,36 +1,37 @@
'use client'
/**
* Product catalog — the unified console home. Every Hanzo product, grouped by the
* ten canonical categories, with its Google Cloud equivalent. Every product is
* open-for-all: each card opens straight into its native in-console surface and
* carries a "Learn more" affordance to its docs — there is no enablement gate and
* no external bounce. Each card can be pinned to the sidebar (persisted to the
* account). Rendered entirely from the catalog registry.
* Product catalog — the unified console home. Every Hanzo product, grouped by
* the ten canonical categories, with its enablement state and Google Cloud
* equivalent. `enabled` and `external` products open straight in (in-console or
* a new tab); `soon` products link to their discover screen. Each card can be
* pinned to the sidebar (persisted to the account). Rendered entirely from the
* catalog registry.
*/
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, Boxes, HandCoins, ExternalLink } from '@hanzogui/lucide-icons-2'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Star, Lock, ExternalLink, ArrowRight, Info } 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 { branding, config } from '~/config'
import { catalogByCategory, visibleCatalog, type CatalogEntry } from '~/lib/products/registry'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { useProductColors } from '~/lib/products/pins'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
import { ProductObservability } from '~/components/products/observability/ProductObservability'
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
// sparklines, streaming activity) — the SAME component every product overview uses.
const OverviewDashboard = livingOverviewModule('overview')
const STATUS_LABEL = { enabled: 'Enabled', external: 'External', soon: 'Soon' } as const
const STATUS_BG = { enabled: '$color5', external: '$color3', soon: '$color4' } as const
function StatusBadge({ entry }: { entry: CatalogEntry }) {
return (
<XStack bg={STATUS_BG[entry.status]} px="$2" py="$1" rounded="$10" items="center" gap="$1">
{entry.admin ? <Lock size={11} opacity={0.6} /> : null}
<Text fontSize="$1" color={entry.status === 'enabled' ? '$color12' : '$color11'} fontWeight="600">
{STATUS_LABEL[entry.status]}
</Text>
</XStack>
)
}
function ProductCard({
entry,
@@ -46,6 +47,7 @@ function ProductCard({
onLearnMore: () => void
}) {
const Icon = entry.icon
const openable = entry.status === 'enabled' || entry.status === 'external'
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" width={272}>
<XStack justify="space-between" items="flex-start">
@@ -63,7 +65,14 @@ function ProductCard({
</YStack>
</XStack>
<XStack gap="$1" items="center">
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
<Button
size="$2"
chromeless
opacity={0.4}
icon={<Info size={15} />}
onPress={onLearnMore}
aria-label={`Learn about ${entry.label}`}
/>
<Button
size="$2"
chromeless
@@ -80,83 +89,16 @@ function ProductCard({
</Text>
<XStack justify="space-between" items="center">
<StatusBadge entry={entry} />
<Button
size="$2"
chromeless
icon={<BookOpen size={14} />}
onPress={onLearnMore}
aria-label={`Learn more about ${entry.label}`}
>
Learn more
</Button>
<Button
size="$2"
bg="$color5"
bg={openable ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={onOpen}
iconAfter={<ArrowRight size={14} />}
onPress={openable ? onOpen : onLearnMore}
iconAfter={entry.kind === 'external' ? <ExternalLink size={14} /> : <ArrowRight size={14} />}
>
Open
</Button>
</XStack>
</Card>
)
}
/**
* Primary action tile — the ONE presentational card for a top-of-home "first action"
* (get an API key, deploy an OSS project, earn from your OSS). Prop-driven and pure: a
* ProductIcon tile (the shared product-color system; omit `color` for the neutral chip),
* a title, a one-line blurb, and a single CTA. Reused for EVERY primary action so the row
* stays DRY — add an action by rendering one more tile, never a new card. `external` swaps
* the CTA's trailing glyph to the new-tab mark; `dataTour` anchors the first-run tour (the
* API-key tile keeps its `api-key` anchor). No data fetch — a tile is cheap on first paint;
* anything heavy (e.g. the OSS catalog) lives behind the CTA, loaded only on press.
*/
function PrimaryActionTile({
icon,
color,
title,
description,
ctaLabel,
external,
dataTour,
onPress,
}: {
icon: IconLike
color?: string
title: string
description: string
ctaLabel: string
external?: boolean
dataTour?: string
onPress: () => void
}) {
return (
<Card flex={1} minW={280} borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4" gap="$3" data-tour={dataTour}>
<XStack items="center" gap="$3">
<ProductIcon icon={icon} color={color} size={40} />
<Text fontSize="$5" fontWeight="800" flex={1} numberOfLines={1}>
{title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11" minH={40}>
{description}
</Text>
<XStack>
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
do, not a call to action — so three white buttons side by side gave the
screen three primaries and therefore none. The one filled action on this
page is the getting-started card's active step: the thing to do NEXT. */}
<Button
size="$3"
borderWidth={1}
borderColor="$borderColor"
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
onPress={onPress}
>
{ctaLabel}
{openable ? 'Open' : 'Learn more'}
</Button>
</XStack>
</Card>
@@ -165,148 +107,37 @@ function PrimaryActionTile({
export default function DashboardHome() {
const router = useRouter()
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
const { account } = useSession()
const { toggle, isPinned } = useFavorites()
const { colorOf } = useProductColors()
const showAdmin = useIsSuperAdmin()
const push = (path: string) => router.push(path)
const groups = visibleCatalogByCategory(showAdmin)
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 (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" />
</XStack>
)
}
// Least privilege: non-admins don't see admin-only product cards on the home.
const groups = catalogByCategory(visibleCatalog(Boolean(account?.isAdmin)))
return (
<YStack gap="$7">
{/* Primary actions — the first, most prominent things a signed-in user can do:
get an API key, deploy an open-source project (the platform template catalog),
and earn from their own OSS (the Authors revenue-share). ONE tile primitive,
three uses; wraps to stack on narrow viewports. The Deploy tile opens the
external OSS catalog on press — no eager fetch, so first paint stays cheap. */}
<XStack flexWrap="wrap" gap="$3">
<PrimaryActionTile
icon={KeyRound}
title="Get your API key"
description={`Call ${config.brandName} models from your apps, SDKs, and CLI with a personal key.`}
ctaLabel="Get API key"
dataTour="api-key"
onPress={() => push('/api-keys')}
/>
<PrimaryActionTile
icon={Boxes}
color={colorOf('store')}
title="Deploy OSS"
description="Deploy Postgres, n8n, Grafana, Supabase and more — one-click open-source apps on Hanzo Cloud."
ctaLabel="Browse the App Store"
onPress={() => push('/store')}
/>
<PrimaryActionTile
icon={HandCoins}
color={colorOf('authors')}
title="Earn from your OSS"
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
ctaLabel="Start earning"
onPress={() => push('/authors')}
/>
</XStack>
<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"
>
<>
<PageHeader
title={branding.name}
subtitle={`See, enable, and manage every ${config.brandName} product from one place.`}
/>
{groups.map((group) => (
<YStack key={group.category} gap="$3">
<Text fontSize="$5" fontWeight="800" color="$color12">
Observability
{group.category}
</Text>
<ArrowRight size={16} opacity={0.5} />
</XStack>
<ProductObservability service="ai" label="AI inference" />
</YStack>
<ResourceOverview />
<YStack gap="$4">
<PageHeader
title="Explore products"
subtitle={`Open and manage every ${config.brandName} product from one place.`}
/>
{groups.map((group, i) => (
<FadeIn key={group.category} index={i} style={{ width: '100%' }}>
<YStack gap="$3">
<XStack
self="flex-start"
items="center"
gap="$2"
cursor="pointer"
hoverStyle={{ opacity: 0.75 }}
onPress={() => push(`/category/${categorySlug(group.category)}`)}
aria-label={`${group.category} overview`}
>
<Text fontSize="$5" fontWeight="800" color="$color12">
{group.category}
</Text>
<ArrowRight size={16} opacity={0.5} />
</XStack>
<XStack flexWrap="wrap" gap="$3">
{group.entries.map((entry) => (
<ProductCard
key={entry.id}
entry={entry}
pinned={isPinned(entry.id)}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
onLearnMore={() => push(`/discover/${entry.id}`)}
/>
))}
</XStack>
</YStack>
</FadeIn>
))}
</YStack>
</YStack>
<XStack flexWrap="wrap" gap="$3">
{group.entries.map((entry) => (
<ProductCard
key={entry.id}
entry={entry}
pinned={isPinned(entry.id)}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
onLearnMore={() => push(`/discover/${entry.id}`)}
/>
))}
</XStack>
</YStack>
))}
</>
)
}
-232
View File
@@ -1,232 +0,0 @@
'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>
)
}
-134
View File
@@ -1,134 +0,0 @@
/**
* Server-gated GLOBAL admin aggregate proxy — the cross-tenant business/platform
* reads (`/v1/admin/{overview,usage,orgs,audit,products,finance,compute,providers}`)
* AND the few GLOBAL-admin mutations that ride the same god-view gate
* (`POST /v1/admin/providers/{toggle,primary}` — flip shared-gateway provider
* 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 `/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,
* must NOT trigger the `org=all` aggregate at all, and must NOT flip a shared
* provider's enabled/primary state.
*
* Defense in depth (RED H1 — the cloud-side gate for `/v1/admin/*` is a separate
* backend contract we cannot see or test from this repo): `getAdminGate` enforces the
* SAME policy the IAM/KMS admin proxies use — a VERIFIED `@<brand.adminDomain>` email
* AND an IAM global-admin flag, fail-closed (→ 403) on any miss. Only then does the
* shared `forwardWithUserBearer` mint a short-lived user bearer and forward to
* cloud-api, applying the usual path-traversal + same-origin-CSRF hardening. On a
* mutating method (POST), that CSRF gate (Sec-Fetch-Site ≠ cross-site AND Origin/
* Referer host == Host, fail-closed 403 BEFORE resolving the user) means a
* cross-site page can never flip a provider on the victim admin's behalf. The
* browser holds no cloud credential and cannot reach this endpoint without passing
* the gate; the client-side `admin: true` nav gate + `AdminManagedNotice` is UI-only
* defense-in-depth, never the boundary.
*
* Least privilege: only the admin aggregate heads are reachable, NOT `iam`/`kms`
* (those keep their own gated proxies with their own tenant-scoping semantics) — this
* is not a general cloud-api tunnel. `allowAdminSurface` admits `v1/admin/<head>[/...]`
* (the exact forwarded upstream shape), so `providers` covers the GET list and the
* `providers/{toggle,primary}` POSTs and nothing else. `next.config.mjs` rewrites
* `/v1/admin/<head>[/...]` here for BOTH GET and POST (dropping the `/v1/` into the
* internal Next route path); this handler re-adds `v1/` for the upstream cloud call,
* and the client calls the clean same-origin `/v1/admin/*` form (unchanged).
*/
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'
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 reconciled to an EMPTY string still resolves the service. */
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
const forbidden = () => NextResponse.json({ status: 'error', msg: 'forbidden' }, { status: 403 })
type Ctx = { params: Promise<{ path: string[] }> }
async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin (tenant
// customer, org-level isAdmin) gets a 403 and never triggers the org=all aggregate.
const gate = await getAdminGate(req)
if (!gate) return forbidden()
// The rewrite feeds the tail after `/v1/admin/` (e.g. `overview`, `audit`); rebuild
// the FULL cloud path, which is `/v1/admin/<head>` — cloud serves every admin route
// under `/v1/admin/*` (the beego `/v1/*` glob in hanzoai/ai + cloud's own
// `clients/admin` `app.Get("/v1/admin/…")`), and `forwardWithUserBearer` forwards to
// `target/path` VERBATIM (no `/v1` prepend), so the `v1/` MUST be part of the path
// here or the request lands on a non-existent bare `/admin/*` and 404s. The rewrite
// destination (`app/admin/aggregate/<head>`) is the internal Next route, not the
// upstream — it deliberately carries no `v1/`; this handler adds it.
// `forwardWithUserBearer` re-validates the exact forwarded path via `allow`
// (`allowAdminSurface`, keyed on the `v1/admin/<head>` shape) + `pathIsClean`.
const path = `v1/admin/${(await ctx.params).path.join('/')}`.replace(/\/+$/, '')
return forwardWithUserBearer(req, {
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',
unauthorizedMessage: 'Sign in as an administrator.',
})
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/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 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/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/caps[/...]`.
*/
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/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)
}
-127
View File
@@ -1,127 +0,0 @@
/**
* Server-gated GLOBAL IAM admin proxy — cross-tenant IAM ops (any org).
*
* The browser holds no IAM credential. It calls this SAME-ORIGIN route with just
* its session cookie; the handler enforces the GLOBAL admin gate (`getAdminGate`:
* verified @<adminDomain> email AND a global-admin flag), then the shared
* `forwardIam` applies the allow-list + tenant scoping (a global admin may act on
* any org) and forwards to IAM as the user. A CUSTOMER managing their OWN org uses
* `/org/iam` instead — this route is global-only.
*
* Least privilege: only an explicit allow-list of admin segments is reachable
* (GET reads / POST mutations); every owner the request references — including
* the mutation BODY owner — is validated by `forwardIam`.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
import { forwardIam } from '~/lib/server/iam-proxy'
export const runtime = 'nodejs'
/** Read segments — reachable via GET only. */
const GET_SEGMENTS = new Set([
'get-organizations',
'get-organization',
'get-users',
'get-user',
'get-applications',
'get-application',
'get-providers',
'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',
])
/**
* Mutation segments — reachable via POST only (JSON body forwarded).
*
* The org-metadata WRITES (`add-organization`/`update-organization`/`delete-organization`)
* are the DATA-DRIVEN white-label backbone: a tenant IS an org record, and its BRAND
* (logo / favicon / themeData) is a real writable IAM field on that record. This is
* how the Tenants board CREATES a tenant and WRITES its brand — no hardcoded brand map.
* These are safe on THIS proxy because the gate is already GLOBAL-ADMIN-ONLY and
* `forwardIam` pins the org NAME (`orgNameSegments` below) so the write is scoped
* (a non-global caller — who can't reach this route anyway — could never retarget
* another tenant's org via the id or the body `name`).
*/
const POST_SEGMENTS = new Set([
'add-user',
'update-user',
'delete-user',
'add-application',
'update-application',
'delete-application',
'add-provider',
'update-provider',
'delete-provider',
'add-role',
'update-role',
'delete-role',
'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',
])
/**
* Organization objects are owned by IAM's built-in `admin`, and the org
* list/get endpoints scope results to the caller's org server-side — so `admin`
* is an acceptable owner THERE (never for tenant data like users/roles). The
* org-metadata WRITES join it: they operate on the `admin`-owned org record.
*/
const ORG_ENDPOINTS = new Set([
'get-organizations',
'get-organization',
'add-organization',
'update-organization',
'delete-organization',
])
/**
* Segments carrying an org NAME to guard — a non-global admin can't read/write
* another org's settings via the `admin` metadata owner. (This route's gate is
* already global-only, so this is defense-in-depth: it keeps the org-name scoping
* identical to the `/org/iam` self-service proxy, one policy for both.)
*/
const ORG_NAME_SEGMENTS = new Set([
'get-organization',
'update-organization',
'delete-organization',
])
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
const gate = await getAdminGate(req)
if (!gate) return forbidden()
return forwardIam(
req,
{ user: gate.user, isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
{
segment: path.join('/'),
method,
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
orgMetaSegments: ORG_ENDPOINTS,
orgNameSegments: ORG_NAME_SEGMENTS,
// The gate is already global-only; global admins may write to any org.
requireAdminForWrite: false,
},
)
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'GET')
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'POST')
}
-149
View File
@@ -1,149 +0,0 @@
/**
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
*
* Same trust boundary as the IAM proxy: the browser sends only its session
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
* default; a global admin may target another org with `?org=`.
*
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
* body, and never derives or stores key material — it is a faithful pass-through
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
* verb + query select the operation:
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
* POST {path,name,env,value} → create/upsert → POST .../secrets
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
*
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
* renders as an honest "listing requires kmsd ≥ next release" state.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
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 SuperAdmin passes ?org=
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
function orgFor(gate: AdminGate, req: NextRequest): string {
return policyOrgFor(
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
req.nextUrl.searchParams.get('org'),
)
}
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
// CSRF: a cross-site page carrying the admin's auto-sent cookie must never be able
// to create / rotate / delete a KMS secret. Refuse a cross-origin MUTATION before
// the admin gate or any body read (safe GET reveals pass). Defense in depth on top
// of the session cookie's own SameSite attribute.
const csrf = csrfRefusal(req)
if (csrf) return csrf
const gate = await getAdminGate(req)
if (!gate) return forbidden()
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
const org = orgFor(gate, req)
// The org travels on the IDENTITY channel, never the URL: cloud's KMS surface
// is /v1/kms/secrets and reads the acted-on org from the validated principal
// (X-Org-Id — for a SuperAdmin, the switched-into org; the same one-predicate
// switch every other subsystem honors). URL-addressed orgs were removed
// server-side because a path that names a tenant is caller-selectable.
const base = `${kmsBaseUrl()}/v1/kms/secrets`
const q = req.nextUrl.searchParams
const name = q.get('name') ?? ''
const path = q.get('path') ?? ''
const env = q.get('env') ?? ''
let target: string
let body: string | undefined
if (req.method === 'GET') {
if (name) {
const params = new URLSearchParams()
if (env) params.set('env', env)
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
} else {
const params = new URLSearchParams()
const prefix = q.get('prefix')
if (prefix) params.set('prefix', prefix)
if (env) params.set('env', env)
target = `${base}${params.toString() ? `?${params}` : ''}`
}
} else if (req.method === 'POST') {
target = base
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
} else if (req.method === 'PATCH') {
if (!name) return notFound()
target = `${base}/${secretRest(path, name)}`
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
} else if (req.method === 'DELETE') {
if (!name) return notFound()
const params = new URLSearchParams()
if (env) params.set('env', env)
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
} else {
return notFound()
}
let bearer: string
try {
bearer = await adminBearer(gate.user)
} catch (e) {
return NextResponse.json(
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json', 'X-Org-Id': org }
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
init.body = body
}
try {
const res = await fetchWithTimeout(target, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
})
} catch (e) {
// Surface only the transport failure — never the request body/value.
return NextResponse.json(
{ message: `KMS 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 handle(req, (await ctx.params).path)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
-81
View File
@@ -1,81 +0,0 @@
/**
* 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)
}
}
-145
View File
@@ -1,145 +0,0 @@
/**
* Keyless AI proxy — the ONE path the console uses to reach the model gateway.
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `sk-` 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.
*
* Least privilege: only the read/inference AI endpoints are proxied (the ALLOWED
* allow-list); anything else 404s, so this is not a general gateway tunnel. The RAG
* retrieval switch (`X-Retrieval`/`X-Retrieval-Store`) is the ONE client-header
* passthrough (allow-listed in `ai-proxy`).
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { retrievalHeaders } from '~/lib/server/ai-proxy'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Gateway the proxied AI calls are forwarded to (gated/priced api.hanzo.ai). */
const AI_GATEWAY_URL = trim(process.env.AI_GATEWAY_URL ?? 'https://api.hanzo.ai')
/** The exact `/v1/<...>` endpoints the console is allowed to reach. */
const ALLOWED = new Set([
'v1/models',
'v1/pricing/models', // the rich model+provider catalog (context, pricing, specs, tier) for Models/Providers pages
'v1/plans', // the subscription tiers + entitlements (rpm/tpm/quota) for the catalog plan badges
'v1/chat',
'v1/chat/completions',
'v1/embeddings',
'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 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 () => {
// 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: 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)),
errorShape: 'openai',
unauthorizedMessage: 'Sign in to use AI.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, 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)
}
+67 -6
View File
@@ -1,18 +1,79 @@
'use client'
/**
* 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.
* IAM OAuth callback. IAM redirects here with `?code&state` (or `?error`). We:
* 1. surface any IdP `error`,
* 2. require `code` + `state`,
* 3. validate `state` against the value we stored at sign-in start (CSRF /
* authorization-code-injection defense) BEFORE exchanging the code,
* 4. exchange code (+ PKCE verifier) for a backend session, and land on `/`.
*
* The exchange runs exactly once (a ref guard) so React's dev double-effect can't
* consume the one-time state twice and false-flag a mismatch.
*/
import { Suspense } from 'react'
import { Suspense, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button, Text, YStack } from '@hanzo/gui'
import { AuthCallback } from '~/components/AuthCallback'
import { ApiError } from '~/lib/api'
import { Loader } from '~/components/ui/Loader'
import { useSession } from '~/lib/auth/session'
import { consumeState, describeAuthError } from '~/lib/auth/iam'
function Callback() {
const params = useSearchParams()
const router = useRouter()
const { completeSignIn } = useSession()
const [error, setError] = useState<string | null>(null)
const ran = useRef(false)
useEffect(() => {
if (ran.current) return
ran.current = true
const idpError = params.get('error')
if (idpError) {
setError(describeAuthError(idpError, params.get('error_description')))
return
}
const code = params.get('code')
const state = params.get('state')
if (!code || !state) {
setError('Missing authorization code.')
return
}
// CSRF / code-injection defense: the returned state MUST match the one we
// stored when starting sign-in. Consume (clear) it either way.
const expected = consumeState()
if (!expected || state !== expected) {
setError('This sign-in could not be verified (state mismatch). Please sign in again.')
return
}
completeSignIn(code, state)
.then(() => router.replace('/'))
.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…" />
}
export default function CallbackPage() {
return (
<Suspense fallback={null}>
<AuthCallback />
<Callback />
</Suspense>
)
}
-116
View File
@@ -1,116 +0,0 @@
/**
* /auth/session — the console's OWN durable, refreshable OAuth session (BFF).
*
* 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 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.
*
* SECURITY. POST is GATED: it mints a console session ONLY for a caller who is
* ALREADY authenticated (a valid casibase/console session — the full login incl. any
* MFA), AND only when the password grant resolves to the SAME principal — so it can
* never be driven standalone with a stolen password, and never bypasses MFA (an MFA
* account never reaches this call). Tokens live only inside the sealed httpOnly
* cookies — never returned to the browser, never logged, never in a URL.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { resolveUser } from '~/lib/server/identity'
import {
accountOf,
applyCookies,
clearCookies,
consoleSession,
passwordGrant,
readRefreshToken,
revokeRefreshToken,
sameSubject,
sealSession,
sessionConfigured,
setCookies,
SessionError,
} from '~/lib/server/session'
export const runtime = 'nodejs'
/** 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> {
const sess = consoleSession(req)
if (!sess || !sess.claims.name) {
return NextResponse.json({ error: 'no session' }, { status: 401 })
}
return NextResponse.json({ account: accountOf(sess.claims), expiresIn: sess.expiresInSec })
}
/** POST { username, password } — establish the console session for the signed-in user. */
export async function POST(req: NextRequest): Promise<NextResponse> {
// CSRF: refuse a cross-origin login (login-CSRF fixes the victim into an attacker's
// session) before touching credentials.
const csrf = csrfRefusal(req)
if (csrf) return csrf
if (!sessionConfigured()) {
// No confidential client wired: the console still runs on the casibase session;
// report "not configured" so the client silently skips the console session.
return NextResponse.json({ error: 'session not configured' }, { status: 501 })
}
let body: { username?: unknown; password?: unknown }
try {
body = (await req.json()) as typeof body
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 })
}
const username = typeof body.username === 'string' ? body.username.trim() : ''
const password = typeof body.password === 'string' ? body.password : ''
if (!username || !password) {
return NextResponse.json({ error: 'missing credentials' }, { status: 400 })
}
// GATE: the caller must already be authenticated (they just completed the casibase
// login incl. any MFA). This binds the console session to a real, MFA-cleared
// session and blocks standalone password abuse.
const authed = await resolveUser(req)
if (!authed) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
}
let tokens
try {
tokens = await passwordGrant(username, password)
} catch (e) {
const status = e instanceof SessionError ? e.status : 502
return NextResponse.json({ error: 'grant failed' }, { status })
}
const sealed = sealSession(tokens)
// The grant MUST resolve to the same principal as the established session — the
// console session is for the already-authenticated user, never a third party.
const grantId =
sealed && sealed.claims.owner && sealed.claims.name ? `${sealed.claims.owner}/${sealed.claims.name}` : ''
if (!sealed || !grantId || !sameSubject(grantId, authed.id)) {
return NextResponse.json({ error: 'identity mismatch' }, { status: 401 })
}
const res = NextResponse.json({
account: accountOf(sealed.claims),
expiresIn: Math.floor(sealed.expiresInMs / 1000),
})
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
}
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
export async function DELETE(req: NextRequest): Promise<NextResponse> {
// CSRF: refuse a cross-origin forced sign-out.
const csrf = csrfRefusal(req)
if (csrf) return csrf
const rt = readRefreshToken(req)
if (rt) await revokeRefreshToken(rt)
return applyCookies(NextResponse.json({ ok: true }), clearCookies())
}
-31
View File
@@ -1,31 +0,0 @@
/**
* 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 })
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Wallet HUSD top-up — verify on-chain, then record to commerce (server route).
*
* Why this lives in console2 and not billing: billing.hanzo.ai is a Next static
* export (`output: 'export'`) and cannot host a runtime POST handler, and the
* commerce backend is owned elsewhere. So the verify-and-record seam lives here
* as a same-origin server route — the same pattern as `app/paas/[...path]`: the
* browser calls the console's OWN origin, the server does the privileged work,
* and config comes from server-only env (sourced via KMS, never `NEXT_PUBLIC`).
*
* Flow: the client sends an HUSD ERC-20 transfer to the treasury and posts the
* tx hash here. We require a valid IAM session and derive the credited USER from
* it (NEVER the request body — that would be an IDOR). We read the receipt from
* the Hanzo EVM, confirm a mined, successful HUSD `Transfer(from → treasury,
* value)`, derive USD cents from the (18-decimal, USD-pegged) value, then record
* it to commerce as a `husd` crypto payment keyed by the tx hash as an
* idempotency key (replay-safe — the same tx never credits twice). The on-chain
* amount — never a client-supplied number — is what gets credited.
*
* Honest failure: if HUSD/treasury are unconfigured (greenfield) we return 501;
* if there is no session we return 401; if the tx is missing/failed/not an
* HUSD-to-treasury transfer we return 400; chain/commerce unreachable → 502.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { ethers } from 'ethers'
import { getServerAccount } from '~/lib/auth/server'
export const runtime = 'nodejs'
const ERC20_TRANSFER_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)']
const isAddr = (a: string): boolean => /^0x[0-9a-fA-F]{40}$/.test(a)
/** Forward the caller's identity (session cookie / bearer) to commerce. */
function authHeaders(req: NextRequest, extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra }
const cookie = req.headers.get('cookie')
if (cookie) h.Cookie = cookie
const auth = req.headers.get('authorization')
if (auth) h.Authorization = auth
return h
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const HUSD_ADDRESS = (process.env.HANZO_HUSD_ADDRESS ?? '').trim()
const TREASURY = (process.env.HANZO_HUSD_TREASURY ?? '').trim()
const RPC_URL = (process.env.HANZO_RPC_URL ?? 'https://rpc.hanzo.network').replace(/\/+$/, '')
const COMMERCE_URL = (process.env.COMMERCE_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
const CHAIN_ID = Number(process.env.HANZO_CHAIN_ID ?? '36900')
// Greenfield gate: no HUSD contract / treasury ⇒ honest "not configured".
if (!isAddr(HUSD_ADDRESS) || !isAddr(TREASURY)) {
return NextResponse.json(
{ error: 'HUSD top-up is not configured yet (HUSD is not deployed on Hanzo Mainnet).' },
{ status: 501 },
)
}
// Authn: the credited user is the SESSION user — never the request body (IDOR).
const account = await getServerAccount(req.headers.get('cookie'), req.nextUrl.origin)
if (!account) {
return NextResponse.json({ error: 'Sign in to top up your balance.' }, { status: 401 })
}
const userId = account.name
let body: { txHash?: string; fromAddress?: string }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
const txHash = (body.txHash ?? '').trim()
const fromAddress = (body.fromAddress ?? '').trim()
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
return NextResponse.json({ error: 'A valid transaction hash is required.' }, { status: 400 })
}
// ── 1. Verify the HUSD transfer on-chain ────────────────────────────────────
let creditedCents: number
let verifiedFrom: string
try {
const provider = new ethers.JsonRpcProvider(RPC_URL, CHAIN_ID)
const receipt = await provider.getTransactionReceipt(txHash)
if (!receipt) {
return NextResponse.json({ error: 'Transaction not found or not yet mined.' }, { status: 400 })
}
if (receipt.status !== 1) {
return NextResponse.json({ error: 'Transaction failed on-chain.' }, { status: 400 })
}
const iface = new ethers.Interface(ERC20_TRANSFER_ABI)
const husd = HUSD_ADDRESS.toLowerCase()
const treasury = TREASURY.toLowerCase()
let value: bigint | null = null
for (const log of receipt.logs) {
if (log.address.toLowerCase() !== husd) continue
let parsed: ethers.LogDescription | null = null
try {
parsed = iface.parseLog({ topics: [...log.topics], data: log.data })
} catch {
continue
}
if (parsed?.name !== 'Transfer') continue
if (String(parsed.args.to).toLowerCase() !== treasury) continue
value = parsed.args.value as bigint
verifiedFrom = ethers.getAddress(String(parsed.args.from))
break
}
if (value === null) {
return NextResponse.json(
{ error: 'No HUSD transfer to the treasury was found in this transaction.' },
{ status: 400 },
)
}
if (fromAddress && isAddr(fromAddress) && verifiedFrom!.toLowerCase() !== fromAddress.toLowerCase()) {
return NextResponse.json({ error: 'Transfer sender does not match the connected wallet.' }, { status: 400 })
}
// HUSD is an 18-decimal, USD-pegged stablecoin → 1e16 base units = 1 cent.
creditedCents = Number(value / 10n ** 16n)
if (creditedCents <= 0) {
return NextResponse.json({ error: 'Transferred amount is below the minimum (1 cent).' }, { status: 400 })
}
} catch (e) {
return NextResponse.json(
{ error: `Could not verify the transaction on Hanzo Mainnet: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
// ── 2. Record to commerce as an HUSD crypto payment ─────────────────────────
// The tx hash is the idempotency key: a replay of the same hash MUST NOT credit
// twice. The hash is globally unique on-chain, so the ledger (commerce) dedupes
// on it — `Idempotency-Key` is the standard request for that guarantee.
try {
const recordRes = await fetch(`${COMMERCE_URL}/v1/billing/payment`, {
method: 'POST',
headers: authHeaders(req, { 'Idempotency-Key': txHash }),
cache: 'no-store',
body: JSON.stringify({
method: 'crypto',
network: 'hanzo',
chainId: CHAIN_ID,
currency: 'husd',
amount: creditedCents,
txHash,
fromAddress: verifiedFrom!,
toAddress: TREASURY,
userId,
}),
})
if (!recordRes.ok) {
const text = await recordRes.text().catch(() => '')
return NextResponse.json(
{ error: `Commerce rejected the payment (HTTP ${recordRes.status}): ${text}`.trim() },
{ status: 502 },
)
}
const payment = (await recordRes.json().catch(() => ({}))) as { status?: string }
// New balance (USD ledger) — best-effort; the credit already landed. The user
// is the SESSION user, never a client-supplied id.
let balance = 0
try {
const balRes = await fetch(
`${COMMERCE_URL}/v1/billing/balance?user=${encodeURIComponent(userId)}&currency=usd`,
{ headers: authHeaders(req), cache: 'no-store' },
)
if (balRes.ok) balance = ((await balRes.json()) as { balance?: number }).balance ?? 0
} catch {
/* balance is informational; the credit is recorded */
}
return NextResponse.json({ creditedCents, balance, txHash, status: payment.status ?? 'recorded' })
} catch (e) {
return NextResponse.json(
{ error: `Could not reach commerce to record the payment: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
-90
View File
@@ -1,90 +0,0 @@
/**
* /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 })
}
-69
View File
@@ -1,69 +0,0 @@
/**
* 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) })
}
-96
View File
@@ -1,96 +0,0 @@
/**
* /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 an IAM 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' // IAM 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 })
}
}
-30
View File
@@ -1,30 +0,0 @@
/* ─────────────────────────────────────────────────────────────────────────────
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
These were vendored under app/design/ (synced 2026-07-24) only because the
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
reads the real dependency and can no longer drift a border rework behind the
rest of the fleet. The token subpaths are named one by one rather than pulling
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
published subpaths are the resolvable form of the same import.
Fonts are deliberately NOT imported from the package: the console loads the
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
typography roles resolve without a second copy.
───────────────────────────────────────────────────────────────────────────── */
@import '@hanzo/design/tokens/colors.css';
@import '@hanzo/design/tokens/typography.css';
@import '@hanzo/design/tokens/spacing.css';
@import '@hanzo/design/tokens/radius.css';
@import '@hanzo/design/tokens/elevation.css';
@import '@hanzo/design/tokens/motion.css';
@import '@hanzo/design/tokens/z.css';
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
let the typography roles (--type-*) resolve without re-importing fonts. */
:root {
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-sans);
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
-35
View File
@@ -1,35 +0,0 @@
'use client'
/**
* `/docs` → the brand documentation site (docs.hanzo.ai / docs.lux.network / …),
* resolved CLIENT-side (task #41, "True 1-binary FE").
*
* Docs are an EXTERNAL product on their own domain, never an in-app route — so a
* typed or bookmarked `<console-host>/docs` must land on the real docs, not the
* catch-all not-found. The old app/docs/route.ts issued a server 308; in the
* one-binary there is no Next runtime (the static export has no server, and a static
* export cannot rewrite), so the redirect is resolved from the per-host brand
* (`config.docsUrl`) in the browser — exactly what the sidebar "Docs" link and the
* header "?" already open. One way, both topologies (embed + standalone).
*
* The target is set in an effect (not during render) so there is no SSR/CSR
* hydration mismatch on the per-brand host between the build-time default and the
* real browser host.
*/
import { useEffect, useState } from 'react'
import { config } from '~/config'
export default function DocsRedirect() {
const [url, setUrl] = useState('')
useEffect(() => {
const target = config.docsUrl
setUrl(target)
window.location.replace(target)
}, [])
return (
<main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
Opening documentation {url ? <a href={url}>Continue</a> : null}
</main>
)
}
-30
View File
@@ -1,30 +0,0 @@
/* Canonical Hanzo faces — Geist Sans (UI/body/headings) + Geist Mono (code/data).
SELF-HOSTED, because a font we serve ourselves is the only kind that arrives.
These were `@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/...')`. The import
ORDER was fixed once already (an @import emitted after the reset rules is invalid and
dropped), but the fonts still never loaded in production: the browser refuses the
cross-origin stylesheet (ERR_BLOCKED_BY_ORB), so `document.fonts.size` was 0 on live
console.hanzo.ai and every customer read the whole product in system-ui while every
rule in the app asked for Geist. A third-party CDN on our own critical render path is
also a dependency we do not control.
One VARIABLE file per family (56K + 58K) spans weights 100-900, so eighteen static
cuts collapse to two requests and any weight the design reaches for already exists —
no second place to add a face. `font-display: swap` keeps text readable while they
load; `local()` lets an installed copy win with no download at all. */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: local('Geist'), url('/fonts/Geist-Variable.woff2') format('woff2');
}
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: local('Geist Mono'), url('/fonts/GeistMono-Variable.woff2') format('woff2');
}
-107
View File
@@ -1,107 +0,0 @@
'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',
}
}
+4 -622
View File
@@ -1,4 +1,3 @@
html,
body,
#__next {
@@ -7,629 +6,12 @@ body,
body {
margin: 0;
background-color: var(--background, #000000);
color: var(--color, #ededf1);
font-family: 'Geist', system-ui, -apple-system, sans-serif;
/* The base of the ONE type scale. Without this the body inherits the browser's
16px root, and every element that does not name a size token — a Gui <Button>
label, a bare <span>, anything the ladder does not reach — renders at a size
that belongs to no scale. That was the single largest source of type drift in
the console: hundreds of nodes painting the retired 16px base beside a 14px
one. `--text-base` is the same 14px the Gui `$3` token resolves to
(gui.config.ts), so the inherited size and the named size agree. */
font-size: var(--text-base, 0.875rem);
/* 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;
/* 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;
}
code,
pre,
kbd,
samp {
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Tabular numerals — metrics, prices, contexts and IDs align on a fixed advance
width so columns of numbers read cleanly (the dashboard-grade detail). */
.hz-tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
/* Display headline — a tight, UNITLESS line-height for large type.
A Gui font-size token ships a line-height tuned for ONE line, so a display
headline overprints itself the moment it wraps (which it always does on a
phone). This must live in CSS: React Native Web reads a bare numeric
`lineHeight` in a style object as PIXELS, so `lineHeight: 1.1` there crushes
the text instead of scaling it. Unitless in real CSS is relative to the
element's own font-size, so ONE rule holds at every size token and
breakpoint. `className` forwards to the DOM node on web, so a Gui <Text>
can wear it. */
.hz-display {
line-height: 1.1;
}
/* 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 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;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
letter-spacing: -0.01em;
background-color: var(--background, #070b13);
color: var(--color, #f2f2f2);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
* {
box-sizing: border-box;
}
/* 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. */
*,
*::before,
*::after {
font-synthesis: none !important;
}
/* ── Console dark theme — TRUE-BLACK canvas + calm text/borders. The ONE place the
console's dark/light palette is set. Overrides the generated @hanzo/gui (Tamagui)
theme variables; the `html:root.t_*` selector is one step more specific than the
library's runtime `:root.t_*` block so it wins regardless of stylesheet insertion
order. DRY: every surface, border and text color in the app reads from these
tokens, so this one block sets the whole product.
Design intent (dark): a TRUE-BLACK #000 canvas (matches hanzo.ai marketing +
hanzo.chat OLED) with a Linear/Vercel-caliber surface-depth ladder above it —
resting panels #050505, the next surface #0a0a0a, interactive/elevated #171717 →
#1f1f1f — so cards read with real depth, never flat voids. Above the surface
ladder the CALM neutral scale (color512) gives premium, low-glare off-white text
and quiet hairline borders (never harsh pure #fff on pure #000). Neutral grey,
monochrome-first; text contrast stays WCAG-AA+ on every surface. */
html:root.t_dark {
--background: #000000;
--backgroundStrong: #000000;
--backgroundHover: #101010;
--backgroundPress: #050505;
--backgroundFocus: #171717;
/* Surface depth ladder over the true-black canvas — panels/cards step up from
#050505 so they separate cleanly without heavy borders (Linear-grade depth). */
--color1: #050505;
--color2: #0a0a0a;
--color3: #171717;
--color4: #1f1f1f;
--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: #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 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(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:
~0.4s ease-out, small upward travel, staggered by the consumer). One place
defines it; <FadeIn> applies the class + per-item delay. */
@keyframes hz-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-fade-up {
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
/* Honor the user's reduced-motion preference — no entrance animation. */
@media (prefers-reduced-motion: reduce) {
.hz-fade-up {
animation: none;
}
}
/* Shell chrome motion — the sidebar collapse (width) and the Linear-style
two-level nav slide (transform). One place defines the easing; the shell
applies the class. `className` forwards to the underlying DOM node on web, so
the browser transitions the Gui-driven inline width/transform. */
.hz-collapse {
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: width;
}
.hz-slide {
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
/* Backdrop cross-fade behind a SlideOver / dialog. */
.hz-fade {
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity;
}
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
.hz-drag-item {
touch-action: none;
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-drag-item[data-dragging='true'] {
transition: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
opacity: 0.96;
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.hz-collapse,
.hz-slide,
.hz-fade,
.hz-drag-item {
transition: none;
}
}
/* Sidebar category accordion — a collapsible level-1 section. The body animates
its HEIGHT via grid-template-rows 0fr↔1fr (no magic max-height — the row
resolves to the real content height) plus a short opacity fade; the header
chevron rotates ▸→▾. Collapsed content stays in the DOM (so both directions
animate) but is `inert` (out of tab order + a11y tree). One place defines the
easing; the shell toggles `data-open`. */
.hz-acc {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-acc[data-open='true'] {
grid-template-rows: 1fr;
}
.hz-acc-inner {
overflow: hidden;
min-height: 0;
opacity: 0;
transition: opacity 180ms ease;
}
.hz-acc[data-open='true'] .hz-acc-inner {
opacity: 1;
}
.hz-chevron {
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.hz-acc,
.hz-acc-inner,
.hz-chevron {
transition: none;
}
}
/* LivingOverview motion — the "videogame-like" living dashboard. The count-up +
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
a brief highlight when a tile's number changes. One place defines the easing. */
/* Skeleton shimmer — an honest "loading", never fabricated content. */
@keyframes hz-shimmer {
0% {
background-position: -160px 0;
}
100% {
background-position: 160px 0;
}
}
.hz-skeleton {
background-color: var(--color3);
background-image: linear-gradient(
90deg,
transparent 0%,
var(--color4) 50%,
transparent 100%
);
background-size: 160px 100%;
background-repeat: no-repeat;
animation: hz-shimmer 1.2s ease-in-out infinite;
}
/* Live-feed pulse — the "Live" dot on a streaming panel. */
@keyframes hz-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
@keyframes hz-row-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-row-in {
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hz-skeleton {
animation: none;
}
.hz-row-in {
animation: none;
}
}
/* RailwayDeploy — the deployment pipeline. A smooth flowing gradient marches along the
active leg of the track (stroke-dashoffset), a soft halo pulses out from the current
station, and the status dot breathes. All reduced-motion-guarded (→ static). One place
defines the easing; RailwayDeploy applies the classes. */
@keyframes hz-rail-flow {
to {
stroke-dashoffset: -28;
}
}
.hz-rail-flow {
stroke-dasharray: 5 9;
animation: hz-rail-flow 0.85s linear infinite;
}
@keyframes hz-rail-pulse {
0% {
transform: scale(1);
opacity: 0.34;
}
70% {
transform: scale(2.1);
opacity: 0;
}
100% {
transform: scale(2.1);
opacity: 0;
}
}
.hz-rail-pulse {
transform-box: fill-box;
transform-origin: center;
animation: hz-rail-pulse 1.7s ease-out infinite;
}
.hz-rail-dot {
animation: hz-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hz-rail-flow,
.hz-rail-pulse,
.hz-rail-dot {
animation: none;
}
}
/* DataTable row — the hover fill eases in/out (Tamagui flips the bg instantly;
this smooths it to the 140ms ease-out the rest of the product uses). */
.hz-row {
transition: background-color 140ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.hz-row {
transition: none;
}
}
/* Row-edge pin — the quiet affordance on a search result. It is a REAL, focusable
control at all times (opacity, never `display:none`), so the keyboard and a
screen reader always reach it; it is simply not drawn until the pointer reaches
its row. A pinned or keyboard-selected row opts out of the class entirely, so
its pin stays lit — the lit ones are STATE, not chrome.
Touch has no hover, so `hover:none` pointers keep it visible: on a phone an
invisible control is an absent one.
Doubled selector (`:root .hz-pin.hz-pin`, specificity 0,3,0) for the same reason
`.hz-paper` is doubled above: Gui injects its compiled style props at `:root ._x-…`
(0,2,0), so a plain `.hz-pin` loses and the pin paints at full strength forever. */
:root .hz-pin.hz-pin {
opacity: 0;
transition: opacity 140ms ease-out;
}
:root .hz-row-pin:hover .hz-pin.hz-pin,
:root .hz-pin.hz-pin:focus-within,
:root .hz-pin.hz-pin:hover {
opacity: 1;
}
@media (hover: none) {
:root .hz-pin.hz-pin {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
:root .hz-pin.hz-pin {
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: var(--z-raised);
/* 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.
Each selector is written `:root .hz-x.hz-x` on purpose. @hanzo/gui (Tamagui)
compiles its own shadow props to an atomic rule it injects at RUNTIME as
`:root ._bxsh-…` — specificity (0,2,0). A plain `.hz-paper` is (0,1,0) and loses;
`.hz-paper.hz-paper` merely TIES, and a tie is settled by stylesheet order, which
runtime injection makes nondeterministic. It lost in practice: an overlay wearing
`hz-paper` rendered Tamagui's `0 12px 24px rgba(0,0,0,.33)` instead of this ladder,
and on the true-black canvas that shadow is nearly invisible — the sheet did not
lift off the page. (0,3,0) wins outright, in either order, with no `!important`. */
:root .hz-elevation-1.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
:root .hz-elevation-2.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
:root .hz-elevation-3.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
:root .hz-elevation-4.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
:root .hz-elevation-5.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. */
:root .hz-paper.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
:root .hz-paper-4.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
:root .hz-paper-5.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 / 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);
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;
}
}
/* 4. ONE typeface per screen. The shared `@hanzogui/shell` chrome (HanzoHeader and
its Meet-Hanzo / Products menus, HanzoFooter, HanzoAppHeader, …) sets its own
SYSTEM font stack as an INLINE style on its root — `fontFamily: CHROME.font`,
i.e. `ui-sans-serif, system-ui, -apple-system, "Segoe UI", …`, which contains no
Geist — and its subtree inherits it (the shell's own buttons re-declare
`font-family: inherit`). So the logged-out console rendered the header wordmark
and nav in the platform's system face while the hero and body below correctly
rendered Geist: mixed typography on one screen. Geist itself loads fine (see
app/fonts.css) — this is a cascade problem, not a loading one.
An inline declaration can only be beaten by `!important`, and the rule has to
reach descendants because of that `inherit`. Every shell root carries
`data-hanzo-shell`, so ONE rule covers the whole set. Code-ish elements keep the
mono face declared above, so the two font invariants stay orthogonal. */
[data-hanzo-shell],
[data-hanzo-shell] :not(code, pre, kbd, samp) {
font-family: var(--font-sans) !important;
}
-96
View File
@@ -1,96 +0,0 @@
/**
* Per-user `sk-` 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 `sk-` 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
* `sk-` 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 sk- 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 `sk-` 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 })
}
}
+5 -40
View File
@@ -1,60 +1,25 @@
import './fonts.css'
import '@hanzogui/core/reset.css'
// Hanzo Design System tokens (vendored from hanzoai/design) — the monochrome
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
// derive its ladder from the design neutral/semantic tokens.
import './design/index.css'
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
// prefixed twins in globals.css below; these are the package's, and without this
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
import '@hanzo/ui/styles/motion.css'
import './globals.css'
import type { Metadata, Viewport } from 'next'
import type { ReactNode } from 'react'
import { headers } from 'next/headers'
import { Provider } from '~/components/Provider'
import { ChunkGuard } from '~/components/ChunkGuard'
import { BrandTitle } from '~/components/BrandTitle'
import { resolveConfig } from '~/config'
import { branding } from '~/config'
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
// brand (console.lux.cloud -> "Lux Cloud Console"), not the build-time default.
// The visible shell resolves the brand client-side from window.location, but the
// tab title is server-rendered — without reading the Host header here the browser
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
//
// The description is the same metadata read by the same brand, so it resolves the
// same way. It did not, and shipped `content="Unified admin console for Hanzo Cloud
// and all cloud products."` to console.lux.cloud and console.zoo.cloud — the title
// beside it was already correct, which is exactly why nobody noticed. Every
// brand-visible string in this function comes from `brandName`; adding a literal
// here re-opens the leak.
export async function generateMetadata(): Promise<Metadata> {
const host = (await headers()).get('host') ?? undefined
const { brandName } = resolveConfig(host)
return {
title: `${brandName} Console`,
description: `Unified admin console for ${brandName} and all cloud products.`,
}
export const metadata: Metadata = {
title: branding.name,
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
}
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 }) {
return (
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
<html lang="en" className="t_dark" style={{ backgroundColor: '#070b13', colorScheme: 'dark' }} suppressHydrationWarning>
<body style={{ margin: 0 }}>
<ChunkGuard />
<BrandTitle />
<Provider>{children}</Provider>
</body>
</html>
-78
View File
@@ -1,78 +0,0 @@
/**
* Server-gated SELF-SERVICE org member proxy — a CUSTOMER managing their OWN org.
*
* The `/admin/iam` proxy is GLOBAL-admin only, so a tenant org owner (e.g.
* Dave/maxpower) could never manage their own members through it. This proxy
* closes that: it admits ANY authenticated user with an org (`getOrgGate`), then
* the shared `forwardIam`:
* - scopes every reference (query `owner`, `id` owner, and the mutation BODY
* owner) to the caller's OWN org — a global admin may cross, a customer never;
* - guards get-organization by org NAME (no reading another org's settings);
* - requires an ORG ADMIN for writes (invite / change-role / remove), while any
* member may READ the roster.
* IAM enforces its own checks on the user-bound bearer too — this is the matching,
* fail-closed server gate, not the only one.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getOrgGate } from '~/lib/server/identity'
import { forwardIam } from '~/lib/server/iam-proxy'
export const runtime = 'nodejs'
/** Reads — any member of the org (own org only, unless global). */
const GET_SEGMENTS = new Set([
'get-users',
'get-user',
'get-roles',
'get-organization',
// Projects live under the org (IAM-served); the console host's /v1 sends /v1/iam/*
// to the cloud binary → 404, so the Projects page routes here (Bearer → IAM).
'get-organization-projects',
])
/** Writes — org admin only, own org only (unless global). */
const POST_SEGMENTS = new Set([
'add-user',
'update-user',
'delete-user',
// Org branding/settings — org-admin only, pinned to the caller's OWN org by both
// the `?id` name AND the body name (below), so a brand admin can't retarget another.
'update-organization',
// Project CRUD — org-admin only (requireAdminForWrite), pinned to the caller's org.
'add-project',
'delete-project',
])
/** Org objects are owned by the `admin` metadata org (name guarded separately). */
const ORG_META = new Set(['get-organization', 'update-organization'])
/** Segments carrying an org NAME to pin to the caller's scope (read id + write body). */
const ORG_NAME = new Set(['get-organization', 'update-organization'])
/** Segments keyed by `organization` (projects) — pin it to the caller's own org so an
* omitted/empty organization can't enumerate/pollute across tenants. */
const ORG_PARAM = new Set(['get-organization-projects', 'add-project', 'delete-project'])
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
const gate = await getOrgGate(req)
if (!gate) return forbidden()
return forwardIam(req, gate, {
segment: path.join('/'),
method,
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
orgMetaSegments: ORG_META,
orgNameSegments: ORG_NAME,
orgParamSegments: ORG_PARAM,
requireAdminForWrite: true,
})
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'GET')
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'POST')
}
+27 -72
View File
@@ -1,95 +1,50 @@
/**
* Same-origin proxy to the PaaS control plane (Job 3 — embedded PaaS). The
* Same-origin proxy to the platform.hanzo.ai control plane (embedded PaaS). The
* browser calls console2's OWN origin (`/paas/...`); this server-side handler
* forwards to the ONE Hanzo API endpoint at `/v1/paas/...`, injecting the
* service token from server-only env (sourced via KMS — never `NEXT_PUBLIC_`,
* never in the browser bundle). This is the real control-plane API, not an
* iframe stub.
* forwards to `platform.hanzo.ai/v1/...`, injecting the service token from
* server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the browser
* bundle). This is the real control-plane API, not an iframe stub.
*
* ONE ENDPOINT: there is no per-service API host. `/v1/paas/*` is served by the
* unified backend behind `api.hanzo.ai` (same `CLOUD_API_URL` every other server
* proxy here uses — in-cluster in prod, the public gateway everywhere else). It
* used to aim at `platform.hanzo.ai`, which serves NO `/v1/paas/*` route at all
* and 401s every `/v1/*` path uniformly, so the board could never load.
* SECURITY (deny-by-default): the proxy attaches a powerful platform service
* token, so EVERY request must first present a valid IAM session (the first-party
* cookie the cloud `/v1` backend mints) AND be an admin — both verified BEFORE
* the token is attached. Unauthenticated → 401, non-admin → 403. Only an
* authenticated admin reaches the forward. (Re-add `PAAS_SERVICE_TOKEN` to the
* deployment once this gate is confirmed.)
*
* SECURITY: the forwarded token is a PLATFORM SERVICE token — full control-plane
* authority, NOT tenant-scoped. So this route is gated to brand admins exactly
* like the IAM/KMS admin proxies: `getAdminGate` resolves the caller from their
* own session and requires a verified brand-admin (no gate → 403). Without this,
* any authenticated browser could drive the whole control plane through the
* service token. The gate is the control, NOT a deploy-time env toggle.
*
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
* can show a truthful "not configured" state — it never fabricates apps/deploys.
*
* SCOPE: the browser stamps the active tenant path (X-Org-Id / X-Project-Id /
* X-Environment) on every call. We forward it to the control plane so PaaS
* resources scope by org → project → environment like the rest of the console —
* but the ORG is re-resolved server-side through the admin policy (`orgFor`): a
* global admin's switched org is honored, a brand admin is PINNED to their own,
* so the forwarded X-Org-Id is authoritative and never the spoofable claim.
* Project + environment are sub-scopes the admin picks WITHIN that org, passed
* through verbatim.
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 (to admins)
* so the UI shows a truthful "not configured" state — it never fabricates apps.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const API_URL = (process.env.CLOUD_API_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
import { getServerAccount, isAdminAccount } from '~/lib/auth/server'
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// CSRF FIRST — the service token below is control-plane god-mode, so a cross-site
// page carrying the admin's auto-sent cookie must never drive a deploy/scale/delete.
// Refuse a cross-origin MUTATION before the admin gate or any body read (safe reads
// pass). Defense in depth on top of the session cookie's own SameSite attribute.
const csrf = csrfRefusal(req)
if (csrf) return csrf
// Brand-admin gate — the service token below is control-plane god-mode.
const gate = await getAdminGate(req)
if (!gate) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
// Deny-by-default authz — verify the session + require admin BEFORE the token.
const account = await getServerAccount(req.headers.get('cookie'), req.nextUrl.origin)
if (!account) {
return NextResponse.json({ error: 'Sign in to use the control plane.' }, { status: 401 })
}
if (!TOKEN) {
if (!isAdminAccount(account)) {
return NextResponse.json({ error: 'Admin access is required for the control plane.' }, { status: 403 })
}
const token = process.env.PAAS_SERVICE_TOKEN ?? ''
if (!token) {
return NextResponse.json(
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
{ status: 501 },
)
}
// 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(
{ 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
// `/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 = `${API_URL}/v1/paas/${path.join('/')}${search}`
const platformUrl = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
const url = `${platformUrl}/v1/${path.join('/')}${req.nextUrl.search}`
const init: RequestInit = {
method: req.method,
headers: {
Authorization: `Bearer ${TOKEN}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Org-Id': org,
...(projectId ? { 'X-Project-Id': projectId } : {}),
...(environment ? { 'X-Environment': environment } : {}),
},
// Never cache control-plane reads.
cache: 'no-store',
@@ -98,7 +53,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
init.body = await req.text()
}
try {
const res = await fetchWithTimeout(url, init)
const res = await fetch(url, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
+13 -8
View File
@@ -1,13 +1,18 @@
'use client'
/**
* 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'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { SignInForm } from '~/components/SignInForm'
import { useSession } from '~/lib/auth/session'
export default function SignInPage() {
return <SignIn />
const { account, loading } = useSession()
const router = useRouter()
useEffect(() => {
if (!loading && account) router.replace('/')
}, [loading, account, router])
return <SignInForm />
}
-43
View File
@@ -1,43 +0,0 @@
/**
* /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' } },
)
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Same-origin proxy to the durable task engine (hanzoai/tasks `tasksd`, the native
* Temporal-style HTTP surface at `/v1/tasks/*`).
*
* `tasksd` runs `TASKSD_REQUIRE_IDENTITY=true`: it validates an IAM **Bearer JWT**
* against the IAM JWKS, unconditionally STRIPS inbound `X-Org-Id`, and mints org +
* user from the JWT claims (`owner`→org). So — like the `/ai` proxy — the console
* calls its OWN origin (`/tasksd/...`) with just the session cookie;
* `forwardWithUserBearer` resolves the signed-in user, mints a short-lived
* user-bound IAM token (shared per-user cache), and forwards it as the Bearer. No
* key in the browser, and every read is org-scoped by the JWT server-side.
*
* READ-ONLY: only GET is proxied (the console never mutates workflows here), scoped
* to the `v1/tasks/*` subtree. When the engine is unreachable the UI shows an honest
* BackendStateCard — never fabricated workflows.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The durable task engine. Public TLS is not live yet → default to the in-cluster
* service. The tasks Service exposes REST on :7243 (http port); there is NO :80,
* so target :7243 explicitly. Override with TASKS_URL. `|| default` (not `??`) so a
* blank env still falls back to the in-cluster service. */
const TASKS_URL = trim(process.env.TASKS_URL?.trim() || 'http://tasks.hanzo.svc.cluster.local:7243')
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const rel = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: TASKS_URL,
path: `v1/tasks/${rel}`,
allow: (p) => p === 'v1/tasks' || p.startsWith('v1/tasks/'),
unauthorizedMessage: 'Sign in to view tasks.',
})
}
-141
View File
@@ -1,141 +0,0 @@
/**
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
* fine-tuning broker `/v1/finetune/*`).
*
* 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, 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/finetune/jobs` is billing-gated upstream 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/*` 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 this 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
* browser — the HuggingFace token (for private repos) is resolved from KMS
* server-side inside the broker, never here.
*/
import { type NextRequest, NextResponse } from 'next/server'
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'
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')
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// Model serving — the org's deployed kserve InferenceServices.
'ml/models',
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
'finetune/deploy',
'finetune/presets',
'finetune/hf/models',
'finetune/hf/datasets',
'finetune/hf/repo',
])
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
const rel = path.join('/')
if (!ALLOWED.has(rel)) {
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
const user = await resolveUser(req)
if (!user) {
return NextResponse.json(
{ status: 'error', msg: 'Sign in to manage training.' },
{ status: 401 },
)
}
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
// proxy uses). cloud-api's `/v1/*` 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> = {
Authorization: `Bearer ${bearer}`,
Accept: 'application/json',
'Content-Type': 'application/json',
// 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. 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')
if (projectId) headers['X-Project-Id'] = projectId
if (environment) headers['X-Environment'] = environment
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = await req.text()
}
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' },
})
} catch (e) {
return NextResponse.json(
{ status: 'error', msg: `Fine-tuning backend 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)
}
-76
View File
@@ -1,76 +0,0 @@
/**
* 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)
}
-87
View File
@@ -1,87 +0,0 @@
/**
* 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)])
}
@@ -1,45 +0,0 @@
/**
* 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.',
})
}
-63
View File
@@ -1,63 +0,0 @@
/**
* 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)])
}
-75
View File
@@ -1,75 +0,0 @@
/**
* 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 })
}
-38
View File
@@ -1,38 +0,0 @@
/**
* 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/methods, and the
* per-invoice PDF), POST (writes: top-up, 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)
}
-70
View File
@@ -1,70 +0,0 @@
/**
* 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)
}
-66
View File
@@ -1,66 +0,0 @@
/**
* 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
* (`/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
* org-scoped SERVER-SIDE — a merchant only ever sees their OWN org's catalog/orders/
* customers. No token reaches the browser, and the org is never browser-supplied.
*
* This is the TENANT store surface (any signed-in org member acts on their own org's
* store), so it is user-scoped (`resolveUser`), NOT the `/paas` god-mode service-token
* path. It is also DISTINCT from the `/billing` proxy: money (balance/usage/invoices/
* Square) stays on `/billing` with its own per-tenant subject scoping — this proxy
* carries only the store catalog/orders/customers. Least privilege on the path:
* `allowCommerceSurface` admits only the merchant REST heads (product/order/user/…),
* so `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants` are NOT reachable here.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowCommerceSurface } 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 already
* wires `COMMERCE_URL` (public egress is CF-gated). Override per-deploy with COMMERCE_URL. */
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/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,
allow: allowCommerceSurface,
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
// X-Environment — the store is org-keyed and commerce re-scopes on the token.
unauthorizedMessage: 'Sign in to manage your store.',
})
})()
}
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)
}
-177
View File
@@ -1,177 +0,0 @@
/**
* 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
* (`/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
* the browser, and the browser can never compose one.
*
* Security (mirrors app/nodes/[...path]/route.ts):
* - 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 `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).
*
* Honest by construction: an unset/unreachable graph host yields a `not-reporting`
* snapshot with the real error — never fabricated markets. The native DEX is a CLOB,
* so the query asks only for the fields the `dex` subgraph really exposes.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { brandFromHost } from '~/config'
import { resolveUser } from '~/lib/server/identity'
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
import {
normalizeMarkets,
normalizeTrades,
normalizeDayData,
type EconomySnapshot,
type RawMarket,
type RawFill,
type RawMarketDayData,
} from '~/lib/api/economy'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/**
* The `dex` subgraph GraphQL endpoint per network — the ONLY endpoint this proxy
* will query. graphd serves the DEX subgraph at `<prefix>/graphql` (default prefix
* `/v1/graph/cchain/dex`); each is overridable per-deploy. An unset/unreachable host
* yields an honest `not-reporting` snapshot — never fake markets. (Cross-cluster
* reach depends on network policy; when unreachable the board shows honest empty.)
*/
const GRAPH_HOSTS: Partial<Record<NodeNetworkId, string>> = {
'lux-mainnet': trim(process.env.DEX_GRAPHQL_MAINNET ?? 'http://graph.lux-mainnet.svc:8080/v1/graph/cchain/dex/graphql'),
'lux-testnet': trim(process.env.DEX_GRAPHQL_TESTNET ?? 'http://graph.lux-testnet.svc:8080/v1/graph/cchain/dex/graphql'),
'lux-devnet': trim(process.env.DEX_GRAPHQL_DEVNET ?? 'http://graph.lux-devnet.svc:8080/v1/graph/cchain/dex/graphql'),
}
/** Per-query timeout (ms). */
const TIMEOUT_MS = Number(process.env.ECONOMY_TIMEOUT_MS ?? 8000)
/**
* The ONE fixed GraphQL query — markets (book summary + accrued 24h aggregates),
* recent fills (the trade feed), and day-data (the historical series, empty until a
* MarketDayData producer emits). Only fields the `dex` subgraph really exposes.
*/
const QUERY = `query LuxEconomy {
markets(first: 100) {
id
symbol
baseToken
quoteToken
assetsBound
openOrders
remaining
bestBid
bestAsk
volume24h
tradeCount
lastPrice
feeTier
}
fills(first: 40) {
id
symbol
price
size
side
timestamp
}
marketDayDatas(first: 90) {
id
date
symbol
volumeUSD
feesUSD
tvlUSD
}
}`
interface GraphResp {
data?: { markets?: RawMarket[]; fills?: RawFill[]; marketDayDatas?: RawMarketDayData[] }
errors?: { message?: string }[]
}
/** Query ONE network's `dex` subgraph → normalized snapshot. Honest not-reporting on failure. */
async function probe(net: NodeNetworkId): Promise<EconomySnapshot> {
const base: EconomySnapshot = { network: net, status: 'not-reporting', markets: [], trades: [], dayData: [] }
const host = GRAPH_HOSTS[net]
if (!host) {
base.error = 'no DEX GraphQL host configured for this network'
return base
}
try {
const res = await fetchWithTimeout(
host,
{
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ query: QUERY }),
cache: 'no-store',
},
{ timeoutMs: TIMEOUT_MS },
)
if (!res.ok) {
base.error = `graphql ${res.status}`
return base
}
const json = (await res.json()) as GraphResp
if (json?.errors?.length) {
base.error = json.errors[0]?.message ?? 'graphql error'
return base
}
const d = json?.data ?? {}
return {
network: net,
status: 'reporting',
markets: normalizeMarkets(d.markets),
trades: normalizeTrades(d.fills),
dayData: normalizeDayData(d.marketDayDatas),
}
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
return base
}
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// 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 })
}
const user = await resolveUser(req)
if (!user) {
return NextResponse.json({ error: 'Sign in to view the market economy.' }, { status: 401 })
}
const brand = brandFromHost(req.headers.get('host'))
let networks = nodeNetworksForBrand(brand)
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
if (only) networks = networks.filter((n) => n === only)
// Query the brand's networks and return the FIRST that reports markets (the live
// economy), else the first reporting network, else the first (honest not-reporting).
const snaps = await Promise.all(networks.map(probe))
const withMarkets = snaps.find((s) => s.status === 'reporting' && s.markets.length > 0)
const reporting = snaps.find((s) => s.status === 'reporting')
const chosen = withMarkets ?? reporting ?? snaps[0]
if (!chosen) {
return NextResponse.json({ network: null, status: 'not-reporting', markets: [], trades: [], dayData: [], error: 'no network in scope' })
}
return NextResponse.json(chosen)
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
-168
View File
@@ -1,168 +0,0 @@
/**
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
* 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
* method ever reaches the browser, and the browser can never choose either.
*
* Security (mirrors app/bootnode/[...path]/route.ts):
* - Session-gated: an unauthenticated caller gets 401 (the RPC data is public,
* but the console surface is authenticated, same as every other module).
* - 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 `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'
import { brandFromHost } from '~/config'
import { resolveUser } from '~/lib/server/identity'
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
import {
NODE_NETWORK_META,
combineInventory,
normalizeChains,
parseHeight,
type NetworkInventory,
type RawBlockchain,
type RawPeer,
type RawValidator,
} from '~/lib/api/nodes'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/**
* Public luxd RPC host per network — the ONLY endpoints this proxy will call.
* These are PUBLIC RPC hosts (not secrets); each is overridable per-deploy so a
* network can be repointed or disabled without a code change. A host that is
* unset/unreachable yields an honest `not-reporting` network — never fake rows.
*/
const HOSTS: Record<NodeNetworkId, string> = {
'lux-mainnet': trim(process.env.LUX_MAINNET_RPC ?? 'https://api.lux.network'),
'lux-testnet': trim(process.env.LUX_TESTNET_RPC ?? 'https://api.lux-test.network'),
'lux-devnet': trim(process.env.LUX_DEVNET_RPC ?? 'https://api.lux-dev.network'),
'pars-mainnet': trim(process.env.PARS_MAINNET_RPC ?? 'https://api.pars.network'),
// Zoo has no confirmed public primary-network host yet; the default is the
// conventional host (api.<brand>.network) and reports honestly when unreachable.
'zoo-mainnet': trim(process.env.ZOO_MAINNET_RPC ?? 'https://api.zoo.network'),
}
/** Per-network probe timeout (ms). */
const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
async function rpc<T>(
host: string,
path: '/v1/bc/P' | '/v1/info',
method: string,
signal: AbortSignal,
): Promise<T> {
const res = await fetch(`${host}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }),
cache: 'no-store',
signal,
})
if (!res.ok) throw new Error(`${method} ${res.status}`)
const json = (await res.json()) as { result?: T; error?: { message?: string } }
if (json?.error) throw new Error(json.error.message ?? `${method} error`)
return json.result as T
}
/** Probe ONE network: validators + peers + version + height, normalized. */
async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
const meta = NODE_NETWORK_META[net]
const host = HOSTS[net]
const base: NetworkInventory = {
id: net,
chain: meta.chain,
env: meta.env,
label: meta.label,
status: 'not-reporting',
validators: 0,
peers: 0,
nodes: [],
chains: [],
}
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
rpc<{ validators?: RawValidator[] }>(host, '/v1/bc/P', 'platform.getCurrentValidators', ctrl.signal),
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/v1/info', 'info.peers', ctrl.signal),
rpc<{ version?: string }>(host, '/v1/info', 'info.getNodeVersion', ctrl.signal),
rpc<{ height?: string }>(host, '/v1/bc/P', 'platform.getHeight', ctrl.signal),
rpc<{ blockchains?: RawBlockchain[] }>(host, '/v1/bc/P', 'platform.getBlockchains', ctrl.signal),
])
const reachable =
valR.status === 'fulfilled' || peerR.status === 'fulfilled' || chainR.status === 'fulfilled'
if (!reachable) {
const reason =
valR.status === 'rejected'
? valR.reason
: peerR.status === 'rejected'
? peerR.reason
: chainR.status === 'rejected'
? chainR.reason
: null
base.error = reason instanceof Error ? reason.message : 'unreachable'
return base
}
const validators = valR.status === 'fulfilled' ? valR.value?.validators : undefined
const peers = peerR.status === 'fulfilled' ? peerR.value?.peers : undefined
const nodes = combineInventory(validators, peers, net)
base.status = 'reporting'
base.nodes = nodes
base.validators = nodes.filter((n) => n.role === 'validator').length
base.peers = nodes.filter((n) => n.role === 'peer').length
if (verR.status === 'fulfilled') base.version = verR.value?.version
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
// Chains are best-effort: a network can report validators/peers yet not answer
// getBlockchains — then the chains list is honestly empty (no fabricated chains).
if (chainR.status === 'fulfilled') base.chains = normalizeChains(chainR.value?.blockchains)
return base
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
return base
} finally {
clearTimeout(timer)
}
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// 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 })
}
const user = await resolveUser(req)
if (!user) {
return NextResponse.json({ error: 'Sign in to view node infrastructure.' }, { status: 401 })
}
const brand = brandFromHost(req.headers.get('host'))
let networks = nodeNetworksForBrand(brand)
// Optional single-network scope, still gated by the brand's allowed set.
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
if (only) networks = networks.filter((n) => n === only)
const inventory = await Promise.all(networks.map(probe))
return NextResponse.json({ brand, networks: inventory })
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
-67
View File
@@ -1,67 +0,0 @@
/**
* 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)
}
-65
View File
@@ -1,65 +0,0 @@
/**
* 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
* (`/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.
*
* NOT the PaaS pattern: PaaS forwards a god-mode SERVICE token and is gated to brand
* admins. Base authorizes PER USER itself — the `tenants` collection's
* `ListRule = "owner_iam_user = @request.auth.id"` and admin-only mutations are
* enforced by Base against the forwarded user identity. So here we forward the
* USER's own minted bearer (least privilege, tenant-scoped by Base), and the only
* gate is "must be signed in" (resolveUser → 401). A non-admin simply sees their own
* tenants and gets Base's 403 on a mutation — honest, not faked.
*
* Least privilege on the path too: only the Base DATA PLANE is proxied — the
* collection schemas (read) and any collection's records (list/get/create/update/
* delete), via `allowBaseSurface`. Base's admin/settings/backup/log surfaces 404,
* so this stays a data-plane proxy, not a general Base tunnel. Base still authorizes
* every read/write per-user and per-collection itself, so a non-admin sees only what
* a collection's rules permit and gets Base's own honest 403 on a denied mutation.
* (The tenants manager rides this same proxy — records/tenants is one such path.)
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowBaseSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The Base control plane the proxied calls are forwarded to. */
const BASE_URL = trim(process.env.BASE_DASHBOARD_URL ?? 'https://base.hanzo.ai')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
// 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,
allow: allowBaseSurface,
unauthorizedMessage: 'Sign in to manage Base records.',
})
})()
}
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 PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
-185
View File
@@ -1,185 +0,0 @@
/**
* 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 `/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
* browser can never choose either.
*
* Security (mirrors app/nodes/[...path]/route.ts):
* - 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 `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.
*
* Two upstreams, one concern each:
* 1. METRICS — GETs the in-cluster maker's Prometheus `:2112/metrics` for a
* network and parses it to `MakerStatus`. The maker Service host per network is
* env-configurable; unset/unreachable → an honest `not-reporting` status.
* 2. ORDERBOOK — reads the DEX CLOB `dex_get_orders?market=<poolHex>` for a market
* on a network. The DEX read host is env-configurable; the private D-Chain is
* not publicly exposed, so an unreachable venue → an honest `not-reporting` book
* (never fabricated bids/asks).
*/
import { type NextRequest, NextResponse } from 'next/server'
import { brandFromHost } from '~/config'
import { resolveUser } from '~/lib/server/identity'
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
import {
parseMakerMetrics,
normalizeBook,
type MakerStatus,
type OrderBook,
type RawBookOrder,
} from '~/lib/api/trading'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/**
* The maker's Prometheus metrics host per network — the ONLY metrics endpoint this
* proxy will scrape. These point at the in-cluster maker Service (`maker-coherence`,
* port 2112) per luxd network namespace. Each is overridable per-deploy; an
* unset/unreachable host yields an honest `not-reporting` status — never fake rows.
* (Cross-cluster reach depends on network policy; when unreachable the console shows
* the honest not-reporting state, exactly like the Nodes surface.)
*/
const MAKER_METRICS_HOSTS: Partial<Record<NodeNetworkId, string>> = {
'lux-mainnet': trim(process.env.MAKER_METRICS_MAINNET ?? 'http://maker-coherence.lux-mainnet.svc:2112'),
'lux-testnet': trim(process.env.MAKER_METRICS_TESTNET ?? 'http://maker-coherence.lux-testnet.svc:2112'),
'lux-devnet': trim(process.env.MAKER_METRICS_DEVNET ?? 'http://maker-coherence.lux-devnet.svc:2112'),
}
/**
* The DEX read host per network — the ONLY DEX endpoint this proxy will query for
* the order book (`<host>/dex/dex_get_orders?market=<poolHex>`). The native D-Chain
* CLOB is not publicly exposed, so these default to the in-cluster luxd router;
* unreachable → an honest `not-reporting` book.
*/
const DEX_READ_HOSTS: Partial<Record<NodeNetworkId, string>> = {
'lux-mainnet': trim(process.env.DEX_READ_MAINNET ?? 'http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/D'),
'lux-testnet': trim(process.env.DEX_READ_TESTNET ?? 'http://luxd-0.luxd-headless.lux-testnet.svc:9640/v1/bc/D'),
'lux-devnet': trim(process.env.DEX_READ_DEVNET ?? 'http://luxd-0.luxd-headless.lux-devnet.svc:9650/v1/bc/D'),
}
/** Per-upstream probe timeout (ms). */
const TIMEOUT_MS = Number(process.env.TRADING_TIMEOUT_MS ?? 8000)
/** Scrape ONE network's maker metrics → MakerStatus. Honest not-reporting on failure. */
async function makerStatus(net: NodeNetworkId): Promise<MakerStatus> {
const host = MAKER_METRICS_HOSTS[net]
const base: MakerStatus = { status: 'not-reporting', symbols: [] }
if (!host) {
base.error = 'no metrics host configured for this network'
return base
}
try {
const res = await fetchWithTimeout(
`${host}/metrics`,
{ headers: { accept: 'text/plain' }, cache: 'no-store' },
{ timeoutMs: TIMEOUT_MS },
)
if (!res.ok) {
base.error = `metrics ${res.status}`
return base
}
const text = await res.text()
const parsed = parseMakerMetrics(text)
return { status: 'reporting', ...parsed }
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
return base
}
}
/** A single allowlisted DEX read call. `method` is fixed here (dex_get_orders). */
async function dexGetOrders(host: string, poolHex: string): Promise<RawBookOrder[]> {
const url = `${host}/dex/dex_get_orders?market=${encodeURIComponent(poolHex)}`
const res = await fetchWithTimeout(url, { headers: { accept: 'application/json' }, cache: 'no-store' }, { timeoutMs: TIMEOUT_MS })
if (!res.ok) throw new Error(`dex_get_orders ${res.status}`)
const json = (await res.json()) as { orders?: RawBookOrder[] }
return Array.isArray(json?.orders) ? json.orders : []
}
/**
* Read ONE market's order book. A `poolId` (32-byte hex) addresses the book
* directly; a `symbol`/`base`/`quote` are echoed for display but the book is only
* readable by poolId (the console does not compute keccak client- or server-side to
* avoid an eth-crypto dep — the caller supplies the poolId, or the book is honestly
* not-reporting). Honest not-reporting when the DEX is unreachable.
*/
async function orderbook(net: NodeNetworkId, params: URLSearchParams): Promise<OrderBook> {
const symbol = params.get('symbol') ?? undefined
const poolId = params.get('poolId') ?? undefined
const base: OrderBook = { network: net, symbol, poolId, status: 'not-reporting', orders: [] }
const host = DEX_READ_HOSTS[net]
if (!host) {
base.error = 'no DEX read host configured for this network'
return base
}
if (!poolId) {
// No poolId → the book can't be addressed. Honest, never fabricated.
base.error = 'order book is read by poolId; none supplied for this market'
return base
}
try {
const raw = await dexGetOrders(host, poolId)
return { ...base, status: 'reporting', orders: normalizeBook(raw), error: undefined }
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
return base
}
}
/** Resolve the brand's networks, optionally narrowed to `?network=` (still gated). */
function scopedNetworks(req: NextRequest): NodeNetworkId[] {
const brand = brandFromHost(req.headers.get('host'))
let networks = nodeNetworksForBrand(brand)
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
if (only) networks = networks.filter((n) => n === only)
return networks
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
const route = path.join('/')
const user = await resolveUser(req)
if (!user) {
return NextResponse.json({ error: 'Sign in to view trading bots.' }, { status: 401 })
}
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.
const net = networks[0]
if (!net) return NextResponse.json({ status: 'not-reporting', symbols: [], error: 'no network in scope' })
const status = await makerStatus(net)
return NextResponse.json(status)
}
if (route === 'orderbook') {
const networks = scopedNetworks(req)
const net = networks[0]
if (!net) {
return NextResponse.json({ network: null, status: 'not-reporting', orders: [], error: 'no network in scope' })
}
const book = await orderbook(net, req.nextUrl.searchParams)
return NextResponse.json(book)
}
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
-67
View File
@@ -1,67 +0,0 @@
/**
* 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
* (`/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
* the browser.
*
* NOT the `/paas` pattern: `/paas` forwards a god-mode control-plane SERVICE token
* and is gated to brand admins. Compute is a TENANT action (any signed-in org user
* may list/manage their own machines), so this is user-scoped (resolveUser), and
* visor itself authorizes the forwarded user bearer.
*
* Least privilege on the path: only the visor `v1/*` surface is reachable
* (`allowVisorSurface`); anything else 404s.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowVisorSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Visor (vm.hanzo.ai). In-cluster ClusterIP on :19000 (its Service has NO :80) — public
* egress is CF-403'd. Override with VISOR_URL (the CR sets visor.hanzo.svc:19000).
* `|| default` (not `??`): if the env is reconciled to an EMPTY string (observed drift on
* the live pod), `??` would keep the blank and every machines/GPUs call would fail —
* `|| default` treats blank/whitespace as unset so visor ALWAYS resolves. */
const VISOR_URL = trim(process.env.VISOR_URL?.trim() || 'http://visor.hanzo.svc:19000')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
// 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,
allow: allowVisorSurface,
// Org is authoritative (Bearer owner). Don't forward browser X-Project-Id/
// X-Environment (unvalidated sub-scopes) — RED MEDIUM.
unauthorizedMessage: 'Sign in to manage compute.',
})
})()
}
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)
}
-9
View File
@@ -1,9 +0,0 @@
// TypeScript 7 reports TS2882 for a side-effect import with no type
// declaration ("Cannot find module or type declarations for side-effect import
// of './globals.css'"). TS 5.x let these pass silently.
//
// Next.js resolves stylesheet imports through its own loader pipeline, so these
// specifiers never reach the TypeScript module resolver at build time. The
// ambient declaration exists to tell the checker they are legitimate, not to
// give them a shape — hence no exported members.
declare module '*.css';
Vendored
-5
View File
@@ -1,5 +0,0 @@
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
// The bundler owns them; TypeScript only needs to know the specifier resolves.
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
// silent, so the declaration lives here — one place, every stylesheet.
declare module '*.css'
+4 -5
View File
@@ -1,10 +1,9 @@
# Unified `/v1` backend endpoints
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
is the row count on list endpoints; the legacy `data2` count is still accepted
as a fallback until every emitter finishes the rename).
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`, the
casibase API). Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
is the total row count on list endpoints).
Client modules live in `src/lib/api/`.
-34
View File
@@ -1,34 +0,0 @@
/**
* 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`,
)
})
}
-93
View File
@@ -1,93 +0,0 @@
/**
* 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
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
properties?: Record<string, string>
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
* to reproduce the production case where the snapshot predates a later write. */
issuedAt?: number
}
/**
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
* out and the app sits on its loader forever.
*/
const b64 = (o: object): string =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
/** The default identity render specs run as — a hanzo-org admin. */
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
displayName: 'Z Admin',
isAdmin: true,
}
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
export function forgeToken(claims: SessionClaims): string {
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
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: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
await page.addInitScript(
({ org, token }: { org: string; token: string }) => {
try {
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
// across tabs (that IS the session), so seeding a per-tab area would leave
// the SDK reading an empty store and every primed spec signed out.
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.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}` }),
}),
)
}
-269
View File
@@ -1,269 +0,0 @@
/**
* The ONE account control, at the foot of the rail — and the ONE org switch.
*
* There used to be three account-ish menus: an org switcher at the top of the
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
* with four ways to sign out between them. They became one control that answered
* BOTH "who am I" and "where am I".
*
* They have now been split again, but by QUESTION rather than by accident: the
* account control at the foot answers who you are (identity, team, personal
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
* where you are (organization + project, together, beside the tenant's mark).
* So the cross-tenant reach is asserted against the context switcher below, and
* the account menu is asserted to no longer offer a tenant at all.
*
* Everything is asserted on computed style and geometry. The failure this guards
* against is a menu that is present in the DOM and unreadable — a library that
* paints with utility class names renders exactly that in this app, because
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
* have passed on the broken build.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** The cross-tenant org list an admin console reaches — none of them memberships. */
const ORGS = [
{ owner: 'admin', name: 'hanzo', displayName: 'Hanzo' },
{ owner: 'admin', name: 'maxpower', displayName: 'Max Power' },
{ owner: 'admin', name: 'acme-industrial', displayName: 'Acme Industrial' },
]
/** Every org-scoped request the page made, with the scope it carried. */
type Scoped = { url: string; org: string | null }
/**
* The account trigger in the persistent rail.
*
* The shell mounts the SAME control three times — the rail, the collapsed-rail
* hover flyout, and the phone drawer — because `SidebarNav` is one component with
* three mounts. All three stay in the DOM (the flyout and drawer are offset, not
* unmounted), which predates this change and belongs to the shell lane; the first
* in document order is the persistent rail, and every geometry assertion below
* checks it really is the one on screen.
*/
const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
/** The trigger inside the phone's account sheet — the last mount in the document. */
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
/** The org + project control at the top-left — the only thing that switches tenant. */
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
async function mountConsole(page: Page, seen: Scoped[]) {
// The standalone console reaches the cross-tenant list through its own gated
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
// Both are covered so the spec does not silently pass on the wrong one.
await page.route(/\/(v1|admin\/iam)\//, async (route) => {
const url = route.request().url()
seen.push({ url, org: route.request().headers()['x-org-id'] ?? null })
if (url.includes('get-organizations')) {
const query = new URL(url).searchParams.get('value') ?? ''
const rows = ORGS.filter((o) => o.displayName.toLowerCase().includes(query.toLowerCase()))
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: rows, data2: rows.length }) })
}
if (url.includes('billing/balance')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ spendableCents: 4250 }) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: [] }) })
})
// The reserved `admin` org IS the super admin — the only identity that reaches
// every tenant, which is what an admin console is for.
// Record every write to the console's org scope. The switch reloads the page and
// the harness re-seeds the scope on load, so the write is observed as it happens.
await page.addInitScript(() => {
const setItem = Storage.prototype.setItem
Storage.prototype.setItem = function (key: string, value: string) {
if (key === 'hanzo.console.org') {
const log = JSON.parse(sessionStorage.getItem('spec.scope.writes') ?? '[]') as string[]
log.push(`${key}=${value}`)
setItem.call(sessionStorage, 'spec.scope.writes', JSON.stringify(log))
}
return setItem.call(this, key, value)
}
})
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin' })
await page.goto('/')
await page.waitForSelector('[data-testid=nav-user]', { state: 'attached', timeout: 30_000 })
}
const px = (v: string) => Number.parseFloat(v)
const rgb = (v: string) => (v.match(/\d+(\.\d+)?/g) ?? []).map(Number)
const luminance = ([r, g, b]: number[]) => {
const f = (c: number) => { const n = c / 255; return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const contrast = (a: number[], b: number[]) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((m, n) => n - m)
return (hi + 0.05) / (lo + 0.05)
}
test.describe('account control', () => {
test('sits at the foot of the rail and paints in a shell with no Tailwind', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
// The control is at the BOTTOM — below the middle of the sidebar, not above it.
const trigger = accountTrigger(page)
const box = (await trigger.boundingBox())!
const viewport = page.viewportSize()!
expect(box.y).toBeGreaterThan(viewport.height / 2)
expect(box.x).toBeLessThan(300)
// Nothing else claims to switch orgs: the top-of-rail switcher is gone.
await expect(page.getByLabel('Switch organization')).toHaveCount(0)
await trigger.click()
const menu = page.locator('[role=menu]')
await menu.waitFor()
const paint = await menu.evaluate((el) => {
const s = getComputedStyle(el)
const r = el.getBoundingClientRect()
return {
bg: s.backgroundColor,
radius: s.borderTopLeftRadius,
borderWidth: s.borderTopWidth,
z: s.zIndex,
font: s.fontFamily,
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
}
})
// It PAINTS — an opaque surface, not a transparent stack of divs.
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
expect(px(paint.radius)).toBeGreaterThanOrEqual(8)
expect(px(paint.borderWidth)).toBeGreaterThanOrEqual(1)
// …in the app's own typeface, not a system fallback.
expect(paint.font).toMatch(/Geist/i)
// It is FULLY on screen and above the shell.
expect(paint.rect.x).toBeGreaterThanOrEqual(0)
expect(paint.rect.y).toBeGreaterThanOrEqual(0)
expect(paint.rect.x + paint.rect.w).toBeLessThanOrEqual(viewport.width + 1)
expect(paint.rect.y + paint.rect.h).toBeLessThanOrEqual(viewport.height + 1)
// Nothing of the shell is painted over it.
const onTop = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + 12)
return el.contains(hit)
})
expect(onTop).toBe(true)
// Rows are padded, tall enough to hit, and readable.
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
els.map((el) => {
const s = getComputedStyle(el)
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
}),
)
expect(rows.length).toBeGreaterThanOrEqual(5)
for (const row of rows) {
expect(px(row.pl), `"${row.text}" padding`).toBeGreaterThanOrEqual(8)
expect(row.h, `"${row.text}" height`).toBeGreaterThanOrEqual(24)
expect(contrast(rgb(row.color), rgb(paint.bg)), `"${row.text}" contrast`).toBeGreaterThanOrEqual(4.5)
}
// Hover is a real state — the switch-that-rendered-identical class of bug.
const first = menu.locator('.hz-iam-row').first()
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
await first.hover()
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
await page.screenshot({ path: 'e2e-shots/account-menu-desktop.png', animations: 'disabled' })
})
test('nothing in it shouts', async ({ page }) => {
await mountConsole(page, [])
await accountTrigger(page).click()
await page.locator('[role=menu]').waitFor()
const shouting = await page.locator('[role=menu]').evaluate((el) =>
[...el.querySelectorAll('*')].filter((n) => getComputedStyle(n).textTransform === 'uppercase').map((n) => n.textContent ?? ''),
)
expect(shouting).toEqual([])
const typedInCaps = await page.locator('[role=menu]').evaluate((el) =>
[...el.querySelectorAll('*')]
.map((n) => (n.children.length ? '' : (n.textContent ?? '').trim()))
.filter((t) => /^[A-Z][A-Z0-9 &/·—-]{3,}$/.test(t)),
)
expect(typedInCaps).toEqual([])
})
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
await contextTrigger(page).click()
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
await acme.waitFor()
// Scoped to the ORG group — the same popover also lists projects, and a bare
// getByRole('radio') would silently count those too.
await expect(orgList.getByRole('radio')).toHaveCount(1)
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
// MONEY PATH. Entering a tenant must go through the console's OWN org scope —
// the single seam that persists `hanzo.console.org`, reloads, and is read back
// as `X-Org-Id` on every call. A switcher that minted its own would bypass the
// scoping and its billing attribution without anything visibly breaking, so the
// write itself is what is asserted. (The scope key is recorded through a wrapped
// setter because the reload re-runs the harness's own seeding.)
await acme.click()
await page.waitForFunction(
() => sessionStorage.getItem('spec.scope.writes')?.includes('acme-industrial') ?? false,
)
const writes: string[] = JSON.parse(
(await page.evaluate(() => sessionStorage.getItem('spec.scope.writes'))) ?? '[]',
)
expect(writes).toContain('hanzo.console.org=acme-industrial')
// And every scoped call the page made before that carried the admin's own
// scope — the menu never issued a request under someone else's tenant.
for (const call of seen.filter((s) => s.org !== null)) expect(call.org).toBe('admin')
})
test('the same control is the account surface on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mountConsole(page, [])
// On a phone the rail is a drawer, so the account control lives in the
// right-hand account sheet — the SAME component, not a phone-only copy.
await page.getByLabel('Account and settings').click()
await drawerTrigger(page).click()
const menu = page.locator('[role=menu]')
await menu.waitFor()
const rect = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
return { x: r.x, y: r.y, w: r.width, h: r.height, bg: getComputedStyle(el).backgroundColor }
})
expect(rect.bg).not.toBe('rgba(0, 0, 0, 0)')
// …and it paints OVER the sheet it was opened from. A sheet pinned at a
// literal 1000 swallowed the menu whole: present, measurable, unclickable.
const onTop = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + 12))
})
expect(onTop).toBe(true)
expect(rect.x).toBeGreaterThanOrEqual(0)
expect(rect.x + rect.w).toBeLessThanOrEqual(391)
expect(rect.y).toBeGreaterThanOrEqual(0)
expect(rect.y + rect.h).toBeLessThanOrEqual(845)
// The page itself never scrolls sideways to accommodate it.
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
await page.screenshot({ path: 'e2e-shots/account-menu-mobile.png', animations: 'disabled' })
})
})
-145
View File
@@ -1,145 +0,0 @@
/**
* 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 })
})
-355
View File
@@ -1,355 +0,0 @@
/**
* Infrastructure admin board — render + interaction proof.
*
* Drives the REAL InfraModule (client + pure logic + the shared sortable DataTable)
* against a mock of `/v1/admin/infra` seeded with the fleet's REAL shape: 58 nodes,
* 295 volumes, 8 clusters, 132 detached, and 3 unreferenced/deletable volumes totalling
* 500 GiB ≈ $50/mo. Nothing here is fabricated beyond the fixture — the assertions are
* about what the board DOES with real numbers.
*
* Proves: the Overview totals render and the droplet-local-disk note is unmissable;
* every tab renders; sorting a column genuinely REORDERS rows (the first row's text
* changes); the `unreferenced` filter yields exactly 3; a NON-deletable volume shows no
* delete control (it shows its blockedReason instead); and a deletable volume's confirm
* states the name, the size in GiB, and the monthly cost being reclaimed.
*
* Screenshots every tab to e2e-shots/admin-infra-<tab>.png.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-infra
*/
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')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
// ── the fixture: the fleet's REAL shape ───────────────────────────────────────
const CLUSTER_NAMES = ['hanzo-k8s', 'lux-k8s', 'zoo-k8s', 'bootnode-k8s', 'pars-k8s', 'ci-arc-k8s', 'edge-k8s', 'staging-k8s']
/** 8 clusters. `zebra-k8s` is deliberately absent — name sorting is proven on the real set. */
const clusters = CLUSTER_NAMES.map((name, i) => ({
id: `c-${i + 1}`,
name,
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
version: '1.31.1-do.4',
status: 'running',
nodePools: 2 + (i % 3),
nodes: [12, 10, 8, 7, 6, 6, 5, 4][i],
pods: 120 - i * 9,
pvs: 40 - i * 3,
pvcs: 40 - i * 3,
idlePVCs: i === 0 ? 6 : i === 1 ? 3 : 0,
scanned: true,
scanError: '',
monthlyCents: [480000, 320000, 180000, 120000, 74000, 60000, 32000, 18000][i],
}))
/** 58 droplets across the 8 clusters; each carries 160 GiB of LOCAL disk (9,280 GiB total). */
const nodes = Array.from({ length: 58 }, (_, i) => ({
id: 1000 + i,
name: `pool-${String.fromCharCode(97 + (i % 8))}-${i + 1}`,
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
status: 'active',
sizeSlug: i % 5 === 0 ? 's-8vcpu-16gb' : 's-4vcpu-8gb',
vcpus: i % 5 === 0 ? 8 : 4,
memoryMiB: i % 5 === 0 ? 16384 : 8192,
localDiskGiB: 160,
monthlyCents: i % 5 === 0 ? 9600 : 4800,
createdAt: '2026-01-04T10:00:00Z',
privateIp: `10.0.${Math.floor(i / 256)}.${i % 256}`,
publicIp: '',
tags: ['k8s', `k8s:c-${(i % 8) + 1}`],
ready: i !== 57,
schedulable: i !== 56,
pods: 4 + (i % 17),
volumes: i % 3 === 0 ? 2 : 1,
}))
/**
* 295 volumes: 163 attached, 129 detached-but-referenced (bound/released), and the 3
* UNREFERENCED ones that are genuinely reclaimable (500 GiB ≈ $50/mo).
* detachedVolumes = 132 = the 129 bound/released + the 3 unreferenced.
*/
const volumes = [
...Array.from({ length: 163 }, (_, i) => ({
id: `v-att-${i}`,
name: `pvc-attached-${String(i).padStart(3, '0')}`,
region: 'nyc3',
sizeGiB: 100,
monthlyCents: 1000,
state: 'attached',
dropletIds: [1000 + (i % 58)],
nodeName: nodes[i % 58].name,
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-att-${i}`,
pvPhase: 'Bound',
pvcNamespace: 'hanzo',
pvcName: `data-${i}`,
mountedBy: [`pod-${i}`],
idle: false,
createdAt: '2026-02-01T00:00:00Z',
deletable: false,
blockedReason: 'Attached to a droplet.',
})),
...Array.from({ length: 118 }, (_, i) => ({
id: `v-bound-${i}`,
name: `pvc-bound-${String(i).padStart(3, '0')}`,
region: 'sfo3',
sizeGiB: 150,
monthlyCents: 1500,
state: 'bound',
dropletIds: [],
nodeName: '',
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-bound-${i}`,
pvPhase: 'Bound',
pvcNamespace: 'hanzo',
pvcName: `idle-${i}`,
mountedBy: [],
idle: true,
createdAt: '2026-02-01T00:00:00Z',
deletable: false,
blockedReason: 'Bound to PVC hanzo/idle — still claimed.',
})),
...Array.from({ length: 11 }, (_, i) => ({
id: `v-rel-${i}`,
name: `pvc-released-${String(i).padStart(3, '0')}`,
region: 'ams3',
sizeGiB: 120,
monthlyCents: 1200,
state: 'released',
dropletIds: [],
nodeName: '',
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-rel-${i}`,
pvPhase: 'Released',
pvcNamespace: '',
pvcName: '',
mountedBy: [],
idle: false,
createdAt: '2026-01-15T00:00:00Z',
deletable: false,
blockedReason: 'PV is Released but not yet reclaimed — retain policy holds the data.',
})),
// The 3 genuinely reclaimable volumes: 500 GiB total, $50.00/mo total.
{
id: 'v-orphan-1', name: 'pvc-abandoned-alpha', region: 'nyc3', sizeGiB: 200, monthlyCents: 2000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: 'c-1',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2025-11-02T00:00:00Z', deletable: true, blockedReason: '',
},
{
id: 'v-orphan-2', name: 'pvc-abandoned-bravo', region: 'sfo3', sizeGiB: 200, monthlyCents: 2000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2025-12-11T00:00:00Z', deletable: true, blockedReason: '',
},
{
id: 'v-orphan-3', name: 'pvc-abandoned-charlie', region: 'ams3', sizeGiB: 100, monthlyCents: 1000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2026-01-20T00:00:00Z', deletable: true, blockedReason: '',
},
]
const loadBalancers = [
{ id: 'lb-1', name: 'edge-ingress', region: 'nyc3', status: 'active', ip: '143.198.10.1', sizeUnit: 3, monthlyCents: 3600, droplets: 12, cluster: 'hanzo-k8s' },
{ id: 'lb-2', name: 'api-gateway', region: 'sfo3', status: 'active', ip: '143.198.10.2', sizeUnit: 1, monthlyCents: 1200, droplets: 10, cluster: 'lux-k8s' },
{ id: 'lb-3', name: 'zoo-edge', region: 'ams3', status: 'new', ip: '', sizeUnit: 1, monthlyCents: 1200, droplets: 0, cluster: 'zoo-k8s' },
{ id: 'lb-4', name: 'bootnode-rpc', region: 'nyc3', status: 'active', ip: '143.198.10.4', sizeUnit: 1, monthlyCents: 1200, droplets: 7, cluster: 'bootnode-k8s' },
]
const findings = [
{ id: 'f-1', severity: 'critical', kind: 'unreferenced-volume', title: 'Three unreferenced volumes', detail: '500 GiB of block storage is referenced by no PV, PVC or droplet.', resource: 'pvc-abandoned-alpha, pvc-abandoned-bravo, pvc-abandoned-charlie', cluster: '', monthlyCents: 5000 },
{ id: 'f-2', severity: 'warn', kind: 'idle-pvc', title: 'Idle PVCs on hanzo-k8s', detail: 'Bound to a PVC but no pod mounts them.', resource: '6 PVCs', cluster: 'hanzo-k8s', monthlyCents: 9000 },
{ id: 'f-3', severity: 'warn', kind: 'released-pv', title: 'Released PVs retained', detail: 'Retain reclaim policy is holding the data.', resource: '11 PVs', cluster: 'lux-k8s', monthlyCents: 13200 },
{ id: 'f-4', severity: 'info', kind: 'cost-outlier', title: 'hanzo-k8s is 28% of fleet spend', detail: 'Largest single cluster by monthly cost.', resource: 'hanzo-k8s', cluster: 'hanzo-k8s', monthlyCents: 480000 },
]
const snapshot = {
at: new Date().toISOString(),
complete: true,
incompleteReason: '',
sources: [
{ name: 'digitalocean', ok: true, rows: 359, error: '', at: new Date().toISOString() },
{ name: 'hanzo-k8s', ok: true, rows: 40, error: '', at: new Date().toISOString() },
],
totals: {
clusters: 8, nodes: 58, volumes: 295, loadBalancers: 4,
volumeGiB: 41200, attachedVolumes: 163, attachedGiB: 16300,
detachedVolumes: 132, detachedGiB: 20120,
unreferencedVolumes: 3, unreferencedGiB: 500,
idlePVCs: 118, localDiskGiB: 9280,
},
cost: { dropletsMonthly: 1284000, volumesMonthly: 412000, loadBalancersMonthly: 7200, totalMonthly: 1703200, reclaimableMonthly: 5000 },
clusters, nodes, volumes, loadBalancers, findings,
}
// ── the spec ──────────────────────────────────────────────────────────────────
/** Mock everything; `/v1/admin/infra` answers with the fixture, all else an empty envelope. */
async function mockFleet(page: import('@playwright/test').Page) {
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/admin/infra' && req.method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: snapshot }) })
}
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 })
}
/**
* Open one tab by URL. This also proves the registry declares the `:tab` route — an
* undeclared tab slug 404s (the v8.4.86 class of bug), which a click-only spec hides.
* URL navigation is also unambiguous: the sidebar carries its own "Clusters" / "Nodes"
* product entries, so a bare button match would be a coin flip.
*/
async function openTab(page: import('@playwright/test').Page, slug: string, tabLabel: string) {
await page.goto(`${BASE_URL}/infra${slug ? `/${slug}` : ''}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Infrastructure').first()).toBeVisible({ timeout: 30_000 })
// The module's own tab bar rendered this tab (and it is the selected one).
await expect(page.getByRole('button', { name: tabLabel, exact: true }).last()).toBeVisible({ timeout: 15_000 })
}
test('infrastructure board renders the fleet, sorts, filters, and gates deletion', async ({ page }) => {
mkdirSync(SHOTS, { recursive: true })
await mockFleet(page)
// ── Overview: the totals + the unmissable local-disk note ───────────────────
await openTab(page, '', 'Overview')
// Cost breakdown: total / droplets / block storage / load balancers / reclaimable.
await expect(page.getByText('$17,032.00').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('$12,840.00').first()).toBeVisible()
await expect(page.getByText('$4,120.00').first()).toBeVisible()
await expect(page.getByText('$72.00').first()).toBeVisible()
// The reclaimable card: the 3 unreferenced volumes ≈ $50/mo, 500 GiB.
await expect(page.getByText('$50.00').first()).toBeVisible()
await expect(page.getByText('3 unreferenced · 500 GiB').first()).toBeVisible()
// Fleet counts.
await expect(page.getByText('8 clusters · 58 nodes').first()).toBeVisible()
await expect(page.getByText('295 volumes · 40.2 TiB').first()).toBeVisible()
// THE distinction: droplet local disk is inside the droplet price, not block storage.
await expect(page.getByText('Droplet local disk is included in the droplet price — it is never billed separately')).toBeVisible()
await expect(page.getByText(/9,280 GiB of local disk is already inside the droplet number/)).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-overview.png'), fullPage: false })
// ── Clusters: sorting a column genuinely REORDERS rows ──────────────────────
await page.getByRole('button', { name: 'Clusters', exact: true }).first().click()
await expect(page.getByText('hanzo-k8s').first()).toBeVisible({ timeout: 15_000 })
// Default sort is Monthly desc → hanzo-k8s ($4,800.00) is first.
const clusterRows = page.locator('.hz-row')
await expect(clusterRows.first()).toContainText('hanzo-k8s')
const beforeSort = (await clusterRows.first().innerText()).trim()
// Click the "Cluster" header → sort by name ASC → bootnode-k8s is first (a different row).
await page.getByLabel('Sort by Cluster').click()
await expect(clusterRows.first()).toContainText('bootnode-k8s', { timeout: 10_000 })
const afterAsc = (await clusterRows.first().innerText()).trim()
expect(afterAsc).not.toBe(beforeSort) // the first row's text genuinely CHANGED
// Click it again → DESC → zoo-k8s is first (the reverse end of the same column).
await page.getByLabel('Sort by Cluster').click()
await expect(clusterRows.first()).toContainText('zoo-k8s', { timeout: 10_000 })
expect((await clusterRows.first().innerText()).trim()).not.toBe(afterAsc)
await page.screenshot({ path: join(SHOTS, 'admin-infra-clusters.png'), fullPage: false })
// ── Nodes: 58 droplets, sortable, with a cordon control ─────────────────────
await page.getByRole('button', { name: 'Nodes', exact: true }).first().click()
await expect(page.getByLabel('Sort by Node')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('button', { name: 'Cordon' }).first()).toBeVisible()
// Sort by vCPU ascending → a 4-vCPU node leads; descending → an 8-vCPU node leads.
const nodeRows = page.locator('.hz-row')
await page.getByLabel('Sort by vCPU').click()
await expect(nodeRows.first()).toContainText('s-4vcpu-8gb', { timeout: 10_000 })
const nodeAsc = (await nodeRows.first().innerText()).trim()
await page.getByLabel('Sort by vCPU').click()
await expect(nodeRows.first()).toContainText('s-8vcpu-16gb', { timeout: 10_000 })
expect((await nodeRows.first().innerText()).trim()).not.toBe(nodeAsc)
await page.screenshot({ path: join(SHOTS, 'admin-infra-nodes.png'), fullPage: false })
// ── Volumes: the unreferenced filter yields EXACTLY 3 ───────────────────────
await page.getByRole('button', { name: 'Volumes', exact: true }).first().click()
await expect(page.getByText('295').first()).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: 'Unreferenced', exact: true }).click()
const volumeRows = page.locator('.hz-row')
await expect(volumeRows).toHaveCount(3, { timeout: 10_000 })
await expect(page.getByText('pvc-abandoned-alpha')).toBeVisible()
await expect(page.getByText('pvc-abandoned-bravo')).toBeVisible()
await expect(page.getByText('pvc-abandoned-charlie')).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-volumes.png'), fullPage: false })
// A DELETABLE volume: the confirm states name + GiB + the monthly cost reclaimed.
await page.getByText('pvc-abandoned-alpha').first().click()
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
const confirmText = page.getByText(/Delete volume “pvc-abandoned-alpha”/)
await expect(confirmText).toBeVisible()
await expect(confirmText).toContainText('200 GiB')
await expect(confirmText).toContainText('$20.00/month')
await expect(confirmText).toContainText('A snapshot is taken first')
await expect(page.getByRole('button', { name: 'Delete pvc-abandoned-alpha' })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-volume-delete.png'), fullPage: false })
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
// A NON-deletable volume: NO delete control anywhere — the blocked reason instead.
await page.getByRole('button', { name: 'Attached', exact: true }).click()
await expect(page.getByText('pvc-attached-000').first()).toBeVisible({ timeout: 10_000 })
await page.getByText('pvc-attached-000').first().click()
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('This volume cannot be deleted')).toBeVisible()
await expect(page.getByText('Attached to a droplet.').first()).toBeVisible()
// The gate, asserted negatively: no delete button, no confirm text, no snapshot toggle.
await expect(page.getByRole('button', { name: /^Delete / })).toHaveCount(0)
await expect(page.getByText(/Delete volume “/)).toHaveCount(0)
await expect(page.getByText('Take a snapshot first')).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
// ── Load balancers ──────────────────────────────────────────────────────────
await page.getByRole('button', { name: 'Load balancers', exact: true }).first().click()
await expect(page.getByText('edge-ingress').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('$36.00').first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-load-balancers.png'), fullPage: false })
// ── Audit: findings grouped by severity, with cost impact ───────────────────
await page.getByRole('button', { name: 'Audit', exact: true }).first().click()
await expect(page.getByText('Three unreferenced volumes').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('critical · 1').first()).toBeVisible()
await expect(page.getByText('warn · 2').first()).toBeVisible()
await expect(page.getByText('info · 1').first()).toBeVisible()
// Group cost impact: the two warns sum to $222.00/mo (9000 + 13200 cents).
await expect(page.getByText('$222.00/mo').first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-audit.png'), fullPage: false })
})
-116
View File
@@ -1,116 +0,0 @@
/**
* 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 })
})
-164
View File
@@ -1,164 +0,0 @@
/**
* 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()
})
-205
View File
@@ -1,205 +0,0 @@
/**
* 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=/SuperAdmin 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)
}
})
})
-170
View File
@@ -1,170 +0,0 @@
/**
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
*
* Three claims, each measured in a real browser rather than inferred from source:
*
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
* viewport) and on the header carrying no assistant control at all.
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
* regression that matters: the rows rendered, hovered, and did nothing, so the
* directory looked interactive and was not. Asserted on where the browser LANDS.
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
* carries an older preferences snapshot — the exact production condition (the
* token is minted at sign-in; a pin made after it is not in it).
*
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
*/
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')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
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()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
await page.route('**/*', mock)
await primeSession(page, claims)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
// Bottom-right quadrant: the whole point of the relocation.
expect(box!.x).toBeGreaterThan(1440 / 2)
expect(box!.y).toBeGreaterThan(900 / 2)
// A comfortable target, not a hairline.
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
// The topbar itself holds no assistant control any more — it used to carry two
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
const inTopbar = await page.evaluate(() =>
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
)
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
// It opens the SAME assistant surface.
await fab.click()
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
await ctx.close()
})
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y).toBeGreaterThan(844 / 2)
const [scrollW, clientW] = await page.evaluate(() => [
document.documentElement.scrollWidth,
document.documentElement.clientWidth,
])
expect(scrollW).toBe(clientW)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
await ctx.close()
})
test('clicking an app in All products opens that app', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await page.getByRole('button', { name: 'All products' }).first().click()
const row = page.getByRole('button', { name: 'Open Agents' })
await expect(row).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
await row.click()
// Where the browser LANDS is the claim — not that a handler fired.
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
await ctx.close()
})
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
// The production condition: the identity token was minted an hour ago and carries a
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
// silently threw away every pin made since — the pin reads as pinned, and is gone
// after a reload.
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
await boot(page, '/', {
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
issuedAt: Math.floor(Date.now() / 1000) - 3600,
})
const openDirectory = async () => {
await page.getByRole('button', { name: 'All products' }).first().click()
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
}
// The snapshot the token carries is what the sidebar starts from.
await openDirectory()
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
.not.toBeNull()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
await openDirectory()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
// …and on what was actually kept (models from the snapshot, agents from the write).
const pins = await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
return (raw.pins ?? []).map((p: { id: string }) => p.id)
})
expect(pins).toContain('agents')
expect(pins).toContain('models')
await ctx.close()
})
-98
View File
@@ -1,98 +0,0 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
*
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
* the two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
* were returned across the whole namespace).
*
* Credentials (env, never in repo). Skips unless BOTH tenants are provided:
* TENANT_A_EMAIL / TENANT_A_PASSWORD (org A)
* TENANT_B_EMAIL / TENANT_B_PASSWORD (org B, a DIFFERENT org)
* BASE_URL default https://console.hanzo.ai
*
* Run: TENANT_A_EMAIL=.. TENANT_A_PASSWORD=.. TENANT_B_EMAIL=.. TENANT_B_PASSWORD=.. pnpm e2e billing-isolation.spec.ts
*/
import { test, expect, type Page } from '@playwright/test'
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
const A = { email: process.env.TENANT_A_EMAIL ?? '', password: process.env.TENANT_A_PASSWORD ?? '' }
const B = { email: process.env.TENANT_B_EMAIL ?? '', password: process.env.TENANT_B_PASSWORD ?? '' }
async function signIn(page: Page, email: string, password: string) {
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')
}
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
* the FIRST path segment, so the two never collide.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
const rows = Array.isArray(body)
? body
: (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)
} catch {
/* non-JSON (e.g. 501 not-configured) — ids stays empty */
}
return { status: res.status, ids }
}, path)
}
test.describe('billing is isolated per tenant through the proxy', () => {
test.skip(
!A.email || !A.password || !B.email || !B.password,
'TENANT_A_* / TENANT_B_* not set — skipping two-tenant billing isolation',
)
test('two distinct-org tenants never see each others subscriptions or payment methods', async ({ browser }) => {
const ctxA = await browser.newContext()
const ctxB = await browser.newContext()
const pageA = await ctxA.newPage()
const pageB = await ctxB.newPage()
await signIn(pageA, A.email, A.password)
await signIn(pageB, B.email, B.password)
// `invoices` is included because its row ids drive the per-invoice PDF URL
// (`/v1/billing/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', 'methods', 'invoices']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
// A 401 would mean the session broke; a 501 means commerce isn't configured
// on this deployment (isolation is vacuously safe — nothing is returned).
expect(a.status, `tenant A /${path} not authorized`).not.toBe(401)
expect(b.status, `tenant B /${path} not authorized`).not.toBe(401)
if (a.status === 501 || b.status === 501) continue
// The core isolation assertion: the two tenants' row-id sets are disjoint.
const overlap = a.ids.filter((id) => b.ids.includes(id))
expect(overlap, `/${path} leaked ${overlap.length} shared rows across tenants`).toEqual([])
}
await ctxA.close()
await ctxB.close()
})
})
-331
View File
@@ -1,331 +0,0 @@
/**
* 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|SuperAdmin 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/methods',
'/v1/billing/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 superadmin-access state)', async () => {
// For a non-global-admin this is honestly `SuperAdminRequired` — that IS a pass.
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|SuperAdmin 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([])
})
})
-165
View File
@@ -1,165 +0,0 @@
/**
* 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']
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([])
})
})
-136
View File
@@ -1,136 +0,0 @@
/**
* 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/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/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 alerts contract.
if (path === '/v1/billing/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()
})
-171
View File
@@ -1,171 +0,0 @@
/**
* 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()
})
-211
View File
@@ -1,211 +0,0 @@
/**
* e2e: brand-forward chrome + voice — mocked-network render proof.
*
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
* of it in a browser.
*
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
* (tour / onboarding / org) that otherwise overlay the page; `/v1/billing/usage` →
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
* the assistant's model list; everything else → an empty-ok envelope.
*
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
* renders — the exact gate `src/lib/voice.test.ts` pins — and it records
* `recognition.start()` calls on `window.__voiceStarted` so the mic → startVoice →
* conversation wiring can be asserted end to end.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test chrome-brand-voice
* (requireFixtureServer skips the file when no local server is reachable.)
*/
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) so the dock's
* Overview loads its ledger (never the empty state) and the "Create key" card shows. */
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 },
},
],
}
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 }) })
}
/**
* A minimal, inert Web Speech stub installed BEFORE the page scripts run, so
* `voiceSupported()` returns true under headless chromium (which ships no
* webkitSpeechRecognition) and the "Talk to Hanzo" mic renders deterministically.
* `start()` bumps `window.__voiceStarted` so the mic → voice wiring is assertable.
*/
function installVoiceStub(page: Page) {
return page.addInitScript(() => {
class FakeRecognition {
lang = ''
continuous = false
interimResults = false
onresult: unknown = null
onerror: unknown = null
onend: unknown = null
start() {
const w = window as unknown as { __voiceStarted?: number }
w.__voiceStarted = (w.__voiceStarted ?? 0) + 1
}
stop() {}
abort() {}
}
const w = window as unknown as Record<string, unknown>
w.SpeechRecognition = FakeRecognition
w.webkitSpeechRecognition = FakeRecognition
})
}
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
async function openHome(page: Page, waitForMount = true) {
await installVoiceStub(page)
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
if (waitForMount) {
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
}
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
// assistant column are all present (they are desktop-only concerns).
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
const page = await ctx.newPage()
await openHome(page)
// 1. The OLD floating circle is GONE — the bubble that covered page content.
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
// true) — and the topbar carries no assistant control at all. The two used to live
// up there beside the search box, which put the assistant in a third place.
// Scoped to the control itself: the assistant's own composer carries a mic with
// the same label, mounted-but-hidden until the panel opens, so a bare
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
const fab = page.getByTestId('assistant-fab')
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
await expect(brand).toBeVisible()
await expect(brand.locator('svg, img').first()).toBeVisible()
// 4. The Developers dock: the always-there bar opens into the drawer with the
// drag-to-resize handle and a LIVE "Create key" in the Overview tab.
await expect(page.locator('text=Developers').first()).toBeVisible()
await page.getByRole('button', { name: 'Open the workbench' }).first().click()
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+). The
// floating control then steps aside: at lg+ the docked column IS the assistant,
// so keeping a button to open it on top of itself would be a second way in.
await fab.locator('[aria-label="Ask Hanzo"]').click()
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
// recognition opens (voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
await expect
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
.toBeGreaterThan(0)
await page.screenshot({ path: join(SHOTS, 'chrome-open.png') })
await ctx.close()
})
test('renders across breakpoints with no horizontal body scroll on a phone; screenshots at 390 / 768 / 1280 / 1680', async ({ browser }) => {
const viewports = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'laptop', width: 1280, height: 900 },
{ name: 'desktop', width: 1680, height: 1050 },
] as const
for (const v of viewports) {
const ctx = await browser.newContext({ viewport: { width: v.width, height: v.height } })
const page = await ctx.newPage()
// Don't hard-fail the mount wait here — the screenshot is captured either way
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
await openHome(page, false)
await page
.locator('[aria-label="Ask Hanzo"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {})
await page.screenshot({ path: join(SHOTS, `chrome-${v.name}.png`) })
if (v.width === 390) {
// The mobile regression this guards: the body must never scroll sideways.
const noHorizontalScroll = await page.evaluate(() => {
const el = document.scrollingElement ?? document.documentElement
return el.scrollWidth <= window.innerWidth + 1
})
expect(noHorizontalScroll).toBe(true)
}
await ctx.close()
}
})
-302
View File
@@ -1,302 +0,0 @@
/**
* e2e: Hanzo Cloud Console — login → API key → AI inference
*
* 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.
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai
* HANZO_PASSWORD required
* HANZO_API_KEY optional; skip UI flow, go straight to inference
* HANZO_API_BASE default https://api.hanzo.ai
* BASE_URL default https://console.hanzo.ai
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const API_KEY = process.env.HANZO_API_KEY ?? ''
const API_BASE = process.env.HANZO_API_BASE ?? 'https://api.hanzo.ai'
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")')
}
/** Wait until we're on the dashboard (route replaced to '/'). */
async function waitForDashboard(page: Page) {
const base = new URL(BASE_URL).origin
await page.waitForURL(url => url.origin === base && url.pathname === '/', { timeout: 30_000 })
// The dashboard renders product grid cards — wait for at least one visible card/link
await page.waitForLoadState('domcontentloaded')
}
// ─── tests ──────────────────────────────────────────────────────────────────
// Public smoke — needs no credentials, so it always runs (in CI, for Dave, etc.)
// and catches a dead/blank sign-in gate. The authenticated flows below gate on
// HANZO_PASSWORD.
test.describe('Hanzo Cloud Console — public', () => {
test('sign-in page renders (email/password + OAuth + passkey)', async ({ page }) => {
await page.goto(`${BASE_URL}/signin`)
await expect(page.locator('input[placeholder="Email"]')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('input[placeholder="Password"]')).toBeVisible()
await expect(page.locator('button:has-text("Sign in")')).toBeVisible()
await expect(page.locator('button:has-text("Continue with GitHub")')).toBeVisible()
await expect(page.locator('button:has-text("Continue with Google")')).toBeVisible()
await expect(page.locator('text=/passkey/i')).toBeVisible()
})
test('root serves the app (200, dark #0a0a0a) + /base resolves', async ({ page, request }) => {
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="#000000"]')).toHaveCount(1)
expect((await request.get(`${BASE_URL}/base`)).status()).toBe(200)
})
// 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('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 }) => {
const res = await request.get(`${BASE_URL}/no-such-surface-xyz`)
expect(res.status()).toBeLessThan(500)
})
})
test.describe('Hanzo Cloud Console e2e', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping live authenticated tests')
test('login as z@hanzo.ai — dashboard renders', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// Dashboard renders — sign-in form must be gone
await expect(page.locator('input[placeholder="Password"]')).not.toBeVisible({ timeout: 10_000 })
// At least one product category or card is visible (Overview / AI / Compute etc.)
await expect(
page.locator('a, button, [role="link"]').filter({ hasText: /models|providers|overview|AI/i }).first()
).toBeVisible({ timeout: 15_000 })
console.log('✓ Signed in; dashboard is rendering')
})
test('admin banner visible (z is isAdmin on console.hanzo.ai)', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// 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)
if (visible) {
console.log('✓ Admin banner visible')
await expect(page.locator('button:has-text("Open admin")')).toBeVisible()
} else {
console.log(' Admin banner was dismissed (localStorage) — OK')
}
})
test('create or confirm API key', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// API Keys module is at /api-keys (catch-all route, id='api-keys')
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
// Wait for the module to hydrate — look for the page header or key cards
await expect(
page.locator('text=/API Keys/i, text=/Cloud API key/i, text=/Create.*API key/i').first()
).toBeVisible({ timeout: 25_000 })
const hasKey = page.locator('text=/Cloud API key/i')
const noKey = page.locator('text=/Create your Cloud API key/i')
const createBtn = page.locator('button:has-text("Create API key")')
const needsCreate = await noKey.isVisible({ timeout: 3_000 }).catch(() => false)
|| await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the sk- key
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (sk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
test('API key works — GET /v1/models', async ({ page }) => {
let apiKey = API_KEY
if (!apiKey) {
await signIn(page)
await waitForDashboard(page)
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
await expect(
page.locator('text=/API Keys/i').first()
).toBeVisible({ timeout: 25_000 })
// Rotate (or create) to show the full key on-screen
const rotateBtn = page.locator('button:has-text("Rotate")')
const createBtn = page.locator('button:has-text("Create API key")')
if (await rotateBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await rotateBtn.click()
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full sk- token (no `…`/`...`).
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
// Verify the key works against api.hanzo.ai
const resp = await page.request.get(`${API_BASE}/v1/models`, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
timeout: 30_000,
})
expect(resp.ok(), `GET /v1/models → ${resp.status()}`).toBe(true)
const json = await resp.json()
expect(json).toMatchObject({ data: expect.any(Array) })
const models: Array<{ id: string }> = json.data
expect(models.length).toBeGreaterThan(0)
const ids = models.map(m => m.id)
console.log(`✓ /v1/models: ${models.length} models`)
console.log(` GLM present: ${ids.some(id => id.includes('glm'))}`)
console.log(` Claude present: ${ids.some(id => id.includes('claude'))}`)
console.log(` DeepSeek present: ${ids.some(id => id.includes('deepseek'))}`)
console.log(` First 5: ${ids.slice(0, 5).join(', ')}`)
})
test('OpenAI inference — glm-5.2', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
model: 'glm-5.2',
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
stream: false,
},
timeout: 30_000,
})
expect(resp.ok(), `glm-5.2 → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.choices?.[0]?.message?.content ?? ''
expect(text).toBeTruthy()
console.log(`✓ glm-5.2: "${text.trim()}"`)
})
test('OpenAI inference — deepseek-v4-pro', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
stream: false,
},
timeout: 30_000,
})
expect(resp.ok(), `deepseek-v4-pro → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.choices?.[0]?.message?.content ?? ''
expect(text).toBeTruthy()
console.log(`✓ deepseek-v4-pro: "${text.trim()}"`)
})
test('Anthropic-compat /v1/messages — live catalog model', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
// Pick a model that is ACTUALLY available right now (the catalog changes;
// a hardcoded id like claude-sonnet-4-6 fails when it isn't provisioned).
// The /v1/messages Anthropic surface accepts any catalog model.
const listed = await page.request.get(`${API_BASE}/v1/models`, {
headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
timeout: 30_000,
})
const ids: string[] = (await listed.json()).data?.map((m: { id: string }) => m.id) ?? []
const model = ids.find((id) => id.includes('claude')) ?? ids.find((id) => id === 'glm-5.2') ?? ids[0]
expect(model, 'no model available in catalog').toBeTruthy()
const resp = await page.request.post(`${API_BASE}/v1/messages`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'anthropic-version': '2023-06-01',
},
data: {
model,
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
},
timeout: 30_000,
})
expect(resp.ok(), `/v1/messages (${model}) → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.content?.[0]?.text ?? ''
expect(text).toBeTruthy()
console.log(`✓ Anthropic-compat /v1/messages (${model}): "${text.trim()}"`)
})
})
-141
View File
@@ -1,141 +0,0 @@
/**
* 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
}
-80
View File
@@ -1,80 +0,0 @@
/**
* e2e regression: the reported launch bug — product routes must render on a
* DIRECT URL load and a browser REFRESH, not only via in-app navigation.
*
* Product modules mount client-only under the catch-all route, so a throw in one
* module's first render used to bubble to Next's root fallback and white-screen
* the whole console with "Application error: a client-side exception has
* occurred" — but ONLY on a direct load / refresh (in-app nav renders fresh and
* hid it). `ProductErrorBoundary` + the dashboard `error.tsx` close that class:
* even a module throw now keeps the shell and shows a retryable card, never a
* white screen. This spec proves each target route:
* 1. direct-loads without a client-exception white-screen,
* 2. refreshes (F5) without one,
* 3. still deep-links its own real content (shell + main region present).
*
* Credentials (env, never in repo): HANZO_EMAIL / HANZO_PASSWORD, BASE_URL.
* Run: HANZO_PASSWORD=xxx BASE_URL=https://console.hanzo.ai pnpm e2e deeplink-refresh.spec.ts
*/
import { test, expect, 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'
// The three routes from the report, plus controls known to deep-link fine.
const TARGETS = ['/playground', '/prompts', '/gpus']
const CONTROLS = ['/models', '/providers']
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
// @hanzo/gui Input binds onChangeText — real keystrokes, not fill().
await page.locator('input[placeholder="Email"]').pressSequentially(EMAIL, { delay: 12 })
await page.locator('input[placeholder="Password"]').pressSequentially(PASSWORD, { delay: 12 })
await page.click('button:has-text("Sign in")')
const origin = new URL(BASE_URL).origin
await page.waitForURL((u) => u.origin === origin && u.pathname === '/', { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
/** Assert the page rendered the console (shell + main) and did NOT white-screen. */
async function assertRendered(page: Page, route: string, phase: string) {
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// No client-exception white-screen.
await expect(
page.locator('text=/Application error|client-side exception|Unhandled Runtime Error/i'),
`${route} (${phase}) must not white-screen`,
).toHaveCount(0)
// Shell survived: the persistent nav ("Overview"/"Apps") is present.
const body = (await page.locator('body').innerText().catch(() => '')) || ''
expect(body.length, `${route} (${phase}) has content`).toBeGreaterThan(200)
expect(body, `${route} (${phase}) kept the shell`).toMatch(/Overview|Apps|Sign out/i)
}
test.describe('deep-link + refresh must not crash', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated deep-link pass')
test.beforeEach(async ({ page }) => {
await signIn(page)
})
for (const route of [...TARGETS, ...CONTROLS]) {
test(`direct load + refresh: ${route}`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(String(e)))
// 1) DIRECT URL load (full navigation, fresh document).
const res = await page.goto(`${BASE_URL}${route}`, { waitUntil: 'domcontentloaded' })
expect(res?.status() ?? 0, `${route} HTTP`).toBeLessThan(500)
await assertRendered(page, route, 'direct')
// 2) REFRESH (F5) — the reported failing action.
await page.reload({ waitUntil: 'domcontentloaded' })
await assertRendered(page, route, 'refresh')
// An uncaught pageerror on these routes is the regression we are locking out.
expect(errors, `${route} uncaught pageerror(s): ${errors.join(' | ').slice(0, 300)}`).toEqual([])
})
}
})
-311
View File
@@ -1,311 +0,0 @@
/**
* The design gate — the invariants of the shell, asserted on COMPUTED STYLE and
* GEOMETRY in a real browser.
*
* This spec is the deliverable, not the screenshots. Every rule below was a real
* defect measured on the running console, and a rule that only lives in a review
* comes back. A status code proves a server answered; this proves a human can
* read the page.
*
* WHAT IT PINS
* 1. No all-caps, anywhere — neither `text-transform: uppercase` nor a string
* TYPED in caps. This is the hard rule and the whole reason the file exists.
* 2. ONE type scale, ONE radius scale, ONE spacing ramp — asserted as
* membership, so a new value cannot be introduced without deciding to.
* 3. Every stacking layer resolves to the ladder in app/design/z.css, never a
* literal. The console had drifted to 9999 / 100000 / 100001 / 100002.
* 4. The overlays actually paint: opaque background, on-screen box. Two of
* tonight's bugs were a control that rendered identically in both states and
* a footer that ate clicks while returning 200.
* 5. Contrast is computed from the colours that actually painted.
* 6. The body never scrolls sideways, at 1440 or at 390.
*
* KNOWN EXEMPTIONS, each deliberate and narrow:
* - Acronyms (`API`, `GPU`, `CIDR`, …) are not shouting; the allow-list is
* explicit so a new one is a decision, not an accident.
* - An avatar/brand MONOGRAM scales with its circle — it is a graphic, not app
* text — so text-size membership skips it. Marking it takes BOTH a
* `[data-monogram]` ancestor AND text of at most three characters, so a marker
* placed around a whole distributed component (the only place it CAN go, since
* @hanzo/ui paints the org mark itself) still cannot exempt that component's
* labels — only its glyph.
* - Tamagui's `circular` variant compiles to a 100000px radius; that is the
* same concept as our pill token, so both count as "pill".
* - Next's dev overlay injects its own chrome; specs run against the app root.
* - An `aria-hidden` subtree is not content. A CLOSED drawer parks off screen by
* design — the nav drawer at x = -320, the account drawer at x = 390 — which is
* how a slide-over animates, not a clip.
* - The ladder governs where OUR chrome sits. A library ordering its own
* internals is its business: @hanzo/gui's Dialog puts its overlay at 1 and its
* content at 2 INSIDE its portal, so the rule applies above 10. And the Gui
* portal HOST itself is pinned to a hardcoded 105001 that no console config can
* reach — REPORTED as a library finding, excluded here by its own class marker
* rather than by raising the ceiling and quietly letting our literals back in.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Genuine acronyms — capitalised because that is how they are spelled. */
const ACRONYMS = new Set([
'AI', 'ML', 'LLM', 'API', 'SDK', 'IDE', 'CLI', 'CDN', 'DNS', 'DNSSEC', 'TTL', 'CIDR', 'VPC',
'IAM', 'KMS', 'HSM', 'MPC', 'SSO', 'MFA', 'TOTP', 'OIDC', 'PKCE', 'JWT', 'CSRF', 'SAFE',
'CPU', 'GPU', 'GPUS', 'RAM', 'SSD', 'VRAM', 'PVC', 'S3', 'KV', 'SQL', 'URL', 'URI', 'JSON',
'HTTP', 'HTTPS', 'POST', 'CNAME', 'AAAA', 'P95', 'P99', 'MRR', 'SKU', 'OSS', 'CRM', 'ERP',
'CMS', 'RAG', 'OTEL', 'OTLP', 'RED', 'ZIP', 'PDF', 'CSV', 'ID', 'IDS', 'UI', 'UX', 'WCAG',
])
/** The ONE type scale (gui.config.ts FONT_SIZE + app/design/typography.css). */
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
/** The ONE radius scale: control · input/row · panel · pill. */
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
/** The ONE spacing ramp (gui.config.ts STEP). */
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
/** The ladder in app/design/z.css — the only stacking values that may paint. */
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
type Audit = {
capsComputed: string[]
capsTyped: string[]
offType: { size: number; text: string }[]
offRadius: { radius: number; cls: string }[]
offSpace: { pad: number; cls: string }[]
offZ: { z: number; cls: string }[]
lowContrast: { ratio: number; text: string; fg: string; bg: string }[]
hScroll: boolean
bodyBg: string
}
/** Runs entirely in the page: reads what PAINTED, never what the source says. */
async function audit(page: Page, acronyms: string[]): Promise<Audit> {
return page.evaluate((acr) => {
const ACR = new Set(acr)
const out: Audit = {
capsComputed: [], capsTyped: [], offType: [], offRadius: [], offSpace: [],
offZ: [], lowContrast: [], hScroll: false, bodyBg: '',
}
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
const cls = (el: Element) => (typeof el.className === 'string' ? el.className.slice(0, 90) : el.tagName)
const px = (v: string) => Math.round(parseFloat(v) || 0)
const rendered = (el: Element) => !!(el as HTMLElement).offsetParent || el === document.body
// sRGB relative luminance → WCAG contrast ratio.
const lum = (c: string) => {
const m = c.match(/[\d.]+/g)
if (!m || m.length < 3) return null
if (m.length > 3 && parseFloat(m[3]) === 0) return null // fully transparent
const [r, g, b] = m.slice(0, 3).map((n) => {
const s = parseFloat(n) / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** The nearest ancestor that actually paints a background. */
const bgOf = (el: Element): string => {
for (let e: Element | null = el; e; e = e.parentElement) {
const b = getComputedStyle(e).backgroundColor
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
}
return getComputedStyle(document.body).backgroundColor
}
for (const el of Array.from(document.querySelectorAll('body *'))) {
if (el.closest('nextjs-portal, [data-nextjs-toast], [aria-hidden="true"]')) continue
const cs = getComputedStyle(el)
if (cs.display === 'none' || cs.visibility === 'hidden') continue
const leaf = el.children.length === 0
const text = (el.textContent || '').trim()
// 1 · caps
if (cs.textTransform === 'uppercase' && leaf && text) out.capsComputed.push(text.slice(0, 48))
// 2 · scales — only on nodes that actually paint
if (rendered(el)) {
const isMonogram = text.length <= 3 && !!el.closest('[data-monogram]')
if (leaf && text && !isMonogram && !el.closest('svg')) {
const s = px(cs.fontSize)
if (!TYPE.has(s)) out.offType.push({ size: s, text: text.slice(0, 40) })
// 5 · contrast, on the colours that painted
const f = lum(cs.color)
const b = lum(bgOf(el))
if (f !== null && b !== null) {
const ratio = (Math.max(f, b) + 0.05) / (Math.min(f, b) + 0.05)
const large = s >= 21 || (s >= 17 && Number(cs.fontWeight) >= 700)
if (ratio < (large ? 3 : 4.5)) {
out.lowContrast.push({ ratio: Math.round(ratio * 100) / 100, text: text.slice(0, 32), fg: cs.color, bg: bgOf(el) })
}
}
}
const r = px(cs.borderTopLeftRadius)
if (r && !RADIUS.has(r)) out.offRadius.push({ radius: r, cls: cls(el) })
for (const p of [cs.paddingLeft, cs.paddingTop]) {
const v = px(p)
if (v && !SPACE.has(v)) out.offSpace.push({ pad: v, cls: cls(el) })
}
}
// 3 · stacking — only where a layer actually paints, only above a library's
// own local ordering, and never the Gui portal host (see the header).
const guiPortalHost = typeof el.className === 'string' && el.className.includes('_dsp_contents')
if (cs.zIndex !== 'auto' && !guiPortalHost) {
const z = Number(cs.zIndex)
if (z > 10 && !Z.has(z)) out.offZ.push({ z, cls: cls(el) })
}
}
// 1b · caps TYPED into a string
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
let n: Node | null
while ((n = w.nextNode())) {
if ((n.parentElement as HTMLElement | null)?.closest('nextjs-portal, [data-monogram]')) continue
const s = (n.nodeValue || '').trim()
if (s.length < 4 || !/^[A-Z][A-Z0-9 &/·—-]+$/.test(s) || !/[A-Z]{4,}/.test(s)) continue
if (!s.split(/[^A-Z0-9]+/).every((p) => !p || ACR.has(p))) out.capsTyped.push(s.slice(0, 48))
}
out.hScroll = document.documentElement.scrollWidth > document.documentElement.clientWidth
out.bodyBg = getComputedStyle(document.body).backgroundColor
return out
}, acronyms)
}
/** Land on a dashboard route with a primed session and let the SPA settle. */
async function open(page: Page, path: string): Promise<void> {
await page.route('**/v1/**', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[],"items":[],"status":"ok"}' }))
await primeSession(page)
await page.goto(path, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('nav[aria-label="Products"]', { timeout: 45_000 }).catch(() => {})
await page.waitForTimeout(6000)
}
const dedupe = <T,>(xs: T[]): T[] => Array.from(new Set(xs.map((x) => JSON.stringify(x)))).map((s) => JSON.parse(s) as T)
// The shell's real states. Level 1 is the rail + home; drilled is the second-level
// nav; settings is the panel-of-rows surface; the palette is the top overlay.
const STATES: [name: string, path: string][] = [
['level 1', '/'],
['drilled', '/agents'],
['settings panels', '/agents/settings'],
]
for (const [name, path] of STATES) {
test(`${name} — no caps, one scale, one ladder`, async ({ page }) => {
await open(page, path)
const a = await audit(page, [...ACRONYMS])
// THE HARD RULE. No exceptions, no text-transform, no typed caps.
expect(dedupe(a.capsComputed), 'text-transform: uppercase').toEqual([])
expect(dedupe(a.capsTyped), 'strings typed in caps').toEqual([])
// ONE of each scale.
expect(dedupe(a.offType), 'font-size off the type scale').toEqual([])
expect(dedupe(a.offRadius), 'border-radius off the radius scale').toEqual([])
expect(dedupe(a.offSpace), 'padding off the 4px ramp').toEqual([])
expect(dedupe(a.offZ), 'z-index not from the --z-* ladder').toEqual([])
// Readable, on the black canvas the brief asks for.
expect(a.bodyBg).toBe('rgb(0, 0, 0)')
expect(dedupe(a.lowContrast), 'text below WCAG AA against its painted background').toEqual([])
expect(a.hScroll, 'the body must never scroll sideways').toBe(false)
})
}
test('the command palette paints, is on screen, and shouts at nobody', async ({ page }) => {
await open(page, '/')
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k')
await page.waitForTimeout(1200)
// It must actually PAINT — a transparent, unstacked overlay is the library
// failure mode this repo has already been bitten by twice.
const box = await page.evaluate(() => {
// Several dialogs live in the DOM at rest — the nav drawer is parked OFF
// screen at x = -320 — so pick the one that is actually on screen.
const el = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]')).find((d) => {
const r = d.getBoundingClientRect()
return r.width > 240 && r.height > 40 && r.left >= 0 && r.right <= innerWidth + 1 &&
getComputedStyle(d).visibility !== 'hidden' && getComputedStyle(d).display !== 'none'
}) ?? null
if (!el) return null
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const bg = (() => {
for (let e: Element | null = el; e; e = e.parentElement) {
const b = getComputedStyle(e).backgroundColor
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
}
return 'rgba(0, 0, 0, 0)'
})()
return { bg, z: cs.zIndex, x: r.x, y: r.y, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
})
expect(box, 'the palette did not open').not.toBeNull()
expect(box!.bg, 'the palette rendered transparent').not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
expect(box!.w).toBeGreaterThan(240)
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.w).toBeLessThanOrEqual(box!.vw + 1)
const a = await audit(page, [...ACRONYMS])
expect(dedupe(a.capsComputed)).toEqual([])
expect(dedupe(a.capsTyped)).toEqual([])
expect(dedupe(a.offZ), 'the palette must sit on the ladder').toEqual([])
})
test('the rail is keyboard-reachable and a collapsed section is out of the tab order', async ({ page }) => {
await open(page, '/')
const reach = await page.evaluate(() => {
const nav = document.querySelector('nav[aria-label="Products"]') as HTMLElement | null
if (!nav) return null
const focusable = Array.from(nav.querySelectorAll<HTMLElement>('button, a[href], [tabindex]:not([tabindex="-1"])'))
.filter((el) => el.offsetParent !== null)
// Rows inside a collapsed accordion are `inert` — present, but not tabbable.
const inertRows = Array.from(nav.querySelectorAll('.hz-acc[data-open="false"] button')).length
const inertTabbable = Array.from(nav.querySelectorAll<HTMLElement>('.hz-acc[data-open="false"] button'))
.filter((el) => el.offsetParent !== null && !el.closest('[inert]')).length
// Every reachable row must be inside the viewport — a control tab lands on
// but cannot be seen is the same defect as one that cannot be reached.
const offscreen = focusable.filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && (r.right < 0 || r.left > innerWidth || r.bottom < 0)
}).length
return { count: focusable.length, offscreen, inertRows, inertTabbable }
})
expect(reach, 'no rail found').not.toBeNull()
expect(reach!.count, 'the rail has no keyboard-reachable rows').toBeGreaterThan(3)
expect(reach!.offscreen, 'a rail row is focusable but painted off screen').toBe(0)
expect(reach!.inertTabbable, 'a collapsed section leaked rows into the tab order').toBe(0)
})
test('nothing scrolls sideways on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await open(page, '/')
const a = await audit(page, [...ACRONYMS])
expect(a.hScroll).toBe(false)
expect(dedupe(a.capsComputed)).toEqual([])
expect(dedupe(a.capsTyped)).toEqual([])
// Painted past the right edge is only a defect when nothing can scroll to it.
// Wide content (a DataTable, a code block) is REQUIRED to scroll inside its own
// container, and DataTable already does — that is correct, not a clip.
const overflow = await page.evaluate(() => {
const scrollable = (el: Element) => {
for (let e: Element | null = el.parentElement; e; e = e.parentElement) {
const ox = getComputedStyle(e).overflowX
if (ox === 'auto' || ox === 'scroll') return true
}
return false
}
return Array.from(document.querySelectorAll('body *'))
.filter((el) => {
if (el.closest('[aria-hidden="true"]')) return false
const r = el.getBoundingClientRect()
return r.width > 0 && r.right > innerWidth + 1 && !scrollable(el)
})
.slice(0, 6)
.map((el) => (el.textContent || el.tagName).trim().slice(0, 44))
})
expect(overflow, 'clipped past the right edge with nothing to scroll it').toEqual([])
})
-101
View File
@@ -1,101 +0,0 @@
/**
* 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()
})
-397
View File
@@ -1,397 +0,0 @@
/**
* e2e: find and do — pin, sort, filter, search, and the keyboard that drives them.
*
* Every claim here is measured in a real browser on COMPUTED STYLE and GEOMETRY,
* because the failures this lane exists to prevent are invisible to a status code:
* a pin that reports success and is gone after a reload, an affordance that renders
* at zero opacity forever, a control painted off its own row.
*
* Local fixture server + mocked network; `primeSession` supplies the IAM-PKCE
* identity. The preference PATCH is mocked to echo nothing, which is the HONEST
* worst case — it is exactly the condition (an account that never reports the key
* back) under which pins used to be lost.
*
* Run: BASE_URL=http://localhost:4300 npx playwright test find-and-do
*/
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:4300'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
/** A small real-shaped model catalog, enough for the Models + Marketplace lists. */
const MODELS = {
object: 'list',
data: [
{ id: 'zen5', owned_by: 'hanzo' },
{ id: 'zen5-mini', owned_by: 'hanzo' },
{ id: 'anthropic/claude-opus-4.6', owned_by: 'anthropic' },
{ id: 'qwen3.5-397b', owned_by: 'hanzo' },
],
}
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname === '/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(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/**
* Sign in and land on `path`. `ready` is what proves the signed-in shell mounted —
* it defaults to the rail's own pin affordances, which exist only on the DESKTOP
* rail (below lg the nav is a drawer), so a mobile viewport passes its own signal.
*/
async function boot(page: Page, path = '/', ready?: () => Promise<void>) {
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
if (ready) return ready()
await expect(page.getByRole('button', { name: /^(Pin|Unpin) / }).first()).toBeVisible({ timeout: 30_000 })
}
/**
* The palette, opened by the real ⌘K path rather than by clicking chrome.
*
* ⌘K is a TOGGLE bound on `window`, so a press that lands mid-navigation (before the
* destination route has mounted its listener) is simply lost. Retrying the real
* gesture is honest — it still proves the shortcut works — where a single press would
* only prove the test's timing.
*/
/** The one mounted palette dialog (the one that owns the search input). */
const palette = (page: Page) =>
page
.locator('[role="dialog"]')
.filter({ has: page.getByPlaceholder('Search apps and commands…') })
.last()
async function openPalette(page: Page) {
const input = page.getByPlaceholder('Search apps and commands…')
await expect(async () => {
await page.keyboard.press('ControlOrMeta+k')
await expect(input).toBeVisible({ timeout: 2_000 })
}).toPass({ timeout: 20_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('a pin survives a reload — the account is silent, the cache is not', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const pin = page.getByRole('button', { name: 'Pin Agents' }).first()
await expect(pin).toBeVisible()
await pin.click()
// It reads as pinned immediately…
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible()
// …and is STILL pinned after a full reload. Before this lane's fix the account's
// (silent) view overwrote the cache here and the pin was gone.
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible({ timeout: 30_000 })
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}'),
)
expect(stored.pins.map((p: { id: string }) => p.id)).toContain('agents')
// Unpinning is just as durable, so the state is genuinely the user's, not sticky.
await page.getByRole('button', { name: 'Unpin Agents' }).first().click()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Pin Agents' }).first()).toBeVisible({ timeout: 30_000 })
await ctx.close()
})
test('typing a product name opens that product — pins never outrank what you typed', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
// Models and Chat are pinned by default. Before this was split, `pinnedFirst` was
// applied to the RANKED list too, so a barely-matching pinned product outranked an
// exact name match and "billing" + ↵ opened /models. Enter is the honest probe:
// it asserts on where the user actually lands, not on DOM order.
for (const [query, path] of [
['agents', '/agents'],
['billing', '/billing'],
['vector', '/vector'],
]) {
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill(query)
await expect(page.locator('#cmdk-active').first()).toBeVisible()
await page.keyboard.press('Enter')
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
.toBe(path)
}
await ctx.close()
})
test('the default view leads with pins, and a result can be pinned without leaving', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
// A product query (not one that also matches a verb like "ask"/"apps"), so the
// selection lands on a destination — actions rank first and carry no pin.
await page.getByPlaceholder('Search apps and commands…').fill('agents')
await expect(page.locator('#cmdk-active')).toBeVisible()
// The pin on the SELECTED result: a real control, at the row's RIGHT edge.
const activeRow = page.locator('#cmdk-active')
const pinBtn = activeRow.getByRole('button', { name: /^(Pin|Unpin) / })
await expect(pinBtn).toHaveCount(1)
const rowBox = await activeRow.boundingBox()
const pinBox = await pinBtn.boundingBox()
expect(rowBox).not.toBeNull()
expect(pinBox).not.toBeNull()
// Right edge: the pin sits in the last quarter of its row, and inside it.
expect(pinBox!.x).toBeGreaterThan(rowBox!.x + rowBox!.width * 0.75)
expect(pinBox!.x + pinBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1)
// Vertically centred on its own row, not floating above or below it.
const rowMid = rowBox!.y + rowBox!.height / 2
const pinMid = pinBox!.y + pinBox!.height / 2
expect(Math.abs(rowMid - pinMid)).toBeLessThan(4)
// A real hit target, not a 2px sliver.
expect(pinBox!.width).toBeGreaterThanOrEqual(20)
expect(pinBox!.height).toBeGreaterThanOrEqual(20)
const label = (await pinBtn.getAttribute('aria-label')) ?? ''
const product = label.replace(/^(Pin|Unpin) /, '')
// ⌥↵ pins the selection and KEEPS the palette open — curating is repeatable.
await page.keyboard.press('Alt+Enter')
await expect(page.getByPlaceholder('Search apps and commands…')).toBeVisible()
await expect(activeRow.getByRole('button', { name: `Unpin ${product}` })).toHaveCount(1)
await expect(activeRow.getByRole('button', { name: /^(Pin|Unpin) / })).toHaveAttribute('aria-pressed', 'true')
await page.screenshot({ path: join(SHOTS, 'palette-pin.png') })
// Clear the query: the default view collects the pins into one leading section,
// under the same word the sidebar uses.
await page.getByPlaceholder('Search apps and commands…').fill('')
// Scoped to the PALETTE. The sidebar has its own "Pinned" heading, so an unscoped
// text match here would pass whether or not the palette groups anything at all —
// and an assertion that can pass for the wrong reason is worse than no assertion.
//
// Read via textContent, not a text locator: the section labels are uppercased in
// CSS, so `getByText('Pinned')` matches the rendered "PINNED" inconsistently.
const scan = await page.evaluate(() => {
const inputs = Array.from(document.querySelectorAll('input')).filter((i) =>
(i.getAttribute('placeholder') ?? '').startsWith('Search apps'),
)
const host = inputs[0]?.closest('[role="dialog"]')
if (!host) return null
const text = Array.from(host.querySelectorAll('*'))
.filter((e) => e.children.length === 0)
.map((e) => (e.textContent ?? '').trim())
.filter(Boolean)
return { palettes: inputs.length, hasPinnedSection: text.includes('Pinned') }
})
expect(scan).not.toBeNull()
// Exactly one palette is mounted, so nothing read here can be a stale copy.
expect(scan!.palettes).toBe(1)
// The pins are collected under their own heading rather than scattered through
// the categories.
expect(scan!.hasPinnedSection).toBe(true)
await page.screenshot({ path: join(SHOTS, 'palette-pinned-first.png') })
// And the selection starts on a PINNED product, so ↵ on the untouched default
// view goes somewhere the user chose. `models` is pinned out of the box.
await page.keyboard.press('Enter')
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/models')
await ctx.close()
})
test('the row pin is quiet until reached, and lit while pinned', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill('agents')
await expect(page.locator('#cmdk-active')).toBeVisible()
// A result that is NOT the keyboard selection: its pin is painted at zero opacity
// — present in the DOM and reachable, but not drawn.
const rows = page.locator('.hz-row-pin')
const count = await rows.count()
let restingOpacity: number | null = null
for (let i = 0; i < count; i++) {
const row = rows.nth(i)
if ((await row.getAttribute('id')) === 'cmdk-active') continue
const quiet = row.locator('.hz-pin')
if ((await quiet.count()) === 0) continue
restingOpacity = await quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity))
// Hovering the ROW reveals it — the affordance appears where the eye already is.
await row.hover()
await expect
.poll(async () => quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity)))
.toBeGreaterThan(0.9)
break
}
expect(restingOpacity).not.toBeNull()
expect(restingOpacity).toBeLessThan(0.05)
// The SELECTED row's pin is drawn without any hover — the keyboard user is never
// shown an empty row where the mouse user is shown a control.
const activePin = page.locator('#cmdk-active').locator('[aria-label^="Pin "], [aria-label^="Unpin "]').first()
const activeOpacity = await activePin.evaluate((el) => Number(getComputedStyle(el).opacity))
expect(activeOpacity).toBeGreaterThan(0.3)
await ctx.close()
})
test('⌘K, arrows, Enter and Escape drive the whole surface', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
const first = await page.locator('#cmdk-active').getAttribute('aria-label').catch(() => null)
const firstText = await page.locator('#cmdk-active').innerText()
// ↓ moves the selection to a different row (the selection is a single element, so
// "moved" is provable by its text changing).
await page.keyboard.press('ArrowDown')
await expect.poll(async () => page.locator('#cmdk-active').innerText()).not.toBe(firstText)
// ↑ returns to it.
await page.keyboard.press('ArrowUp')
await expect.poll(async () => page.locator('#cmdk-active').innerText()).toBe(firstText)
expect(first === null || typeof first === 'string').toBe(true)
await page.screenshot({ path: join(SHOTS, 'palette-keyboard.png') })
// Esc closes.
await page.keyboard.press('Escape')
await expect(page.getByPlaceholder('Search apps and commands…')).toHaveCount(0)
// ↵ on a selection navigates — the palette is a way to ACT, not just to look.
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill('marketplace')
await expect(page.locator('#cmdk-active')).toBeVisible()
await page.keyboard.press('Enter')
await expect.poll(() => page.url(), { timeout: 15_000 }).toContain('/marketplace')
await ctx.close()
})
test('a list keeps the narrowing you gave it, and Reset gives it back', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
const search = page.getByPlaceholder('Search listings, providers, descriptions…')
// Drilled into a product the rail shows that product's sub-nav, not the catalog
// with its pins — so the surface under test is its own readiness signal.
await boot(page, '/marketplace', async () => {
await expect(search).toBeVisible({ timeout: 30_000 })
})
// Nothing is narrowed yet, so Reset is not there. A control that is always present
// but usually inert teaches a user to ignore it.
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
await search.fill('zen')
const available = page.getByRole('button', { name: 'Available now' })
await available.click()
await expect(available).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('button', { name: 'Reset filters' })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'list-narrowed.png') })
// Navigate away and back: the view is exactly as it was left. This is the whole
// point of persisting it — a list you must re-narrow on every visit is a list you
// stop narrowing.
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
await expect(page.getByPlaceholder('Search models across every family…')).toBeVisible({ timeout: 30_000 })
await page.goto(`${BASE_URL}/marketplace`, { waitUntil: 'domcontentloaded' })
const back = page.getByPlaceholder('Search listings, providers, descriptions…')
await expect(back).toBeVisible({ timeout: 30_000 })
await expect(back).toHaveValue('zen')
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'true')
// …and it survives a full reload, like the pins.
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('zen', {
timeout: 30_000,
})
// Reset clears every narrowing at once and takes its own control away with it.
await page.getByRole('button', { name: 'Reset filters' }).click()
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('')
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'false')
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
await ctx.close()
})
test('the list bar reads on the black canvas and never scrolls the page sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
const search = page.getByPlaceholder('Search models across every family…')
// At 390px the rail is a drawer, so the list bar itself is the readiness signal.
await boot(page, '/models', async () => {
await expect(search).toBeVisible({ timeout: 30_000 })
})
await search.fill('zen')
// The placeholder/typed text must actually be legible against what is behind it.
const contrast = await search.evaluate((el) => {
const lum = (c: string) => {
const [r, g, b] = (c.match(/[\d.]+/g) ?? ['0', '0', '0']).slice(0, 3).map(Number)
const f = (v: number) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
}
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
// Walk up for the first non-transparent background actually painted behind it.
let node: HTMLElement | null = el as HTMLElement
let bg = 'rgb(0, 0, 0)'
while (node) {
const c = getComputedStyle(node).backgroundColor
if (c && !c.includes('rgba(0, 0, 0, 0)')) {
bg = c
break
}
node = node.parentElement
}
const fg = getComputedStyle(el as HTMLElement).color
const a = lum(fg)
const b = lum(bg)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
})
expect(contrast).toBeGreaterThanOrEqual(4.5)
// The body must never scroll sideways at 390px.
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
await page.screenshot({ path: join(SHOTS, 'list-bar-mobile.png') })
await ctx.close()
})
-118
View File
@@ -1,118 +0,0 @@
/**
* 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()
})
-148
View File
@@ -1,148 +0,0 @@
/**
* 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()
})
-200
View File
@@ -1,200 +0,0 @@
/**
* 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|superadmin 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`)
}
})
})
-64
View File
@@ -1,64 +0,0 @@
/**
* The OAuth return raises EXACTLY ONE toast.
*
* A unit test cannot see this bug. It is a render loop: the toast provider built
* its context value fresh on every render and used it as the value, so every
* useToast() consumer got a new identity whenever a toast was added — and the
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
* one re-rendered the provider, which handed the effect a new api, which raised
* another. Live this stacked ~15 identical "Connected slack" cards down the
* viewport. Stripping the query params could not stop it: router.replace is
* asynchronous, so the params are still readable on the renders in between.
*
* So the assertion is a COUNT after the loop has had time to run, on the real
* rendered DOM — the only place the defect exists.
*/
import { test, expect } from '@playwright/test'
import { primeSession } from './_session'
const PROVIDERS = [
{
id: 'slack',
name: 'Slack',
description: 'Post messages and receive events in your Slack workspace.',
category: 'Communication',
available: true,
connected: true,
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
},
]
test.describe('integrations OAuth return', () => {
test.beforeEach(async ({ page }) => {
// Everything else answers empty so the module mounts standalone.
await page.route('**/v1/**', async (route) => {
const url = route.request().url()
if (url.includes('/v1/integrations')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
})
await primeSession(page)
})
test('a connected= return raises exactly one toast', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
const toasts = page.getByText('Connected slack')
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
// Give the loop every chance to run: the effect re-fires on each provider
// re-render, and the pre-fix build had stacked well past a dozen by now.
await page.waitForTimeout(3_000)
expect(await toasts.count()).toBe(1)
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
})
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
})
})
-135
View File
@@ -1,135 +0,0 @@
/**
* 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()
})
-232
View File
@@ -1,232 +0,0 @@
/**
* e2e: the LOGGED-OUT landing chrome — every footer link reachable on a phone, ONE
* typeface, ONE sign-in.
*
* These three came out of a rendered-DOM audit of the live surface, and each is
* invisible to a unit test because each is a LAYOUT/CASCADE fact of a real browser:
*
* 1. The footer's legal links sat PAST the right edge at 390px, on a document that
* cannot scroll sideways (`html,body{overflow-x:clip}`) — a legally-required link
* that could not be reached. `documentElement.scrollWidth` does NOT reveal that
* (clip hides the overflow from the scroll box), so this asserts the geometry
* directly: every link's box inside the viewport, hit-testing to the link itself,
* and nothing on the page painted past the right edge.
* 2. The shared `@hanzogui/shell` header sets its own SYSTEM font stack as an inline
* style, so the header chrome rendered in the platform face while the page body
* rendered Geist. `document.fonts.check()` is WORTHLESS as evidence here (it
* answers true on a page with no @font-face at all), so this reads the ACTUAL
* rendered fonts out of CDP `CSS.getPlatformFontsForNode` — family, glyph count,
* and custom-vs-system — and requires the header to resolve the same face as the
* body.
* 3. The header rendered TWO "Sign in" affordances (the shell's default account link
* beside our own primary CTA). Exactly one is the standing requirement.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test landing-chrome
*/
import { test, expect, type Locator, type Page, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const SHOTS = join(process.cwd(), 'e2e-shots')
// A local render spec — skip cleanly when the target origin isn't up.
requireFixtureServer()
const API_RE = /\/(v1|ai|auth|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
/**
* Anonymous by construction: every API call answers 401, so the session resolves to
* "no account" at once and `/` mounts the PUBLIC landing (the surface under audit).
* Nothing off-origin is ever reached.
*/
async function anon(route: Route): Promise<void> {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
if (!local || API_RE.test(url.pathname)) {
return route.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"anon"}' })
}
return route.continue()
}
/** Every footer link, keyed by the href it carries — unambiguous, because the header's
* own Docs link points at docs.hanzo.ai, not hanzo.ai/docs. */
const FOOTER_LINKS: ReadonlyArray<readonly [label: string, href: string]> = [
['Docs', 'https://hanzo.ai/docs'],
['API', 'https://hanzo.ai/docs/api'],
['Webhooks', '/webhooks'],
['Support', 'https://hanzo.ai/support'],
['Privacy', 'https://hanzo.ai/privacy'],
['Terms', 'https://hanzo.ai/terms'],
]
type Rendered = { family: string; custom: boolean; glyphs: number }
/** The REAL rendered fonts for the first node matching `selector` (CDP, never a guess). */
async function renderedFont(page: Page, selector: string): Promise<Rendered> {
const cdp = await page.context().newCDPSession(page)
try {
await cdp.send('DOM.enable')
await cdp.send('CSS.enable')
const { root } = await cdp.send('DOM.getDocument', { depth: -1 })
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector })
expect(nodeId, `no node matched ${selector}`).toBeTruthy()
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId })
expect(fonts.length, `${selector}: CDP reported no rendered font (no text?)`).toBeGreaterThan(0)
// One text run per node here, so the first entry IS the face it renders in.
const f = fonts[0]
return { family: f.familyName, custom: f.isCustomFont, glyphs: f.glyphCount }
} finally {
await cdp.detach()
}
}
/** Mark a node so CDP can address it by selector (for text CDP can't select on). */
async function tag(locator: Locator, name: string): Promise<string> {
await locator.first().evaluate((el, n) => el.setAttribute('data-probe', n), name)
return `[data-probe="${name}"]`
}
/** `Geist:1234:custom` — the shape the audit reported, printed for the record. */
const summary = (r: Rendered): string => `${r.family}:${r.glyphs}:${r.custom ? 'custom' : 'SYSTEM'}`
async function landing(page: Page, w: number, h: number): Promise<void> {
await page.setViewportSize({ width: w, height: h })
await page.goto('/', { waitUntil: 'domcontentloaded' })
// The hero is the anon landing's mount signal.
await expect(page.getByRole('heading', { name: 'The AI cloud, one platform' })).toBeVisible({ timeout: 30_000 })
await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15_000 })
}
test.beforeEach(async ({ page }) => {
await page.route('**/*', anon)
})
test.beforeAll(() => {
mkdirSync(SHOTS, { recursive: true })
})
test('390x844 — every footer link is inside the viewport and hit-tests to the link', async ({ page }) => {
await landing(page, 390, 844)
// The page must never scroll sideways — the fix has to WRAP, not add scroll.
const metrics = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
}))
console.log(
` documentElement scrollWidth=${metrics.scrollWidth} clientWidth=${metrics.clientWidth} body.scrollWidth=${metrics.bodyScrollWidth}`,
)
expect(metrics.scrollWidth).toBe(metrics.clientWidth)
for (const [label, href] of FOOTER_LINKS) {
const link = page.locator(`a[href="${href}"]`).first()
await link.scrollIntoViewIfNeeded()
await expect(link, `${label} link missing`).toBeVisible()
const box = (await link.boundingBox())!
console.log(` ${label.padEnd(9)} x=${Math.round(box.x)}..${Math.round(box.x + box.width)} y=${Math.round(box.y)}`)
expect(box.x, `${label} starts left of the viewport`).toBeGreaterThanOrEqual(0)
expect(box.x + box.width, `${label} ends past the 390px viewport`).toBeLessThanOrEqual(390)
// Reachable, not merely inside: the link must be the topmost box at its own centre.
const hit = await link.evaluate((el) => {
const r = el.getBoundingClientRect()
const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
return {
own: !!t && (t === el || el.contains(t)),
got: t ? `${t.tagName.toLowerCase()}.${t.getAttribute('class') ?? ''}` : null,
}
})
expect(hit.own, `${label} does not hit-test to itself (topmost was ${hit.got})`).toBe(true)
}
// Nothing painted past the right edge — the clipped overflow `scrollWidth` hides.
const past = await page.evaluate((w) => {
const out: string[] = []
for (const el of Array.from(document.querySelectorAll('body *'))) {
const cs = getComputedStyle(el)
if (cs.position === 'fixed' || cs.display === 'none' || cs.visibility === 'hidden') continue
const r = el.getBoundingClientRect()
if (r.width < 1 || r.height < 1) continue
if (r.right > w + 0.5) {
out.push(
`${el.tagName.toLowerCase()}.${el.getAttribute('class') ?? ''} right=${Math.round(r.right)} "${(el.textContent ?? '').trim().slice(0, 40)}"`,
)
}
}
return out.slice(0, 12)
}, 390)
expect(past, 'elements painted past the 390px right edge').toEqual([])
await page.screenshot({ path: join(SHOTS, 'landing-footer-mobile.png'), fullPage: true })
})
test('the header chrome renders the same Geist face as the page body', async ({ page }) => {
await landing(page, 1440, 900)
// The body was already correct and is the control every header node must match.
const heroSel = 'h1.hz-display'
const subheadSel = await tag(page.getByText('Models, compute, training'), 'subhead')
const signInSel = await tag(page.locator('header[data-hanzo-shell]').getByRole('link', { name: 'Sign in', exact: true }), 'cta')
const probes: ReadonlyArray<readonly [what: string, selector: string]> = [
['hero h1 (body control)', heroSel],
['body paragraph (control)', subheadSel],
['footer Terms link', 'a[href="https://hanzo.ai/terms"]'],
['header nav link', 'header[data-hanzo-shell] nav a'],
['header Meet Hanzo button', 'header[data-hanzo-shell] button'],
['header sign-in CTA', signInSel],
]
const seen: Rendered[] = []
for (const [what, selector] of probes) {
const r = await renderedFont(page, selector)
console.log(` ${what.padEnd(26)} ${selector} -> ${summary(r)}`)
// The hard gate: it renders GEIST, not a system face.
expect(r.family, `${what} renders in ${r.family}`).toMatch(/Geist/)
// And it resolves EXACTLY the way the body does — no mixed typography, whatever
// this machine's font situation is (an installed Geist satisfies the @font-face
// `local()` source, so `custom` is a property of the host, not of the fix).
expect({ what, family: r.family, custom: r.custom }).toEqual({ what, family: seen[0]?.family ?? r.family, custom: seen[0]?.custom ?? r.custom })
seen.push(r)
}
// The header's computed stack must name Geist (it used to resolve through a stack
// that omitted it entirely: `ui-sans-serif, system-ui, -apple-system, "Segoe UI"`).
for (const sel of ['header[data-hanzo-shell]', 'header[data-hanzo-shell] nav a', signInSel]) {
const stack = await page.locator(sel).first().evaluate((el) => getComputedStyle(el).fontFamily)
console.log(` stack ${sel} -> ${stack}`)
expect(stack, `${sel} font stack omits Geist`).toMatch(/Geist/)
}
// At 390 the header collapses to icon controls (no chrome text of its own), so the
// phone check is the page's own type.
await landing(page, 390, 844)
for (const [what, selector] of [['hero h1 (mobile)', heroSel], ['footer Terms (mobile)', 'a[href="https://hanzo.ai/terms"]']] as const) {
const r = await renderedFont(page, selector)
console.log(` ${what.padEnd(26)} ${selector} -> ${summary(r)}`)
expect(r.family).toMatch(/Geist/)
}
})
test('1440x900 — exactly ONE sign-in affordance in the header, and it is the primary', async ({ page }) => {
await landing(page, 1440, 900)
const header = page.locator('header[data-hanzo-shell]')
const signIn = header.getByRole('link', { name: 'Sign in', exact: true })
await expect(signIn).toHaveCount(1)
// The one that survives is the filled primary, not the plain text link.
const bg = await signIn.evaluate((el) => getComputedStyle(el).backgroundColor)
console.log(` the one sign-in: background=${bg}`)
expect(bg).not.toBe('rgba(0, 0, 0, 0)')
expect(bg).not.toBe('transparent')
await page.screenshot({ path: join(SHOTS, 'landing-header-desktop.png') })
// Mobile collapses to the disclosure button — no duplicate there either.
await page.setViewportSize({ width: 390, height: 844 })
await expect(header.getByRole('button', { name: 'Open menu' })).toBeVisible()
await expect(header.getByRole('link', { name: 'Sign in', exact: true })).toHaveCount(0)
})
-97
View File
@@ -1,97 +0,0 @@
/**
* e2e: LEADING — no rendered text may have a line-height smaller than its font-size.
*
* This exists because of a trap that is invisible to every unit test and to every
* type-check: react-native-web's style compiler appends `px` to a numeric style value
* unless the property is on its unitless allow-list — and `lineHeight` is NOT on it
* (`react-native-web/dist/exports/StyleSheet/compiler/unitlessNumbers.js`). React DOM's
* own allow-list DOES include `lineHeight`, so `style={{ lineHeight: 1.12 }}` is a
* correct, idiomatic RATIO in plain React and silently becomes the absurd
* `line-height: 1.12px` under @hanzo/gui (Tamagui/RNW).
*
* The failure mode is not subtle once rendered: the line box collapses to ~1px, the
* heading's descenders fall into whatever sits beneath it, and the element above is
* clipped. It shipped on every product landing (the guide PitchHero headline).
*
* So this asserts the INVARIANT rather than the one call site — every visible text node
* on the surface must have `line-height >= font-size` — which catches the next numeric
* lineHeight anyone writes, anywhere, without them having to know about RNW's list.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test leading
*/
import { test, expect, type Page, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
requireFixtureServer()
const API_RE = /\/(v1|cloud|ai|auth|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
/** Everything the shell asks for answers an empty-ok envelope — this spec measures TYPE, not data. */
async function stub(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
if (!API_RE.test(new URL(req.url()).pathname)) return route.continue()
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true,"items":[],"data":[]}' })
}
/** Every visible text node whose computed line-height is smaller than its own font-size. */
async function collapsedLines(page: Page) {
return page.evaluate(() => {
const bad: { text: string; fontSize: string; lineHeight: string; height: number }[] = []
document.querySelectorAll('*').forEach((el) => {
if (el.children.length) return
const text = (el as HTMLElement).innerText?.trim()
if (!text) return
const cs = getComputedStyle(el)
const size = parseFloat(cs.fontSize)
const lead = parseFloat(cs.lineHeight) // `normal` → NaN, which is never a defect
if (!Number.isFinite(lead) || !Number.isFinite(size) || lead >= size) return
bad.push({
text: text.slice(0, 60),
fontSize: cs.fontSize,
lineHeight: cs.lineHeight,
height: Math.round(el.getBoundingClientRect().height),
})
})
return bad
})
}
for (const path of ['/models', '/agents', '/playground']) {
test(`no collapsed line box on ${path}`, async ({ page }) => {
await page.route('**/*', stub)
await primeSession(page)
await page.goto(path, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
expect(await collapsedLines(page)).toEqual([])
})
}
test('the product guide headline leads its own subhead', async ({ page }) => {
await page.route('**/*', stub)
await primeSession(page)
await page.goto('/models', { waitUntil: 'domcontentloaded' })
const guide = page.getByTestId('product-guide')
await expect(guide).toBeVisible({ timeout: 20_000 })
const box = await guide.evaluate((g) => {
const texts = [...g.querySelectorAll('*')].filter(
(e) => !e.children.length && (e as HTMLElement).innerText?.trim(),
) as HTMLElement[]
const headline = texts.reduce((a, b) =>
parseFloat(getComputedStyle(b).fontSize) > parseFloat(getComputedStyle(a).fontSize) ? b : a,
)
const cs = getComputedStyle(headline)
return {
fontSize: parseFloat(cs.fontSize),
lineHeight: parseFloat(cs.lineHeight),
height: headline.getBoundingClientRect().height,
}
})
// A display headline leads between 1.0 and 1.5 — and its box is at least one line tall.
expect(box.lineHeight).toBeGreaterThanOrEqual(box.fontSize)
expect(box.lineHeight).toBeLessThanOrEqual(box.fontSize * 1.5)
expect(box.height).toBeGreaterThanOrEqual(box.fontSize)
})
-208
View File
@@ -1,208 +0,0 @@
/**
* e2e: ONE level-2 nav.
*
* Clicking into a product must reveal ITS options rather than replacing the screen,
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
* a competing tab strip in the content, which is what `/models` used to do (eight
* items in the rail, four in the content, disagreeing on the index's own name).
*
* These are assertions only a browser can make. They read COMPUTED style and
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
* DOM, so `toBeVisible()` — which resolves to `display`/`visibility`/box-size — is
* the only honest test of "is there a second nav on screen".
*
* Run: BASE_URL=http://localhost:4111 npx playwright test level-2-nav
*/
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',
email: 'z@hanzo.ai',
displayName: 'Z Admin',
isAdmin: true,
}
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) })
/**
* Every backend answers 401 — this spec is about NAV, not data, and an unauthorized
* read is the state every module already handles honestly. (A fabricated empty
* envelope is NOT interchangeable: a module that expects an object and is handed
* `[]` throws into its error boundary, which would make this spec a data test.)
*/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
}
async function open(page: Page, path: string) {
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: 30_000 })
await page.waitForTimeout(1200)
}
/** The level-2 nav rendered in the CONTENT column (`SubNav`). Located by test id,
* not by role: a `display: none` element leaves the accessibility tree, and this
* spec must be able to find it precisely when it is hidden. */
const strip = (page: Page, id: string) => page.locator(`[data-testid="subnav-${id}"]`)
/** A level-2 row/tab by its label, anywhere on screen, VISIBLE only. */
const visibleTab = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true }).filter({ visible: true })
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('desktop: the sidebar owns level 2 — the content strip is not a second nav', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page, '/models')
// The rail drilled into Models and shows the product's own options.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(visibleTab(page, 'Leaderboard').first()).toBeVisible()
await expect(visibleTab(page, 'Blend').first()).toBeVisible()
// Exactly ONE of each — a duplicate would mean two navs painting at once.
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
expect(await visibleTab(page, label).count(), `${label} appears once`).toBe(1)
}
// The content strip is in the DOM but PAINTS NOTHING at lg+ (computed display).
await expect(strip(page, 'models')).toBeAttached()
await expect(strip(page, 'models')).not.toBeVisible()
expect(await strip(page, 'models').evaluate((el) => getComputedStyle(el).display)).toBe('none')
await page.screenshot({ path: join(SHOTS, 'level2-desktop-models.png'), fullPage: false })
await ctx.close()
})
test('phone: the strip carries level 2 where the sidebar is a drawer', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page, '/models')
// The rail is off-canvas, so the strip is the ONE nav — and it is the SAME list.
await expect(strip(page, 'models')).toBeVisible()
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
// Routing is admin-only and this account is an ORG admin, not a global one — the
// one nav gates it, so a customer is never offered a surface they cannot open.
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
// The strip wraps rather than pushing the page sideways.
const scrolls = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
)
expect(scrolls, 'body must not scroll horizontally').toBe(false)
// Every tab is a real hit target — measured, not assumed.
const boxes = await strip(page, 'models').getByRole('button').all()
for (const b of boxes) {
const box = await b.boundingBox()
expect(box, 'a tab must have a painted box').not.toBeNull()
expect(box!.height, 'a tab must be tall enough to tap').toBeGreaterThanOrEqual(28)
expect(box!.x + box!.width, 'a tab must not paint past the right edge').toBeLessThanOrEqual(391)
}
await strip(page, 'models').scrollIntoViewIfNeeded()
await page.screenshot({ path: join(SHOTS, 'level2-mobile-models.png'), fullPage: false })
await ctx.close()
})
test('the URL carries the level — a deep link and a reload land on the same tab', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page, '/models/blend')
const current = async () =>
strip(page, 'models').locator('[aria-current="page"]').first().innerText()
expect(await current()).toBe('Blend')
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
expect(await current(), 'reload keeps the level').toBe('Blend')
expect(new URL(page.url()).pathname).toBe('/models/blend')
await ctx.close()
})
test('back returns a level without losing pinned state', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page, '/models')
// Pin state is account-backed, not view state — it must survive a drill + back.
const pinsBefore = await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))
await visibleTab(page, 'Blend').first().click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models/blend')
await page.goBack({ waitUntil: 'domcontentloaded' })
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models')
// Still drilled into Models with the same options — Back moved the LEVEL, it did
// not throw the user out to the product list.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
await ctx.close()
})
/**
* Every product that used to carry its own `const TABS` — the whole conversion, in
* one sweep. For each: the page renders, the rail drills into it, and the content
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
* and it is the thing that regresses the moment someone adds a tab bar back.
*/
const CONVERTED = [
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
'automations', 'embeddings', 'tasks', 'functions', 'profile', 'router', 'settings',
'zero-trust', 'billing', 'captable', 'crm',
] as const
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
for (const id of CONVERTED) {
await open(page, `/${id}`)
await expect(strip(page, id), `${id}: declares one level-2 nav`).toBeAttached()
expect(
await strip(page, id).evaluate((el) => getComputedStyle(el).display),
`${id}: the content strip must not paint while the rail owns level 2`,
).toBe('none')
await expect(
page.getByRole('button', { name: 'Back to all products' }),
`${id}: the rail drilled in`,
).toBeVisible()
}
await ctx.close()
})
-157
View File
@@ -1,157 +0,0 @@
/**
* LIVE confirmation for feat/billing-usage-admin (v8.4.15) on the deployed cluster.
*
* Topology (verified live):
* - console.hanzo.ai: the console app is directly reachable at `/`; `/v1/*` is
* routed by the ingress to cloud-api (hanzoai/gateway), which enforces its OWN
* `global admin required` gate. The console's own H1 gate is reachable directly
* at `/admin/aggregate/*`. z@hanzo.ai is a global admin on this host.
* - admin.hanzo.ai: an EDGE forward-auth (`admin-guard@file`, org=admin, cookie
* on `.hanzo.ai`) sits in front of the SAME console image. A browser must carry
* a valid `.hanzo.ai` guard/session cookie to pass; a cold hit is 401.
*
* Three required checks (task bar):
* (a) admin business board (LivingOverview: MRR/usage/orgs/top-agents/fleet)
* renders for the GLOBAL admin z@hanzo.ai.
* (b) an unprivileged caller gets 403 on /v1/admin/* — no cross-org leak
* (fail-closed gate); iam/kms are not tunneled.
* (c) billing Reports shows the product/agent cost DIMENSION (honest-empty ok).
*/
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'
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
/** Sign in via the console app sign-in form (email/password → cloud /v1/signin). */
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 })
await page.waitForLoadState('domcontentloaded')
}
// ─── (b) fail-closed gate — needs no credentials, always runs ─────────────────
test.describe('LIVE v8.4.15 — (b) admin gate fail-closed', () => {
test('/v1/admin/* → 403 unauthenticated on console.hanzo.ai; iam/kms not tunneled', async ({ request }) => {
for (const head of ['overview', 'usage', 'orgs', 'audit', 'products']) {
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 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`)
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}`)
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`)
expect(edge.status(), 'admin.hanzo.ai must be edge-gated (401/403)').toBeGreaterThanOrEqual(401)
expect(edge.status()).toBeLessThan(404)
console.log('✓ (b) console /v1/admin/* → 403 unauth; /admin/aggregate/{iam,kms} not tunneled; admin.hanzo.ai edge-gated')
})
})
// ─── (a) + (c) authenticated as the global admin z@hanzo.ai ───────────────────
test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set')
test('(a) admin business board renders for global admin z@hanzo.ai', async ({ page }) => {
await signIn(page, CONSOLE)
// The business board (catalog id `business`, admin:true) renders the ONE
// LivingOverview for `admin-business`: MRR / revenue / active orgs / customers,
// revenue+usage trend, top-agents-by-cost donut, fleet health.
await page.goto(`${CONSOLE}/business`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
// Global admin sees the board (not the admin-only "not authorized" gate).
const board = page.locator(
'text=/MRR|Revenue|Active orgs|Customers|Usage cost|Top agents|Fleet|Business/i'
).first()
await expect(board, 'admin business board did not render for the global admin').toBeVisible({ timeout: 30_000 })
// The client admin gate did NOT block z (would show a forbidden/hidden state).
await expect(page.locator('text=/not authorized|access denied|admin only|forbidden/i')).toHaveCount(0)
await page.screenshot({ path: `${SHOTS}/a-business-board.png`, fullPage: true })
console.log('✓ (a) admin business board rendered for global admin z@hanzo.ai')
})
// (a2) The god-view gate is consistent with the account's ACTUAL grant. z@hanzo.ai
// lives in org `hanzo` (a brand/org admin), NOT the global `admin` org, so the
// all-orgs god view is correctly refused (403) and the board shows the honest
// "managed by Hanzo" fallback — no fabricated cross-org KPIs, no leak. A member of
// the `admin` org would instead get 200 and the KPI board. Either way the gate is
// fail-closed and matches what the board renders.
test('(a2) god-view gate matches the account grant (hanzo org-admin → honest 403)', async ({ page }) => {
await signIn(page, CONSOLE)
const acct = await (await page.request.get(`${CONSOLE}/v1/get-account`)).json().catch(() => ({}))
const owner = acct?.data?.owner
const res = await page.request.get(`${CONSOLE}/v1/admin/overview`)
if (owner === 'admin') {
expect(res.status(), 'global admin must pass the gate').not.toBe(403)
console.log(`✓ (a2) global-admin (org=admin) /v1/admin/overview → ${res.status()} (gate passed)`)
} else {
expect(res.status(), 'non-global-admin must be refused the god view').toBe(403)
console.log(`✓ (a2) org-admin (org=${owner}) → 403 on the god view; board shows honest managed fallback (no cross-org leak)`)
}
})
// (a3) admin.hanzo.ai: after establishing the shared `.hanzo.ai` session, the
// edge guard should admit the global admin and render the same board. If the
// guard still refuses (its own OIDC bootstrap), record the honest state — the
// board is proven on console.hanzo.ai (same image) and the edge gate is proven
// fail-closed above.
test('(a3) admin.hanzo.ai admits the global admin (or is honestly edge-gated)', async ({ page }) => {
await signIn(page, CONSOLE) // sets the `.hanzo.ai`-scoped session
const res = await page.request.get(`${ADMIN}/`)
if (res.status() === 200) {
await page.goto(`${ADMIN}/business`, { waitUntil: 'domcontentloaded' })
const board = page.locator('text=/MRR|Revenue|Active orgs|Customers|Top agents|Business/i').first()
await expect(board).toBeVisible({ timeout: 30_000 })
await page.screenshot({ path: `${SHOTS}/a3-admin-host-board.png`, fullPage: true })
console.log('✓ (a3) admin.hanzo.ai admitted the global admin; board rendered')
} else {
console.log(` (a3) admin.hanzo.ai edge-guard returned ${res.status()} for the shared session — board verified on console.hanzo.ai (same image)`)
}
})
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
// The route no longer resolves to the commerce proxy JSON.
await expect(page.locator('text=/^\\{"error":"not found"\\}$|could not be found/i'),
'reports still shadowed by the /billing proxy').toHaveCount(0, { timeout: 20_000 })
// The Cost-table Reports surface: the "spend by <dimension>" control. model +
// provider are always offered; product + agent appear the moment the commerce
// ledger tags a row (honest — never a fabricated column). Assert a dimension
// affordance renders (BillingReports mounted).
const dim = page.getByText(/by model|by provider|by product|by agent|group by|dimension|spend by|Cost by/i).first()
await expect(dim, 'cost dimension control did not render').toBeVisible({ timeout: 30_000 })
await expect(page.locator('text=/something went wrong|application error/i')).toHaveCount(0)
await page.screenshot({ path: `${SHOTS}/c-billing-reports.png`, fullPage: true })
console.log('✓ (c) /billing/reports rendered the BillingReports cost-dimension control (unshadowed)')
})
})
-225
View File
@@ -1,225 +0,0 @@
/**
* 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()
})
-118
View File
@@ -1,118 +0,0 @@
/**
* Onboarding — Continue never moves, and Skip is always reachable.
*
* The complaint this pins: the Continue button landed at a different height on
* every step, so a user clicking through had to re-aim each time. StepActions was
* the LAST CHILD of a flex column, so its y was whatever the step's content
* happened to add up to. It is now a SLOT on StepShell above a content area with
* a reserved height — one placement, decided in one place.
*
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
* test (both shapes render the same button with the same label); only the painted
* box says whether the thing the user complained about is fixed.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Every step whose footer must line up, in flow order. */
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
/** The y of the actions row, in page coordinates. */
async function actionsY(page: Page): Promise<number> {
const row = page.getByTestId('onboarding-actions')
await expect(row).toBeVisible()
const box = await row.boundingBox()
if (!box) throw new Error('actions row has no box')
return Math.round(box.y)
}
/** Advance past the current step, preferring Skip so the flow stays clickable. */
async function advance(page: Page): Promise<void> {
const row = page.getByTestId('onboarding-actions')
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
if (await skip.count()) {
await skip.first().click()
return
}
// Consent has no Skip by design (accepting Terms is not optional), so tick the
// agreement and use Continue. Tick only if Continue is still disabled — a caller
// may already have ticked it, and toggling twice turns it back OFF.
const cont = row.getByRole('button', { name: /Continue/ })
if (await cont.isDisabled()) {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
await cont.click()
}
test.beforeEach(async ({ page }) => {
// Anything the steps reach for answers empty — they are best-effort and must
// still render. Registered BEFORE primeSession so its handlers win.
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await primeSession(page)
// primeSession marks onboarding DONE so other specs can reach the app. This
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
await page.addInitScript(() => {
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
})
})
test('Continue lands at the same height on every step', async ({ page }) => {
await page.goto('/')
const seen: { step: string; y: number }[] = []
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
seen.push({ step, y: await actionsY(page) })
await advance(page)
}
const ys = seen.map((s) => s.y)
const spread = Math.max(...ys) - Math.min(...ys)
expect(
spread,
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
).toBeLessThanOrEqual(2)
})
test('every step always offers an enabled way forward', async ({ page }) => {
await page.goto('/')
// The real invariant behind "skip so easy to click through": on every step there
// is ALWAYS at least one enabled control that advances you — Skip where there is
// something to decline, Continue where there is not. Credits swaps between the
// two on purpose (Skip appears only when a card could be added; otherwise
// Continue carries you), so asserting a literal "Skip" everywhere would be
// asserting the wrong thing. Being STUCK is the defect.
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
// Consent gates Continue on accepting the Terms — not optional, so tick it
// first and then assert the way forward exists.
if (step === 'Data & consent') {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
const row = page.getByTestId('onboarding-actions')
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
const n = await forward.count()
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
let usable = 0
for (let i = 0; i < n; i++) {
const b = forward.nth(i)
if (await b.isDisabled()) continue
const box = await b.boundingBox()
if (!box || box.height < 24) continue
const hit = await b.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
})
if (hit) usable++
}
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
await advance(page)
}
})
-103
View File
@@ -1,103 +0,0 @@
/**
* e2e: the Open Edition (run-for-pay) overview renders its real, product-specific
* content — not just "a page mounted".
*
* `pages.spec.ts` proves /open-edition is reachable + doesn't crash (the generic
* sweep). THIS spec proves the run-for-pay board actually rendered the things that
* make it the Open Edition board: the "Open Edition" heading, the run-for-pay
* framing, the "cost + 25%" margin caption on the Spend KPI, and the spend/tokens
* KPI labels — all sourced from the config in
* src/components/products/overview/living/registry.ts (id `open-edition`), which
* reads the REAL commerce usage ledger scoped to the open-edition product tag.
*
* These are content assertions on the live surface, not a stub: if the config is
* unwired, the route unrouted, or the board silently swapped for another usage
* view, a specific assertion fails.
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai (global admin — sees every product)
* HANZO_PASSWORD required (skips when unset)
* BASE_URL default https://console.hanzo.ai
*
* Run: HANZO_PASSWORD=xxx pnpm e2e open-edition.spec.ts
*/
import { test, expect, 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'
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')
}
test.describe('Open Edition — run-for-pay overview renders real content', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated Open Edition visual gate')
let ctx: import('@playwright/test').BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
ctx = await browser.newContext()
page = await ctx.newPage()
await signIn(page)
})
test.afterAll(async () => {
await ctx?.close()
})
test('the /open-edition board mounts, is reachable, and does not crash', async () => {
const errors: string[] = []
const onErr = (e: Error) => errors.push(String(e))
page.on('pageerror', onErr)
const res = await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
expect(res?.status() ?? 0, '/open-edition HTTP').toBeLessThan(500)
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
expect(bodyText.trim().length, '/open-edition rendered content').toBeGreaterThan(0)
page.off('pageerror', onErr)
if (errors.length) console.log(`⚠ /open-edition pageerror: ${errors.join(' | ').slice(0, 200)}`)
})
test('renders the Open Edition run-for-pay header + the cost+25% Spend KPI', async () => {
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// The product heading — this is the Open Edition board, not a generic usage view.
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
// The run-for-pay framing from the config subtitle (`Run open-source workloads
// for pay …`). Matches on the distinctive phrase so it can't pass on another board.
await expect(page.getByText(/run open-source workloads for pay/i).first()).toBeVisible()
// The load-bearing pricing detail: the Spend KPI carries the "cost + 25% margin"
// caption (the served revenue R = cost + resell margin). This is the visible
// proof the 25% run-for-pay model is surfaced, not just tokens.
await expect(page.getByText(/cost \+ 25% margin/i).first()).toBeVisible()
// The run-for-pay KPI labels the config declares (spend billed, tokens run).
await expect(page.getByText(/spend billed/i).first()).toBeVisible()
await expect(page.getByText(/tokens run/i).first()).toBeVisible()
})
test('captures a full-page screenshot of the Open Edition board', async () => {
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// Wait for the heading so the shot is of the rendered board, not a spinner frame.
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
await page.screenshot({ path: 'e2e/screenshots/open-edition.png', fullPage: true })
})
})
-153
View File
@@ -1,153 +0,0 @@
/**
* e2e: the console's ORG identity in the chrome — mocked-network render proof.
*
* Two things this pins, both of which the shipped console got wrong:
*
* 1. The top-left mark is the ORG's, never the house glyph. With a logo it is
* that logo; with none it is the org's MONOGRAM — the treatment the account
* widget gives a person — and NOT the brand mark, and NOT the org's name set
* as running text.
* 2. The org switcher is the PEER of the account control: same height, same
* mark size, same type, same hit area, same left edge.
*
* Both are measured off the RENDERED boxes, not off class names, so a styling
* regression fails here.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test org-identity
*/
import { test, expect, type Page, type Route } 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')
requireFixtureServer()
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
/** A tenant whose id carries a separator — its monogram must read AL, not A. */
const ORG = 'acme-labs'
const LOGO = 'https://cdn.example.test/acme-labs.png'
const API_RE = /\/(v1|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const envelope = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data, data2: 0 })
/** Mount the shell as a member of `acme-labs`; `logo` decides which mark shows. */
async function openShell(page: Page, logo: string | null) {
await page.route('**/*', async (route: Route) => {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
// The ONE org read the chrome makes (`useOrgIdentity` → get-organization).
if (url.pathname.endsWith('/v1/iam/get-organization')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: envelope({ owner: 'admin', name: ORG, displayName: 'Acme Labs', logo: logo ?? '' }),
})
}
// The logo bytes — a 1x1 PNG, so the <img> genuinely paints.
if (url.href === LOGO) {
return route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
})
}
if (url.origin === new URL(BASE_URL).origin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({ status: 200, contentType: 'application/json', body: envelope([]) })
})
// A plain tenant member (owner !== 'admin'), i.e. NOT a super admin — the case
// that has no cross-tenant org list to draw its own row from.
await primeSession(page, { owner: ORG, name: 'dave', email: 'dave@acme.test', displayName: 'Dave Lorenzini', isAdmin: false })
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /account menu/i }).first()).toBeVisible({ timeout: 30_000 })
}
const orgMark = (page: Page) => page.getByRole('link', { name: /— home/ }).first()
const orgTrigger = (page: Page) => page.getByRole('button', { name: /switch organization/i }).first()
const accountTrigger = (page: Page) => page.getByRole('button', { name: /account menu/i }).first()
test('the top-left mark is the org monogram — never the house mark, never the name as text', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, null)
const mark = orgMark(page)
await expect(mark).toBeVisible()
// The monogram of the org's DISPLAY name, by the account widget's own rule.
await expect(mark).toHaveText('AL')
// Not the house glyph: the slot paints no SVG at all.
expect(await mark.locator('svg').count()).toBe(0)
// Not the org name as running text.
await expect(mark).not.toContainText('Acme Labs')
await page.screenshot({ path: join(SHOTS, 'org-identity-monogram.png') })
await ctx.close()
})
test('the orgs OWN logo replaces the mark when IAM carries one', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, LOGO)
const logo = orgMark(page).locator('img')
await expect(logo).toHaveAttribute('src', LOGO)
// The logo REPLACES the monogram — one mark, not both.
await expect(orgMark(page)).toHaveText('')
expect(await orgMark(page).locator('svg').count()).toBe(0)
await page.screenshot({ path: join(SHOTS, 'org-identity-logo.png') })
await ctx.close()
})
test('the org switcher reads as the peer of the account control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, null)
const org = orgTrigger(page)
const account = accountTrigger(page)
await expect(org).toBeVisible()
await expect(account).toBeVisible()
const [orgBox, accountBox] = [await org.boundingBox(), await account.boundingBox()]
if (!orgBox || !accountBox) throw new Error('a switcher did not lay out')
// Same height, same width, same left edge — one hit area, one column.
expect(Math.round(orgBox.height)).toBe(Math.round(accountBox.height))
expect(Math.abs(orgBox.width - accountBox.width)).toBeLessThanOrEqual(1)
expect(Math.abs(orgBox.x - accountBox.x)).toBeLessThanOrEqual(1)
// A real target, not a caption.
expect(orgBox.height).toBeGreaterThanOrEqual(44)
// Same type: the org name and the account name are set identically.
const type = (root: typeof org, name: string) =>
root.locator(`text=${name}`).first().evaluate((el) => {
const s = getComputedStyle(el)
return { size: s.fontSize, weight: s.fontWeight }
})
expect(await type(org, 'Acme Labs')).toEqual(await type(account, 'Dave Lorenzini'))
// Same mark size — the org monogram tile matches the account avatar tile.
const tile = async (root: typeof org) => {
const b = await root.locator('div,span').filter({ hasText: /^(AL|DL)$/ }).last().boundingBox()
return b ? { w: Math.round(b.width), h: Math.round(b.height) } : null
}
expect(await tile(org)).toEqual(await tile(account))
await page.screenshot({ path: join(SHOTS, 'org-identity-peers.png') })
// The sidebar column alone — the two controls, top and bottom, side by side.
await page.screenshot({ path: join(SHOTS, 'org-identity-sidebar.png'), clip: { x: 0, y: 0, width: 300, height: 900 } })
await ctx.close()
})
-121
View File
@@ -1,121 +0,0 @@
/**
* e2e: screenshot every console page + assert each renders (FE↔BE wired).
*
* Signs in as z@hanzo.ai (global admin, sees every product), then visits all 89
* registered product routes. For each page it:
* - navigates to /<id>,
* - waits for hydration,
* - asserts the app shell is present and the page did NOT hit a hard crash
* (Next error overlay / "Application error" / a blank body),
* - captures a full-page screenshot into e2e/screenshots/<id>.png.
*
* This is the "screenshot it all, make sure every page is wired" pass. It does
* NOT click destructive buttons — it proves each surface mounts, renders real
* state (or an honest empty/loading/403), and is reachable end to end. Deeper
* per-button flows live in console.spec.ts (API key create/rotate, inference).
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai
* HANZO_PASSWORD required (skips when unset)
* BASE_URL default https://console.hanzo.ai
*
* Run: HANZO_PASSWORD=xxx pnpm e2e pages.spec.ts
*/
import { test, expect, 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'
/**
* Every product route id in the registry (src/lib/products/registry.tsx).
* Kept as a literal list so the spec has zero import coupling to the app bundle;
* a page added to the registry gets a screenshot by adding its id here.
*/
const PAGES: string[] = [
'overview', 'dashboards', 'status',
// AI
'models', 'model-catalog' /* alias */, 'providers', 'playground', 'chat', 'inference',
'agents', 'prompts', 'finetuning', 'embeddings', 'evals', 'datasets', 'experiments',
'annotation-queues', 'scores', 'score-configs', 'observations', 'traces', 'sessions',
// Compute / hypervisor
'machines', 'gpus', 'clusters', 'kubernetes', 'containers', 'networks', 'vpc',
'load-balancer', 'service-mesh', 'edge', 'zero-trust', 'dns', 'cdn',
// Data / storage
's3', 'sql', 'vector', 'datastore', 'kv', 'search', 'docdb', 'base', 'memory', 'indexer',
// Platform
'functions', 'pipelines', 'builds', 'releases', 'environments', 'projects', 'oracles',
// Identity / security
'iam', 'users', 'team', 'authz', 'kms', 'secrets', 'mpc', 'hsm', 'attestations',
'api-keys', 'tokens', 'applications',
// Business / analytics — the unified Billing Center + its sub-pages (Cost /
// Subscriptions / Payment-methods are now tabs of /billing, not top-level routes).
'billing', 'billing/reports', 'billing/budgets', 'billing/invoices',
'billing/subscriptions', 'billing/payment-methods', 'billing/credits',
'ai-metrics', 'open-edition', 'metrics', 'settlement', 'wallet', 'plans', 'referrals', 'marketplace',
// Ops / tooling
'logs', 'alerts', 'o11y', 'gateway', 'tasks', 'integrations', 'registry', 'audit',
'sdks', 'cli', 'ide', 'studio', 'desktop', 'bot', 'profile', 'settings',
]
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')
}
// Sign in ONCE and reuse the session for all 94 pages. A per-test login (94×)
// trips IAM's "too many login attempts" rate-limit around page 35, which is a
// SECURITY FEATURE working correctly — not a page failure. One shared context
// signs in once, then every page reuses the cookie. Serial so they share it.
test.describe.configure({ mode: 'serial' })
test.describe('Hanzo Cloud Console — every page renders + screenshot', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated screenshot pass')
let ctx: import('@playwright/test').BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
ctx = await browser.newContext()
page = await ctx.newPage()
await signIn(page)
})
test.afterAll(async () => {
await ctx?.close()
})
for (const id of PAGES) {
test(`page: /${id}`, async () => {
const errors: string[] = []
const onErr = (e: Error) => errors.push(String(e))
page.on('pageerror', onErr)
const res = await page.goto(`${BASE_URL}/${id}`, { waitUntil: 'domcontentloaded' })
// Route must not 5xx.
expect(res?.status() ?? 0, `/${id} HTTP`).toBeLessThan(500)
// App shell mounted (the console renders a nav + main region on every page).
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// Hard-crash guards: no Next error overlay, no generic crash banner.
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
// The body must have real content (not a blank white page).
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
expect(bodyText.trim().length, `/${id} rendered content`).toBeGreaterThan(0)
await page.screenshot({ path: `e2e/screenshots/${id}.png`, fullPage: true })
page.off('pageerror', onErr)
// Surface (don't fail on) any console page errors for triage.
if (errors.length) console.log(`⚠ /${id} pageerror: ${errors.join(' | ').slice(0, 200)}`)
})
}
})
-93
View File
@@ -1,93 +0,0 @@
/**
* e2e: OSS App Store + Platform deploy home — mocked-network render proof.
*
* Same pattern as workbench/models-surfaces: a LOCAL dev server with the network
* mocked. primeSession seeds the IAM-PKCE identity; the OSS catalog
* (`templates.hanzo.ai/meta.json`) is mocked with a small real-shaped set, logos
* are left to 404 (proving the monogram fallback), and `/v1/platform/projects`
* returns empty so the deploy dialog loads. Proves:
* - `/store` renders the App Store grid (real-shaped cards), search filters it,
* the maker "Earn 20%" hook shows, and the Deploy dialog opens over the real
* PaaS path.
* - `/platform` renders the deploy HOME (hero · App Store tile · featured OSS
* strip · projects) — what platform.hanzo.ai boots into.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test platform-store
*/
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')
/** A small real-shaped slice of the live meta.json (the exact field set). */
const CATALOG = [
{ id: 'n8n', name: 'n8n', description: 'Workflow automation for technical people', version: 'latest', logo: 'logo.png', tags: ['automation', 'self-hosted'], links: { github: 'https://github.com/n8n-io/n8n', website: 'https://n8n.io' } },
{ id: 'postgres', name: 'Postgres', description: 'The worlds most advanced open-source database', version: '16', logo: 'logo.svg', tags: ['database'], links: { github: 'https://github.com/postgres/postgres' } },
{ id: 'grafana', name: 'Grafana', description: 'Dashboards and observability', version: 'latest', logo: 'logo.svg', tags: ['monitoring', 'self-hosted'], links: { github: 'https://github.com/grafana/grafana' } },
{ id: 'ghost', name: 'Ghost', description: 'Professional publishing platform', version: 'latest', logo: 'logo.png', tags: ['cms'], links: { website: 'https://ghost.org' } }, // no github → View app, no earn hook
]
async function mockCatalog(page: import('@playwright/test').Page): Promise<void> {
// The OSS catalog CDN (cross-origin; Playwright serves it, bypassing CORS).
await page.route('**/meta.json', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CATALOG) }),
)
// Logos 404 → the card's monogram fallback (never a broken image).
await page.route('**/blueprints/**', (route: Route) => route.fulfill({ status: 404, body: '' }))
// The org's PaaS projects (empty → the deploy dialog offers a new auto-named project).
await page.route('**/v1/platform/projects**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ projects: [] }) }),
)
}
test.describe('OSS App Store', () => {
test('the store renders the catalog, filters, and opens the deploy dialog', async ({ page }) => {
await mockCatalog(page)
await primeSession(page)
await page.goto(`${BASE_URL}/store`, { waitUntil: 'domcontentloaded' })
// The page + payout banner + the real-shaped cards.
await expect(page.getByText('App Store', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Built one of these?', { exact: false })).toBeVisible()
await expect(page.getByText('n8n', { exact: true }).first()).toBeVisible()
await expect(page.getByText('Postgres', { exact: true }).first()).toBeVisible()
// The maker "Earn 20%" hook (derived from links.github).
await expect(page.getByText('Maintainer? Earn 20%', { exact: false }).first()).toBeVisible()
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: join(SHOTS, 'store-grid.png'), fullPage: true })
// Search narrows to Postgres.
await page.getByPlaceholder('Search 1000+ open-source apps…').fill('postgres')
await expect(page.getByText('Postgres', { exact: true }).first()).toBeVisible()
await expect(page.getByText('n8n', { exact: true })).toHaveCount(0)
// Deploy opens the dialog over the real PaaS path.
await page.getByPlaceholder('Search 1000+ open-source apps…').fill('')
await page.getByRole('button', { name: 'Deploy', exact: true }).first().click()
await expect(page.getByText('Deploy n8n', { exact: false }).or(page.getByText('Deploy Postgres', { exact: false })).first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'store-deploy.png'), fullPage: true })
})
})
test.describe('Platform deploy home', () => {
test('/platform renders the deploy hero, tiles, and featured OSS strip', async ({ page }) => {
await mockCatalog(page)
await primeSession(page)
await page.goto(`${BASE_URL}/platform`, { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Deploy anything.', { exact: false })).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Browse the App Store', { exact: false }).first()).toBeVisible()
await expect(page.getByText('One-click apps', { exact: false })).toBeVisible()
// A featured card from the live catalog + the projects section.
await expect(page.getByText('n8n', { exact: true }).first()).toBeVisible()
await expect(page.getByText('Your projects', { exact: true })).toBeVisible()
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: join(SHOTS, 'platform-home.png'), fullPage: true })
})
})
-75
View File
@@ -1,75 +0,0 @@
/**
* Playground layout: the Response panel sits UNDER the surface tabs — above
* the composer — at every width. Render-proven on the local dev server with a
* fully mocked network (no gateway, no billing, no catalog): what is asserted
* is GEOMETRY, which mocks cannot fake.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test playground-responsive
*/
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'
// The module shell resolves the product registry from the local fixture server,
// like every other module render spec; skip cleanly when it is down.
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const WIDTHS = [
{ name: 'phone', width: 390, height: 844 },
{ name: 'tablet', width: 834, height: 1112 },
{ name: 'laptop', width: 1440, height: 900 },
{ name: 'desktop', width: 1920, height: 1080 },
]
// Minimal honest bodies for everything the page asks the backend.
const mock = async (route: Route) => {
const url = route.request().url()
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
if (url.includes('/pricing/models')) return json({ models: [] })
if (url.includes('/v1/models'))
return json({ object: 'list', data: [{ id: 'zen5-flash', owned_by: 'Hanzo' }] })
if (url.includes('/billing/subscriptions')) return json({ subscriptions: [] })
if (url.includes(':4000') || url.startsWith(BASE_URL)) return route.continue()
return json({})
}
for (const vp of WIDTHS) {
test(`response renders under the tabs at ${vp.name} (${vp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/ai/playground`, { waitUntil: 'domcontentloaded' })
// The three landmarks: the surface tabs, the Response panel, the composer.
const tabs = page.getByRole('button', { name: 'Completions' }).first()
const response = page.getByText('Response', { exact: true }).first()
const composer = page.getByText('System prompt', { exact: true }).first()
await expect(tabs).toBeVisible({ timeout: 20000 })
await expect(response).toBeVisible()
await expect(composer).toBeVisible()
const [tabsBox, respBox, compBox] = await Promise.all([
tabs.boundingBox(),
response.boundingBox(),
composer.boundingBox(),
])
if (!tabsBox || !respBox || !compBox) throw new Error('a landmark has no box')
// ORDER: tabs, then Response, then the composer — at every width.
expect(respBox.y, 'Response sits below the tabs').toBeGreaterThan(tabsBox.y)
expect(compBox.y, 'the composer sits below the Response panel top').toBeGreaterThan(respBox.y)
// RESPONSIVE: nothing forces a horizontal scroll.
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
expect(scrollW, 'no horizontal overflow').toBeLessThanOrEqual(vp.width + 1)
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: join(SHOTS, `playground-${vp.name}-${vp.width}.png`) })
})
}
-241
View File
@@ -1,241 +0,0 @@
/**
* 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),
* - the landing header has no link to nowhere and exactly ONE sign-in,
* - 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)
})
/**
* The landing header must carry exactly ONE sign-in, and it must go somewhere.
*
* @hanzogui/shell 7.5.1 defaulted `signInHref` to '#' and rendered its default
* account affordance unconditionally, so the landing shipped TWO "Sign in"
* controls side by side — the surface's own primary CTA (→ /signin) and a
* second one that was a live-looking anchor to nowhere. It survived unnoticed
* because the header collapses below 900px, so only DESKTOP shows it; that is
* why this asserts at 1440×900 and not at the mobile widths above.
*/
test('landing header has no dead links, and exactly one sign-in', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('header a', { timeout: 20_000 })
const links = await page.$$eval('header a', (as) =>
as.map((a) => ({ text: (a.textContent ?? '').trim(), href: a.getAttribute('href') ?? '' })),
)
const dead = links.filter((l) => l.href === '' || l.href === '#')
expect(dead, `header links to nowhere: ${JSON.stringify(dead)}`).toEqual([])
const signIns = links.filter((l) => /^sign in$/i.test(l.text))
expect(signIns.length, `header sign-in controls: ${JSON.stringify(signIns)}`).toBe(1)
})
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()
}
})
})

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