A host the brand does not state is a guest-only copy

The bundle is served on more hosts than its IAM clients return to: the Sites
plane publishes it a second time at chat2.hanzo.app, and a fork runs it on its
own domain. There the silent probe and the Log in button both started an
authorize whose redirect_uri the issuer has never registered, and the issuer
answers that with its own error page instead of sending the browser back.

brand.ts now says whether the host is one the brand states (`stated`) and
whether a sign-in may start here at all (`signsIn`: a stated host, the desktop,
or loopback). The probe runs only on a stated host, which also covers the
loopback case it special-cased. On any other host, Log in and Sign up hand the
visitor to the brand's own /login, which signs in and returns there. Stated
hosts behave exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-24 10:18:13 -07:00
co-authored by Claude Opus 5.5
parent d219b53fea
commit 28cebd13e9
3 changed files with 33 additions and 5 deletions
+24
View File
@@ -222,6 +222,30 @@ export const clientId = `${brand.org}-${shell() ? 'app' : brand.app}`
/** Whether the workspace surfaces render: in the desktop, and on a host that serves the whole app. */
export const team = shell() || brand.app === 'app'
/**
* Whether the issuer returns to THIS host.
*
* A brand's client is registered for the hosts the brand states. Anywhere else
* this bundle is served — the Sites plane publishes it a second time at
* chat2.hanzo.app, a fork runs it on its own domain — an authorize carrying this
* origin as its redirect_uri is refused before any sign-in screen is drawn
* (RFC 6749 §4.1.2.1: the issuer must not send the browser back to an address
* it does not trust), so the visitor is left on the issuer's error page. Such a
* host is a guest-only copy: it never probes, and signing in happens on the
* brand's own host.
*/
export const stated = brand.hosts.includes(host)
/**
* Whether a sign-in may start here: a stated host, the desktop (its own client,
* returning to the shell), or loopback, where a developer registers their own
* port with their own issuer.
*/
export const signsIn = stated || shell() || host === 'localhost' || host === '127.0.0.1'
/** The brand's own sign-in, for a host that cannot start one. */
export const home = `https://${brand.hosts[0]}`
/**
* Put the brand on the document.
*
+5 -4
View File
@@ -1,6 +1,7 @@
/**
* Ask the issuer, once per visit, whether this browser is already signed in.
*/
import { stated } from '../brand.ts'
import { keepHere } from './back.ts'
const SLOT = 'chat.probed'
@@ -46,10 +47,10 @@ export const noSession = (error: string | null | undefined): boolean =>
export const probe = async (go: () => Promise<void>): Promise<boolean> => {
if (typeof window === 'undefined') return false
// On localhost, skip the silent redirect probe to prevent OAuth origin mismatch errors.
const isLocal =
window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
if (isLocal) return false
// Only where the brand's client returns: anywhere else — loopback, the
// desktop, a copy on another host — the issuer refuses the redirect_uri and
// the visitor is left on its error page instead of back here as a guest.
if (!stated) return false
const storage = slot()
if (!storage || storage.getItem(SLOT) === '1') return false
+4 -1
View File
@@ -33,8 +33,9 @@ import {
type ReactNode,
} from 'react'
import { home, signsIn } from '../brand.ts'
import { client } from './origin.ts'
import { api } from './api.ts'
import { api, loginPath } from './api.ts'
import { keepHere, take } from './back.ts'
import { guest } from './guest.ts'
import { iam } from './iam.ts'
@@ -162,6 +163,7 @@ export const Session = ({ children }: { children: ReactNode }) => {
const nobody = useMemo(guest, [])
const signIn = useCallback(() => {
if (!signsIn) return visit(`${home}${loginPath}`)
keepHere()
void iam().getSigninUrl().then(visit)
}, [])
@@ -177,6 +179,7 @@ export const Session = ({ children }: { children: ReactNode }) => {
* back, by which time this visit has spent its probe.
*/
const signUp = useCallback(() => {
if (!signsIn) return visit(`${home}${loginPath}`)
keepHere()
void (async () => {
const authorize = new URL(await iam().getSigninUrl())