Files
zeekay 35c6961ade apps: /apps is a root of the app, for the apps in beta that draw inside it
The edge answers every path under /apps with its one page (lib/edge.ts `APP`,
`ROOTS`, `DATA`), the page signs a stranger in to the address and draws the app
for a member, and hosted.spec.ts holds who sees what: a member holding beta.crm
alone has CRM and its live host in the pane, staff and a SuperAdmin have all
five, a reader the switch is off for has the request path, a stranger is sent to
sign in and back, a phone has the apps as rows.

Needs @hanzo/app >= 0.2.38 (lib/hosted).

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:29:13 -07:00

242 lines
11 KiB
TypeScript

/**
* The edge in front of the export, as data.
*
* hanzo.ai is a static export behind the ingress staticFiles plane, which has
* no redirects of its own. These are the rules hanzoai/universe declares in
* front of it (charts/app/values/hanzo/static-sites.yaml, between the markers
* `scripts/edge.mjs` writes), and the gates run the same rules against the
* export. Regexes are Go RE2 and match the whole URL, query included.
*
* Every address has one form: https, no www, no trailing slash, no `.html`,
* and a retired address answers with a 301 to its page in one hop, query kept.
* `//x` never reaches a rule: the ingress cleans the path before routing.
* Build-time only.
*/
import { redirects as retired } from './moved.ts'
import { HOSTED, SHELVES } from './publish.ts'
export const SITE = 'https://hanzo.ai'
/** A rewrite of the whole URL: a 301 when the result differs. */
export interface Rule {
name: string
regex: string
replacement: string
}
const HEAD = '^https?://[^/]+'
/** What a page's address may carry after its path and still be that page. */
const TAIL = '(?:/index\\.html|\\.html|/)?(\\?.*)?$'
/** A path as RE2 matching it literally; a colon also as crawlers encode it. */
const literal = (path: string) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:/g, '(?::|%3[Aa])')
/** Stable, readable middleware names: the target, as a DNS label. */
function label(target: string): string {
const slug = target
.replace(/^https:\/\//, '')
.replace(/^\//, '')
.replace(/[^a-z0-9]+/gi, '-')
.replace(/^-|-$/g, '')
.toLowerCase()
const name = `hanzo-ai-to-${slug || 'root'}`
if (name.length <= 63) return name
let hash = 0
for (const c of target) hash = (hash * 31 + c.charCodeAt(0)) >>> 0
return `${name.slice(0, 54)}-${hash.toString(36).slice(0, 8)}`
}
/** Drops a trailing slash, `.html` and `/index.html`, and the www host with them. */
export const CANONICAL: Rule = {
name: 'hanzo-ai-canonical',
regex: `${HEAD}/([^?]+?)(?:/index\\.html|\\.html|/+)(\\?.*)?$`,
replacement: `${SITE}/\${1}\${2}`,
}
/**
* `/auth/*` addresses carry a sign-in's code or a support session's login hint in
* their query, and a page's own stylesheet and script requests send its address as
* `Referer` before any script on it has run (Next writes them first in the head).
* So no referrer leaves those pages, from the edge, which is the only place that can
* say so before the first request.
*/
export const NOREFERRER = { name: 'hanzo-ai-noreferrer', prefix: '/auth/', headers: { 'Referrer-Policy': 'no-referrer' } } as const
/**
* The sign-in pages, where a person types a credential or presses Join: no page
* may frame them, so no other site can lay its own page over a press. The site's
* own policy (universe `hanzo-ai-csp`) is report-only, so these two are sent,
* enforced, beside it on exactly these pages. `/auth/` has a route of its own
* (`NOREFERRER`), which carries them too.
*/
export const FRAME = {
name: 'hanzo-ai-frame',
pages: ['/login', '/signup', '/forget', '/join'],
headers: { 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "frame-ancestors 'none'" },
} as const
/** Whether the edge sends `FRAME`'s headers with `pathname`: a sign-in page, or anything under /auth/. */
export const framed = (pathname: string): boolean =>
pathname.startsWith(NOREFERRER.prefix) || FRAME.pages.some((page) => pathname === page || pathname.startsWith(`${page}/`))
/** www.hanzo.ai is hanzo.ai. */
export const WWW: Rule = { name: 'hanzo-ai-www', regex: '^https?://www\\.hanzo\\.ai/(.*)$', replacement: `${SITE}/\${1}` }
/**
* Next writes its not-found page as a file (`404.html`, `_not-found.html`), so
* asked for by name it answered 200. This rewrites those names to a path the
* export does not hold, so the plane answers them as it answers every miss: 404,
* with the page.
*/
export const MISSING = { name: 'hanzo-ai-missing', regex: '^/(?:404|_not-found)(?:/index\\.html|\\.html|/)?$', replacement: '/.missing' }
/**
* The app's addresses are paths — `/chat/<id>` a conversation,
* `/dev/run/<id>` a run, `/dev/projects/<org>/<repo>` a project,
* `/studio/c/<id>` a Studio conversation — and the export holds one page for
* each mode, `chat.html`, `dev.html` and `studio.html`, whose app reads the path
* it was opened at. So every path under /dev is answered with Dev's page, every
* one under /studio with Studio's, and every one segment under /chat with
* Chat's but `shared`, a page of its own (RE2 has no lookahead, so the pattern
* spells "any segment but `shared`"). They run after the redirects, so
* `/dev/x/` and `/chat/x/index.html` still reach their one address first.
*/
export const APP = [
{ name: 'hanzo-ai-app', regex: '^/dev/.+$', replacement: '/dev' },
{ name: 'hanzo-ai-studio', regex: '^/studio/.+$', replacement: '/studio' },
{ name: 'hanzo-ai-apps', regex: '^/apps/.+$', replacement: '/apps' },
{
name: 'hanzo-ai-chat',
regex: '^/chat/(?:[^/]{1,5}|[^/]{7,}|[^s/][^/]{5}|s[^h/][^/]{4}|sh[^a/][^/]{3}|sha[^r/][^/]{2}|shar[^e/][^/]|share[^d/])$',
replacement: '/chat',
},
] as const
/**
* The app's roots, which universe routes above the twins (160) through
* hanzo-ai-edge and so through `APP`: `dev-on-hanzo-ai` at 300 for /dev, every
* path under it and /code, `chat-on-hanzo-ai` at 200 for every path that starts
* /chat, and `studio-on-hanzo-ai` at 300 for /studio and every path under it,
* through `STUDIO_EDGE`, the same chain with Studio's policy, and `apps-on-hanzo-ai`
* at 300 for /apps and every path under it: the apps in beta that draw inside the
* app (@hanzo/app lib/hosted), /apps/<id> each, one page for all of them.
*/
export const ROOTS = [
{ route: 'dev-on-hanzo-ai', priority: 300, regex: '^/(?:dev(?:/.*)?|code/?)$' },
{ route: 'chat-on-hanzo-ai', priority: 200, regex: '^/chat' },
{ route: 'studio-on-hanzo-ai', priority: 300, regex: '^/studio(?:/.*)?$' },
{ route: 'apps-on-hanzo-ai', priority: 300, regex: '^/apps(?:/.*)?$' },
] as const
/**
* The chain /studio runs: hanzo-ai-edge with Studio's own policy
* (universe `hanzo-ai-csp-studio`: its engine, its faces, a PDF in a blob frame,
* previews from anywhere) in place of the site's.
*/
export const STUDIO_EDGE = { name: 'hanzo-ai-edge-studio', csp: 'hanzo-ai-csp-studio' } as const
/**
* A root's own files are served as they are: Next's segment data
* (`/chat/__next._tree.txt`, `/dev/__next.<segment>.txt`) and a page's twin
* (`/chat/shared.txt`). Under the roots' routes `APP` would answer each with the
* page instead, so the client router read HTML where it asked for its data. Its
* route stands above both, with the twins' middlewares.
*/
export const DATA = { name: 'hanzo-ai-data', priority: 310, regex: '^/(?:chat|dev|studio|apps)/.*\\.(?:md|txt)$' } as const
/**
* The retired addresses, as few rules as keep one hop each.
*
* A target that is part of its own source, as its start (`/models/openai/gpt-4`
* to `/models/openai`, `/x:free` to `/x`) or its end (`/products/async/mq` to
* `/mq`), is captured from the URL itself, so all of those share one rule. Any
* other target gets a rule of its own.
*/
export function moved(redirects: Readonly<Record<string, string>> = retired()): Rule[] {
const heads = new Map<string, string[]>()
const tails = new Map<string, string[]>()
const fixed = new Map<string, string[]>()
const add = (m: Map<string, string[]>, k: string, v: string) => m.set(k, [...(m.get(k) ?? []), v])
for (const [from, to] of Object.entries(redirects).sort(([a], [b]) => (a < b ? -1 : 1))) {
const inside = to.startsWith('/') && to !== '/'
if (inside && from.startsWith(to) && /^[/:]/.test(from.slice(to.length))) add(heads, to, from.slice(to.length))
else if (inside && from.endsWith(to)) add(tails, to, from.slice(0, -to.length))
else add(fixed, to, from)
}
const arms = [
...[...heads].map(([to, rests]) => `(${literal(to)})(?:${rests.map(literal).join('|')})`),
...[...tails].map(([to, heads]) => `(?:${heads.map(literal).join('|')})(${literal(to)})`),
]
const rules: Rule[] = []
if (arms.length) {
const groups = arms.map((_, i) => `\${${i + 1}}`).join('')
rules.push({
name: 'hanzo-ai-to-within',
regex: `${HEAD}(?:${arms.join('|')})${TAIL}`,
replacement: `${SITE}${groups}\${${arms.length + 1}}`,
})
}
for (const [to, froms] of [...fixed].sort(([a], [b]) => (a < b ? -1 : 1))) {
rules.push({
name: label(to),
regex: `${HEAD}(?:${froms.map(literal).join('|')})${TAIL}`,
replacement: `${to.startsWith('/') ? SITE + to : to}\${1}`,
})
}
return rules
}
/** One rule per shelf: the same path under its new home. */
export function shelves(): Rule[] {
return SHELVES.map(({ from, to }) => ({
name: `hanzo-ai-shelf-${from.replace(/[^a-z0-9]+/g, '')}`,
regex: `${HEAD}${literal(from)}([^?]*?)${TAIL}`,
replacement: `${to}\${1}\${2}`,
}))
}
/** The request path a shelf's route leaves to the site: its kept pages and their files. */
export function kept(from: string, keep: readonly string[]): string {
return `^${literal(from)}(?:${keep.map((k) => literal(k.slice(from.length))).join('|')})(?:[./].*)?$`
}
/** Apply one rule to a URL the way the ingress does: Go's ReplaceAllString. */
export function apply(rule: Rule, url: string): string | null {
const re = new RegExp(rule.regex)
const m = re.exec(url)
if (!m) return null
return rule.replacement.replace(/\$\{(\d+)\}/g, (_, i) => m[Number(i)] ?? '')
}
/**
* What the edge answers for a URL on hanzo.ai or www: a Location to 301 to, or
* the path the site is asked for. It follows the routes universe declares.
*/
export function answer(url: string): { location: string } | { path: string } {
const u = new URL(url)
const pass = { path: u.pathname }
// A page another host owns forwards there by its own route (universe
// `app-on-hanzo-ai`), above every rule here.
if (Object.hasOwn(HOSTED, u.pathname)) return { location: new URL(HOSTED[u.pathname]).href }
if (u.searchParams.get('go-get') === '1') return pass
const hop = (rules: Rule[]) => {
for (const rule of rules) {
const to = apply(rule, url)
if (to !== null && to !== url) return { location: to }
}
return null
}
// The routes by priority: a root's own files (DATA), then the roots, whose
// chain is hanzo-ai-edge, then the twins below them.
if (new RegExp(DATA.regex).test(u.pathname)) return hop([WWW]) ?? pass
const root = ROOTS.some((r) => new RegExp(r.regex).test(u.pathname))
if (!root && /\.(md|txt)$/.test(u.pathname)) return hop([WWW]) ?? pass
const s = SHELVES.find(({ from }) => u.pathname.startsWith(from))
if (s && !(s.keep.length && new RegExp(kept(s.from, s.keep)).test(u.pathname))) {
return hop([...moved(), ...shelves()]) ?? pass
}
if (new RegExp(MISSING.regex).test(u.pathname)) return { path: MISSING.replacement }
const page = APP.find((r) => new RegExp(r.regex).test(u.pathname))
return hop([...moved(), CANONICAL, WWW]) ?? (page ? { path: page.replacement } : pass)
}