Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345015b745 | ||
|
|
a318a23065 |
+3
-11
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)}¤cy=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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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 (color5–12) 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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
-9
@@ -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';
|
||||
@@ -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
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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}` }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 other’s 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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()}"`)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 org’s 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()
|
||||
})
|
||||
@@ -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)}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 world’s 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 })
|
||||
})
|
||||
})
|
||||
@@ -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`) })
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user