Merge main: a devnet identity host's policy and stream go to its own gateway

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-10-16 11:33:11 -07:00
co-authored by Hanzo Dev
10 changed files with 169 additions and 20 deletions
+1 -1
View File
@@ -1 +1 @@
{"iamTenantConfigJson":"{\n \"hanzo.id\": {\"orgId\":\"hanzo\",\"canonical\":true,\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"iam.hanzo.ai\": {\"orgId\":\"hanzo\",\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"iamUrl\":\"https://iam.hanzo.ai\",\"publicOrigin\":\"https://iam.hanzo.ai\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"auth.hanzo.ai\": {\"orgId\":\"hanzo\",\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"iamUrl\":\"https://auth.hanzo.ai\",\"publicOrigin\":\"https://auth.hanzo.ai\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"lux.id\": {\"orgId\":\"lux\", \"canonical\":true,\"payUrl\":\"https://pay.lux.cloud\",\"clientId\":\"lux-cloud\", \"appName\":\"lux-cloud\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@luxfi/[email protected]/brand.json\"},\n \"id.lux.network\": {\"orgId\":\"lux\", \"payUrl\":\"https://pay.lux.cloud\",\"clientId\":\"lux-cloud\", \"appName\":\"lux-cloud\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@luxfi/[email protected]/brand.json\"},\n \"zoolabs.id\": {\"orgId\":\"zoo\", \"canonical\":true,\"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"www.zoolabs.id\": {\"orgId\":\"zoo\", \"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"id.zoo.network\": {\"orgId\":\"zoo\", \"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"pars.id\": {\"orgId\":\"pars\", \"canonical\":true,\"clientId\":\"pars-console\", \"appName\":\"pars-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@parsdao/[email protected]/brand.json\"},\n \"id.pars.network\": {\"orgId\":\"pars\", \"clientId\":\"pars-console\", \"appName\":\"pars-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@parsdao/[email protected]/brand.json\"},\n \"osage.id\": {\"orgId\":\"osage\",\"canonical\":true,\"clientId\":\"osage-id-portal\",\"appName\":\"osage-id\"},\n \"www.osage.id\": {\"orgId\":\"osage\",\"clientId\":\"osage-id-portal\",\"appName\":\"osage-id\"},\n \"id.bootno.de\": {\"orgId\":\"bootnode\",\"canonical\":true,\"clientId\":\"bootnode-platform\",\"appName\":\"bootnode-platform\"},\n \"id.adnexus.dev\": {\"orgId\":\"adnexus\",\"canonical\":true,\"clientId\":\"adnexus-app\",\"appName\":\"adnexus-app\"}\n}\n","ingestKeyring":"{\n \"hanzo\": \"pk-rM_CdaF2MQckGCrla113SrR1oH4zvqN8xh2I95Z9tY8\",\n \"lux\": \"pk-gUZp6ZVfhJzSwK-rb4oLbVkpCnMBx5uSCpxf_5yEhQk\",\n \"zoo\": \"pk-3TKpKnERV9AQSsBUERWkZejC1O1mUxc1jRzsP3MPbs4\"\n}\n","v":1}
{"iamTenantConfigJson":"{\n \"hanzo.id\": {\"orgId\":\"hanzo\",\"canonical\":true,\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"iam.hanzo.ai\": {\"orgId\":\"hanzo\",\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"iamUrl\":\"https://iam.hanzo.ai\",\"publicOrigin\":\"https://iam.hanzo.ai\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"auth.hanzo.ai\": {\"orgId\":\"hanzo\",\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"iamUrl\":\"https://auth.hanzo.ai\",\"publicOrigin\":\"https://auth.hanzo.ai\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"id-dev.hanzo.ai\": {\"orgId\":\"hanzo\",\"network\":\"devnet\",\"clientId\":\"hanzo-console\",\"appName\":\"hanzo-console\",\"iamUrl\":\"https://id-dev.hanzo.ai\",\"publicOrigin\":\"https://id-dev.hanzo.ai\",\"appUrl\":\"https://app-dev.hanzo.ai\",\"apiUrl\":\"https://api-dev.hanzo.ai\",\"payUrl\":\"https://app-dev.hanzo.ai/pay\",\"brandUrl\":\"https://cdn.jsdelivr.net/npm/@hanzo/[email protected]/brand.json\"},\n \"lux.id\": {\"orgId\":\"lux\", \"canonical\":true,\"payUrl\":\"https://pay.lux.cloud\",\"clientId\":\"lux-cloud\", \"appName\":\"lux-cloud\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@luxfi/[email protected]/brand.json\"},\n \"id.lux.network\": {\"orgId\":\"lux\", \"payUrl\":\"https://pay.lux.cloud\",\"clientId\":\"lux-cloud\", \"appName\":\"lux-cloud\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@luxfi/[email protected]/brand.json\"},\n \"zoolabs.id\": {\"orgId\":\"zoo\", \"canonical\":true,\"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"www.zoolabs.id\": {\"orgId\":\"zoo\", \"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"id.zoo.network\": {\"orgId\":\"zoo\", \"payUrl\":\"https://pay.zoo.cloud\",\"clientId\":\"zoo-console\", \"appName\":\"zoo-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@zooai/[email protected]/brand.json\"},\n \"pars.id\": {\"orgId\":\"pars\", \"canonical\":true,\"clientId\":\"pars-console\", \"appName\":\"pars-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@parsdao/[email protected]/brand.json\"},\n \"id.pars.network\": {\"orgId\":\"pars\", \"clientId\":\"pars-console\", \"appName\":\"pars-console\", \"brandUrl\":\"https://cdn.jsdelivr.net/npm/@parsdao/[email protected]/brand.json\"},\n \"osage.id\": {\"orgId\":\"osage\",\"canonical\":true,\"clientId\":\"osage-id-portal\",\"appName\":\"osage-id\"},\n \"www.osage.id\": {\"orgId\":\"osage\",\"clientId\":\"osage-id-portal\",\"appName\":\"osage-id\"},\n \"id.bootno.de\": {\"orgId\":\"bootnode\",\"canonical\":true,\"clientId\":\"bootnode-platform\",\"appName\":\"bootnode-platform\"},\n \"id.adnexus.dev\": {\"orgId\":\"adnexus\",\"canonical\":true,\"clientId\":\"adnexus-app\",\"appName\":\"adnexus-app\"}\n}\n","ingestKeyring":"{\n \"hanzo\": \"pk-rM_CdaF2MQckGCrla113SrR1oH4zvqN8xh2I95Z9tY8\",\n \"lux\": \"pk-gUZp6ZVfhJzSwK-rb4oLbVkpCnMBx5uSCpxf_5yEhQk\",\n \"zoo\": \"pk-3TKpKnERV9AQSsBUERWkZejC1O1mUxc1jRzsP3MPbs4\"\n}\n","v":1}
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Org } from '@hanzo/id-shared'
import { Empty, Section } from './ui'
import { appsFor, billingFor } from '../marketing'
import { appsOf, billingOf } from '../marketing'
/**
* Everything this account opens, and where the money is.
@@ -13,8 +13,8 @@ import { appsFor, billingFor } from '../marketing'
* it actually is.
*/
export function Apps({ org }: { org: Org }) {
const apps = appsFor(org.orgId)
const billing = billingFor(org.orgId) ?? org.payUrl
const apps = appsOf(org)
const billing = billingOf(org) ?? org.payUrl
return (
<>
+8 -4
View File
@@ -34,6 +34,7 @@ import { CONSENT_EVENT, createAnalytics, readConsent, serveConsent, type Transpo
import { AnalyticsProvider as EventProvider, useConsent, usePageview } from '@hanzo/event/react'
import { ingestKey, loadRuntime, resolveOrg } from '@hanzo/id-shared'
/** The gateway telemetry goes to when the host's catalog entry names none (`apiUrl`). */
const HOST = 'https://api.hanzo.ai'
const PRODUCT = 'id'
@@ -228,9 +229,9 @@ export async function brandKey(host: string): Promise<string | undefined> {
* from an older policy, or a refusal cloud saw on the request, is undone here
* before anything is stored or sent, and the banner asks again.
*/
async function served(key: string, host: string): Promise<boolean> {
async function served(key: string, host: string, api = HOST): Promise<boolean> {
try {
const res = await fetch(`${HOST}/v1/project/tags?${new URLSearchParams({ key, host })}`, { credentials: 'omit' })
const res = await fetch(`${api}/v1/project/tags?${new URLSearchParams({ key, host })}`, { credentials: 'omit' })
if (!res.ok) return false
const body = (await res.json()) as { consent?: unknown }
if (body.consent == null) return false
@@ -283,8 +284,11 @@ export function Analytics({ children }: { children: ReactNode }) {
void (async () => {
const key = await brandKey(host)
if (cancelled || !key) return
if (!(await served(key, host)) || cancelled || !readConsent().analytics) return
setClient(createAnalytics({ product: PRODUCT, host: HOST, ingestKey: key, enabled: true, transport }))
// The host's own gateway when its catalog entry names one (a devnet host's),
// for the policy and the stream both.
const api = resolveOrg(host, { catalog: (await loadRuntime()).catalog }).apiUrl || HOST
if (!(await served(key, host, api)) || cancelled || !readConsent().analytics) return
setClient(createAnalytics({ product: PRODUCT, host: api, ingestKey: key, enabled: true, transport }))
setLive(true)
})()
return () => {
+21
View File
@@ -34,3 +34,24 @@ test('the app launcher is unaffected', () => {
assert.ok(appsFor(brand).length > 0)
}
})
// A host on another network (the devnet's id-dev.hanzo.ai) names its own app in
// the catalog, and every link the portal draws for it stays on that network.
test('a host that names its network\'s app links only there', async () => {
const { appsOf, billingOf, teamOf } = await import('./marketing')
const devnet = {
orgId: 'hanzo',
network: 'devnet',
appUrl: 'https://app-dev.hanzo.ai',
payUrl: 'https://app-dev.hanzo.ai/pay',
} as Parameters<typeof appsOf>[0]
assert.deepEqual(appsOf(devnet).map((a) => a.href), ['https://app-dev.hanzo.ai'])
assert.equal(billingOf(devnet), 'https://app-dev.hanzo.ai/pay')
assert.equal(teamOf(devnet), 'https://app-dev.hanzo.ai/account/organization')
// Mainnet's hanzo answers from the brand tables, unchanged.
const mainnet = { orgId: 'hanzo' } as Parameters<typeof appsOf>[0]
assert.deepEqual(appsOf(mainnet), appsFor('hanzo'))
assert.equal(billingOf(mainnet), billingFor('hanzo'))
assert.equal(teamOf(mainnet), 'https://hanzo.ai/account/organization')
})
+27
View File
@@ -9,6 +9,8 @@
* hostname switches; unknown orgs fall back to `hanzo`.
*/
import type { Org } from '@hanzo/id-shared'
export interface Quote {
readonly text: string
readonly author: string
@@ -145,3 +147,28 @@ const TEAM: Record<string, string> = {
export function teamFor(orgId: string): string | undefined {
return TEAM[orgId]
}
/**
* A host whose catalog entry names its network's app (`appUrl`: the devnet's
* https://app-dev.hanzo.ai) is answered from that entry, never from the brand's
* mainnet tables above: its one app, its own pay surface (`payUrl`), and that
* app's organization page. Every other host answers from the tables exactly as
* before, so no mainnet host changes.
*/
export function appsOf(org: Org): readonly AppLink[] {
const listed = appsFor(org.orgId)
if (org.appUrl) return [{ ...listed[0]!, href: org.appUrl }]
return listed
}
/** The billing surface for this host: its network's pay surface, or the brand's billing host. */
export function billingOf(org: Org): string | undefined {
if (org.appUrl) return org.payUrl
return billingFor(org.orgId)
}
/** The organization page for this host: on its network's app, or the brand's. */
export function teamOf(org: Org): string | undefined {
if (org.appUrl) return `${org.appUrl}/account/organization`
return teamFor(org.orgId)
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useId, useState, type FormEvent } from 'react'
import type { Brand, Org } from '@hanzo/id-shared'
import { Alert, SignupForm, Submit, type Account, type AuthClient } from '@hanzo/id-auth'
import { BrandFooter } from '../components/BrandFooter'
import { teamFor } from '../marketing'
import { teamOf } from '../marketing'
import { clientIdFrom } from '../route'
/**
@@ -72,7 +72,7 @@ export function Join({ client, brand, org }: { client: AuthClient; brand: Brand;
const here = `/join?${new URLSearchParams({ ...app, invite: code, org: owner })}`
const signin = `/login?${new URLSearchParams({ ...app, return: here })}`
// Onward, to where this brand manages an organization; its own portal otherwise.
const onward = teamFor(org.orgId) ?? '/'
const onward = teamOf(org) ?? '/'
// One call for every step. Without `withCode` it asks to join, and for a pinned
// invitation that is also how a code is sent: IAM sends one to the account's
+4 -4
View File
@@ -6,7 +6,7 @@ import type { Brand, Org } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { Login } from './Login'
import { BrandFooter } from '../components/BrandFooter'
import { appsFor, billingFor } from '../marketing'
import { appsOf, billingOf } from '../marketing'
type Auth =
| { s: 'loading' }
@@ -36,7 +36,7 @@ export function Portal({
// step; the launcher below renders only for a brand that names no app.
// A sign-in bounced from /onboarding (its token_bounce guard is set) goes
// back there to finish the plan step.
const home = appsFor(org.orgId)[0]?.href
const home = appsOf(org)[0]?.href
if ((account || justSignedIn) && home) {
window.location.replace(sessionStorage.getItem('onboarding.token_bounce') ? '/onboarding' : home)
return
@@ -86,10 +86,10 @@ export function Portal({
if (auth.s === 'anon') return <Login client={client} brand={brand} />
// Signed in: the apps launcher.
const apps = appsFor(org.orgId)
const apps = appsOf(org)
// undefined for a brand with no billing host — UserMenu renders the row only when
// it has a URL, so the tile disappears rather than pointing at a dead hostname.
const billingUrl = billingFor(org.orgId)
const billingUrl = billingOf(org)
// signOut, not logout: the latter only builds the IdP URL and leaves this
// browser's `hanzo_iam_*` keys in place, so the token string outlived the
// session it named. Called on click, not at render — it clears storage.
+62
View File
@@ -320,3 +320,65 @@ test('a brand that declares no legal pages resolves none', () => {
assert.equal(org.termsUrl, undefined)
assert.equal(org.privacyUrl, undefined)
})
// The devnet's identity host, id-dev.hanzo.ai, is org `hanzo` on another
// network: its own IAM and its own API. An alias of org hanzo on mainnet goes
// to hanzo.id, and a devnet host must never go there, so front doors are read
// per network.
test('a devnet host is its own network: never sent to mainnet\'s front door', () => {
const catalog = parseCatalog(
JSON.stringify({
'hanzo.id': { orgId: 'hanzo', canonical: true },
'iam.hanzo.ai': { orgId: 'hanzo' },
'id-dev.hanzo.ai': { orgId: 'hanzo', network: 'devnet' },
}),
)
assert.equal(frontDoor(catalog, 'hanzo'), 'https://hanzo.id')
assert.equal(frontDoor(catalog, 'hanzo', 'devnet'), '', 'the devnet names no front door, so its host stays put')
assert.equal(aliasRedirect(catalog, 'hanzo', 'https://id-dev.hanzo.ai', '/login/oauth/authorize', '?client_id=hanzo-console'), null)
assert.equal(aliasRedirect(catalog, 'hanzo', 'https://id-dev.hanzo.ai', '/', ''), null)
// Mainnet's alias is unchanged.
assert.equal(aliasRedirect(catalog, 'hanzo', 'https://iam.hanzo.ai', '/login', '?a=1'), 'https://hanzo.id/login?a=1')
// A devnet front door is the devnet's, and mainnet's aliases never reach it.
const both = parseCatalog(
JSON.stringify({
'hanzo.id': { orgId: 'hanzo', canonical: true },
'id-dev.hanzo.ai': { orgId: 'hanzo', network: 'devnet', canonical: true },
'alias-dev.hanzo.ai': { orgId: 'hanzo', network: 'devnet' },
}),
)
assert.equal(aliasRedirect(both, 'hanzo', 'https://alias-dev.hanzo.ai', '/x', ''), 'https://id-dev.hanzo.ai/x')
assert.equal(aliasRedirect(both, 'hanzo', 'https://id-dev.hanzo.ai', '/x', ''), null)
assert.equal(frontDoor(both, 'hanzo'), 'https://hanzo.id')
})
// The catalog this export ships (apps/web/public/config.json) is what id-dev
// reads. Asserted through the reader: every URL it resolves to is the devnet's
// own, and nothing of mainnet's (hanzo.id, api.hanzo.ai, the apex) is named.
test('the shipped catalog keeps id-dev on the devnet', async () => {
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const payload = JSON.parse(await readFile(join(import.meta.dirname, '../../../apps/web/public/config.json'), 'utf8'))
const catalog = parseCatalog(catalogOf(payload))
const org = resolveOrg('id-dev.hanzo.ai', { catalog })
assert.equal(org.orgId, 'hanzo')
assert.equal(org.network, 'devnet')
assert.equal(org.iamUrl, 'https://id-dev.hanzo.ai')
assert.equal(org.iamIssuer, 'https://id-dev.hanzo.ai')
assert.equal(org.publicOrigin, 'https://id-dev.hanzo.ai')
assert.equal(org.appUrl, 'https://app-dev.hanzo.ai')
assert.equal(org.apiUrl, 'https://api-dev.hanzo.ai')
assert.equal(org.payUrl, 'https://app-dev.hanzo.ai/pay')
assert.equal(org.brandPackage, '@hanzo/brand')
for (const url of [org.iamUrl, org.iamIssuer, org.publicOrigin, org.appUrl, org.apiUrl, org.payUrl]) {
assert.match(new URL(url!).hostname, /^[a-z0-9]+-dev\.hanzo\.ai$/, `${url} is a devnet host`)
}
assert.equal(aliasRedirect(catalog, org.orgId, 'https://id-dev.hanzo.ai', '/login', ''), null)
// Mainnet's hosts resolve as they did.
assert.equal(resolveOrg('hanzo.id', { catalog }).iamIssuer, 'https://hanzo.id')
assert.equal(resolveOrg('hanzo.id', { catalog }).network, undefined)
assert.equal(aliasRedirect(catalog, 'hanzo', 'https://iam.hanzo.ai', '/login', ''), 'https://hanzo.id/login')
})
+19 -3
View File
@@ -197,6 +197,9 @@ function fromCatalog(entry: CatalogEntry | undefined): Partial<Org> {
'publicOrigin',
'brandPackage',
'payUrl',
'network',
'appUrl',
'apiUrl',
'termsUrl',
'privacyUrl',
] as const) {
@@ -243,10 +246,10 @@ function stripPort(h: string): string {
* Returns '' when the org names no front door — local dev, an unregistered host,
* or `bootnode`, whose only host IS its front door. '' means "stay here".
*/
export function frontDoor(catalog: Catalog, orgId: string): string {
export function frontDoor(catalog: Catalog, orgId: string, network = ''): string {
if (!orgId) return ''
for (const [host, e] of Object.entries(catalog)) {
if (e?.canonical && e.orgId === orgId) return `https://${stripPort(host)}`
if (e?.canonical && e.orgId === orgId && (e.network ?? '') === network) return `https://${stripPort(host)}`
}
return ''
}
@@ -273,11 +276,24 @@ export function aliasRedirect(
search: string,
): string | null {
if (pathname === '/callback' || pathname.startsWith('/callback/')) return null
const door = frontDoor(catalog, orgId)
// The front door is the one on THIS host's network: a devnet host is never
// sent to a mainnet one, and an org with none on its network stays put.
const door = frontDoor(catalog, orgId, networkOf(catalog, origin))
if (!door || door === TRIM_TRAILING_SLASH(origin)) return null
return `${door}${pathname}${search}`
}
/** The network the catalog states for the host of `origin`; '' is mainnet. */
function networkOf(catalog: Catalog, origin: string): string {
let host = ''
try {
host = new URL(origin).hostname.toLowerCase()
} catch {
return ''
}
return catalog[host]?.network ?? ''
}
function normalize(t: Org): Org {
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
const iamIssuer = TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl)
+22 -3
View File
@@ -40,9 +40,10 @@ export interface Org {
/**
* This host is the org's front door — the one its people sign in on.
*
* At most one entry per org sets it. Every other host for that org is an
* alias and redirects here, carrying its path and query (see `aliasRedirect`).
* An org that sets it nowhere has no front door and every host stays put,
* At most one entry per org per `network` sets it. Every other host for
* that org on that network is an alias and redirects here, carrying its path
* and query (see `aliasRedirect`). An org that sets it nowhere on a network
* has no front door there, and every host on it stays put,
* which is the right answer for a single-host brand and for local dev.
*
* It is a fact about the ORG stated in a table keyed by HOST, which is why it
@@ -51,6 +52,24 @@ export interface Org {
* thing to keep in step.
*/
readonly canonical?: boolean
/**
* The network this host serves: absent is mainnet, `devnet` the dev network
* (its own IAM, its own API, sandbox money). An org has a front door per
* network, so an alias is sent to its own network's front door and a devnet
* host is never sent to a mainnet one.
*/
readonly network?: string
/**
* The brand's main app on this host's network, where a signed-in person is
* sent and the one app the launcher lists. Absent, the brand's product list
* (`appsFor`) answers. NO trailing slash.
*/
readonly appUrl?: string
/**
* The gateway this host reports its telemetry to. Absent, api.hanzo.ai.
* NO trailing slash.
*/
readonly apiUrl?: string
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
readonly brandPackage: string
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from