fix(console): route all data-product clients through the canonical /v1/* client (#79)

Decomplect: make a non-canonical API path architecturally impossible for the
data-product surface. The 7 clients that hand-rolled a service-prefixed
/<svc>/v1/… path (billing, aimetrics, compute, visor, platform, provisioning,
storage) plus the Settlement component now build a bare /v1/<resource> via the
one originV1Url helper; next.config rewrites each head to its hardened
same-origin BFF proxy (service-token / user-bearer injection unchanged). Also
stamp X-Actor-Id (the signed-in user) in baseHeaders alongside
X-Org-Id/X-Project-Id, so org+project+user pass on EVERY call.

- billing/aimetrics: /billing/v1/<x> -> /v1/billing/<x>  (rewrite -> app/billing/v1)
- compute:  /cloud/v1/gpus[/alerts|/pools] -> /v1/gpus…  (rewrite -> /cloud)
- visor:    /cloud/v1/machines… -> /v1/machines…; /vm/v1/{regions,sizes} ->
            /v1/{regions,sizes}; /vm/v1/gpus (catalog) -> /v1/gpu-sizes
            (DISTINCT head: /v1/gpus is the cloud-api INVENTORY, not the catalog)
- platform: /cloud/v1/{clusters…,org/…/cluster} -> /v1/…  (rewrite -> /cloud)
- provisioning/storage: /cloud/v1/{sql,vector,…,s3/…} -> /v1/…  (rewrite -> /cloud)
- delete the per-client base-path builders (billingUrl / vm / clustersUrl-via-cloud);
  grep -rE '/(cloud|vm|ai|billing|org)/v1' src/lib/api/*.ts is clean (only the
  client.ts BFF-helper docs for the out-of-scope clients remain).
- X-Actor-Id sourced from a new lib/actor-scope (SessionProvider keeps it in
  lockstep with the resolved account: the auth twin of org-scope).

tsc --noEmit ok; vitest 1432 pass; next build ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
z
2026-07-03 15:47:19 -07:00
committed by GitHub
co-authored by hanzo-dev
parent 79d495efe8
commit 482251e389
16 changed files with 319 additions and 90 deletions
+20
View File
@@ -92,6 +92,16 @@ const devCloudRewrites = () =>
]
: []
// Native cloud INFRA + managed-data heads the data-product clients call at a clean
// `/v1/<head>` (nothing before /v1/); each is rewritten to the same-origin user-
// bearer `/cloud` proxy (app/cloud) — which mints a per-user token and forwards to
// cloud-api — and is allow-listed in proxy-allow.ts CLOUD_HEADS (defense in depth).
const CLOUD_INFRA_V1_HEADS = ['machines', 'gpus', 'clusters', 'org', 'sql', 'vector', 'datastore', 'kv', 'search', 's3', 'docdb']
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor `/vm` proxy
// (app/vm). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes` so it
// never collides with the cloud-api GPU INVENTORY at `/v1/gpus`.
const VM_V1_HEADS = ['regions', 'sizes']
const aiSurfaceRewrites = () => ({
beforeFiles: [
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
@@ -100,6 +110,16 @@ const aiSurfaceRewrites = () => ({
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/v1/${h}/:path*` })),
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
// Data-product clients (compute / visor / platform / provisioning / storage) —
// clean `/v1/<head>` → the user-bearer `/cloud` proxy (org from the Bearer owner).
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
// Public compute catalog → the visor `/vm` proxy.
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/vm/v1/${h}` })),
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/vm/v1/${h}/:path*` })),
{ source: `/v1/gpu-sizes`, destination: `/vm/v1/gpus` },
// Per-tenant billing DATA → the service-token commerce proxy (app/billing/v1).
{ source: `/v1/billing/:path*`, destination: `/billing/v1/:path*` },
...devCloudRewrites(),
],
})
+5 -5
View File
@@ -4,7 +4,7 @@
* Settlement — outbound payouts (settlement of balances to a bank account or card),
* tracked by the commerce billing ledger.
*
* Reads the payout ledger from commerce via the same-origin `/billing/v1` proxy
* Reads the payout ledger from commerce via the same-origin commerce billing proxy
* (`GET /v1/billing/payouts` → commerce `ListPayouts`), which injects the commerce
* service token server-side and scopes every read to the caller's OWN org namespace
* (server-resolved `X-Org-Id`, never client-supplied). The endpoint returns a bare
@@ -18,15 +18,15 @@ import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet } from '~/lib/api/client'
import { restGet, originV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
/** Same-origin commerce billing DATA proxy (app/billing/v1/[...path]) — injects the
* commerce service token + pins the caller's own org server-side. */
const billing = (path: string) => `/billing/v1/${path.replace(/^\/+/, '')}`
/** Canonical billing DATA path (`/v1/billing/*`, nothing before /v1/); `next.config`
* rewrites it to the same-origin commerce proxy (service token + server-pinned org). */
const billing = (path: string) => originV1Url(`billing/${path.replace(/^\/+/, '')}`)
/** One payout as commerce's `ListPayouts` returns it (`payoutResponse`). */
type Payout = {
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, afterEach } from 'vitest'
import { currentActor, setCurrentActor } from './actor-scope'
/** A Map-backed localStorage so set/get round-trips (real browser semantics). */
function stubWindow(): void {
const store = new Map<string, string>()
;(globalThis as { window?: unknown }).window = {
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, val: string) => void store.set(k, val),
removeItem: (k: string) => void store.delete(k),
},
}
}
afterEach(() => {
delete (globalThis as { window?: unknown }).window
})
describe('actor-scope', () => {
it('defaults to empty (no actor before sign-in)', () => {
stubWindow()
expect(currentActor()).toBe('')
})
it('round-trips a principal id and clears on empty', () => {
stubWindow()
setCurrentActor('hanzo/z')
expect(currentActor()).toBe('hanzo/z')
setCurrentActor('')
expect(currentActor()).toBe('')
})
it('is empty on the server (no window)', () => {
expect(currentActor()).toBe('')
})
})
+38
View File
@@ -0,0 +1,38 @@
/**
* Active actor scope — WHO the console is currently acting as (the signed-in user).
*
* Orthogonal to org-scope (`lib/org-scope.ts`, the acting ORG) and resource-scope
* (`lib/scope.ts`, project + environment): this module's single concern is the user
* identity, so the three compose into the full tenant path — org -> project ->
* environment, acting AS this user — with no layer knowing another's internals.
*
* Held in localStorage (browser-only) and read SYNCHRONOUSLY by `client.ts`'s
* `baseHeaders`, which stamps it as `X-Actor-Id` on every call (present only once a
* session is resolved — absent pre-sign-in / SSR). The value is the casibase
* principal `<owner>/<name>` (globally unique). `SessionProvider` is the ONE writer,
* keeping it in lockstep with the resolved account (set on sign-in, cleared on
* sign-out) — the auth twin of how `OrgGate` keeps org-scope in sync.
*/
const KEY = 'hanzo.console.actor'
/** The signed-in user's principal id (`<owner>/<name>`), or '' when none is set. */
export function currentActor(): string {
if (typeof window === 'undefined') return ''
try {
return window.localStorage.getItem(KEY) ?? ''
} catch {
// localStorage blocked (private mode) — no actor id; the call omits X-Actor-Id.
return ''
}
}
/** Set (or clear, with '') the active actor id. Browser-only; a no-op on the server. */
export function setCurrentActor(actor: string): void {
if (typeof window === 'undefined') return
try {
if (actor) window.localStorage.setItem(KEY, actor)
else window.localStorage.removeItem(KEY)
} catch {
// Storage blocked — the actor simply stays unset (X-Actor-Id omitted).
}
}
+2 -2
View File
@@ -296,9 +296,9 @@ describe('fetchUsageRecords — through the per-tenant /billing proxy', () => {
delete (globalThis as { window?: unknown }).window
})
it('hits the same-origin /billing/v1/usage proxy (never commerce directly)', async () => {
it('hits the same-origin /v1/billing/usage proxy (never commerce directly)', async () => {
const recs = await fetchUsageRecords()
expect(fetched[0]).toBe(`${ORIGIN}/billing/v1/usage`)
expect(fetched[0]).toBe(`${ORIGIN}/v1/billing/usage`)
expect(recs).toHaveLength(1)
expect(recs[0].model).toBe('gpt-4o-mini')
})
+5 -10
View File
@@ -22,16 +22,11 @@
* series/breakdowns (honest-empty), and a proxy failure throws a typed `ApiError`
* the caller renders as an honest state — never placeholder spend.
*/
import { ApiError, restGet } from './client'
import { ApiError, restGet, originV1Url } from './client'
import type { CloudBalance } from './wallet'
/** Same-origin URL for the billing DATA proxy (`/billing/v1/*` → commerce; NOT the
* `/v1` gateway). Namespaced under `/billing/v1/` so it never shadows the billing
* UI tab URLs (`/billing/reports`, …), which fall through to the SPA. */
const billingUrl = (path: string): string => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/billing/v1/${path.replace(/^\/+/, '')}`
}
// Billing usage/balance use the canonical `/v1/billing/*` (one builder, `originV1Url`);
// `next.config` rewrites it to the same-origin commerce billing proxy.
/**
* One usage record as commerce returns it under `usage[]`. All fields are
@@ -317,12 +312,12 @@ export function recent(records: UsageRecord[], n: number): UsageRecord[] {
/** Fetch the org's raw usage records through the per-tenant `/billing/usage` proxy. */
export async function fetchUsageRecords(): Promise<UsageRecord[]> {
return restGet<unknown>(billingUrl('usage')).then(normalizeUsageRecords)
return restGet<unknown>(originV1Url('billing/usage')).then(normalizeUsageRecords)
}
/** Fetch the org's cloud-credit balance (USD cents) through the same proxy. */
export async function fetchBalance(currency = 'usd'): Promise<CloudBalance> {
return restGet<CloudBalance>(`${billingUrl('balance')}?currency=${encodeURIComponent(currency)}`)
return restGet<CloudBalance>(`${originV1Url('billing/balance')}?currency=${encodeURIComponent(currency)}`)
}
export { ApiError }
+14 -21
View File
@@ -18,21 +18,14 @@
* On a 404/501/401 the caller renders the shared `BackendStateCard` — never
* fabricated spend, balance, or card data.
*/
import { restGet, restPost } from './client'
import { restGet, restPost, originV1Url } from './client'
import type { CloudBalance } from './wallet'
import { normalizeUsageRecords, perModel, totalsOf } from './aimetrics'
/**
* Same-origin URL for the billing DATA proxy (`/billing/v1/*` → commerce; NOT the
* `/v1` gateway). The proxy is namespaced under `/billing/v1/` so it never shadows
* the billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …), which fall
* through to the SPA — a route handler always wins over the catch-all page for a
* matching segment, so the data plane and the tab slugs must not share path space.
*/
const billingUrl = (path: string): string => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/billing/v1/${path.replace(/^\/+/, '')}`
}
// Billing DATA calls use the canonical `/v1/billing/*` (nothing before /v1/); one
// builder for every module (`originV1Url`). `next.config` rewrites that head to the
// same-origin commerce proxy (service token + server-pinned org) — the client never
// hand-rolls a service-prefixed path, so a non-canonical billing URL can't exist.
/** One metered line — spend grouped by product/model over the window. */
export type UsageLine = {
@@ -331,7 +324,7 @@ export type TopupResult = {
export const BillingApi = {
/** Cloud credit balance (USD cents) — same proxy as the Wallet/sidebar. */
balance: (currency = 'usd'): Promise<CloudBalance> =>
restGet<CloudBalance>(`${billingUrl('balance')}?currency=${encodeURIComponent(currency)}`),
restGet<CloudBalance>(`${originV1Url('billing/balance')}?currency=${encodeURIComponent(currency)}`),
/** Metered spend over an optional window (commerce defaults the period). */
usage: (params?: { start?: string; end?: string }): Promise<Usage> => {
@@ -339,19 +332,19 @@ export const BillingApi = {
if (params?.start) qs.set('start', params.start)
if (params?.end) qs.set('end', params.end)
const q = qs.toString()
return restGet<unknown>(`${billingUrl('usage')}${q ? `?${q}` : ''}`).then(normalizeUsage)
return restGet<unknown>(`${originV1Url('billing/usage')}${q ? `?${q}` : ''}`).then(normalizeUsage)
},
/** The tenant's invoice history (most recent first, as commerce returns it). */
invoices: (): Promise<Invoice[]> => restGet<unknown>(billingUrl('invoices')).then(normalizeInvoices),
invoices: (): Promise<Invoice[]> => restGet<unknown>(originV1Url('billing/invoices')).then(normalizeInvoices),
/** The org's subscriptions (plan, status, renewal) — read-only. */
subscriptions: (): Promise<Subscription[]> =>
restGet<unknown>(billingUrl('subscriptions')).then(normalizeSubscriptions),
restGet<unknown>(originV1Url('billing/subscriptions')).then(normalizeSubscriptions),
/** The org's saved payment methods (masked brand + last4 only) — read-only. */
paymentMethods: (): Promise<PaymentMethod[]> =>
restGet<unknown>(billingUrl('payment-methods')).then(normalizePaymentMethods),
restGet<unknown>(originV1Url('billing/payment-methods')).then(normalizePaymentMethods),
/**
* The org's spend alerts / budgets (`GET /v1/billing/spend-alerts`). The proxy
@@ -359,7 +352,7 @@ export const BillingApi = {
* only the caller's budgets.
*/
spendAlerts: (): Promise<SpendAlert[]> =>
restGet<unknown>(billingUrl('spend-alerts')).then(normalizeSpendAlerts),
restGet<unknown>(originV1Url('billing/spend-alerts')).then(normalizeSpendAlerts),
/**
* Create a spend alert / budget (`POST /v1/billing/spend-alerts`). The subject is
@@ -368,7 +361,7 @@ export const BillingApi = {
* another tenant. Returns the created alert.
*/
createSpendAlert: (input: { title: string; thresholdCents: number; currency?: string }): Promise<SpendAlert> =>
restPost<unknown>(billingUrl('spend-alerts'), {
restPost<unknown>(originV1Url('billing/spend-alerts'), {
title: input.title,
threshold: Math.round(input.thresholdCents),
currency: (input.currency ?? 'usd').toLowerCase(),
@@ -381,7 +374,7 @@ export const BillingApi = {
* through its single SQUARE_ENVIRONMENT authority, so the app id the browser
* tokenizes with always matches the account commerce will charge.
*/
paymentConfig: (): Promise<PaymentConfig> => restGet<PaymentConfig>(billingUrl('payment-config')),
paymentConfig: (): Promise<PaymentConfig> => restGet<PaymentConfig>(originV1Url('billing/payment-config')),
/**
* Charge a Square Web Payments nonce and credit the org's CANONICAL balance
@@ -394,7 +387,7 @@ export const BillingApi = {
* the first result rather than double-charging. Returns the new balance (cents).
*/
topupWithCard: (input: { sourceId: string; amountCents: number; currency?: string }): Promise<TopupResult> =>
restPost<TopupResult>(billingUrl('topup/token'), {
restPost<TopupResult>(originV1Url('billing/topup/token'), {
sourceId: input.sourceId,
amountCents: Math.round(input.amountCents),
currency: (input.currency ?? 'usd').toLowerCase(),
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { get } from './client'
import { setScope } from '~/lib/scope'
import { setCurrentActor } from '~/lib/actor-scope'
import { setCurrentOrg } from '~/lib/org-scope'
import { VisorApi } from './visor'
import { ComputeApi } from './compute'
import { ProvisioningApi } from './provisioning'
import { StorageApi } from './storage'
import { BillingApi } from './billing'
import { PlatformApi } from './platform'
const ORIGIN = 'https://console.hanzo.ai'
let lastUrl = ''
let lastInit: RequestInit | undefined
/** Stub window (Map-backed localStorage) + a single JSON fetch; capture url + init. */
function stub(body: unknown, status = 200): void {
lastUrl = ''
lastInit = undefined
const store = new Map<string, string>()
;(globalThis as { window?: unknown }).window = {
location: { origin: ORIGIN, hostname: 'console.hanzo.ai' },
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, val: string) => void store.set(k, val),
removeItem: (k: string) => void store.delete(k),
},
}
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
lastUrl = String(url)
lastInit = init
return Promise.resolve(
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
}),
)
})
}
afterEach(() => {
vi.unstubAllGlobals()
delete (globalThis as { window?: unknown }).window
})
// Every refactored client must hit a CANONICAL `/v1/<resource>` (nothing before
// /v1/) — never `/<svc>/v1/...`. The next.config rewrites route each head to its
// hardened same-origin BFF proxy, so the browser URL stays prefix-free.
describe('canonical /v1 client paths (no service prefix before /v1/)', () => {
it('VisorApi.machines -> /v1/machines', async () => {
stub({ machines: [] })
await VisorApi.machines()
expect(lastUrl).toBe(`${ORIGIN}/v1/machines`)
})
it('VisorApi.regions -> /v1/regions', async () => {
stub({ regions: [] })
await VisorApi.regions()
expect(lastUrl).toBe(`${ORIGIN}/v1/regions`)
})
it('VisorApi.gpus (catalog) -> /v1/gpu-sizes (distinct from the inventory head)', async () => {
stub({ gpus: [] })
await VisorApi.gpus()
expect(lastUrl).toBe(`${ORIGIN}/v1/gpu-sizes`)
})
it('ComputeApi.gpus (inventory) -> /v1/gpus', async () => {
stub({ gpus: [] })
await ComputeApi.gpus()
expect(lastUrl).toBe(`${ORIGIN}/v1/gpus`)
})
it('ProvisioningApi.list(sql) -> /v1/sql', async () => {
stub([])
await ProvisioningApi.list('sql')
expect(lastUrl).toBe(`${ORIGIN}/v1/sql`)
})
it('StorageApi.buckets -> /v1/s3/buckets', async () => {
stub({ buckets: [] })
await StorageApi.buckets()
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets`)
})
it('BillingApi.balance -> /v1/billing/balance', async () => {
stub({ balanceCents: 0 })
await BillingApi.balance()
expect(lastUrl).toBe(`${ORIGIN}/v1/billing/balance?currency=usd`)
})
it('PlatformApi.listClusters -> /v1/clusters', async () => {
stub({ clusters: [] })
await PlatformApi.listClusters()
expect(lastUrl).toBe(`${ORIGIN}/v1/clusters`)
})
it('never emits a /<svc>/v1/ path', async () => {
stub({ machines: [] })
await VisorApi.machines()
expect(lastUrl).not.toMatch(/\/(cloud|vm|ai|billing|org)\/v1\//)
})
})
// baseHeaders stamps the FULL tenant path on every call: org (always), project
// (when selected), and the signed-in actor (when a session is resolved).
describe('baseHeaders — org + project + actor on every call', () => {
beforeEach(() => {
stub({ status: 'ok', msg: '', data: {} })
})
it('stamps X-Org-Id + X-Project-Id + X-Actor-Id', async () => {
setCurrentOrg('maxpower')
setScope({ project: 'proj-1', environment: 'mainnet' })
setCurrentActor('hanzo/z')
await get('anything')
const h = (lastInit?.headers ?? {}) as Record<string, string>
expect(h['X-Org-Id']).toBe('maxpower')
expect(h['X-Project-Id']).toBe('proj-1')
expect(h['X-Actor-Id']).toBe('hanzo/z')
})
it('omits X-Project-Id + X-Actor-Id when neither project nor actor is set', async () => {
setScope({ project: undefined, environment: 'mainnet' })
setCurrentActor('')
await get('anything')
const h = (lastInit?.headers ?? {}) as Record<string, string>
expect(h['X-Project-Id']).toBeUndefined()
expect(h['X-Actor-Id']).toBeUndefined()
expect(h['X-Org-Id']).toBeTruthy()
})
})
+7
View File
@@ -11,6 +11,7 @@
*/
import { config } from '~/config'
import { currentOrg } from '~/lib/org-scope'
import { currentActor } from '~/lib/actor-scope'
import { getScope } from '~/lib/scope'
import { refreshSession } from '~/lib/auth/refresh'
@@ -57,6 +58,7 @@ const baseHeaders = (hasBody: boolean): Record<string, string> => {
// that compose here, so this ONE stamp makes EVERY module (o11y, api-keys,
// deploys, …) org + project + environment scoped with no per-module change.
const s = getScope()
const actor = currentActor()
return {
'Accept-Language': acceptLanguage(),
// ONE canonical org header. Both the provisioning sub-service and the casibase
@@ -70,6 +72,11 @@ const baseHeaders = (hasBody: boolean): Record<string, string> => {
// Project sub-scope — the canonical `X-Project-Id` (evalsvc reads it; other
// svcs as project scoping lands). Sent only when a project is selected.
...(s.project ? { 'X-Project-Id': s.project } : {}),
// Actor sub-identity — the signed-in USER (`<owner>/<name>`), so org + project +
// USER all pass on EVERY call. Present only when a session is resolved (absent
// pre-sign-in / SSR); the gateway treats it as advisory, identity being
// authoritative from the validated JWT — exactly like `X-Org-Id`.
...(actor ? { 'X-Actor-Id': actor } : {}),
'X-Environment': s.environment,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
}
+5 -5
View File
@@ -25,7 +25,7 @@
*
* Per-GPU telemetry time-series (utilization-over-time, temps), pools, and alerts
* stay honestly empty until a provider/agent connects them upstream. ALL calls are
* org-scoped server-side: `/cloud/v1/*` by the minted Bearer's owner claim, and the
* org-scoped server-side: `/v1/gpus*` by the minted Bearer's owner claim, and the
* `/v1` usage ledger by the `X-Org-Id` (`currentOrg()`) `client.ts` stamps.
*
* Reuses (does NOT duplicate) `PlatformApi.listClusters` / `clustersFromApps` from
@@ -33,7 +33,7 @@
* helpers, kept here (not braided into `platform.ts`) so the concurrent Machines work
* on `platform.ts` and this stay orthogonal.
*/
import { get, restGet, cloudProxyV1Url } from './client'
import { get, restGet, originV1Url } from './client'
import type { Cluster } from './platform'
// ── Wire types ───────────────────────────────────────────────────────────────
@@ -450,21 +450,21 @@ export const GPU_NODE_SIZES = [
export const ComputeApi = {
/** Per-GPU inventory + telemetry from the native cloud (`GET /v1/gpus`). */
gpus: async (): Promise<Gpu[]> => {
const r = await restGet<unknown>(cloudProxyV1Url('gpus'))
const r = await restGet<unknown>(originV1Url('gpus'))
const arr = Array.isArray(r) ? r : rec(r).gpus
return (Array.isArray(arr) ? arr : []).map((g) => normalizeGpu(g))
},
/** GPU alerts from the native cloud (`GET /v1/gpus/alerts`). */
alerts: async (): Promise<GpuAlert[]> => {
const r = await restGet<unknown>(cloudProxyV1Url('gpus/alerts'))
const r = await restGet<unknown>(originV1Url('gpus/alerts'))
const arr = Array.isArray(r) ? r : rec(r).alerts
return (Array.isArray(arr) ? arr : []).map((a, i) => normalizeAlert(a, i))
},
/** GPU scheduling pools from the native cloud (`GET /v1/gpus/pools`). */
pools: async (): Promise<GpuPool[]> => {
const r = await restGet<unknown>(cloudProxyV1Url('gpus/pools'))
const r = await restGet<unknown>(originV1Url('gpus/pools'))
const arr = Array.isArray(r) ? r : rec(r).pools
return (Array.isArray(arr) ? arr : []).map((p, i) => normalizePool(p, i))
},
+5 -4
View File
@@ -22,7 +22,7 @@
* - DELETE /v1/clusters/:cid/pools/:pid → remove a node pool.
* - POST /v1/org/{org}/cluster → provision a fresh dedicated cluster (`/paas`).
*/
import { restGet, restPost, restDelete, cloudProxyV1Url } from './client'
import { restGet, restPost, restDelete, originV1Url } from './client'
/** Where a cluster lives: shared multi-tenant Hanzo Cloud, or a BYO/managed DOKS. */
export type ClusterKind = 'shared' | 'byo' | (string & {})
@@ -169,8 +169,9 @@ const clustersOf = (payload: unknown): Cluster[] => {
return []
}
/** Same-origin native-cloud path for the clusters surface (`/cloud/v1/clusters…`). */
const clustersUrl = (path = ''): string => cloudProxyV1Url(`clusters${path}`)
/** Canonical path for the clusters surface (`/v1/clusters…`); `next.config` rewrites
* it to the same-origin user-bearer `/cloud` proxy. */
const clustersUrl = (path = ''): string => originV1Url(`clusters${path}`)
export const PlatformApi = {
/** The apps inventory — the real "what is running" board across all clusters (`/paas`). */
@@ -214,7 +215,7 @@ export const PlatformApi = {
* service token. (Same native-`/v1` path as `listClusters`.)
*/
provisionCluster: async (org: string, input: ProvisionClusterInput): Promise<Cluster> => {
const r = await restPost<{ cluster: Cluster }>(cloudProxyV1Url(`org/${enc(org)}/cluster`), input)
const r = await restPost<{ cluster: Cluster }>(originV1Url(`org/${enc(org)}/cluster`), input)
return r.cluster
},
}
+6 -6
View File
@@ -11,7 +11,7 @@
* sends cookie credentials only. One client, parameterized by `kind` — the
* ResourceModule factory binds a kind and gets a working admin surface.
*/
import { restGet, restPost, restDelete, cloudProxyV1Url } from './client'
import { restGet, restPost, restDelete, originV1Url } from './client'
/** Wire kind = the REST path segment the provisioning service serves. */
export type ResourceKind =
@@ -54,7 +54,7 @@ export type ResourceCreated = Resource & {
* or one level of nesting (e.g. a Qdrant-style `{ result: { collections: [...] } }`).
* A non-array body reaching the list view's `for…of` / `.length` throws DURING
* render and blanks the whole module behind the error boundary — the observed
* Vector regression (`GET /cloud/v1/vector` 200, but the API returns a wrapped
* Vector regression (`GET /v1/vector` 200, but the API returns a wrapped
* shape, so nothing renders while SQL/KV — which return bare arrays — render fine).
*
* So we validate + unwrap at the transport boundary (ONE place, every kind) and
@@ -85,14 +85,14 @@ export function normalizeResourceList(body: unknown): Resource[] {
export const ProvisioningApi = {
list: async (kind: ResourceKind): Promise<Resource[]> =>
normalizeResourceList(await restGet<unknown>(cloudProxyV1Url(kind))),
normalizeResourceList(await restGet<unknown>(originV1Url(kind))),
get: (kind: ResourceKind, name: string) =>
restGet<Resource>(cloudProxyV1Url(`${kind}/${encodeURIComponent(name)}`)),
restGet<Resource>(originV1Url(`${kind}/${encodeURIComponent(name)}`)),
create: (kind: ResourceKind, name: string) =>
restPost<ResourceCreated>(cloudProxyV1Url(kind), { name }),
restPost<ResourceCreated>(originV1Url(kind), { name }),
remove: (kind: ResourceKind, name: string) =>
restDelete(cloudProxyV1Url(`${kind}/${encodeURIComponent(name)}`)),
restDelete(originV1Url(`${kind}/${encodeURIComponent(name)}`)),
}
+8 -8
View File
@@ -124,39 +124,39 @@ describe('StorageApi transport (same-origin /cloud proxy)', () => {
beforeEach(() => stubJson({ buckets: [] }))
afterEach(teardown)
it('buckets() GETs /cloud/v1/s3/buckets', async () => {
it('buckets() GETs /v1/s3/buckets', async () => {
await StorageApi.buckets()
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets`)
})
it('objects() encodes the prefix as a query param', async () => {
stubJson({ objects: [] })
await StorageApi.objects('photos', 'a/b/')
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets/photos/objects?prefix=a%2Fb%2F`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets/photos/objects?prefix=a%2Fb%2F`)
})
it('objects() with no prefix omits the query', async () => {
stubJson({ objects: [] })
await StorageApi.objects('photos')
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets/photos/objects`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets/photos/objects`)
})
it('presignDownload() preserves nested key slashes in the path', async () => {
stubJson({ url: 'https://s3.hanzo.ai/x', method: 'GET' })
await StorageApi.presignDownload('photos', 'a/b/c.png')
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets/photos/objects/a/b/c.png`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets/photos/objects/a/b/c.png`)
})
it('deleteObject() targets the object path (204 resolves)', async () => {
stubJson(null, 204)
await StorageApi.deleteObject('photos', 'a/b/c.png')
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets/photos/objects/a/b/c.png`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets/photos/objects/a/b/c.png`)
})
it('createBucket() POSTs the name to /cloud/v1/s3/buckets', async () => {
it('createBucket() POSTs the name to /v1/s3/buckets', async () => {
stubJson({ name: 'new-bucket' }, 201)
const b = await StorageApi.createBucket('new-bucket')
expect(lastUrl).toBe(`${ORIGIN}/cloud/v1/s3/buckets`)
expect(lastUrl).toBe(`${ORIGIN}/v1/s3/buckets`)
expect(b?.name).toBe('new-bucket')
})
})
+12 -10
View File
@@ -9,7 +9,8 @@
*
* TRANSPORT — metadata operations (list buckets/objects, create/delete bucket,
* delete object, mint a presigned URL) go through the same-origin `/cloud`
* user-bearer proxy (`cloudProxyV1Url` → the server mints a short-lived user
* user-bearer proxy (`originV1Url` → `next.config` rewrites `/v1/s3` to `/cloud`,
* which mints a short-lived user
* token; `s3` is allow-listed in proxy-allow.ts). Plain REST (raw JSON / 201 /
* 204), like the provisioning + functions facades.
*
@@ -24,9 +25,10 @@
* rather than throwing. Nothing is fabricated — an unreachable/unconfigured
* backend surfaces through `classifyBackend` as an honest state.
*/
import { restGet, restPost, restDelete, cloudProxyV1Url } from './client'
import { restGet, restPost, restDelete, originV1Url } from './client'
/** File-manager base path (same-origin `/cloud/v1/s3` → cloud-api `/v1/s3`). */
/** File-manager base path — canonical `/v1/s3` (rewritten to the same-origin `/cloud`
* proxy → cloud-api). */
const BASE = 's3'
const enc = encodeURIComponent
@@ -129,15 +131,15 @@ export function normalizePresigned(payload: unknown): Presigned | null {
export const StorageApi = {
/** List the caller's buckets (`GET /v1/s3/buckets`). Honest-empty on 200 with no rows. */
buckets: (): Promise<Bucket[]> => restGet<unknown>(cloudProxyV1Url(`${BASE}/buckets`)).then(normalizeBuckets),
buckets: (): Promise<Bucket[]> => restGet<unknown>(originV1Url(`${BASE}/buckets`)).then(normalizeBuckets),
/** Create a bucket (`POST /v1/s3/buckets`). */
createBucket: (name: string): Promise<Bucket | null> =>
restPost<unknown>(cloudProxyV1Url(`${BASE}/buckets`), { name }).then(normalizeBucket),
restPost<unknown>(originV1Url(`${BASE}/buckets`), { name }).then(normalizeBucket),
/** Delete an EMPTY bucket (`DELETE /v1/s3/buckets/:bucket`). */
deleteBucket: (bucket: string): Promise<void> =>
restDelete(cloudProxyV1Url(`${BASE}/buckets/${enc(bucket)}`)),
restDelete(originV1Url(`${BASE}/buckets/${enc(bucket)}`)),
/**
* List one folder level (`GET /v1/s3/buckets/:bucket/objects?prefix=`). Folder-
@@ -146,7 +148,7 @@ export const StorageApi = {
*/
objects: (bucket: string, prefix = ''): Promise<S3Object[]> =>
restGet<unknown>(
cloudProxyV1Url(`${BASE}/buckets/${enc(bucket)}/objects${prefix ? `?prefix=${enc(prefix)}` : ''}`),
originV1Url(`${BASE}/buckets/${enc(bucket)}/objects${prefix ? `?prefix=${enc(prefix)}` : ''}`),
).then(normalizeObjects),
/**
@@ -154,20 +156,20 @@ export const StorageApi = {
* The caller then PUTs the file bytes DIRECTLY to `url` (see `uploadTo`).
*/
presignUpload: (bucket: string, key: string): Promise<Presigned | null> =>
restPost<unknown>(cloudProxyV1Url(`${BASE}/buckets/${enc(bucket)}/objects`), { key }).then(normalizePresigned),
restPost<unknown>(originV1Url(`${BASE}/buckets/${enc(bucket)}/objects`), { key }).then(normalizePresigned),
/**
* Mint a presigned GET URL for a download
* (`GET /v1/s3/buckets/:bucket/objects/<key>`). The caller opens `url` directly.
*/
presignDownload: (bucket: string, key: string): Promise<Presigned | null> =>
restGet<unknown>(cloudProxyV1Url(`${BASE}/buckets/${enc(bucket)}/objects/${encodeKey(key)}`)).then(
restGet<unknown>(originV1Url(`${BASE}/buckets/${enc(bucket)}/objects/${encodeKey(key)}`)).then(
normalizePresigned,
),
/** Delete one object (`DELETE /v1/s3/buckets/:bucket/objects/<key>`). */
deleteObject: (bucket: string, key: string): Promise<void> =>
restDelete(cloudProxyV1Url(`${BASE}/buckets/${enc(bucket)}/objects/${encodeKey(key)}`)),
restDelete(originV1Url(`${BASE}/buckets/${enc(bucket)}/objects/${encodeKey(key)}`)),
}
/**
+14 -15
View File
@@ -6,10 +6,11 @@
* through the unified cloud binary at `/v1/machines*`, via the same-origin
* user-bearer `/cloud` proxy (`app/cloud/[...path]/route.ts` → cloud-api, org
* resolved from the Bearer owner). This is the visor-backed native cloud surface.
* - The public compute CATALOG (regions / sizes / GPU accelerators, un-scoped) still
* reads visor directly via the `/vm` proxy (`app/vm/[...path]/route.ts` →
* `visor.hanzo.svc/v1/*`): it powers the launch picker and can't share the
* `/v1/gpus` head (that is the GPU INVENTORY surface, a different shape).
* - The public compute CATALOG (regions / CPU sizes / GPU accelerators, un-scoped)
* reads the canonical `/v1/{regions,sizes,gpu-sizes}` heads, which `next.config`
* rewrites to the visor `/vm` proxy (`app/vm/[...path]/route.ts` → visor). The GPU
* accelerator catalog is `/v1/gpu-sizes` — a DISTINCT head, because the bare
* `/v1/gpus` is the GPU INVENTORY surface (cloud-api, a different shape).
*
* This is deliberately NOT the `/paas` platform control plane (a god-mode SERVICE
* token, admin-only). Compute is a TENANT action: any signed-in org user may list +
@@ -21,10 +22,8 @@
* machines yet" state, and a not-routed/unavailable upstream degrades to a
* customer-appropriate "managed compute" state — NOT an infra error.
*/
import { restGet, restPost, restDelete, ApiError, cloudProxyV1Url } from './client'
import { restGet, restPost, restDelete, ApiError, originV1Url } from './client'
/** Public compute CATALOG (regions / sizes / GPU accelerators) — visor `/vm` proxy. */
const vm = (path: string): string => `/vm/v1/${path.replace(/^\/+/, '')}`
const enc = encodeURIComponent
/** One customer machine as visor reports it. Missing fields render `—`. */
@@ -233,25 +232,25 @@ function unwrapEnvelope(r: unknown): Record<string, unknown> {
export const VisorApi = {
/** The signed-in org's own machines (`GET /v1/machines`, org-scoped by the Bearer owner). */
machines: async (): Promise<VisorMachine[]> => {
const r = await restGet<unknown>(cloudProxyV1Url('machines'))
const r = await restGet<unknown>(originV1Url('machines'))
return arrayUnder(r, ['machines', 'instances', 'data', 'items', 'rows', 'droplets']).map((m, i) => normalizeMachine(m, i))
},
/** The real region catalog (`GET /v1/regions`). */
regions: async (): Promise<VisorRegion[]> => {
const r = await restGet<unknown>(vm('regions'))
const r = await restGet<unknown>(originV1Url('regions'))
return arrayUnder(r, ['regions', 'data', 'items', 'rows']).map(normalizeRegion)
},
/** The real standard (CPU) size catalog with pricing (`GET /v1/sizes`). */
sizes: async (): Promise<VisorSize[]> => {
const r = await restGet<unknown>(vm('sizes'))
const r = await restGet<unknown>(originV1Url('sizes'))
return arrayUnder(r, ['sizes', 'data', 'items', 'rows']).map(normalizeSize)
},
/** The real GPU accelerator catalog with pricing (`GET /v1/gpus`). */
/** The real GPU accelerator catalog with pricing (`GET /v1/gpu-sizes` -> visor). */
gpus: async (): Promise<VisorGpuSize[]> => {
const r = await restGet<unknown>(vm('gpus'))
const r = await restGet<unknown>(originV1Url('gpu-sizes'))
return arrayUnder(r, ['gpus', 'data', 'items', 'rows']).map(normalizeGpuSize)
},
@@ -261,7 +260,7 @@ export const VisorApi = {
* SAME figure the real launch charges and the catalog shows (one pricing source).
*/
quote: async (input: LaunchInput): Promise<LaunchQuote> => {
const r = await restPost<unknown>(cloudProxyV1Url('machines/launch'), {
const r = await restPost<unknown>(originV1Url('machines/launch'), {
size: input.size, instanceType: input.size, region: input.region, name: input.name || 'quote', dryRun: true,
})
const d = unwrapEnvelope(r)
@@ -280,7 +279,7 @@ export const VisorApi = {
* new machine is billed to the org's Hanzo balance; a 402 (or "insufficient balance")
* surfaces to the caller as an honest "add credits" — never a fabricated success. */
launch: async (input: LaunchInput): Promise<VisorMachine> => {
const r = await restPost<unknown>(cloudProxyV1Url('machines/launch'), {
const r = await restPost<unknown>(originV1Url('machines/launch'), {
size: input.size, instanceType: input.size, region: input.region, name: input.name, dryRun: false,
})
return normalizeMachine(unwrapEnvelope(r))
@@ -288,7 +287,7 @@ export const VisorApi = {
/** Terminate (destroy) a machine (`DELETE /v1/machines/:id`). Stops metering; the
* backend authorizes the delete against the Bearer owner's org. Resolves on 2xx/204. */
terminate: (id: string): Promise<void> => restDelete(cloudProxyV1Url(`machines/${enc(id)}`)),
terminate: (id: string): Promise<void> => restDelete(originV1Url(`machines/${enc(id)}`)),
}
// ── Formatters (cells) ───────────────────────────────────────────────────────
+12 -4
View File
@@ -20,6 +20,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
import { AccountApi, type Account } from '~/lib/api'
import { getProviderSigninUrl, getSigninUrl, stashReturnTo } from './iam'
import { refreshSession } from './refresh'
import { setCurrentActor } from '~/lib/actor-scope'
type SessionState = {
account: Account | null
@@ -48,6 +49,13 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadRef = useRef<() => void>(() => {})
// Set the account AND keep the synchronous actor id (read by the API client's
// baseHeaders) in lockstep — one source of auth truth for org (org-scope) + user.
const applyAccount = useCallback((a: Account | null) => {
setAccount(a)
setCurrentActor(a && a.owner && a.name ? `${a.owner}/${a.name}` : '')
}, [])
/** (Re)arm the proactive refresh timer for the given remaining lifetime. */
const armRefresh = useCallback((expiresIn: number | null) => {
if (timerRef.current) {
@@ -68,7 +76,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
setLoading(true)
try {
const { account: acct, expiresIn } = await AccountApi.session()
setAccount(acct)
applyAccount(acct)
armRefresh(acct ? expiresIn : null)
} finally {
setLoading(false)
@@ -100,14 +108,14 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const completeSignIn = useCallback(async (code: string, state: string) => {
const res = await AccountApi.signin(code, state)
setAccount(res.data ?? (await AccountApi.current()))
applyAccount(res.data ?? (await AccountApi.current()))
}, [])
const establishConsoleSession = useCallback(
async (username: string, password: string) => {
const r = await AccountApi.establishSession(username, password)
if (r?.account) {
setAccount(r.account)
applyAccount(r.account)
armRefresh(r.expiresIn)
}
},
@@ -120,7 +128,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
timerRef.current = null
}
await AccountApi.signout()
setAccount(null)
applyAccount(null)
// Redirect DETERMINISTICALLY to /signin. AuthGate's reactive redirect (on
// account → null) can be pre-empted by an in-flight session read re-hydrating
// the account, leaving the user stranded on `/` even though the server session