Compare commits

...
Author SHA1 Message Date
zooqueen a33acca87f console 8.5.78 — re-release the terminal dock (8.5.77's image OOM'd in CI)
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 6m9s
No code change from ff9d42e0 (the dock that frames cloud's /v1/sandboxes/:id/terminal
page); the 8.5.77 build died on the ARC runner, so this re-triggers a clean build.
2026-08-06 20:50:11 -07:00
zooqueen ff9d42e0ba console 8.5.77 — the dock frames the terminal cloud serves
Hanzo CI/CD / cicd (push) Successful in 13m2s
CI/CD / cicd (push) Successful in 13m2s
Cloud now serves the terminal whole — emulator, socket, resize, reconnect, one
self-contained page at the same address as the socket — so the console frames it
instead of building one. That is not laziness about an emulator: the console is
one of several hosts that show a shell, and a terminal built per host is a
terminal that is subtly different in each of them. One implementation, one place
a fix lands, and @xterm leaves this repo's dependencies entirely.

What is left here is the only part that was ever the console's: which sandbox,
and what to say while it comes up. The credential too — a frame carries no
Authorization header any more than a socket does, so the ticket is fetched
through the same-origin /v1 proxy, where identity lives, and handed to the page
in its URL. Single-use and thirty seconds is what makes that safe.

The dock names its tmux session, so reopening it reattaches to the shell it left
rather than opening a fresh one over the user's work.

Readiness is a HANDSHAKE, not a guess: the page posts {source:'hanzo-term'} when
its socket opens, and only the frame we opened, from the API origin, is allowed
to speak for it. Without a deadline on that message a frame that failed into
something else is indistinguishable from one still loading, and the dock would
sit on "Starting…" forever instead of offering the reconnect that fixes it.

Ticket path follows cloud: /v1/sandboxes/:id/terminal/ticket.
2026-08-06 19:07:57 -07:00
zooqueen a70a66b1cc the level-2 spec asserted the sub-nav order the code stopped shipping
Hanzo CI/CD / cicd (push) Successful in 5m59s
CI/CD / cicd (push) Successful in 10m27s
f6df104ec8 moved Status to the end of BASE_SUBPAGES and updated match-core.test.ts,
but this browser spec carries its own copy of the expected labels and was missed. It
asserted Settings · Status · Logs · Metrics, so it failed against every build from
8.5.75 on — measured live on console.hanzo.ai, where the deployed bundle serves
Settings · Logs · Metrics · Status.

The spec is right to hold the whole list rather than just the tail: it is the one
place the ORDER a phone actually paints is checked, and a partial assertion is how
the order drifted in the first place.
2026-08-06 19:00:22 -07:00
zooqueen 344bb1cfc2 console 8.5.76 — cloud shell in the Developers dock
Hanzo CI/CD / cicd (push) Successful in 22m58s
CI/CD / cicd (push) Successful in 22m59s
The Shell tab was an explorer wearing a terminal's clothes: a `$` prompt that
took `GET /v1/models` and printed the response. Nothing could be RUN in it, so
the prompt was a promise the dock could not keep. It is a terminal now — a login
shell on a pty inside the org's own sandbox, over
/v1/sandboxes/:id/terminal/ws. Whatever the image carries, the hanzo CLI
included, is a command the user types; nothing here decides what may run, because
that decision belongs to the runtime boundary the pod already has and a second
one in a browser tab would only be a fiction.

The socket leaves the origin, once and deliberately. Every other read goes
through the same-origin /v1 proxy, which mints a user-bound bearer server-side —
and a WebSocket cannot use it, because that proxy is a route handler and a route
handler forwards requests, not upgrades. So the two halves split: the ticket is
fetched through the proxy exactly like every other call, and the socket dials the
API host carrying it. Single-use, thirty seconds, bound to one sandbox. That is
what makes putting it in a URL safe, and why nothing long-lived ever goes there.

`sandboxes` joins CLOUD_HEADS so the ticket can be fetched at all. The socket is
not and cannot be proxied — see the note beside the entry.

Which sandbox is "mine" is a QUERY, not a stored id: one live sandbox per project
is the server's own rule, so reopening the dock finds the running one and a
reload has nothing to go stale. The `console` project names the volume, which is
what makes it the same shell tomorrow.

Honest states throughout — "Starting your cloud shell…", then the terminal, then
a calm Disconnected with the reason and a Reconnect that restarts the whole
session as a unit. Never a fake prompt.

xterm loads in the browser and not at import: it reads `document` as it
constructs and this component is server-rendered first. Its element is always
laid out and the status covers it, because an element that is display:none has no
size and the fit that runs when the socket opens would hand the shell an 80x24
window it never corrects.

The /v1 explorer's parser goes with it — `parseCommand` and `hanzoCli` had one
caller each and it was the tab this replaces.
2026-08-06 18:45:18 -07:00
zooqueen 4d17b91b31 console 8.5.75 — the release that carries the org mark, the sub-page icons and Status-last
Hanzo CI/CD / cicd (push) Successful in 21m15s
CI/CD / cicd (push) Successful in 21m15s
f6df104ec8 (org mark in the switcher, the seven billing sub-page icons, and the
Settings · Logs · Metrics · Status order) has been on main unreleased: the pin
still names 8.5.73, so none of it is serving.

.74 is skipped rather than reused. A v8.5.74 tag was already cut at a commit that
PREDATES f6df104ec8, and a version number is an identity — reusing it would make
one name mean two trees, which is the same class of mistake as `8.5.35` naming
both a git tag with no image and an image with no v. .75 is free in both.

The image publishes bare (`8.5.75`), matching what charts/app/values/hanzo/console.yaml
already reads; the git tag keeps the repo's v-prefix. Two namespaces, one number.
2026-08-06 18:42:32 -07:00
zooqueen 7b97b6752b console 8.5.73 — drop 7 dead Docs links (apm/automations/cms/finance/helpdesk/knowledge/nodes)
Hanzo CI/CD / cicd (push) Successful in 8m40s
CI/CD / cicd (push) Successful in 8m41s
Seven products' Docs button deep-linked to docs.hanzo.ai/docs/<slug> pages that
404 (verified live: baseline docs root + a known-good slug return 200, these seven
return 404). The docs-lane redirects v8.4.93 expected for apm/cms/helpdesk/nodes
never landed. Same dead-button class as the duplicate Settings tab.

Fix (registry data only, the v8.4.87 pattern): drop the docs: field from those
seven entries. The consumers read entry.docs ?? config.docsUrl, so the button now
opens the docs home (a real 200 page) instead of a dead deep link — zero 404. The
other 62 docs deep links are verified 200 and unchanged; no inline module link
targets the removed slugs.

tsc clean; vitest 3345 passed / 8 skipped.
2026-08-06 18:16:13 -07:00
zooqueenandhanzo-dev 139d7fc6f9 console: signed-out views can report now — token first, key second
Hanzo CI/CD / cicd (push) Successful in 6m11s
CI/CD / cicd (push) Successful in 6m12s
Signed-out console reported nothing. Not partially: a credential-less POST
to the ingest door is refused outright, `401 ingest_key_required`,
measured live with and without a browser Origin, for pageviews and
exceptions alike.

The signed-in path is untouched and is still the point. Cloud resolves the
tenant from the IAM bearer's own owner claim, so a visitor's events land in
THEIR org on every brand host — cloud.hanzo.ai, cloud.lux.cloud,
cloud.zoo.cloud — with no per-brand configuration. The JWT carries the org
and everything downstream is scoped by it.

The key is supplied through `getToken`, NOT as `ingestKey`, and that is the
whole design. @hanzo/event resolves the outgoing credential as
`ingestKey ?? token`: passing `ingestKey` would REPLACE a signed-in user's
bearer and file their events under the key's org instead of their own,
which on a multi-brand image is a cross-tenant defect. Feeding it through
getToken inverts the precedence to `token ?? key`, so the key can only ever
fill the gap where no token exists and can never displace one.

This file also carried the claim that a keyless beacon degrades to an
"anonymous lane" admitting pageview and error under a $public tenant with a
200 receipt. That lane is not implemented in the deployed cloud. The same
claim appeared in four other repos and is why keyless surfaces were
believed to be half-working when they were reporting nothing at all;
corrected here too.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 18:15:07 -07:00
zooqueen f6df104ec8 console: org mark in the switcher, sub-page icons, Status-last nav order
Hanzo CI/CD / cicd (push) Successful in 5m58s
CI/CD / cicd (push) Successful in 5m58s
- Org switcher (ContextSwitcher) leads with the shared @hanzo/ui OrgMark — the
  same mark SidebarBrand and the account widget wear — so an org with no uploaded
  logo (e.g. Hanzo) shows its monogram instead of a bare name. White-label safe.
  (The user switcher, AccountMenu, already IS the shared @hanzo/iam UserMenu that
  hanzo.chat mounts — the two shared widgets are now aligned.)
- Every level-2 sub-page has a real icon: expanded the ONE shared SUBPAGE_ICON map
  with reports/accounts/budgets/invoices/subscriptions/payment-methods/credits, so
  Billing (and any product reusing those slugs) drops the quiet Circle fallback.
- Uniform sub-nav tail reordered Settings · Logs · Metrics · Status — Status last
  (the live-health verdict follows the signals it's derived from). One order, every
  product. Tests updated to the new order (196 pass).
2026-08-06 17:43:13 -07:00
zooqueen db164c8470 sign out ends the session at the issuer, not just in this tab
Hanzo CI/CD / cicd (push) Successful in 4m48s
CI/CD / cicd (push) Successful in 4m49s
Reported twice as "sign out is not working". The first fix was on hanzo.id's
portal; this is console, and it is the MIRROR IMAGE of that bug, which is why
the first fix changed nothing here.

hanzo.id called the IdP logout and forgot local storage. Console does the
opposite: iamSignOut() clears this browser's tokens and nothing else, then
assigns /signin. The iam_session_id cookie lives on hanzo.id and this origin
cannot touch it, so it survives -- and /signin's first act is to ask the issuer
for a code from the EXISTING session. Measured against production, same probe
either side of the sign-out:

    signed in                            status: ok, code minted
    after console-style sign-out         status: ok, code minted
    after a real RP-initiated logout     "please sign in first", nothing

So the user landed straight back in the console they had just left. The server
end was never broken; nothing ever asked it to end anything.

Sign-out is now ONE navigation, through RP-initiated logout, returning via
post_logout_redirect_uri to the same /signin the old assign targeted. The two
local calls stay -- they clear this tab immediately, which the redirect cannot.

Two details the shape depends on:

  - The origin is a PARAMETER, not window.location.origin read inside. This
    module is imported by server-rendered code and every sibling guards
    `typeof window === 'undefined'`; a function that reads window unguarded
    throws the moment SSR touches it. Passing it removes the hazard rather than
    guarding it, and makes the URL a pure function of its inputs -- testable in
    this repo's node environment, which has no DOM at all.

  - The return leg must not leave this origin. An absolute URL beats a base in
    `new URL`, so without the check a caller could hand the IdP an open redirect
    to hand back. A foreign return is never what sign-out meant, so it falls
    back to our own sign-in page.

Guard is known to bite: dropping the same-origin check fails that case by name
while the other four stay green.
2026-08-06 17:19:54 -07:00
zooqueen 6976badf4e the profile photo the access token was never going to carry
Reported as "I still don't see the profile photo in the Hanzo console". It was
not a stale deploy and not the upload: account.avatar was undefined for every
user, always, so the menu fell back to initials no matter what anyone uploaded.

session() resolves identity from the access token's own claims:

    const claims = decodeJwtClaims(token) ?? (await iamUserInfo())

The IAM access token IS a JWT, so decodeJwtClaims never returns null and the
userinfo arm is dead code. And IAM publishes the avatar on `picture` in the
USERINFO response ONLY (internal/oidc/userinfo.go putIf(info,"picture",u.Avatar),
under the profile scope the console already requests) -- deliberately, because
an avatar is a bounded data URI and carrying it would add kilobytes to every
JWT on every request. hanzoai/app's lib/profile.ts writes it under that same
contract from the other side: "surfaces on the picture claim in the USERINFO
response only, never in the ID token".

So the token is authoritative about who you are and silent about your picture,
and we were reading only the token. Both halves are right on their own; nothing
joined them.

The token stays the identity source -- it is self-contained, costs no round
trip, and is immune to getUserInfo() answering null on a 200, which once
dead-ended the session into a /signin loop. One extra fetch fills the one claim
it omits, best-effort: a failure, a 401, or a user with no photo all resolve to
"no avatar", never to a broken session. Cached by identity, and the MISS is
cached too -- a user with no photo should cost one request, not one per load --
and dropped on sign-out so the next account never wears the previous face.

Ruled out first, so the diagnosis is not a guess: CSP allows data: on all three
hosts (measured); the photo does reach IAM (hanzo.app writes user.avatar
through the confidential client, one store, not a second copy); and the
userinfo claim is gated only on the `profile` scope, which console requests.

Guard is known to bite: reverting to the token-only read fails 3 of the 5 by
name, including the neighbour cases (token-carried avatar still wins; a failing
userinfo still yields a session). 3268 pass, typecheck clean.
2026-08-06 17:19:54 -07:00
zooqueen 85b7e6accb 8.5.72 — the static base serves the index in place
Hanzo CI/CD / cicd (push) Successful in 7m50s
CI/CD / cicd (push) Successful in 7m51s
hanzoai/static v0.5.7 (digest 46b9a9b3…) answers / with the index bytes
instead of a 301 to /index.html, so the address bar stops carrying the
internal filename and the breadcrumb stops reading 'Home > index.html'.
2026-08-06 17:08:10 -07:00
zooqueen f464508249 o11y: the logs/traces read speaks v5 — builderQueries dies with the v3 pin it cited
Hanzo CI/CD / cicd (push) Successful in 9m4s
CI/CD / cicd (push) Successful in 9m6s
The flat /v1/o11y/query_range now resolves to the module's v5 querier,
whose strict composite refuses the v3 envelope outright — the overview's
observability panel rendered that 400 as 'Could not reach observability'.
rawQueryPayload/parseRawRows are the matched v5 pair: requestType raw,
one builder_query envelope, rows under data.data.results[].rows with the
OTel attribute maps flattened back into the one namespace the row
readers always addressed.

Order and filter stay OFF the wire deliberately: both route through
telemetry-metadata key resolution, which the deployed runtime cannot
serve ('failed to get logs keys'), while raw is newest-first by default
— verified live. Service scoping remains the client-side re-filter these
readers always ran; a scoped read asks for the deepest page instead.
When the metadata store heals, the filter expression is a one-line
return.
2026-08-06 17:04:23 -07:00
zooqueen d37dc23580 console 8.5.71 — honest empty logs, one Settings crumb, the assistant in the dock
Hanzo CI/CD / cicd (push) Successful in 7m54s
CI/CD / cicd (push) Successful in 7m55s
- Logs: a managed capability with no dedicated service shows the logs surface in
  its calm empty state ("no logs yet"), not a dead "No product log source" card.
  The "nothing is fabricated / not placeholder data / never placeholder" self-talk
  is removed from the Logs, Status, RuntimeNotice and observability copy — an empty
  state just says what it is.
- Breadcrumbs: drop the category crumb when it only repeats the product's own name
  (no more Home / Settings / Settings / logs), and label a trailing base sub-page
  from the product's own nav (… / Logs, not the raw `logs` slug).
- Developers dock: the mic + Ask-Hanzo launcher live in the bar on desktop; the
  floating bubble is hidden at lg+ (its home is the dock there) and kept on phones.
  The redundant Open/Hide chevron is gone.
2026-08-06 13:30:23 -07:00
zooqueen 38eb16f391 console 8.5.70 — silent refresh is resilient, no false 'session expired'
Hanzo CI/CD / cicd (push) Successful in 11m28s
CI/CD / cicd (push) Successful in 11m29s
The console signs you in with a short-lived IAM access token and silently
refreshes it (a proactive timer at ~80% of the token's life + a reactive 401 ->
refresh). Both paths funnel through the ONE single-flight refreshSession, which
ran exactly ONE refresh attempt: a single transient failure (a network blip, a
5xx from IAM, a lost rotation race) returned a bare false, and the caller took
that one 'no' as a definitive sign-out — so 'session expired, sign in again'
fired mid-task on a hiccup.

A transient failure and a genuinely-dead refresh token are indistinguishable at
this layer (the SDK collapses both to a null token), so refreshSession now
retries a bounded few times with short backoff WHILE a session still exists
(new iamHasSession): a blip self-heals (session preserved, no false eviction),
and a truly-expired session resolves false ~1s later before the honest re-auth
card. An anonymous visitor (no stored token) never waits through the backoff.

The retry orchestration is the pure, injected-deps resilientRefresh — the same
idiom the API client's resilientFetch uses — so it is unit-testable in isolation.
AccountApi.session() reuses it at boot, so a transient IAM blip at load no longer
dumps a signed-in user on the sign-in card either. Single-flight preserved.

tsc clean; vitest 3334 passed / 8 skipped (+7 refresh: transient-recovery,
bounded-retry-exhaust, no-session-no-spin, mid-retry-stop, browser-only,
single-flight).
2026-08-06 13:16:25 -07:00
zooqueen e1ff150012 console 8.5.69 — one Settings, one Logs, one Metrics, one Status
Hanzo CI/CD / cicd (push) Successful in 13m54s
CI/CD / cicd (push) Successful in 13m55s
A product whose own id is a base sub-page concern (Settings, Status, Logs,
Metrics) was also getting a base sub-tab of that same name, so the Settings
product's level-2 nav read General · Branding · Settings · Status · Logs ·
Metrics — a self-referential Settings tab of itself. Same duplicate on the
Logs/Metrics/Status Observe products.

One rule, read by both the nav and the router (baseSubpagesFor): a product
never gets a base sub-tab bearing its own id. The nav drops the duplicate; the
router agrees (a /settings/settings URL lands on the index, never a second
Settings screen).
2026-08-06 13:04:02 -07:00
hanzo-dev a47de37ff5 console 8.5.68 — the release number the registry has left
Hanzo CI/CD / cicd (push) Successful in 5m35s
CI/CD / cicd (push) Successful in 5m35s
8.5.63 cannot name this build. It is already a tag in ghcr.io/hanzoai/console,
pushed against commit 8a7ae9be, and a v* tag build publishes the v-stripped
alias beside the tag it was given (hanzoai/ci build.yml: VER="${REL#v}"), so
cutting v8.5.63 would move `8.5.63` off those bytes and onto these. One name,
two digests, is the single thing imgver and pin.sh are both built to prevent —
pin.sh resolves tag and digest from ONE registry answer precisely so a name
cannot come to mean two images, and nodes run pullPolicy: IfNotPresent, so a
re-pointed tag reaches only the nodes that never pulled it.

The number is therefore the next one free above the registry, not the next one
free after the last release: main-push builds now mint a derived semver on
every push (imgver: max(declared, published) + a patch), so 8.5.60 through
8.5.67 are spent, 8.5.67 being this branch's own base. 8.5.68 is unused as both
`v8.5.68` and `8.5.68`, which is what a cut tag needs, and declaring it here
keeps the manifest and the release the same number.
2026-08-06 11:36:01 -07:00
37 changed files with 1173 additions and 521 deletions
+17 -1
View File
@@ -33,6 +33,18 @@ ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# identically to one served from inside cloud.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
# The publishable ingest key, for SIGNED-OUT views only. A signed-in visitor is
# still attributed by their own IAM bearer -- src/lib/event.ts feeds this through
# getToken as `token ?? key`, never as `ingestKey`, so it can only fill the gap
# where there is no token and can never displace one.
#
# PUBLISHABLE_KEY is the name in KMS (org hanzo, path deploy, env prod) and on the
# --build-arg; NEXT_PUBLIC_ is what makes the bundler inline it and is a property
# of THIS build, so it is applied here and the secret store keeps the one plain
# name. No default: absent means signed-out views report nothing, exactly as
# before, which is a degradation and not a break.
ARG PUBLISHABLE_KEY
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
@@ -44,7 +56,11 @@ RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
# cannot change under a rebuild. (The console's OWN release is named by semver in
# the values file — that is the version a human reads.)
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
#
# v0.5.7: serves a directory's index IN PLACE. The prior pin 301'd `/` to
# `/index.html`, so the address bar carried the internal filename and the
# console's breadcrumb dutifully read "Home > index.html".
FROM ghcr.io/hanzoai/static@sha256:46b9a9b359b24377e228d39fb3d4e485af594d55bf1034dcc7b7a1e858a0bba6
COPY --from=build /console/out/ /srv/
EXPOSE 3000
ENTRYPOINT ["/static"]
+6 -1
View File
@@ -119,7 +119,12 @@ test('phone: the strip carries level 2 where the sidebar is a drawer', async ({
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 tail reads raw → summary: Logs, then Metrics, then Status LAST (the
// live-health verdict comes after the signals it is derived from). f6df104ec8
// reordered BASE_SUBPAGES and updated match-core.test.ts but not this spec, so it
// asserted the retired order and failed against every build from 8.5.75 on.
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status'])
// The strip wraps rather than pushing the page sideways.
const scrolls = await page.evaluate(
+4
View File
@@ -41,3 +41,7 @@ images:
context: .
dockerfile: Dockerfile
repo: ghcr.io/hanzoai/console
# Fetched from KMS (deploy/PUBLISHABLE_KEY, env prod) and passed as
# --build-arg PUBLISHABLE_KEY. Signed-out views need it to be admitted at all;
# signed-in ones keep their own bearer. Same name the rest of the estate uses.
build_secrets: [PUBLISHABLE_KEY]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.5.63",
"version": "8.5.78",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
+11 -3
View File
@@ -31,6 +31,7 @@ import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { OrgMark } from '@hanzo/ui/product'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
@@ -126,9 +127,16 @@ export function ContextSwitcher() {
) : null}
</XStack>
) : (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
// No uploaded logo: lead with the org's shared OrgMark (its monogram —
// the SAME mark SidebarBrand and the account widget wear), so the switcher
// is never a bare name. White-label safe: OrgMark is the tenant's OWN mark
// (the org's IAM logo when set, else its monogram), never the house glyph.
<XStack items="center" gap="$2" flex={1} minW={0}>
<OrgMark org={org} size={20} />
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
</XStack>
)}
</Button>
</Popover.Trigger>
+7 -5
View File
@@ -216,10 +216,12 @@ function ChatSheet({
* listening. The mic renders only where the browser can actually listen, so there is
* never a dead control.
*
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
* exists only there), and its caller suppresses it exactly where the assistant is
* already on screen: while the sheet is open, on the pages that ARE a composer
* (`/chat`, `/playground`), and while the assistant IS the column.
* It is the assistant's entry point on phones/tablets (`<lg`). At `lg+` the
* Developers dock at the foot of the page hosts the same mic + brand-mark, so the
* bubble is hidden there (`$lg` display:none) — one launcher per viewport, never two.
* Its caller also suppresses it where the assistant is already on screen: while the
* sheet is open, on the pages that ARE a composer (`/chat`, `/playground`), and while
* the assistant IS the column.
*/
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
const [voiceOk] = useState(() => voiceSupported())
@@ -229,7 +231,7 @@ function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () =>
position="fixed"
r={20}
b={20}
$lg={{ b: 64 }}
$lg={{ display: 'none' }}
items="center"
gap="$2"
style={{ zIndex: Z.raised }}
@@ -137,7 +137,7 @@ export function ProductObservability({
{!service ? (
<Text fontSize="$3" color="$color11">
{label} is a managed surface with no dedicated telemetry service its signals roll up under the
platform-wide Observe views. No per-product metrics are fabricated here.
platform-wide Observe views.
</Text>
) : st.error ? (
<RuntimeNotice surface="observability" error={st.error} />
@@ -45,10 +45,10 @@ export function RuntimeNotice({ surface, error }: { surface: string; error: unkn
const status = classifyRuntime(error)
const message = error instanceof Error ? error.message : String(error)
const body: Record<RuntimeStatus, string> = {
'not-initialized': `Observability runtime initializing — your ${surface} will appear here once it's enabled. The /v1/o11y routes are mounted, but the runtime (telemetry stores, query service) is not initialized on this deployment yet. This page shows live ${surface} the moment the runtime is online — it never shows placeholder data.`,
'not-initialized': `Observability runtime initializing — your ${surface} will appear here once it's enabled. The /v1/o11y routes are mounted, but the runtime (telemetry stores, query service) is not initialized on this deployment yet. This page shows live ${surface} the moment the runtime is online.`,
unavailable: `The /v1/o11y/${surface} surface is not proxied on this host yet.`,
// 403 for a signed-in user — observability isn't provisioned for their org.
access: `Observability isn't enabled for your organization yet, so your ${surface} can't be read. It appears here automatically once it is — never placeholder data.`,
access: `Observability isn't enabled for your organization yet, so your ${surface} can't be read. It appears here automatically once it is.`,
// 401 — the session itself lapsed.
signin: `Your session has expired or isn't recognized here. Sign in again to view your ${surface}.`,
error: message,
@@ -11,16 +11,17 @@
* org's lines (o11y scopes every query by the JWT owner → `X-Org-Id`).
*
* DRY: reuses `ApmApi.logs(window, limit, service)` + the shared o11y `RuntimeNotice`
* — there is ONE o11y client and ONE honest-state card, parameterized per product.
* — there is ONE o11y client and ONE empty-state card, parameterized per product.
* The rows are additionally re-filtered client-side to the product's service, so a
* runtime that ignored the filter can never leak another service's lines here.
*
* Honest states, never a fabricated/blank grid:
* - product with no backing service → an honest "no product log source" card;
* States:
* - product with no dedicated service → the logs surface in its empty state (a
* calm "no logs yet" card), never a dead end;
* - o11y 503 (initializing) / 404 (unrouted) / 401 (session) / 403 (not enabled) →
* the shared `RuntimeNotice` (names the reason + endpoint);
* - o11y answered but the window is empty (the service ships no OTLP logs yet) →
* an honest "Connected · no logs in the last <range>" card, never placeholder.
* a "no logs in the last <range>" card.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
@@ -110,11 +111,16 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
{ key: 'body', header: 'Message', render: (l) => <Text fontSize="$2" color="$color11" numberOfLines={1}>{l.body || '—'}</Text> },
]
const hasService = Boolean(o11yService)
return (
<>
<PageHeader
title={`${entry.label} · Logs`}
subtitle={`Live application logs for the ${entry.label} service, from the o11y runtime.`}
subtitle={
hasService
? `Live application logs for the ${entry.label} service, from the o11y runtime.`
: `Application logs for ${entry.label}.`
}
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(rangeIdx)}>
Refresh
@@ -122,32 +128,25 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
}
/>
{!o11yService ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={640}>
<XStack gap="$2" items="center">
<ScrollText size={16} color="$color10" />
<Text fontSize="$4" fontWeight="700">No product log source</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{entry.label} is a managed capability with no discrete service that ships logs. Application
logs appear here automatically if it ever emits OpenTelemetry logs to o11y nothing is fabricated.
</Text>
</Card>
) : state.phase === 'error' ? (
{state.phase === 'error' ? (
<RuntimeNotice surface="logs" error={state.err} />
) : (
<YStack gap="$3">
<XStack gap="$3" items="center" flexWrap="wrap" justify="space-between">
<XStack gap="$2" items="center" flexWrap="wrap">
<ScrollText size={16} />
<Text fontSize="$2" color="$color10">Range</Text>
<Segmented value={RANGES[rangeIdx].key} options={RANGES} onChange={(k) => setRangeIdx(RANGES.findIndex((r) => r.key === k))} />
{/* The range + severity controls only make sense against a live service; a
managed capability with no service shows the empty surface alone. */}
{hasService ? (
<XStack gap="$3" items="center" flexWrap="wrap" justify="space-between">
<XStack gap="$2" items="center" flexWrap="wrap">
<ScrollText size={16} />
<Text fontSize="$2" color="$color10">Range</Text>
<Segmented value={RANGES[rangeIdx].key} options={RANGES} onChange={(k) => setRangeIdx(RANGES.findIndex((r) => r.key === k))} />
</XStack>
<SelectMenu options={severityOptions} value={severity} onChange={setSeverity} allLabel="All severities" />
</XStack>
<SelectMenu options={severityOptions} value={severity} onChange={setSeverity} allLabel="All severities" />
</XStack>
) : null}
{state.phase === 'ready' && all.length === 0 ? (
<NoLogs entry={entry} range={RANGES[rangeIdx].label} service={o11yService} />
<NoLogs entry={entry} range={hasService ? RANGES[rangeIdx].label : null} service={o11yService} />
) : (
<DataTable
columns={columns}
@@ -159,9 +158,15 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
)}
<Text fontSize="$1" color="$color10">
Application logs from the Hanzo o11y runtime (OTLP O11y), scoped to service{' '}
<Text fontSize="$1" color="$color11" fontWeight="600">{o11yService}</Text> and your organization. If {entry.label} isn&apos;t
instrumented yet, no lines appear never placeholder data.
Application logs from the Hanzo o11y runtime (OTLP), scoped to{' '}
{hasService ? (
<>
service <Text fontSize="$1" color="$color11" fontWeight="600">{o11yService}</Text> and your organization. Lines
appear here as {entry.label} emits them.
</>
) : (
<>your organization. Lines appear here as {entry.label} emits them.</>
)}
</Text>
</YStack>
)}
@@ -169,20 +174,34 @@ export function ProductLogsView({ entry }: { entry: CatalogEntry }) {
)
}
/** Honest "connected · no logs in window" — o11y answered, this service ships no OTLP logs yet. */
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string; service: string }) {
/**
* Empty logs surface. Two shapes, one card:
* - a product WITH a service, whose window came back empty → "no logs in the last <range>";
* - a managed capability with no dedicated service → a calm "no logs yet".
*/
function NoLogs({ entry, range, service }: { entry: CatalogEntry; range: string | null; service: string | null }) {
const connected = Boolean(service)
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={680}>
<XStack gap="$2" items="center">
<Server size={16} />
<Server size={16} color={connected ? undefined : '$color10'} />
<Text fontSize="$4" fontWeight="700">
Connected · no logs for {entry.label} in the last {range}
{connected ? `No logs for ${entry.label} in the last ${range}` : 'No logs yet'}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
The o11y log runtime answered, but service <Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no
OpenTelemetry logs for your organization in this window. This is a real empty result, not placeholder data lines appear
here as {entry.label} emits OTLP logs. Try a wider range.
{connected ? (
<>
The o11y runtime is connected, but service{' '}
<Text fontSize="$3" color="$color12" fontWeight="600">{service}</Text> shipped no logs for your organization in this
window. Lines appear here as {entry.label} emits them try a wider range.
</>
) : (
<>
{entry.label} is a managed capability with no dedicated service. Application logs appear here as it emits them to the
o11y runtime.
</>
)}
</Text>
</Card>
)
@@ -179,7 +179,7 @@ function O11yHealthBand({ label, health }: { label: string; health: ServiceHealt
<Text fontSize="$1" color="$color10">
Live RED metrics from the o11y runtime for service{' '}
<Text fontSize="$1" color="$color11" fontWeight="600">{health.service}</Text>, scoped to your organization. {label} is
serving traffic this is real telemetry, not a fabricated status.
serving traffic.
</Text>
</Card>
)
@@ -210,8 +210,8 @@ function ManagedCard({ entry, hasService }: { entry: CatalogEntry; hasService: b
</XStack>
<Text fontSize="$3" color="$color11">
{hasService
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported. No status is fabricated.`
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API; there is no fabricated status shown.`}
? `Neither the o11y runtime nor the control-plane inventory reports a running ${entry.label} service for your organization right now. It may be a shared managed service reported elsewhere, or idle in this window — its live health lights up automatically once it serves traffic or is reported.`
: `${entry.label} is a managed Hanzo Cloud capability with no discrete service to report health for. It is available through the API.`}
</Text>
</Card>
)
+12 -2
View File
@@ -12,6 +12,7 @@ import { Text, XStack } from '@hanzo/gui'
import { ChevronRight } from '@hanzogui/lucide-icons-2'
import { findEntry, categoryFromSlug } from '~/lib/products/registry'
import { productSubpages } from '~/lib/products/match'
type Crumb = { label: string; href?: string }
@@ -38,9 +39,18 @@ function crumbsFor(pathname: string): Crumb[] {
const entry = findEntry(segs[0])
if (entry) {
crumbs.push({ label: entry.category })
// Skip the category crumb when it just repeats the product's own name — the
// `Settings` product lives in the `Settings` category, and `Home / Settings /
// Settings / …` says the same word twice.
if (entry.category !== entry.label) crumbs.push({ label: entry.category })
crumbs.push({ label: entry.label, href: segs.length > 1 ? `/${entry.id}` : undefined })
for (let i = 1; i < segs.length; i++) crumbs.push({ label: decodeURIComponent(segs[i]) })
// Label trailing segments from the product's own sub-page list (so `/settings/logs`
// reads `… / Logs`, not the raw slug); detail params that aren't sub-pages pass through.
const subs = productSubpages(entry)
for (let i = 1; i < segs.length; i++) {
const sp = subs.find((s) => s.slug === segs[i])
crumbs.push({ label: sp ? sp.label : decodeURIComponent(segs[i]) })
}
} else {
for (const s of segs) crumbs.push({ label: decodeURIComponent(s) })
}
+16
View File
@@ -27,9 +27,16 @@ import {
Activity,
BarChart3,
Circle,
Coins,
CreditCard,
FileText,
House,
Receipt,
Repeat,
ScrollText,
SlidersHorizontal,
Target,
Users,
} from '@hanzogui/lucide-icons-2'
import type { ComponentType } from 'react'
@@ -45,6 +52,15 @@ const SUBPAGE_ICON: Record<string, ComponentType<{ size?: number }>> = {
status: Activity,
logs: ScrollText,
metrics: BarChart3,
// Common billing/finance slugs get a real icon here (the ONE shared map) rather
// than the quiet dot, so every product that names one is iconed — not just billing.
reports: FileText,
accounts: Users,
budgets: Target,
invoices: Receipt,
subscriptions: Repeat,
'payment-methods': CreditCard,
credits: Coins,
}
/** A View is flex-shrink:0 by default; a wrapping row needs this to wrap at all. */
+202
View File
@@ -0,0 +1,202 @@
'use client'
/**
* The cloud shell — a REAL terminal in the Developers dock.
*
* What was here before was an explorer: a prompt that took `GET /v1/models` and
* printed the response. It looked like a shell and answered like a form, and the
* gap between the two is the whole reason this exists — there was no way to run
* anything, so the `$` in the dock was a promise the dock could not keep.
*
* THE SHELL IS A SANDBOX. Not a simulator, not a command allow-list: a login
* shell on a pseudo-terminal inside the org's own gVisor pod. Whatever the image
* carries — the hanzo CLI is on its PATH — is a command the user types, and
* nothing here decides what may run. That decision belongs to the runtime
* boundary the pod already has, and a second one in a browser tab would only be a
* fiction.
*
* THE TERMINAL IS NOT BUILT HERE. Cloud serves it, whole, at the same address as
* the socket, and this frames it. That is not laziness about an emulator — it is
* that the console is one of several hosts that show a shell, and a terminal
* built per host is a terminal that is subtly different in each of them. One
* implementation, one place a fix lands, and this file is left with the only part
* that is genuinely the console's: which sandbox, and what to say while it is
* coming up.
*
* WHAT THIS STILL OWNS is the credential. A frame carries no Authorization
* header any more than a socket does, so the ticket is fetched through the
* same-origin `/v1` proxy — where identity lives — and handed to the page in its
* URL. Single-use, thirty seconds, bound to one sandbox: that is what makes
* putting it in a URL safe, and why nothing long-lived ever goes there.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, cloudProxyV1Url, restGet, restPost } from '~/lib/api/client'
import { config } from '~/config'
import { toneColor } from '~/components/ui/tone'
import { terminalFor } from './logic'
/**
* The project the dock's shell holds. A `dev` sandbox is attached to a project
* and the project names the VOLUME, so this constant is what makes the shell the
* same shell tomorrow: the checkout and the caches are still there when the lease
* has long since ended. One live sandbox per project is the server's rule, which
* is also why reopening the dock finds the running one instead of leasing a
* second.
*/
const PROJECT = 'console'
/** The tmux session this dock attaches to, so reopening it finds the same shell. */
const SESSION = 'dock'
/**
* How long to wait for the terminal to say it is up.
*
* The page posts `{source:'hanzo-term'}` when its socket opens. Without a
* deadline a frame that failed into something else — an expired ticket, an
* origin that refused to be framed — is indistinguishable from one that is still
* loading, and the dock would sit on "Starting…" forever rather than offering the
* reconnect that fixes it.
*/
const READY_BY = 6000
type Phase = 'starting' | 'live' | 'gone'
type Sandbox = { id: string; status: string; project?: string }
/**
* The org's running dock sandbox, or a freshly leased one.
*
* Asking the server which one is live is what makes the shell survive a reload
* without remembering anything: there is exactly one live sandbox per project by
* the server's own rule, so the answer to "which one is mine" is a query and
* never a stored id that can go stale. The match is re-checked here rather than
* trusted from the query string — a filter is the server's convenience, and the
* sandbox this reattaches to had better be the right one.
*/
async function sandbox(): Promise<Sandbox> {
const live = await restGet<{ sandboxes?: Sandbox[] }>(
cloudProxyV1Url(`sandboxes?project=${PROJECT}&status=running`),
)
const held = live.sandboxes?.find((m) => m.status === 'running' && m.project === PROJECT)
if (held) return held
return restPost<Sandbox>(cloudProxyV1Url('sandboxes'), { class: 'dev', project: PROJECT })
}
const reason = (err: unknown): string =>
err instanceof ApiError
? `${err.message}${err.status ? ` (${err.status})` : ''}`
: err instanceof Error
? err.message
: String(err)
export function Terminal() {
const [phase, setPhase] = useState<Phase>('starting')
const [why, setWhy] = useState('')
const [src, setSrc] = useState('')
// A change to this is the ONE way a session restarts: the effect below owns the
// whole lifetime — sandbox, ticket, frame — and reruns as a unit, so there is
// no half-torn-down session to reason about. A ticket is spent once, so a
// reconnect is a new ticket and never the old frame reloaded.
const [attempt, setAttempt] = useState(0)
const frame = useRef<HTMLIFrameElement>(null)
const retry = useCallback(() => {
setPhase('starting')
setWhy('')
setSrc('')
setAttempt((n) => n + 1)
}, [])
useEffect(() => {
// `alive` is the barrier for everything this effect started. React mounts an
// effect twice in development, and a ticket fetched by the first pass would
// otherwise land in a frame the second pass has replaced.
let alive = true
let waiting: ReturnType<typeof setTimeout> | null = null
const end = (message: string) => {
if (!alive) return
setWhy(message)
setPhase('gone')
}
// The readiness handshake. Only the frame we opened may speak for it: the
// origin is checked against the API host, so another page cannot post its way
// into a terminal that is not there.
const heard = (e: MessageEvent) => {
if (!alive || e.source !== frame.current?.contentWindow) return
if (new URL(config.apiUrl).origin !== e.origin) return
const d = e.data as { source?: string } | null
if (d && d.source === 'hanzo-term') {
if (waiting) clearTimeout(waiting)
setPhase('live')
}
}
window.addEventListener('message', heard)
void (async () => {
try {
const m = await sandbox()
if (!alive) return
const pass = await restPost<{ ticket: string }>(
cloudProxyV1Url(`sandboxes/${m.id}/terminal/ticket`),
)
if (!alive) return
setSrc(terminalFor(config.apiUrl, m.id, pass.ticket, SESSION))
waiting = setTimeout(() => end('The terminal did not come up.'), READY_BY)
} catch (err) {
end(reason(err))
}
})()
return () => {
alive = false
if (waiting) clearTimeout(waiting)
window.removeEventListener('message', heard)
}
}, [attempt])
// The frame is ALWAYS laid out and the status covers it, because a frame that
// is display:none has no size — and a terminal sized to nothing measures 80x24
// and never corrects.
return (
<YStack flex={1} minH={0} position="relative" bg="#000">
{src ? (
<iframe
ref={frame}
src={src}
title="Cloud shell"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
/>
) : null}
{phase === 'live' ? null : (
<YStack position="absolute" t={0} l={0} r={0} b={0} items="center" justify="center" gap="$2" p="$4" bg="$color1">
{phase === 'starting' ? (
<Text fontSize="$2" color="$color10">
Starting your cloud shell
</Text>
) : (
<>
<XStack items="center" gap="$2">
<Text fontSize="$2" color={toneColor('critical')}>
Disconnected
</Text>
<Button size="$2" onPress={retry} aria-label="Reconnect the cloud shell">
Reconnect
</Button>
</XStack>
{why ? (
<Text fontSize="$1" color="$color10" className="hz-mono">
{why}
</Text>
) : null}
</>
)}
</YStack>
)}
</YStack>
)
}
+24 -7
View File
@@ -31,9 +31,8 @@ import { useRouter } from 'next/navigation'
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import {
Activity,
ChevronDown,
ChevronUp,
Maximize2,
Mic,
Minimize2,
RefreshCw,
ScrollText,
@@ -43,6 +42,9 @@ import {
import { fetchUsageRecords, type UsageRecord } from '~/lib/api/aimetrics'
import { usePreferences } from '~/lib/products/preferences'
import { useFloatingChat } from '~/components/FloatingChat'
import { BrandMark } from '~/components/ui/BrandLogo'
import { voiceSupported } from '~/lib/voice'
import {
EventsTab,
HealthTab,
@@ -73,6 +75,10 @@ const LEDGER_TABS: ReadonlySet<TabId> = new Set<TabId>(['overview', 'logs', 'eve
export function WorkbenchDock() {
const router = useRouter()
const { get, set } = usePreferences()
// The assistant lives in this bar on desktop (the floating bubble is suppressed
// at lg+). The mic starts it listening; the brand mark opens it.
const { openChat, startVoice } = useFloatingChat()
const [voiceOk] = useState(() => voiceSupported())
const open = get<boolean>('workbenchOpen', false)
const [tab, setTab] = useState<TabId>('overview')
// Freely resizable dock height (px), persisted per-user — drag the top handle to
@@ -283,7 +289,7 @@ export function WorkbenchDock() {
$
</Text>
<Text flex={1} fontSize="$2" color="$color10" className="hz-mono" numberOfLines={1}>
Run a /v1 command models, agents, logs
Open a cloud shell a real terminal in your sandbox
</Text>
</XStack>
) : (
@@ -291,13 +297,24 @@ export function WorkbenchDock() {
)}
<Button size="$2" chromeless icon={<Activity size={16} />} onPress={() => openTo('overview')} aria-label="API activity" />
<Button size="$2" chromeless icon={<ScrollText size={16} />} onPress={() => openTo('logs')} aria-label="Recent API logs" />
{/* The assistant's home on desktop — mic starts it listening, the brand mark
opens it. The floating bubble is hidden at lg+ (this is where it lives);
it stays on phones, where this dock is not shown. */}
{voiceOk ? (
<Button size="$2" chromeless icon={<Mic size={16} />} onPress={startVoice} aria-label="Talk to Hanzo" />
) : null}
<Button
size="$2"
icon={open ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
onPress={() => set('workbenchOpen', !open)}
aria-label={open ? 'Collapse the workbench' : 'Open the workbench'}
bg="$color4"
borderWidth={1}
borderColor="$borderColor"
icon={<BrandMark size={16} />}
onPress={openChat}
aria-label="Ask Hanzo"
>
{open ? 'Hide' : 'Open'}
<Text fontSize="$2" fontWeight="700" color="$color12">
AI
</Text>
</Button>
</XStack>
</YStack>
+24 -31
View File
@@ -1,32 +1,6 @@
import { describe, expect, it } from 'vitest'
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput } from './logic'
describe('parseCommand', () => {
it('accepts every documented form and normalizes to a /v1-relative path', () => {
expect(parseCommand('GET /v1/models')).toEqual({ path: 'models' })
expect(parseCommand('get v1/models')).toEqual({ path: 'models' })
expect(parseCommand('/v1/models')).toEqual({ path: 'models' })
expect(parseCommand('models')).toEqual({ path: 'models' })
expect(parseCommand(' agents?limit=5 ')).toEqual({ path: 'agents?limit=5' })
})
it('is read-only — every mutating method is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(parseCommand(`${m} /v1/agents`)).toHaveProperty('error')
}
})
it('refuses empty, multi-path, traversal, and smuggled inputs', () => {
expect(parseCommand('')).toHaveProperty('error')
expect(parseCommand('GET')).toHaveProperty('error')
expect(parseCommand('/v1')).toHaveProperty('error')
expect(parseCommand('GET /v1/a /v1/b')).toHaveProperty('error')
expect(parseCommand('/v1/../admin')).toHaveProperty('error')
expect(parseCommand('//evil.com/x')).toHaveProperty('error')
expect(parseCommand('https://evil.com/x')).toHaveProperty('error')
})
})
import { curlFor, eventsFrom, inspectorRoute, renderOutput, terminalFor } from './logic'
describe('renderOutput', () => {
it('pretty-prints JSON and passes strings through', () => {
@@ -77,10 +51,6 @@ describe('show code', () => {
expect(curlFor('models')).toBe('curl https://api.hanzo.ai/v1/models \\\n -H "Authorization: Bearer $HANZO_API_KEY"')
expect(curlFor('/v1/agents')).toContain('https://api.hanzo.ai/v1/agents')
})
it('builds the Hanzo CLI form', () => {
expect(hanzoCli('/v1/models')).toBe('hanzo api get /v1/models')
expect(hanzoCli('agents')).toBe('hanzo api get /v1/agents')
})
})
describe('eventsFrom', () => {
@@ -105,3 +75,26 @@ describe('eventsFrom', () => {
expect(eventsFrom([])).toEqual([])
})
})
describe('cloud shell — where the terminal is', () => {
it('addresses the API host, not the console origin — a frame cannot ride the /v1 proxy', () => {
expect(terminalFor('https://api.hanzo.ai', 'm_abc', 'tok', 'dock')).toBe(
'https://api.hanzo.ai/v1/sandboxes/m_abc/terminal?ticket=tok&arg=dock',
)
})
it('tolerates a trailing slash and escapes what it interpolates', () => {
expect(terminalFor('https://api.hanzo.ai/', 'm_1', 't', 'dock')).toContain(
'api.hanzo.ai/v1/sandboxes/m_1/terminal',
)
// An id is hex and a ticket is base64url, but neither is trusted to be: an
// unescaped `&` would silently truncate the credential.
expect(terminalFor('https://api.hanzo.ai', 'a/b', 'x&y=z', 'p 1')).toBe(
'https://api.hanzo.ai/v1/sandboxes/a%2Fb/terminal?ticket=x%26y%3Dz&arg=p%201',
)
})
it('names a session, so reopening the dock reattaches instead of starting over', () => {
expect(terminalFor('https://api.hanzo.ai', 'm_1', 't', 'dock')).toContain('arg=dock')
})
})
+31 -40
View File
@@ -1,41 +1,10 @@
/**
* Workbench logic — the PURE half of the bottom Developers dock (parse a shell
* command into a safe same-origin `/v1` read; render a response for the terminal).
* No React/Gui/registry imports so it is node-testable in isolation.
* Workbench logic — the PURE half of the bottom Developers dock: where the cloud
* shell's socket lives and what goes over it, how a response renders, and how an
* id routes to the `/v1` read that explains it. No React/Gui/registry imports, so
* every decision here is node-testable on its own.
*/
export type Command = { path: string } | { error: string }
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
/** Path charset — segments, query string; nothing that can smuggle a scheme/host. */
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
/**
* Parse a workbench shell line into a `/v1`-relative GET path. Accepted forms:
* `GET /v1/models` · `/v1/models` · `v1/models` · `models` (a bare head). The
* shell is deliberately READ-ONLY (GET) — a mutation belongs in the product UI,
* where it gets its confirm/undo affordances — and `/v1`-only (the one API root).
*/
export function parseCommand(input: string): Command {
const words = input.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return { error: 'Enter a path — e.g. GET /v1/models' }
let rest = words
const head = words[0].toUpperCase()
if (METHODS.has(head)) {
if (head !== 'GET') return { error: 'The workbench shell is read-only — only GET is supported.' }
rest = words.slice(1)
}
if (rest.length !== 1) return { error: 'One path per command — e.g. GET /v1/models' }
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(rest[0])) return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
let path = rest[0].replace(/^\/+/, '')
if (path === 'v1' || path === 'v1/') return { error: 'Name a resource — e.g. /v1/models' }
if (path.startsWith('v1/')) path = path.slice(3)
if (!path || !PATH_OK.test(path) || path.includes('..') || path.includes('//')) {
return { error: 'Only a /v1 path is allowed — e.g. /v1/models' }
}
return { path }
}
/** Pretty-print a shell response, bounded so a huge payload never wedges the DOM. */
export function renderOutput(value: unknown, maxChars = 20000): string {
let text: string
@@ -50,6 +19,9 @@ export function renderOutput(value: unknown, maxChars = 20000): string {
// ── Inspector — route an object id to its `/v1` GET ───────────────────────────
/** Path charset — segments and a query string; nothing that can smuggle a scheme/host. */
const PATH_OK = /^[A-Za-z0-9\-_./?=&%,:+]+$/
/** A resolved inspect target (a same-origin `/v1` GET) or an honest parse error. */
export type InspectTarget = { path: string; kind: string; label: string } | { error: string }
@@ -110,11 +82,6 @@ export function curlFor(path: string, origin = 'https://api.hanzo.ai'): string {
return `curl ${origin}/v1/${bareResource(path)} \\\n -H "Authorization: Bearer $HANZO_API_KEY"`
}
/** The Hanzo CLI form of the same `/v1` GET. */
export function hanzoCli(path: string): string {
return `hanzo api get /v1/${bareResource(path)}`
}
// ── Events — project the usage ledger into a platform-event stream ────────────
/** One platform event, projected from a real charged ledger row (never fabricated). */
@@ -152,3 +119,27 @@ export function eventsFrom(records: EventSource[]): PlatformEvent[] {
})
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity))
}
// ── Cloud shell — where the terminal is ──────────────────────────────────────
/**
* The terminal's address on the API host.
*
* Cloud SERVES the terminal — emulator, socket, resize and reconnect, one
* self-contained page — so a host that wants a shell frames this rather than
* building one. It is the one address in the console that is not same-origin, and
* that is forced rather than chosen: the same-origin `/v1` proxy is a Next route
* handler, and a route handler forwards requests, not sockets and not frames.
* What crosses instead of the session is the single-use ticket the proxy just
* fetched, which is the only credential a URL can safely hold.
*
* `arg` names a tmux session, so reopening the dock reattaches to the shell it
* left instead of opening a fresh one over the user's work.
*/
export function terminalFor(apiBase: string, id: string, ticket: string, session: string): string {
const base = apiBase.trim().replace(/\/+$/, '')
return (
`${base}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(session)}`
)
}
+9 -114
View File
@@ -51,7 +51,8 @@ import { config } from '~/config'
import { MetricCard } from '~/components/ui/Metric'
import { RuntimeNotice } from '~/components/products/observability/RuntimeNotice'
import { TracesModule } from '~/components/products/TracesModule'
import { curlFor, eventsFrom, hanzoCli, inspectorRoute, parseCommand, renderOutput, type PlatformEvent } from './logic'
import { curlFor, eventsFrom, inspectorRoute, renderOutput, type PlatformEvent } from './logic'
import { Terminal } from './Terminal'
import { toneColor } from '~/components/ui/tone'
const usd = (cents: number, dp = 2): string => `$${(cents / 100).toFixed(dp)}`
@@ -903,119 +904,13 @@ export function TracesTab() {
)
}
// ── Shell / API Explorer ─────────────────────────────────────────────────────────
type ShellEntry = { cmd: string; path: string; output: string; ok: boolean }
/** Quick GET resources — the common `/v1` reads, run with one tap. */
const SHELL_RESOURCES = ['models', 'agents', 'prompts', 'functions', 'automations/flows', 'billing/usage'] as const
// ── Shell — the cloud shell ──────────────────────────────────────────────────
/**
* The Shell tab IS a terminal. The component that carries it lives on its own
* (Terminal.tsx) because it loads xterm in the browser and owns a socket, and
* neither belongs in a file of render-only tabs.
*/
export function ShellTab() {
const [entries, setEntries] = useState<ShellEntry[]>([])
const [input, setInput] = useState('')
const [running, setRunning] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'nearest' })
}, [entries])
const run = useCallback(async (line: string) => {
const cmd = line.trim()
if (!cmd || running) return
setInput('')
const parsed = parseCommand(cmd)
if ('error' in parsed) {
setEntries((e) => [...e, { cmd, path: '', output: parsed.error, ok: false }])
return
}
setRunning(true)
try {
const data = await restGet<unknown>(cloudProxyV1Url(parsed.path))
setEntries((e) => [...e, { cmd, path: parsed.path, output: renderOutput(data), ok: true }])
} catch (err) {
const output =
err instanceof ApiError ? `HTTP ${err.status}${err.message}` : err instanceof Error ? err.message : String(err)
setEntries((e) => [...e, { cmd, path: parsed.path, output, ok: false }])
} finally {
setRunning(false)
}
}, [running])
return (
<YStack flex={1} minH={0}>
{/* Resource picker — run a common /v1 GET with one tap. */}
<XStack items="center" gap="$1" px="$2" height={34} borderBottomWidth={1} borderColor="$borderColor" flexWrap="wrap">
<Text fontSize="$1" color="$color10">
GET
</Text>
{SHELL_RESOURCES.map((r) => (
<Button
key={r}
size="$1"
chromeless
bg="$color3"
rounded="$4"
px="$2"
onPress={() => void run(`GET /v1/${r}`)}
aria-label={`Run GET /v1/${r}`}
>
<Text fontSize="$1" color="$color11" className="hz-mono">
{r}
</Text>
</Button>
))}
</XStack>
<ScrollView flex={1} minH={0}>
<YStack p="$3" gap="$2">
{entries.length === 0 ? (
<Text fontSize="$1" color="$color10" className="hz-mono">
Read-only /v1 explorer pick a resource above or type `GET /v1/models`. Runs as you, in your org.
</Text>
) : null}
{entries.map((e, i) => (
<YStack key={i} gap="$1">
<XStack items="center" gap="$2">
<Text fontSize="$1" color="$color11" className="hz-mono" flex={1} numberOfLines={1}>
$ {e.cmd}
</Text>
{e.ok && e.path ? (
<>
<CopyBtn value={curlFor(e.path)} label="curl" />
<CopyBtn value={hanzoCli(e.path)} label="CLI" />
</>
) : null}
</XStack>
<Text fontSize="$1" color={e.ok ? '$color12' : toneColor('critical')} className="hz-mono" style={{ whiteSpace: 'pre-wrap' }}>
{e.output}
</Text>
</YStack>
))}
<div ref={endRef} />
</YStack>
</ScrollView>
<XStack items="center" gap="$2" px="$3" height={44} borderTopWidth={1} borderColor="$borderColor">
<Text fontSize="$2" color="$color10" className="hz-mono">
$
</Text>
<Input
flex={1}
unstyled
autoFocus
value={input}
onChangeText={setInput}
onSubmitEditing={() => void run(input)}
placeholder={running ? 'Running…' : 'Enter a /v1 command…'}
fontSize="$2"
color="$color12"
className="hz-mono"
autoCapitalize="none"
autoCorrect={false}
aria-label="Workbench shell command"
/>
</XStack>
</YStack>
)
return <Terminal />
}
+1
View File
@@ -25,6 +25,7 @@ vi.mock('./client', async () => {
})
vi.mock('~/lib/auth/iam', () => ({
iamValidAccessToken: vi.fn(),
iamHasSession: vi.fn(() => false),
iamUserInfo: vi.fn(),
iamExpiresInSeconds: vi.fn(),
iamSignOut: vi.fn(),
+1
View File
@@ -6,6 +6,7 @@ const token = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('~/lib/auth/iam', () => ({
iamValidAccessToken: async () => token.value,
iamHasSession: () => token.value != null,
iamUserInfo: async () => null,
iamExpiresInSeconds: () => 3600,
iamSignOut: () => {},
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// The IAM seam, both halves. The access TOKEN answers who you are; USERINFO is the
// only place IAM publishes the avatar (`picture`) — deliberately, so a bounded data
// URI never rides every JWT. `userinfoCalls` counts the round trips so the cache can
// be asserted rather than assumed.
const token = vi.hoisted(() => ({ value: null as string | null }))
const userinfo = vi.hoisted(() => ({
value: null as Record<string, unknown> | null,
calls: 0,
throws: false,
}))
vi.mock('~/lib/auth/iam', () => ({
iamValidAccessToken: async () => token.value,
iamUserInfo: async () => {
userinfo.calls++
if (userinfo.throws) throw new Error('userinfo unreachable')
return userinfo.value
},
iamExpiresInSeconds: () => 3600,
iamSignOut: () => {},
}))
const { AccountApi } = await import('~/lib/api/account')
function jwt(claims: Record<string, unknown>): string {
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.`
}
// The claim set hanzo.id issues. Note what is NOT here: no `avatar`, no `picture`.
// That absence IS the contract — reading only these is why the console showed
// initials for every user who had uploaded a photo.
const CLAIMS = { iss: 'https://hanzo.id', sub: 'u-1', owner: 'hanzo', name: 'z', email: 'z@hanzo.ai' }
const PHOTO = 'data:image/webp;base64,UklGRhoAAABXRUJQ'
describe('the profile photo the token does not carry', () => {
beforeEach(async () => {
token.value = null
userinfo.value = null
userinfo.calls = 0
userinfo.throws = false
await AccountApi.signout() // drops the cached photo between cases
})
it('reads the photo from userinfo when the token has none', async () => {
token.value = jwt(CLAIMS)
userinfo.value = { picture: PHOTO }
const { account } = await AccountApi.session()
expect(account?.avatar).toBe(PHOTO)
})
it('prefers the token when it DOES carry one, and asks userinfo nothing', async () => {
token.value = jwt({ ...CLAIMS, avatar: 'data:image/png;base64,FROMTOKEN' })
userinfo.value = { picture: PHOTO }
const { account } = await AccountApi.session()
expect(account?.avatar).toBe('data:image/png;base64,FROMTOKEN')
expect(userinfo.calls).toBe(0)
})
it('costs ONE round trip across repeated loads — including for a user with no photo', async () => {
token.value = jwt(CLAIMS)
userinfo.value = { picture: PHOTO }
await AccountApi.session()
await AccountApi.session()
await AccountApi.session()
expect(userinfo.calls).toBe(1)
await AccountApi.signout()
userinfo.value = {} // no photo — the MISS must cache too, or it refetches forever
userinfo.calls = 0
await AccountApi.session()
await AccountApi.session()
expect(userinfo.calls).toBe(1)
})
it('never shows the previous accounts face after a switch', async () => {
token.value = jwt(CLAIMS)
userinfo.value = { picture: PHOTO }
expect((await AccountApi.session()).account?.avatar).toBe(PHOTO)
// A different principal, same tab, no sign-out in between.
token.value = jwt({ ...CLAIMS, owner: 'maxpower', name: 'dave' })
userinfo.value = {}
expect((await AccountApi.session()).account?.avatar).toBeUndefined()
})
it('a failing userinfo costs the photo, never the session', async () => {
token.value = jwt(CLAIMS)
userinfo.throws = true
const { account } = await AccountApi.session()
expect(account?.owner).toBe('hanzo')
expect(account?.name).toBe('z')
expect(account?.avatar).toBeUndefined()
})
})
+1
View File
@@ -18,6 +18,7 @@ const iam = vi.hoisted(() => ({ token: 'live-access-token' as string | null }))
vi.mock('~/lib/auth/iam', () => ({
iamAccessToken: () => iam.token,
iamValidAccessToken: async () => iam.token,
iamHasSession: () => iam.token != null,
iamExpiresInSeconds: () => 3600,
iamUserInfo: async () => null,
iamSignOut: () => {},
+62 -2
View File
@@ -11,8 +11,10 @@ import {
iamValidAccessToken,
iamUserInfo,
iamExpiresInSeconds,
iamHasSession,
iamSignOut,
} from '~/lib/auth/iam'
import { refreshSession } from '~/lib/auth/refresh'
import { config } from '~/config'
import { type Account } from './types'
@@ -92,6 +94,53 @@ function decodeJwtClaims(token: string): Record<string, unknown> | null {
}
}
/**
* The profile photo, which the access token does not carry and never will.
*
* Identity above is read from the token's own claims — self-contained, no round
* trip, and immune to `getUserInfo()` answering null on a 200, which once
* dead-ended the session into a /signin loop. That is the right source and this
* does not move it. But IAM puts the avatar on `picture` in the USERINFO
* response ONLY (`internal/oidc/userinfo.go`), deliberately: an avatar is a
* bounded data URI, so carrying it would add kilobytes to every JWT on every
* request for a value almost nothing reads. hanzoai/app's `lib/profile.ts`
* states the same contract from the writing side.
*
* So the token is authoritative for WHO you are and silent about your picture,
* and reading only the token meant `account.avatar` was undefined for every
* user, forever — the account menu fell back to initials no matter what anyone
* uploaded. One fetch fills exactly that gap.
*
* Best-effort and cached by design. A failure, a signed-out 401, or a user with
* no photo all resolve to "no avatar" — never a broken session, because a
* missing picture must not cost anyone their sign-in. The cache is keyed by
* identity so switching accounts cannot show the previous face, and it holds
* the miss too: a user with no photo should cost one request, not one per load.
*/
let avatarCache: { key: string; url: string | undefined } | null = null
async function withAvatar(account: Account): Promise<Account> {
if (account.avatar) return account
const key = `${account.owner}/${account.name}`
if (avatarCache?.key !== key) {
let url: string | undefined
try {
const info = await iamUserInfo()
const pic = info?.['picture'] ?? info?.['avatar']
if (typeof pic === 'string' && pic) url = pic
} catch {
/* no photo is not a session failure */
}
avatarCache = { key, url }
}
return avatarCache.url ? { ...account, avatar: avatarCache.url } : account
}
/** Drop the cached photo — sign-out, so the next account never inherits this face. */
function forgetAvatar(): void {
avatarCache = null
}
export const AccountApi = {
/**
* Resolve the current session from IAM: a valid access token (refreshed if
@@ -100,14 +149,24 @@ export const AccountApi = {
* silently refreshed by the SDK before the claims are read.
*/
session: async (): Promise<SessionResult> => {
const token = await iamValidAccessToken()
let token = await iamValidAccessToken()
// A transient IAM blip (network / a 5xx from the token endpoint) yields a null
// token even though the browser still holds a session — don't boot the user to the
// sign-in card on a hiccup at load. Retry via the ONE resilient, single-flight
// refresh before concluding signed-out; an anonymous visitor (no stored token)
// skips it (iamHasSession is false) and resolves signed-out immediately.
if (!token && iamHasSession() && (await refreshSession())) {
token = await iamValidAccessToken()
}
if (!token) return { account: null, expiresIn: null }
// Resolve identity from the access-token JWT claims directly — self-contained
// and immune to the SDK's getUserInfo() returning null on a 200 (which dead-ended
// the session and looped /signin). Fall back to userinfo only if not a JWT.
const claims = decodeJwtClaims(token) ?? (await iamUserInfo())
if (!claims) return { account: null, expiresIn: null }
return { account: accountFromClaims(claims), expiresIn: iamExpiresInSeconds() }
const account = accountFromClaims(claims)
if (!account) return { account: null, expiresIn: null }
return { account: await withAvatar(account), expiresIn: iamExpiresInSeconds() }
},
/** The current signed-in account, or null. */
@@ -115,6 +174,7 @@ export const AccountApi = {
/** Sign out: clear the IAM tokens (client) and best-effort the casibase session. */
signout: async (): Promise<void> => {
forgetAvatar()
iamSignOut()
try {
await post('signout')
+1
View File
@@ -8,6 +8,7 @@ const iam = vi.hoisted(() => ({ token: null as string | null }))
vi.mock('~/lib/auth/iam', () => ({
iamAccessToken: () => iam.token,
iamValidAccessToken: async () => iam.token,
iamHasSession: () => iam.token != null,
iamExpiresInSeconds: () => (iam.token ? 3600 : null),
iamUserInfo: async () => null,
iamSignOut: () => {},
+24 -17
View File
@@ -17,48 +17,55 @@ vi.mock('./client', () => ({
import { ApmApi, apmWindow } from './apm'
type Body = { compositeQuery: { builderQueries: { A: { filters: { items: { op: string; value: string; key: { key: string } }[] } } } } }
type Body = {
requestType: string
compositeQuery: { queries: { type: string; spec: { signal: string; limit: number } & Record<string, unknown> }[] }
}
describe('ApmApi.logs — the per-product o11y query builds with the service filter + maps real rows', () => {
describe('ApmApi.logs — the per-product o11y v5 query builds + maps real rows, service-scoped', () => {
beforeEach(() => restPost.mockReset())
it('sends a service.name filter and normalizes a real O11y logs response to service-scoped rows', async () => {
it('asks the v5 raw surface for a deep page and normalizes rows to the ONE service', async () => {
const nsTs = String(Date.parse('2026-07-03T00:00:00Z') * 1_000_000) // O11y ns epoch
restPost.mockResolvedValueOnce({
data: { result: [{ list: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', 'service.name': 'iam', body: 'signed in' } }] }] },
data: { data: { results: [{ rows: [{ timestamp: nsTs, data: { id: 'l1', severity_text: 'INFO', resources_string: { 'service.name': 'iam' }, body: 'signed in' } }] }] } },
})
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
// 1) the outgoing query carried the per-service filter (serviceFilterItem)
// 1) the outgoing query is the v5 raw shape on the version-less canonical
// surface, addressed via the /v1 bearer BFF. Service scoping is client-side
// (the runtime's key resolution is down — see rawQueryPayload), so a scoped
// read asks for the deepest page instead of a server filter.
const [url, body] = restPost.mock.calls[0] as [string, Body]
// Version-less canonical o11y surface, addressed via the /v1 bearer BFF.
expect(url).toBe('/v1/o11y/query_range')
const items = body.compositeQuery.builderQueries.A.filters.items
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({ op: '=', value: 'iam', key: { key: 'service.name' } })
expect(body.requestType).toBe('raw')
const q = body.compositeQuery.queries[0]
expect(q.type).toBe('builder_query')
expect(q.spec).toMatchObject({ signal: 'logs', limit: 1000 })
expect('filter' in q.spec).toBe(false)
// 2) the real response maps to real, normalized rows
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'signed in' })
})
it('re-filters client-side so a runtime that ignored the filter cannot leak another service onto the page', async () => {
it('re-filters client-side so another service can never leak onto the page', async () => {
restPost.mockResolvedValueOnce({
data: { result: [{ list: [
{ timestamp: '2', data: { body: 'mine', 'service.name': 'iam' } },
{ timestamp: '1', data: { body: 'not mine', 'service.name': 'kms' } },
] }] },
data: { data: { results: [{ rows: [
{ timestamp: '2', data: { body: 'mine', resources_string: { 'service.name': 'iam' } } },
{ timestamp: '1', data: { body: 'not mine', resources_string: { 'service.name': 'kms' } } },
] }] } },
})
const rows = await ApmApi.logs(apmWindow(3600), 500, 'iam')
expect(rows.map((r) => r.body)).toEqual(['mine'])
})
it('sends NO filter for the org-wide stream (back-compat with the Observe Logs board)', async () => {
restPost.mockResolvedValueOnce({ data: { result: [] } })
it('keeps the caller limit for the org-wide stream (no service, no deep page)', async () => {
restPost.mockResolvedValueOnce({ data: { data: { results: [] } } })
await ApmApi.logs(apmWindow(3600), 500)
const [, body] = restPost.mock.calls[0] as [string, Body]
expect(body.compositeQuery.builderQueries.A.filters.items).toEqual([])
expect(body.compositeQuery.queries[0].spec.limit).toBe(500)
})
})
+82 -68
View File
@@ -14,11 +14,10 @@ import {
normalizeExceptions,
normalizeDashboard,
normalizeDashboards,
listQueryPayload,
serviceFilterItem,
rawQueryPayload,
pickService,
serviceHealthOf,
parseListRows,
parseRawRows,
toIso,
normalizeLogRow,
normalizeLogs,
@@ -234,65 +233,55 @@ describe('normalizeDashboard / normalizeDashboards', () => {
})
})
describe('listQueryPayload (O11y v3 query_range LIST)', () => {
describe('rawQueryPayload (O11y v5 query_range RAW)', () => {
const w = apmWindow(3600)
type V5 = {
schemaVersion: string
start: number
end: number
requestType: string
compositeQuery: { queries: { type: string; spec: Record<string, unknown> }[] }
}
it('builds a noop list builder query over the given dataSource with ms bounds', () => {
const p = listQueryPayload('logs', w, 100) as {
start: number
end: number
compositeQuery: { queryType: string; panelType: string; builderQueries: Record<string, Record<string, unknown>> }
}
it('builds ONE builder_query envelope over the given signal with ms bounds', () => {
const p = rawQueryPayload('logs', w, 100) as V5
expect(p.schemaVersion).toBe('v1')
expect(p.start).toBe(w.startMs)
expect(p.end).toBe(w.endMs)
expect(p.compositeQuery.queryType).toBe('builder')
expect(p.compositeQuery.panelType).toBe('list')
const A = p.compositeQuery.builderQueries.A
expect(A.dataSource).toBe('logs')
expect(A.aggregateOperator).toBe('noop')
expect(A.expression).toBe('A')
expect(A.pageSize).toBe(100)
// newest-first
expect(A.orderBy).toEqual([{ columnName: 'timestamp', order: 'desc' }])
expect(p.requestType).toBe('raw')
expect(p.compositeQuery.queries).toHaveLength(1)
const q = p.compositeQuery.queries[0]
expect(q.type).toBe('builder_query')
expect(q.spec).toMatchObject({ name: 'A', signal: 'logs', disabled: false, limit: 100, offset: 0 })
})
it('carries dataSource=traces for a span search', () => {
const p = listQueryPayload('traces', w, 50) as { compositeQuery: { builderQueries: { A: { dataSource: string } } } }
expect(p.compositeQuery.builderQueries.A.dataSource).toBe('traces')
it('carries signal=traces for a span search', () => {
const p = rawQueryPayload('traces', w, 50) as V5
expect(p.compositeQuery.queries[0].spec.signal).toBe('traces')
})
it('clamps pageSize into [1,1000] and floors it', () => {
const big = listQueryPayload('logs', w, 99999) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
const zero = listQueryPayload('logs', w, 0) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
const frac = listQueryPayload('logs', w, 12.9) as { compositeQuery: { builderQueries: { A: { pageSize: number } } } }
expect(big.compositeQuery.builderQueries.A.pageSize).toBe(1000)
expect(zero.compositeQuery.builderQueries.A.pageSize).toBe(1)
expect(frac.compositeQuery.builderQueries.A.pageSize).toBe(12)
it('clamps limit into [1,1000] and floors it', () => {
const lim = (n: number) => (rawQueryPayload('logs', w, n) as V5).compositeQuery.queries[0].spec.limit
expect(lim(99999)).toBe(1000)
expect(lim(0)).toBe(1)
expect(lim(12.9)).toBe(12)
})
it('defaults to NO filters (whole-org stream) when none are given — back-compat', () => {
const p = listQueryPayload('logs', w, 100) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [], op: 'AND' })
it('keeps order and filter OFF the wire — the runtime 500s resolving any key today', () => {
// Both spec features route through telemetry-metadata key resolution, which the
// deployed runtime cannot serve ("failed to get logs keys"). Raw is newest-first
// by default, and service scoping is the client-side re-filter in ApmApi.
const spec = (rawQueryPayload('logs', w, 100) as V5).compositeQuery.queries[0].spec
expect('order' in spec).toBe(false)
expect('filter' in spec).toBe(false)
})
it('carries a per-service filter into the builder query when given (per-product scope)', () => {
const item = serviceFilterItem('logs', 'iam')
const p = listQueryPayload('logs', w, 100, [item]) as { compositeQuery: { builderQueries: { A: { filters: { items: unknown[]; op: string } } } } }
expect(p.compositeQuery.builderQueries.A.filters).toEqual({ items: [item], op: 'AND' })
})
})
describe('serviceFilterItem (scope a logs/traces query to one OTel service.name)', () => {
it('builds the service.name resource-attribute equality O11y expects', () => {
const f = serviceFilterItem('logs', 'vector')
expect(f.op).toBe('=')
expect(f.value).toBe('vector')
expect(f.key.key).toBe('service.name')
expect(f.key.type).toBe('resource')
expect(f.key.isColumn).toBe(false) // logs: resource attribute, not an indexed column
})
it('marks service.name as an indexed column for traces (where it is materialized)', () => {
expect(serviceFilterItem('traces', 'gateway').key.isColumn).toBe(true)
it('never emits a v3 field the strict v5 decoder refuses', () => {
const p = rawQueryPayload('logs', w, 100) as Record<string, unknown>
const composite = p.compositeQuery as Record<string, unknown>
expect('builderQueries' in composite).toBe(false)
expect('queryType' in composite).toBe(false)
expect('panelType' in composite).toBe(false)
})
})
@@ -338,21 +327,40 @@ describe('serviceHealthOf (RED verdict for one service)', () => {
})
})
describe('parseListRows', () => {
it('reads rows from data.result[].list', () => {
const body = { data: { result: [{ list: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } }
expect(parseListRows(body)).toHaveLength(2)
describe('parseRawRows', () => {
it('reads rows from the {status,data:{data:{results:[{rows}]}}} envelope', () => {
const body = {
status: 'success',
data: { type: 'raw', data: { results: [{ queryName: 'A', rows: [{ timestamp: '1', data: { body: 'a' } }, { timestamp: '2', data: { body: 'b' } }] }] } },
}
expect(parseRawRows(body)).toHaveLength(2)
})
it('reads rows from the nested data.newResult.data.result[].list mirror', () => {
const body = { data: { newResult: { data: { result: [{ list: [{ timestamp: '1', data: {} }] }] } } } }
expect(parseListRows(body)).toHaveLength(1)
it('reads rows from a bare {data:{results}} response', () => {
const body = { data: { results: [{ rows: [{ timestamp: '1', data: {} }] }] } }
expect(parseRawRows(body)).toHaveLength(1)
})
it('flattens the OTel attribute maps over the row scalars into ONE namespace', () => {
const body = {
data: { data: { results: [{ rows: [{
timestamp: '2026-08-06T23:00:03Z',
data: {
body: 'request',
severity_text: 'info',
resources_string: { 'service.name': 'cloud' },
attributes_string: { 'http.method': 'GET' },
attributes_number: { 'http.status_code': 200 },
},
}] }] } },
}
const [row] = parseRawRows(body)
expect(row.data).toMatchObject({ body: 'request', 'service.name': 'cloud', 'http.method': 'GET', 'http.status_code': 200 })
})
it('returns [] for empty/garbage/missing shapes (never throws)', () => {
expect(parseListRows(null)).toEqual([])
expect(parseListRows({})).toEqual([])
expect(parseListRows({ data: { result: null } })).toEqual([])
expect(parseListRows({ data: { result: [{ list: null }] } })).toEqual([])
expect(parseListRows('nope')).toEqual([])
expect(parseRawRows(null)).toEqual([])
expect(parseRawRows({})).toEqual([])
expect(parseRawRows({ data: { results: null } })).toEqual([])
expect(parseRawRows({ data: { results: [{ rows: null }] } })).toEqual([])
expect(parseRawRows('nope')).toEqual([])
})
})
@@ -402,15 +410,21 @@ describe('normalizeLogRow / normalizeLogs', () => {
expect(l.body).toBe('hi')
expect(l.id).toBe('5-3') // ts-idx fallback
})
it('maps a full query_range logs response, newest-first order preserved', () => {
it('maps a full v5 query_range logs response, newest-first order preserved', () => {
const body = {
data: { result: [{ list: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] },
data: { data: { results: [{ rows: [{ timestamp: '2', data: { body: 'newer' } }, { timestamp: '1', data: { body: 'older' } }] }] } },
}
const rows = normalizeLogs(body)
expect(rows.map((r) => r.body)).toEqual(['newer', 'older'])
})
it('empty result → empty list (honest empty, not a throw)', () => {
expect(normalizeLogs({ data: { result: [] } })).toEqual([])
it('reads the service from the nested resources_string map (as the runtime emits it)', () => {
const body = {
data: { data: { results: [{ rows: [{ timestamp: '1', data: { body: 'hi', severity_text: 'INFO', resources_string: { 'service.name': 'iam' } } }] }] } },
}
expect(normalizeLogs(body)[0]).toMatchObject({ service: 'iam', severity: 'info', body: 'hi' })
})
it('empty results → empty list (honest empty, not a throw)', () => {
expect(normalizeLogs({ data: { data: { results: [] } } })).toEqual([])
})
})
@@ -432,8 +446,8 @@ describe('normalizeTraceSpan / normalizeSpans', () => {
expect(normalizeTraceSpan({ data: { spanID: 's' } }, 0).durationNano).toBeNull()
expect(normalizeTraceSpan({ data: { spanID: 's', durationNano: '' } }, 0).durationNano).toBeNull()
})
it('maps a full query_range traces response', () => {
const body = { data: { result: [{ list: [{ timestamp: '1', data: { traceID: 't', name: 'op' } }] }] } }
it('maps a full v5 query_range traces response', () => {
const body = { data: { data: { results: [{ rows: [{ timestamp: '1', data: { trace_id: 't', name: 'op' } }] }] } } }
expect(normalizeSpans(body)).toHaveLength(1)
})
})
+78 -114
View File
@@ -532,122 +532,86 @@ export function normalizeDashboards(body: unknown): Dashboard[] {
return rows.map(normalizeDashboard).filter((d) => d.uuid !== '')
}
// ── Logs + Traces (O11y composite query_range) ──────────────────────────────
// ── Logs + Traces (O11y v5 composite query_range) ────────────────────────────
//
// The universal `POST /v1/o11y/query_range` builder query (version-less canonical
// surface). A `list`-panel `noop` query over `dataSource: logs | traces` returns RAW
// rows (recent log lines / spans), newest first — the one true logs/traces read.
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure (JSON
// in, view-model out) so it unit-tests without a live runtime.
// The universal `POST /v1/o11y/query_range` builder query. The runtime behind the
// flat path is the module's V5 querier — its composite is `{queries: [{type,
// spec}]}` and its decoder is STRICT, so the old v3 `{queryType, panelType,
// builderQueries}` envelope is refused outright ("unknown field \"builderQueries\"
// in composite query"), which the overview panel rendered as "Could not reach
// observability". A `requestType: "raw"` query over `signal: logs | traces`
// returns raw rows (recent log lines / spans), newest first — the server's own
// default order for raw.
//
// Two v5 spec features are deliberately NOT sent, verified against the live
// runtime: an `order` clause and a `filter` expression both route through
// telemetry-metadata key resolution, which currently fails server-side ("failed
// to get logs keys", 500) — the runtime's key tables are not reachable from the
// embedded store. Raw already returns newest-first without `order`, and service
// scoping is enforced by the client-side re-filter below (which these readers
// always did as their leak-proofing). When the metadata store heals, the filter
// expression (`service.name = '<svc>'`) is the one-line addition.
//
// Time is epoch MILLISECONDS = `ApmWindow.startMs/endMs`. Every helper is pure
// (JSON in, view-model out) so it unit-tests without a live runtime.
/** The telemetry signal a builder query reads. */
export type O11yDataSource = 'logs' | 'traces' | 'metrics'
/**
* One O11y builder-query filter item — the `{key, op, value}` shape the explorer
* sends. `key` carries the attribute's name + type so the runtime resolves it
* correctly (a resource attribute vs an indexed column).
* The v5 `query_range` RAW payload — ONE `builder_query` envelope keyed `A`.
* `limit` is clamped into [1,1000] (the page a reader shows). No `order`, no
* `filter` — see the section note above for why both stay off the wire today.
*/
export type QueryFilterItem = {
key: { key: string; dataType: 'string'; type: string; isColumn: boolean }
op: string
value: string
}
/**
* Filter a logs/traces list query to ONE OpenTelemetry `service.name`. `service.name`
* is a RESOURCE attribute on both signals (on traces it is also materialized as an
* indexed column, so `isColumn` is set there) — this is the exact filter item O11y's
* own explorer emits for a service-scoped list, so the runtime resolves it and never
* 400s. The caller ALSO re-filters the normalized rows client-side (belt-and-suspenders),
* so a runtime that ignores the item can never leak another service's rows onto a
* per-product page.
*/
export function serviceFilterItem(dataSource: O11yDataSource, service: string): QueryFilterItem {
return {
key: { key: 'service.name', dataType: 'string', type: 'resource', isColumn: dataSource === 'traces' },
op: '=',
value: service,
}
}
/**
* The `list`-panel `selectColumns` per signal. The traces v4 list builder HARD-fails
* (`select columns cannot be empty for panelType list`, → 500) when a noop list query
* carries no `selectColumns`; the query already emits timestamp/spanID/traceID, so
* these ADD the display fields `normalizeTraceSpan` reads. Each name is a materialized
* static trace column (o11y `StaticFieldsTraces`), so the runtime resolves it verbatim.
* Logs list does NOT require selectColumns (its noop path returns the default row set),
* so it stays empty — adding trace columns there would reference non-existent log columns.
*/
const listSelectColumns: Record<O11yDataSource, Array<Record<string, unknown>>> = {
traces: [
{ key: 'name', dataType: 'string', type: 'tag', isColumn: true },
{ key: 'duration_nano', dataType: 'float64', type: 'tag', isColumn: true },
{ key: 'response_status_code', dataType: 'string', type: 'tag', isColumn: true },
],
logs: [],
metrics: [],
}
/**
* The exact v3 `query_range` LIST payload — ONE `noop` builder query keyed `A`,
* newest-first, paged by `offset`/`pageSize`. Mirrors what O11y's own explorer
* sends (verified against the frontend + the server `BuilderQuery` struct), so the
* runtime never 400s on shape. `filters` (default none) scopes the query — e.g. a
* `serviceFilterItem` restricts it to one product's OTel service. `selectColumns`
* is REQUIRED by the traces list builder (empty → 500); see `listSelectColumns`.
*/
export function listQueryPayload(
dataSource: O11yDataSource,
w: ApmWindow,
limit: number,
filters: QueryFilterItem[] = [],
): Record<string, unknown> {
const pageSize = Math.max(1, Math.min(1000, Math.floor(limit)))
export function rawQueryPayload(signal: O11yDataSource, w: ApmWindow, limit: number): Record<string, unknown> {
const capped = Math.max(1, Math.min(1000, Math.floor(limit)))
return {
schemaVersion: 'v1',
start: w.startMs,
end: w.endMs,
step: 60,
requestType: 'raw',
compositeQuery: {
queryType: 'builder',
panelType: 'list',
builderQueries: {
A: {
queryName: 'A',
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: {},
expression: 'A',
disabled: false,
stepInterval: 60,
filters: { items: filters, op: 'AND' },
selectColumns: listSelectColumns[dataSource],
groupBy: [],
having: [],
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
limit: null,
offset: 0,
pageSize,
queries: [
{
type: 'builder_query',
spec: { name: 'A', signal, disabled: false, limit: capped, offset: 0 },
},
},
],
},
}
}
/** A `list`-panel query returns rows under `data.result[].list` (newer runtimes
* mirror them under `data.newResult.data.result[].list`); each is `{timestamp,data}`. */
/** One raw row, its attribute maps flattened so `pick` reads one namespace. */
type ListRow = { timestamp?: string | number; data?: Record<string, unknown> | null }
/** Pull the flat list rows out of either result location, never throwing on shape. */
export function parseListRows(body: unknown): ListRow[] {
const r = (body ?? {}) as { data?: { result?: unknown; newResult?: { data?: { result?: unknown } } } }
const direct = r?.data?.result
const nested = r?.data?.newResult?.data?.result
const results = (Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : []) as { list?: unknown }[]
/**
* Pull the raw rows out of a v5 `query_range` response, never throwing on shape.
* The envelope is `{status, data: {type: "raw", data: {results: [{queryName,
* rows: [{timestamp, data}]}]}}}` (a bare `{data: {results}}` is also accepted).
* Each row's `data` nests the OTel attribute maps (`resources_string`,
* `attributes_string`, `attributes_number`, `attributes_bool`); they are
* flattened over the row's own scalars so downstream readers keep addressing one
* flat namespace (`body`, `severity_text`, `service.name`, …) exactly as the v3
* list rows carried it.
*/
export function parseRawRows(body: unknown): ListRow[] {
const r = (body ?? {}) as { data?: { data?: { results?: unknown }; results?: unknown } }
const results = ([] as unknown[]).concat(
(Array.isArray(r?.data?.data?.results) ? r.data.data.results : Array.isArray(r?.data?.results) ? r.data.results : []) as unknown[],
) as { rows?: unknown }[]
const out: ListRow[] = []
for (const res of results) {
if (Array.isArray(res?.list)) out.push(...(res.list as ListRow[]).filter((x): x is ListRow => x != null))
if (!Array.isArray(res?.rows)) continue
for (const raw of res.rows as ListRow[]) {
if (raw == null) continue
const d = (raw.data ?? {}) as Record<string, unknown>
const flat: Record<string, unknown> = { ...d }
for (const mapKey of ['resources_string', 'attributes_string', 'attributes_number', 'attributes_bool']) {
const m = d[mapKey]
if (m && typeof m === 'object' && !Array.isArray(m)) Object.assign(flat, m as Record<string, unknown>)
}
out.push({ timestamp: raw.timestamp, data: flat })
}
}
return out
}
@@ -691,7 +655,7 @@ export function normalizeLogRow(row: ListRow, idx: number): LogRow {
/** Normalize a logs `query_range` response → LogRow[] (newest first). */
export function normalizeLogs(body: unknown): LogRow[] {
return parseListRows(body).map(normalizeLogRow)
return parseRawRows(body).map(normalizeLogRow)
}
/** One trace/span row from a `dataSource: traces` list query. */
@@ -726,7 +690,7 @@ export function normalizeTraceSpan(row: ListRow, idx: number): TraceSpan {
/** Normalize a traces `query_range` response → TraceSpan[] (newest first). */
export function normalizeSpans(body: unknown): TraceSpan[] {
return parseListRows(body).map(normalizeTraceSpan)
return parseRawRows(body).map(normalizeTraceSpan)
}
// ── Transport ─────────────────────────────────────────────────────────────────
@@ -734,12 +698,11 @@ export function normalizeSpans(body: unknown): TraceSpan[] {
const u = (path: string): string => cloudProxyV1Url(`o11y/${path}`)
// The composite builder query rides the FLAT public path `/v1/o11y/query_range` (one
// /v1/, no nested /api/vN). `listQueryPayload` + `parseListRows` are a matched v3 pair
// (`compositeQuery.{queryType,builderQueries}` `data.result[].list`); the cloud
// clients/o11y flat route (query.go) resolves this flat path to the v3 engine handler
// SERVER-SIDE. (The upstream module's version-less alias would instead resolve to the
// HIGHEST engine version (v5), whose composite accepts only `{queries:[…]}` and 400s
// the v3 shape — which is exactly why the mapping is pinned in cloud, not here.)
// /v1/, no nested /api/vN). The version-less address resolves to the module's HIGHEST
// engine — the v5 querier — so `rawQueryPayload` + `parseRawRows` are its matched pair
// (`compositeQuery.queries[]` → `data.data.results[].rows`). The v3 pin this comment
// used to cite (cloud's query.go) is deleted; a v3 envelope sent here is refused by
// the strict v5 decoder, not quietly served.
const COMPOSITE_QUERY_RANGE = 'query_range'
/** The APM POST body — a start/end window + optional tags filter (O11y shape). */
@@ -782,19 +745,20 @@ export const ApmApi = {
dashboards: async (): Promise<Dashboard[]> => normalizeDashboards(await restGet<unknown>(u('dashboards'))),
dashboard: (uuid: string): Promise<unknown> => restGet<unknown>(u(`dashboards/${encodeURIComponent(uuid)}`)),
// ── Logs + Traces (composite query_range; `/v1/o11y/logs` is a stub) ──
// A `service` scopes the query to ONE product's OTel `service.name` (the per-product
// Logs sub-page); omit it for the org-wide stream. The rows are re-filtered client-side
// to the same service so a runtime ignoring the item can never leak other services' lines.
// ── Logs + Traces (v5 raw query_range; `/v1/o11y/logs` is a stub) ──
// A `service` scopes the read to ONE product's OTel `service.name` (the
// per-product Logs sub-page); omit it for the org-wide stream. Scoping is the
// CLIENT-SIDE re-filter — the server-side filter expression is off the wire
// while the runtime's key resolution is down (see rawQueryPayload) — so a
// scoped read asks for a deeper page (up to the 1000 cap) and keeps what
// matches. A row with no service survives the filter, as it always did here.
logs: async (w: ApmWindow, limit = 200, service?: string): Promise<LogRow[]> => {
const filters = service ? [serviceFilterItem('logs', service)] : []
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('logs', w, limit, filters)))
return service ? rows.filter((r) => !r.service || r.service === service) : rows
const rows = normalizeLogs(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('logs', w, service ? 1000 : limit)))
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
},
traceSearch: async (w: ApmWindow, limit = 200, service?: string): Promise<TraceSpan[]> => {
const filters = service ? [serviceFilterItem('traces', service)] : []
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), listQueryPayload('traces', w, limit, filters)))
return service ? rows.filter((r) => !r.service || r.service === service) : rows
const rows = normalizeSpans(await restPost<unknown>(u(COMPOSITE_QUERY_RANGE), rawQueryPayload('traces', w, service ? 1000 : limit)))
return service ? rows.filter((r) => !r.service || r.service === service).slice(0, limit) : rows
},
// ── Per-product service health (RED metrics for ONE product's OTel service) ──
+2 -2
View File
@@ -283,8 +283,8 @@ export {
normalizeIssueDetail,
normalizeDashboard,
normalizeDashboards,
listQueryPayload,
parseListRows,
rawQueryPayload,
parseRawRows,
toIso,
normalizeLogRow,
normalizeLogs,
+53
View File
@@ -80,6 +80,18 @@ export function iamAccessToken(): string | null {
}
}
/**
* True when the browser still holds an IAM session to refresh — an access token is
* stored, even if the SDK considers it expired (`getAccessToken` returns the raw
* stored token). Cheap + synchronous, no network. Used to BOUND the refresh retry:
* an anonymous visitor (no stored token) never waits through the backoff, and a
* retry stops the moment the session is gone from storage (a revoked token the SDK
* cleared cannot be brought back by retrying).
*/
export function iamHasSession(): boolean {
return iamAccessToken() != null
}
/** A valid (auto-refreshed if needed) access token, or null. */
export async function iamValidAccessToken(): Promise<string | null> {
if (typeof window === 'undefined') return null
@@ -132,6 +144,47 @@ export function iamSignOut(): void {
}
}
/**
* Where to send the browser to END THE SESSION AT THE ISSUER.
*
* `iamSignOut()` above drops this tab's tokens and nothing else, which is only
* half of signing out and is the half nobody notices. The other half is the
* `iam_session_id` cookie at hanzo.id: leave it and signing out does not stick,
* because the very next thing the app does is bounce to `/signin`, and sign-in
* asks the issuer for a code from the EXISTING session. Measured against prod —
* clear the tokens the way this module did and silent SSO still answers
* `status: ok` with a code, so the user lands straight back in the console they
* just left. Clearing the whole session cookie jar is not an option either: it
* is set on hanzo.id, and this origin cannot touch it.
*
* Only the issuer can end the issuer's session, so sign-out has to be a
* NAVIGATION there. RP-initiated logout returns the browser to
* `post_logout_redirect_uri`, which is why this replaces the `/signin` assign
* rather than racing it: one navigation, and it lands where the old one did.
*
* Verified: after this URL, the same silent-SSO probe answers
* "please sign in first" and mints nothing.
*/
export function iamSignOutUrl(origin: string, returnPath = '/signin'): string {
// The origin is a PARAMETER, not `window.location.origin` read in here. Every
// other function in this module guards `typeof window === 'undefined'` because
// this file is imported by server-rendered code; one that reads `window`
// unguarded throws the moment anything touches it during SSR. Taking it as an
// argument removes the hazard instead of guarding it, and makes the URL a pure
// function of its inputs — testable with no DOM, which is what this repo's
// suite runs.
const url = new URL('/v1/iam/oauth/logout', config.iamUrl)
// Resolve the return against OUR origin, then require it to have stayed there.
// An absolute URL wins over a base in `new URL`, so a caller passing a foreign
// one would otherwise hand the IdP an open redirect to hand back. A return leg
// that left this origin is never what sign-out meant, so it falls back to the
// sign-in page rather than being honored.
const back = new URL(returnPath, origin)
const safe = back.origin === origin ? back : new URL('/signin', origin)
url.searchParams.set('post_logout_redirect_uri', safe.toString())
return url.toString()
}
// -- Graceful re-auth: return the user to their task after re-signing in --------
// A mid-task session expiry (a 401) should not dump the user on the home page — we
// stash where they were and the callback lands them back there.
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// refreshSession is browser-only and delegates to the IAM SDK; mock the SDK wrapper so
// the single-flight wiring is exercised in the node test env. resilientRefresh below is
// pure over injected deps, so it needs no mock (it never touches iam).
vi.mock('./iam', () => ({
iamValidAccessToken: vi.fn(),
iamHasSession: vi.fn(() => true),
}))
import { resilientRefresh, refreshSession, REFRESH_RETRY_MS } from './refresh'
import { iamValidAccessToken } from './iam'
const noSleep = (_ms: number) => Promise.resolve()
// The FIX itself — the exact `resilientFetch` injected-deps idiom the API client uses.
describe('resilientRefresh — a transient blip self-heals; a dead session does not spin', () => {
it('returns true on the first attempt, no retry, no sleep', async () => {
const attempt = vi.fn().mockResolvedValue('tok')
const sleep = vi.fn(noSleep)
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
expect(attempt).toHaveBeenCalledTimes(1)
expect(sleep).not.toHaveBeenCalled()
})
it('recovers a TRANSIENT failure: null then a token → true (the whole point of the fix)', async () => {
const attempt = vi.fn().mockResolvedValueOnce(null).mockResolvedValue('tok')
const sleep = vi.fn(noSleep)
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(true)
expect(attempt).toHaveBeenCalledTimes(2)
expect(sleep).toHaveBeenCalledTimes(1)
expect(sleep).toHaveBeenCalledWith(REFRESH_RETRY_MS[0])
})
it('a genuinely-dead session resolves false only after exhausting the bounded retries', async () => {
const attempt = vi.fn().mockResolvedValue(null)
const sleep = vi.fn(noSleep)
expect(await resilientRefresh({ attempt, hasSession: () => true, sleep })).toBe(false)
// one initial attempt + one per backoff slot
expect(attempt).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length + 1)
expect(sleep).toHaveBeenCalledTimes(REFRESH_RETRY_MS.length)
expect(sleep.mock.calls.map((c) => c[0])).toEqual(REFRESH_RETRY_MS)
})
it('never waits through the backoff when there is no session to refresh (anonymous / revoked)', async () => {
const attempt = vi.fn().mockResolvedValue(null)
const sleep = vi.fn(noSleep)
expect(await resilientRefresh({ attempt, hasSession: () => false, sleep })).toBe(false)
expect(attempt).toHaveBeenCalledTimes(1) // one try, then hasSession() false → stop
expect(sleep).not.toHaveBeenCalled()
})
it('stops the moment the session disappears mid-retry (a revoked token the SDK cleared)', async () => {
const attempt = vi.fn().mockResolvedValue(null)
const sleep = vi.fn(noSleep)
const hasSession = vi.fn().mockReturnValueOnce(true).mockReturnValue(false)
expect(await resilientRefresh({ attempt, hasSession, sleep })).toBe(false)
expect(attempt).toHaveBeenCalledTimes(2) // initial + one retry, then session gone
expect(sleep).toHaveBeenCalledTimes(1)
})
})
const mockAttempt = iamValidAccessToken as ReturnType<typeof vi.fn>
// The wiring: browser-only + single-flight (concurrent callers share ONE rotation —
// load-bearing for a one-time-use rotating refresh token).
describe('refreshSession — browser-only, single-flight', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('window', {} as unknown as Window & typeof globalThis)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('is a no-op on the server (no window) — resolves false, never touches the SDK', async () => {
vi.unstubAllGlobals() // remove the window stub → typeof window === 'undefined'
expect(await refreshSession()).toBe(false)
expect(mockAttempt).not.toHaveBeenCalled()
})
it('collapses concurrent callers onto ONE rotation (the timer + N parallel 401s)', async () => {
mockAttempt.mockResolvedValue('tok')
const p1 = refreshSession()
const p2 = refreshSession()
expect(p1).toBe(p2) // same in-flight promise
expect(await Promise.all([p1, p2])).toEqual([true, true])
expect(mockAttempt).toHaveBeenCalledTimes(1) // one rotation, not two
// Settled → a later caller starts a fresh rotation (inflight cleared).
expect(await refreshSession()).toBe(true)
expect(mockAttempt).toHaveBeenCalledTimes(2)
})
})
+59 -15
View File
@@ -11,8 +11,53 @@
* the token, the second replays the now-invalid one and 400s, needlessly killing a
* healthy session. Sharing ONE in-flight promise means every concurrent caller (the
* timer + N parallel 401s) awaits the SAME single rotation.
*
* RESILIENT, not jumpy. One attempt used to be the whole story: a single transient
* failure (a network blip, a 5xx from IAM, a lost rotation race) yielded a bare
* `false`, and the caller took that one "no" as a definitive sign-out — the "session
* expired, sign in again" card fired mid-task on a hiccup. A transient failure and a
* genuinely-dead refresh token are indistinguishable at this layer (the SDK collapses
* both to a null token), so we RETRY a bounded few times with short backoff WHILE a
* session still exists: a blip self-heals (session preserved, no false eviction), and a
* truly-expired session resolves `false` ~1s later — an acceptable delay before the
* honest re-auth card. An anonymous visitor (no stored token) never waits through the
* backoff (`hasSession` short-circuits on the first miss).
*/
import { iamValidAccessToken } from './iam'
import { iamValidAccessToken, iamHasSession } from './iam'
/** Backoff before each retry AFTER the first attempt — transient recovery only. Worst
* case added before an honest `false` when a stored token is dead: ~1.6s. */
export const REFRESH_RETRY_MS = [400, 1200]
/** Injected dependencies for `resilientRefresh` — real ones in `refreshSession`, fakes
* in tests. Mirrors the `ResilientDeps` idiom the API client uses for `resilientFetch`. */
export interface RefreshDeps {
/** One refresh attempt: a live access token, or null (a TRANSIENT failure OR a
* genuinely signed-out state — this layer cannot tell them apart). Never throws. */
attempt: () => Promise<string | null>
/** True while the browser still holds a session to refresh (else retrying is futile). */
hasSession: () => boolean
sleep: (ms: number) => Promise<void>
}
/**
* Pure refresh orchestration (over its injected deps): try once, then retry a bounded
* few times with backoff — but ONLY while a session still exists. So a network blip /
* lost rotation race self-heals (returns true on recovery), while a genuinely signed-out
* state resolves false without wasted retries. Bounded by `REFRESH_RETRY_MS`.
*/
export async function resilientRefresh(deps: RefreshDeps): Promise<boolean> {
for (let i = 0; ; i++) {
if (await deps.attempt()) return true
// First attempt failed. Stop if we've exhausted the budget OR the session is gone
// from storage (an anonymous visitor, or a revoked token the SDK cleared — retrying
// cannot bring it back). Otherwise wait and retry: the failure may be transient.
if (i >= REFRESH_RETRY_MS.length || !deps.hasSession()) return false
await deps.sleep(REFRESH_RETRY_MS[i])
}
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
let inflight: Promise<boolean> | null = null
@@ -24,19 +69,18 @@ let inflight: Promise<boolean> | null = null
export function refreshSession(): Promise<boolean> {
if (typeof window === 'undefined') return Promise.resolve(false)
if (inflight) return inflight
inflight = (async () => {
try {
// getValidAccessToken() returns the current token, or transparently runs the
// refresh grant when it is expired — the SDK's own single rotation.
const token = await iamValidAccessToken()
return !!token
} catch {
return false
} finally {
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
// rotation and one arriving after it starts a fresh one.
inflight = null
}
})()
inflight = resilientRefresh({
// getValidAccessToken() returns the current token, or transparently runs the refresh
// grant when it is expired — the SDK's own single rotation. iamValidAccessToken wraps
// it browser-safe and never throws (a failure is a null token).
attempt: iamValidAccessToken,
hasSession: iamHasSession,
sleep,
})
// Cleared AFTER this round settles, so a caller arriving mid-flight joins THIS
// rotation and one arriving after it starts a fresh one.
void inflight.finally(() => {
inflight = null
})
return inflight
}
+16 -5
View File
@@ -19,7 +19,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
import { AccountApi, type Account } from '~/lib/api'
import { withTimeout } from '~/lib/with-timeout'
import { signinRedirect, stashReturnTo, iamSignOut } from './iam'
import { signinRedirect, stashReturnTo, iamSignOut, iamSignOutUrl } from './iam'
import { refreshSession } from './refresh'
import { setCurrentActor } from '~/lib/actor-scope'
import { claimReferralOnce, stashReferralCode } from '~/lib/referrals/claim'
@@ -133,10 +133,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
await AccountApi.signout()
iamSignOut()
applyAccount(null)
// Redirect DETERMINISTICALLY to /signin. A hard navigation is the single source of
// truth for "signed out -> /signin" and clears all in-memory state (org scope,
// caches, balances) — the same `window.location.assign` the sign-IN path uses.
if (typeof window !== 'undefined') window.location.assign('/signin')
// Redirect DETERMINISTICALLY, and through the ISSUER. A hard navigation is
// still the single source of truth for "signed out -> /signin" — it clears
// all in-memory state (org scope, caches, balances) exactly as the sign-IN
// path does — but it has to go via RP-initiated logout, which returns here
// through post_logout_redirect_uri.
//
// Assigning '/signin' directly is what made sign-out not stick: the two
// calls above end the session HERE, and the `iam_session_id` cookie at the
// issuer survives, so /signin's silent SSO immediately mints a code from it
// and puts the user back in the console they just left. Measured: same
// probe, `status: ok` with a code before this change, "please sign in
// first" after.
if (typeof window !== 'undefined') {
window.location.assign(iamSignOutUrl(window.location.origin, '/signin'))
}
}, [applyAccount])
return (
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
// Only the issuer origin matters here; the rest of `config` is irrelevant to the
// URL under test, so the mock stays the size of the dependency.
vi.mock('~/config', () => ({ config: { iamUrl: 'https://hanzo.id' } }))
const { iamSignOutUrl } = await import('~/lib/auth/iam')
const HERE = 'https://console.hanzo.ai'
const back = (u: string) => new URL(u).searchParams.get('post_logout_redirect_uri')!
/**
* Signing out has to end the session at the ISSUER, not just in this tab.
*
* `iamSignOut()` drops this browser's tokens and nothing else. That is half of it,
* and the half nobody notices: the `iam_session_id` cookie lives on hanzo.id, this
* origin cannot touch it, and the very next thing the app does is bounce to
* /signin — which asks the issuer for a code from the EXISTING session. Measured
* against production before the fix: clear the tokens the way this module did, and
* silent SSO still answered `status: ok` with a code, so the user landed straight
* back in the console they had just left.
*/
describe('sign-out ends the session at the issuer', () => {
it('points at RP-initiated logout on the ISSUER, not this origin', () => {
const u = new URL(iamSignOutUrl(HERE))
expect(u.origin).toBe('https://hanzo.id')
expect(u.pathname).toBe('/v1/iam/oauth/logout')
})
it('returns the browser to an absolute URL on THIS origin', () => {
// RP-initiated logout redirects; a bare path would be resolved against the
// ISSUER, landing the user on hanzo.id/signin instead of the console's.
expect(back(iamSignOutUrl(HERE, '/signin'))).toBe(`${HERE}/signin`)
})
it('defaults to /signin, so a caller cannot forget where to land', () => {
expect(back(iamSignOutUrl(HERE))).toBe(`${HERE}/signin`)
})
it('refuses a return leg that leaves this origin', () => {
// An absolute URL beats a base in `new URL`, so without the check the IdP
// would be handed an open redirect to hand back. A foreign return is never
// what sign-out meant — fall back to our own sign-in page.
expect(back(iamSignOutUrl(HERE, 'https://evil.example.com/steal'))).toBe(`${HERE}/signin`)
expect(back(iamSignOutUrl(HERE, '//evil.example.com/steal'))).toBe(`${HERE}/signin`)
})
it('is a pure function of its inputs — no window, so SSR cannot throw', () => {
// This module is imported by server-rendered code. Every sibling guards
// `typeof window === 'undefined'`; this one has nothing to guard, which is
// why the test can run in the repo's node environment at all.
expect(typeof globalThis.window).toBe('undefined')
expect(() => iamSignOutUrl(HERE)).not.toThrow()
})
})
+37 -32
View File
@@ -19,9 +19,10 @@
* Wiring for the console SPA:
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`. The
* client NEVER sends an org/tenant — Cloud stamps it from the validated bearer.
* - `getToken` — the signed-in visitor's own Hanzo IAM access token. THIS is what
* attributes the stream, and it is the only mechanism that is correct here; see
* the note below for why no publishable key is passed.
* - `getToken` — the signed-in visitor's own Hanzo IAM access token, falling back
* to the publishable key when there is none. The token attributes the stream to
* the visitor's own org on every brand host; the key exists so a SIGNED-OUT view
* is admitted at all. Token first, key second — see the note below.
* - `dsn` (`NEXT_PUBLIC_HANZO_EVENT_DSN`) — the error plane's own credential,
* shaped `https://<key>@api.hanzo.ai/v1/sentry/<project>`. Publishable by design
* (it ships in the client bundle). Unset → errors are captured and dropped.
@@ -51,38 +52,42 @@ function consented(): boolean {
return dnt !== '1' && dnt !== 'yes'
}
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
// ── The token FIRST, the publishable key only when there is no token ─────────
//
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
// here.
// The signed-in path is unchanged and remains the point: cloud resolves the
// tenant from the IAM bearer's OWN owner claim, so each visitor's events land in
// THEIR org on every brand host — cloud.hanzo.ai, cloud.lux.cloud,
// cloud.zoo.cloud — with no per-brand configuration. The JWT carries the org;
// everything downstream is org-scoped by it.
//
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
// this image is brand-agnostic: one build serves cloud.hanzo.ai, cloud.lux.cloud and
// cloud.zoo.cloud, with the brand resolved at RUNTIME from the request hostname
// (src/config). Baking a key would file every brand's — and every customer's —
// traffic into whichever org the key belongs to, which is both wrong data and a
// cross-tenant leak. It is the same reason Dockerfile bakes no NEXT_PUBLIC_*.
// What changed is the SIGNED-OUT path, which reported nothing at all. A
// credential-less POST is refused outright — `401 ingest_key_required`, measured
// against the live door with and without a browser Origin, for pageviews and
// exceptions alike. (An earlier version of this comment described an "anonymous
// lane" that admitted pageview + error under a `$public` tenant and answered 200.
// That lane is not implemented in the deployed cloud; the same claim was wrong in
// four other repos and is why keyless surfaces were believed to be half-working.)
//
// Worse, it would be SILENT: @hanzo/event resolves the outgoing credential as
// `ingestKey ?? token`, so a key takes PRECEDENCE over the bearer — setting one
// would OVERRIDE each signed-in user's own identity rather than supplement it.
// So the publishable key is a FALLBACK, never an override. That distinction is
// load-bearing: @hanzo/event resolves the outgoing credential as
// `ingestKey ?? token`, so passing `ingestKey` would REPLACE each signed-in
// user's bearer and file their events under the key's org instead of their own —
// on a multi-brand image, that is a cross-tenant defect. Supplying the key
// through `getToken` inverts that precedence into `token ?? key`, which is the
// order this product actually wants.
//
// THE COOKIE IS NOT A CREDENTIAL HERE. This file used to claim that posting
// same-origin let the first-party session ride along, so signed-in traffic landed
// correctly. It does not. That cookie is the casibase session, while cloud resolves
// a tenant from a VALIDATED IAM bearer (SanitizeIdentity) so a cookie-only POST
// carries no principal. It is not refused: it silently takes the ANONYMOUS lane,
// which files every row under the `$public` tenant (a partition no org can read) and
// drops `identify` with a 200 receipt. Production proved it — 498 console rows, all
// `$public`, zero identified users.
//
// `getToken` below is the fix, and it has neither problem: cloud resolves the tenant
// from the token's OWN owner claim, so each visitor's events land in THEIR org, on
// every brand host, with no per-brand configuration. Logged-out views carry no token
// and stay anonymous — the honest outcome for a visitor who has not identified
// themselves, and still the open question for the public/marketing faces (closing
// that needs a PER-HOST key resolved at RUNTIME, e.g. the `GET /v1/brand?host=`
// shape src/config already anticipates; a module-scope const cannot receive it).
// THE COOKIE IS NOT A CREDENTIAL HERE. This file once claimed that posting
// same-origin let the first-party session ride along. It does not: that cookie is
// the casibase session, while cloud resolves a tenant from a VALIDATED IAM bearer
// (SanitizeIdentity), so a cookie-only POST carries no principal.
/**
* Publishable ingest key — the SIGNED-OUT credential only. Org-scoped and
* write-only by construction, so it is safe in the bundle. Unset → signed-out
* views go back to reporting nothing, which is the previous behaviour and not a
* crash.
*/
const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined
/** Error-plane credential. Unset → captureError is inert (fail-safe). */
const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
@@ -100,7 +105,7 @@ export const eventClient: Analytics = createAnalytics({
// The client calls this at flush time, so a sign-in — and every silent refresh
// after it — is picked up with no rebuild. Returns undefined on the server and
// when signed out, which is the anonymous path.
getToken: () => iamAccessToken() ?? undefined,
getToken: () => iamAccessToken() ?? PUBLISHABLE_KEY,
dsn,
enabled: consented(),
})
+55 -7
View File
@@ -11,6 +11,7 @@ import {
canonicalSlug,
SLUG_ALIASES,
BASE_SUBPAGES,
baseSubpagesFor,
subpageSlug,
subpageHref,
activeSubpage,
@@ -183,24 +184,46 @@ describe('productSubpages — Overview + specifics + uniform base set', () => {
const slugs = (e: CatalogEntry, showAdmin = true) => productSubpages(e, showAdmin).map((s) => s.slug)
it('auto-adds Overview + the base set to a single-screen product', () => {
expect(slugs(vpc)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
expect(slugs(vpc)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
})
it('places a specific between Overview and the base set', () => {
// models declares Routing (admin) — visible to an admin, before the base set.
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'status', 'logs', 'metrics'])
expect(slugs(models, true)).toEqual(['', 'routing', 'settings', 'logs', 'metrics', 'status'])
})
it('does NOT duplicate a base slug a product declares as a specific', () => {
const withMetrics = mod('x', { subpages: [{ slug: 'metrics', label: 'Metrics' }] })
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'status', 'logs'])
expect(slugs(withMetrics)).toEqual(['', 'metrics', 'settings', 'logs', 'status'])
})
it('hides an admin-only specific from a customer', () => {
expect(slugs(models, false)).toEqual(['', 'settings', 'status', 'logs', 'metrics'])
expect(slugs(models, false)).toEqual(['', 'settings', 'logs', 'metrics', 'status'])
})
it('drops a base slug that IS the product — Settings has no Settings child', () => {
// The org-Settings product owns the `settings` concept; a base `settings`
// sub-page beneath it is the `Settings Settings` the rail used to show.
expect(slugs(mod('settings'))).toEqual(['', 'logs', 'metrics', 'status'])
// Same rule for the Observe products named after a base slug.
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
})
it('fails closed (empty sub-pages) for a non-module entry', () => {
expect(productSubpages(nonModule)).toEqual([])
})
it('BASE_SUBPAGES is exactly Settings · Status · Logs · Metrics', () => {
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'status', 'logs', 'metrics'])
it('BASE_SUBPAGES is exactly Settings · Logs · Metrics · Status', () => {
expect(BASE_SUBPAGES.map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
})
it('a product that IS a base concern never gets a self-referential base tab', () => {
// The Settings product: General (index) · Branding, then the base set MINUS
// its own 'settings' — no second "Settings" tab of itself (the reported bug).
const settings = mod('settings', { indexLabel: 'General', subpages: [{ slug: 'branding', label: 'Branding' }] })
expect(slugs(settings)).toEqual(['', 'branding', 'logs', 'metrics', 'status'])
// Same one rule for the other three Observe products named after a base slug.
expect(slugs(mod('logs'))).toEqual(['', 'settings', 'metrics', 'status'])
expect(slugs(mod('metrics'))).toEqual(['', 'settings', 'logs', 'status'])
expect(slugs(mod('status'))).toEqual(['', 'settings', 'logs', 'metrics'])
// The rule is expressed once: baseSubpagesFor drops only the self-named slug.
expect(baseSubpagesFor(mod('settings')).map((s) => s.slug)).toEqual(['logs', 'metrics', 'status'])
expect(baseSubpagesFor(mod('vpc')).map((s) => s.slug)).toEqual(['settings', 'logs', 'metrics', 'status'])
})
})
@@ -208,7 +231,7 @@ describe('the ONE level-2 nav — one declaration, read by both the rail and the
it('names the index after the product when it owns one (Models is a Catalog)', () => {
const named = mod('models2', { indexLabel: 'Catalog', subpages: [{ slug: 'blend', label: 'Blend' }] })
expect(productSubpages(named).map((s) => s.label)).toEqual([
'Catalog', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics',
'Catalog', 'Blend', 'Settings', 'Logs', 'Metrics', 'Status',
])
})
it('falls back to Overview when the product does not name its index', () => {
@@ -276,6 +299,31 @@ describe('resolveProductView — base sub-pages are the shared per-product view
// …but an UNowned base slug on the same product is still the shared view.
expect(resolveProductView(cat, mods, ['emb', 'status']).kind).toBe('subpage')
})
it('the router agrees with the nav: a product IS-that-concern URL is not a base subpage', () => {
// Settings (a :tab product): /settings/settings is NOT the shared per-product
// Settings view — it falls through to the module (which lands on the index),
// so there is never a self-referential Settings screen. Its OTHER base slugs
// still render the shared view.
const settings = mod('settings', {
indexLabel: 'General',
subpages: [{ slug: 'branding', label: 'Branding' }],
routes: [
{ path: '', component: C },
{ path: ':tab', component: C },
],
})
const cat = [settings]
const mods = cat.map((e) => e as unknown as ProductModule)
expect(resolveProductView(cat, mods, ['settings', 'settings']).kind).not.toBe('subpage')
expect(resolveProductView(cat, mods, ['settings', 'status']).kind).toBe('subpage')
// A single-screen product named after a base slug: the self-URL is an honest
// 404 (nothing links there), while its other base slugs render the shared view.
const logs = mod('logs')
const lcat = [logs]
const lmods = lcat.map((e) => e as unknown as ProductModule)
expect(resolveProductView(lcat, lmods, ['logs', 'logs']).kind).toBe('notfound')
expect(resolveProductView(lcat, lmods, ['logs', 'metrics']).kind).toBe('subpage')
})
it('stubs a DECLARED non-base specific that has no route yet (Tasks Queues)', () => {
const v = view(['tasks', 'queues'])
expect(v.kind).toBe('stub')
+21 -6
View File
@@ -121,15 +121,30 @@ export const indexSubpage = (entry: CatalogEntry): ProductSubpage =>
*/
export const BASE_SUBPAGES: ProductSubpage[] = [
{ slug: 'settings', label: 'Settings' },
{ slug: 'status', label: 'Status' },
// Observability trio reads raw → summary: Logs, then Metrics, then Status LAST
// (the live-health verdict comes after the signals it is derived from).
{ slug: 'logs', label: 'Logs' },
{ slug: 'metrics', label: 'Metrics' },
{ slug: 'status', label: 'Status' },
]
/**
* The base sub-pages a product actually gets: the uniform set minus any whose
* slug IS the product's own id. A product that already IS one of these concerns
* — Settings, Status, Logs, Metrics — must not also carry a base sub-tab bearing
* its own name (that is a self-referential duplicate: the Settings product would
* show a "Settings" tab of itself). One rule, read by both the nav and the
* router, so the two never disagree on whether that tab exists.
*/
export const baseSubpagesFor = (entry: CatalogEntry): ProductSubpage[] =>
BASE_SUBPAGES.filter((b) => b.slug !== entry.id)
/**
* The full ordered level-2 nav for a product: Overview, then its declared
* SPECIFIC sub-pages, then the uniform base set (a base slug the product already
* declares as a specific is not duplicated). Non-module entries have none.
* SPECIFIC sub-pages, then the uniform base set (`baseSubpagesFor` — the uniform
* set minus any slug that IS the product's own id, so Settings has no "Settings"
* child). A base slug the product already declares as a specific is not duplicated.
* Non-module entries have none.
*
* `showAdmin` gates admin-only specifics (e.g. Models Routing): a customer
* never sees them in the sub-nav (default true keeps every existing caller
@@ -140,7 +155,7 @@ export function productSubpages(entry: CatalogEntry, showAdmin = true): ProductS
const specifics = (entry.subpages ?? []).filter((s) => s.slug !== '' && (showAdmin || !s.admin))
const seen = new Set(specifics.map((s) => s.slug))
const out: ProductSubpage[] = [indexSubpage(entry), ...specifics]
for (const b of BASE_SUBPAGES) if (!seen.has(b.slug)) out.push(b)
for (const b of baseSubpagesFor(entry)) if (!seen.has(b.slug)) out.push(b)
return out
}
@@ -293,7 +308,7 @@ export function resolveProductView(
if (entry && entry.kind === 'module') {
const seg = slug[1]
const ownsAsSpecific = (entry.subpages ?? []).some((s) => s.slug === seg)
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
if (base && !ownsAsSpecific) return { kind: 'subpage', entry, subpage: base }
}
}
@@ -306,7 +321,7 @@ export function resolveProductView(
if (entry && entry.kind === 'module') {
const seg = slug[1]
const declared = (entry.subpages ?? []).find((s) => s.slug === seg)
const base = BASE_SUBPAGES.find((s) => s.slug === seg)
const base = baseSubpagesFor(entry).find((s) => s.slug === seg)
const sp = declared ?? base
if (sp) return { kind: 'stub', entry, subpage: sp }
}
-7
View File
@@ -1279,7 +1279,6 @@ export const catalog: CatalogEntry[] = [
status: 'enabled',
brands: ['hanzo'],
repo: 'hanzoai/cloud',
docs: `${DOCS}/automations`,
kind: 'module',
routes: [
{ path: '', component: AutomationsModule },
@@ -1380,7 +1379,6 @@ export const catalog: CatalogEntry[] = [
category: 'AI',
status: 'enabled',
repo: 'hanzoai/cloud',
docs: `${DOCS}/knowledge`,
kind: 'module',
routes: [{ path: '', component: KnowledgeModule }],
},
@@ -1713,7 +1711,6 @@ export const catalog: CatalogEntry[] = [
category: 'Network',
status: 'enabled',
repo: 'luxfi/node',
docs: `${DOCS}/nodes`,
kind: 'module',
routes: [{ path: '', component: NodesModule }],
},
@@ -2472,7 +2469,6 @@ export const catalog: CatalogEntry[] = [
category: 'Observe',
status: 'enabled',
repo: 'hanzoai/o11y',
docs: `${DOCS}/apm`,
kind: 'module',
routes: [{ path: '', component: ServiceMapModule }],
},
@@ -2611,7 +2607,6 @@ export const catalog: CatalogEntry[] = [
category: 'Observe',
status: 'enabled',
repo: 'hanzoai/finance',
docs: `${DOCS}/finance`,
kind: 'module',
routes: [{ path: '', component: FinanceModule }],
},
@@ -3225,7 +3220,6 @@ export const catalog: CatalogEntry[] = [
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/cloud',
docs: `${DOCS}/cms`,
kind: 'module',
routes: [
{ path: '', component: CmsModule },
@@ -3266,7 +3260,6 @@ export const catalog: CatalogEntry[] = [
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/cloud',
docs: `${DOCS}/helpdesk`,
kind: 'module',
routes: [
{ path: '', component: HelpModule },
+11
View File
@@ -246,6 +246,17 @@ export const CLOUD_HEADS: readonly string[] = [
'gpus',
'fleet',
'clusters',
// Sandboxes (cloud apps/sandbox): the org's leased gVisor pods — lease/list/get/
// end, exec, fs, and the ticket that opens an interactive terminal
// (/v1/sandboxes[/:id[/exec|/fs|/terminal]]). Same gate as the rest: the handler
// resolves the org from the Bearer owner and answers 403 without one, and an id
// belonging to another org is a 404.
//
// The terminal's SOCKET does not come through here and cannot: a Next route
// handler proxies requests, not upgrades. The browser dials the API host
// directly, carrying the single-use ticket this proxy fetched for it — which is
// the whole reason the ticket exists.
'sandboxes',
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
'vpcs',