Compare commits

...
Author SHA1 Message Date
hanzo-dev 5974540522 tracker.hanzo.ai: cloud serves the board, it does not point at another one
The host stops being a Huly workspace. It does not become a redirect either:
cloud's apps/tracker IS the one canonical project tracker — org-scoped,
IAM-gated, per-(org,project) — so the host now routes to the cloud binary, which
serves BOTH halves of it: the native board + timeline SPA at /tracker/* and its
API at /v1/tracker/*.

No path split and no second backend, because same-origin is the design rather
than a convenience: cloud mints the validated org from the IAM session before
either half runs, so the SPA sends no tenancy of its own. Splitting the host
across two backends would put the UI on an origin that has to carry a token and
name an org — the client-supplied tenancy the tracker refuses.

The bare `/` is the only redirect, to the SPA's base path, at a higher priority
so every other path falls through untouched. Same pair, same order, as
tasks.hanzo.ai.

The Huly removal is unchanged: the host leaves team-tls, the Ingress tls.hosts
and the team-go rule set, and hanzo.team, api.hanzo.team, team.hanzo.ai and
team-v2.hanzo.ai keep every rule they had. No Certificate is added — the
*.hanzo.ai wildcard already covers this host, which is why the SAN could go.

The live spec follows the behaviour: it asserted a 302 to the forge, and now
asserts what actually has to be true — the root sends the browser to /tracker/,
cloud serves a bundle built for that base, /v1/tracker answers on the same
origin, /v1/team no longer does, and the board list renders.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:16:24 -07:00
blueandhanzo-dev a991026ad7 tracker.hanzo.ai: serve the forge's native project boards
tracker.hanzo.ai served the Huly `front` SPA as a peer host of the team
workspace, which made it a second tracker over a second data plane. Issues,
boards and milestones are forge primitives; this host becomes a door onto
them and stops carrying an implementation of its own.

- routes.yaml: add the tracker-hanzo-ai router + tracker-redirect-to-forge
  middleware (302 to git.hanzo.ai/hanzoai/-/projects). The redirect is flat,
  not path-preserving: the paths this host used to answer are Huly workbench
  routes and none of them map onto a forge path.
- routes.yaml: drop tracker.hanzo.ai from the team-love /_love rule. The
  other four team hosts keep it.
- team-go/ingress.yaml: drop the tracker.hanzo.ai host rules, its entry in
  the Ingress tls hosts, and its team-tls dnsName. TLS for the host comes
  from the *.hanzo.ai wildcard, the same cert tasks.hanzo.ai is served.
- 120-workbench-unified-live: assert the redirect and the absence of a
  /v1/team plane on this host instead of a shared Huly workspace.

hanzo.team, api.hanzo.team, team.hanzo.ai and team-v2.hanzo.ai are unchanged
and keep the Huly front.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:16:24 -07:00
4 changed files with 90 additions and 94 deletions
+35 -22
View File
@@ -20,8 +20,8 @@ import { completeHanzoIdLogin } from '../helpers/app-login'
* • studio.hanzo.ai — the shared `hz` shell carries the same dock.
* • hanzo.bot — the read-only api.hanzo.ai Developers dock.
* • hanzo.team — Chat (Chunter) + Tracker + the seeded AI bots (des/dev/vi).
* • tracker.hanzo.ai — the UNIFIED peer host: SSO lands in the SAME workspace
* as hanzo.team, same bots (the standalone empty Huly was retired).
* • tracker.hanzo.ai — cloud's OWN project boards: the native board + timeline
* SPA and its /v1/tracker API, one process, one origin. No Huly workspace.
*
* Auth is the ONE shared hanzo.id session: sign into the console once, and every
* other Hanzo surface in the same context completes SSO silently. Real content is
@@ -203,28 +203,41 @@ test.describe('120 — workbench + unified surfaces (live, logged-in)', () => {
await page.goto(`${TEAM_URL}/workbench/${ws}/chunter`, { waitUntil: 'domcontentloaded' })
await expect(page.locator('text=Channels').first()).toBeVisible({ timeout: 30_000 })
await shot(page, 'team-chat')
// Store the workspace id for the unified-tracker assertion.
process.env.__E2E_TEAM_WS = ws
})
test('tracker.hanzo.ai is unified — SSO lands in the SAME workspace + bots', async () => {
test('tracker.hanzo.ai serves cloud\'s native board, not a Huly workspace', async () => {
// The bare root is the ONLY redirect: 302 to the SPA's base path, the same
// shape tasks.hanzo.ai has. Asserted on the raw response, before any hop, so
// a broken rule cannot hide behind something else answering 200.
const root = await page.request.get('https://tracker.hanzo.ai/', { maxRedirects: 0 })
expect(root.status(), 'bare root must send the browser to the SPA').toBe(302)
expect(root.headers()['location']).toBe('https://tracker.hanzo.ai/tracker/')
// The SPA itself is served by cloud, from this host — not fetched from a
// second origin. index.html must reference /tracker/assets/, which is what
// proves the bundle was built for the prefix this host serves.
const spa = await page.request.get('https://tracker.hanzo.ai/tracker/')
expect(spa.status(), 'cloud must serve the tracker SPA').toBe(200)
const html = await spa.text()
expect(html).toContain('Hanzo Tracker')
expect(html, 'bundle built for the /tracker/ base').toContain('/tracker/assets/')
// Its API is the SAME origin — that is the whole design: cloud mints the
// validated org from the IAM session before either half runs, so the page
// sends no tenancy of its own.
const health = await page.request.get('https://tracker.hanzo.ai/v1/tracker/health')
expect(health.status(), '/v1/tracker answers on this host').toBe(200)
// The old Huly data plane is GONE from this host. /v1/team belonged to the
// team-go Ingress rule that no longer names tracker.hanzo.ai, so cloud
// answers it as an unknown route rather than proxying a second tracker.
const team = await page.request.get('https://tracker.hanzo.ai/v1/team/bots', { maxRedirects: 0 })
expect(team.status(), 'no Huly team backend on this host').not.toBe(200)
// And it renders: the board list for the signed-in session's own org.
await page.goto('https://tracker.hanzo.ai/', { waitUntil: 'domcontentloaded', timeout: 60_000 })
await page.getByText('Continue with Hanzo').click().catch(() => {})
await completeHanzoIdLogin(page, TENANT_USER, TENANT_PASSWORD)
await page.waitForURL(/tracker\.hanzo\.ai\/workbench\//, { timeout: 45_000 })
const ws = page.url().match(/workbench\/([^/]+)/)?.[1] ?? ''
// SAME workspace id as hanzo.team → proves one shared data plane, not a clone.
if (process.env.__E2E_TEAM_WS) expect(ws).toBe(process.env.__E2E_TEAM_WS)
// Same seeded bots reachable from THIS host (same cloud /v1/team backend).
const bots = await page.evaluate(async () => {
const r = await fetch('/v1/team/bots', { credentials: 'same-origin' })
return r.ok ? await r.json() : null
})
expect(bots?.bots?.length ?? 0, 'same bots as hanzo.team').toBeGreaterThan(0)
await expect(page.locator('text=Tracker').first()).toBeVisible({ timeout: 30_000 })
await shot(page, 'tracker-unified')
await page.waitForURL(/tracker\.hanzo\.ai\/tracker\//, { timeout: 45_000 })
await expect(page.getByRole('heading', { name: /boards/i }).first()).toBeVisible({ timeout: 30_000 })
await shot(page, 'tracker-native-boards')
})
})
+54 -6
View File
@@ -584,6 +584,15 @@ http:
permanent: false
regex: ^https?://tasks\.hanzo\.ai/?$
replacement: https://tasks.hanzo.ai/tasks/
tracker-root-redirect:
# tracker.hanzo.ai/ → /tracker/ — the embedded Tracker SPA is served by the
# unified cloud binary (apps/tracker/ui) under the base path /tracker/, so
# the bare root has no route; send it to the SPA entrypoint (302). Exactly
# the shape tasks-root-redirect above has, for exactly the same reason.
redirectRegex:
permanent: false
regex: ^https?://tracker\.hanzo\.ai/?$
replacement: https://tracker.hanzo.ai/tracker/
favicon-to-hanzo:
# The login flow passes through hanzo.id (favicon = a heart) then
# iam.hanzo.ai (serves HTML for /favicon.ico), so the browser tab shows
@@ -1760,7 +1769,7 @@ http:
- websecure
middlewares:
- team-love-strip
rule: (Host(`hanzo.team`) || Host(`api.hanzo.team`) || Host(`team.hanzo.ai`) || Host(`team-v2.hanzo.ai`) || Host(`tracker.hanzo.ai`)) && PathPrefix(`/_love`)
rule: (Host(`hanzo.team`) || Host(`api.hanzo.team`) || Host(`team.hanzo.ai`) || Host(`team-v2.hanzo.ai`)) && PathPrefix(`/_love`)
service: team-love
tls: {}
gallery-hanzo-ai:
@@ -2863,11 +2872,50 @@ http:
rule: Host(`tasks-api.hanzo.ai`)
service: tasks-api-hanzo-ai
tls:
# tracker.hanzo.ai is now a PEER HOST of the unified team workspace, served
# by the team-go k8s Ingress (infra/k8s/team-go/ingress.yaml) → the SAME
# cloud /v1/team backend + front SPA + bots as hanzo.team. The old
# file-provider router + `tracker` service (the empty standalone Huly in ns
# `tracker`) are RETIRED — do not re-add them; the standalone had no data.
# tracker.hanzo.ai — the org's project boards, served by cloud.
#
# This host previously served the Huly `front` SPA as a peer of the team
# workspace, which made it a SECOND tracker over a second data plane. cloud's
# apps/tracker is the one canonical project tracker — org-scoped, IAM-gated,
# per-(org,project) — and it now serves BOTH halves of this host from ONE
# process: the SPA at /tracker/* and its API at /v1/tracker/*.
#
# That is why there is no path split here and no second backend: the whole
# host goes to cloud, so the page and the data it reads are same-origin. It
# has to be. cloud mints the validated org from the IAM session before either
# half runs, so the SPA sends no tenancy of its own; splitting the host
# across two backends would put the UI on an origin that has to carry a token
# and name an org, which is the client-supplied tenancy the tracker refuses.
#
# Priority 1000 so this beats any leftover routing for the host while the
# team-go Ingress rule is being removed (the two land in one commit, but
# Traefik loads the file provider and the k8s provider independently).
#
# The -root router is the bare `/` case only, at a LOWER priority so every
# other path falls through to the plain router untouched. Same pair, same
# order, as tasks.hanzo.ai.
#
# Reversible: drop both routers + the tracker-root-redirect middleware and
# re-add the host to infra/k8s/team-go/ingress.yaml.
tracker-hanzo-ai-root:
entryPoints:
- websecure
middlewares:
- tracker-root-redirect
- hsts-headers
priority: 1100
rule: Host(`tracker.hanzo.ai`) && Path(`/`)
service: cloud-api-hanzo-ai
tls:
tracker-hanzo-ai:
entryPoints:
- websecure
middlewares:
- hsts-headers
priority: 1000
rule: Host(`tracker.hanzo.ai`)
service: cloud-api-hanzo-ai
tls:
services:
agency-hanzo-ai:
loadBalancer:
-65
View File
@@ -57,11 +57,6 @@ spec:
- api.hanzo.team
- team.hanzo.ai
- team-v2.hanzo.ai
# tracker.hanzo.ai is a PEER host of the ONE unified team workspace — a
# focused (Trello-like) tracker door onto the SAME cloud /v1/team backend +
# front SPA + bots as hanzo.team. The empty standalone Huly (ns tracker) it
# replaced is retired. Native + same-origin (no cross-origin CORS/WS).
- tracker.hanzo.ai
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod-cf
@@ -138,7 +133,6 @@ spec:
- api.hanzo.team
- team.hanzo.ai
- team-v2.hanzo.ai
- tracker.hanzo.ai
secretName: team-tls
rules:
- host: hanzo.team
@@ -361,62 +355,3 @@ spec:
name: front-ext
port:
number: 8080
# tracker.hanzo.ai — PEER host of the unified team workspace (same cloud-ext
# backend + front-ext SPA + bots as hanzo.team). Same rule set as the aliases
# above; retires the empty standalone Huly. A focused tracker door, one data
# plane, native + same-origin.
- host: tracker.hanzo.ai
http:
paths:
- path: /v1/team
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
- path: /v1/billing
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
- path: /v1/usage
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
- path: /collaborator/rpc
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
- path: /collaborator
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
# The virtual office. apps/meet mints LiveKit room tokens in the cloud
# binary; without this carve /v1/meet falls to the `/` catch-all below
# and the SPA answers index.html — a 200 that is not meet.
- path: /v1/meet
pathType: Prefix
backend:
service:
name: cloud-ext
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: front-ext
port:
number: 8080
+1 -1
View File
@@ -37,7 +37,7 @@ spec:
- key: app
operator: In
values:
- front # the team SPA static server (hanzo.team, team.hanzo.ai, tracker.hanzo.ai)
- front # the team SPA static server (hanzo.team, team.hanzo.ai, team-v2.hanzo.ai)
ingress:
- {} # allow all sources — see the note above on hostNetwork pod identity
policyTypes: