sites

package
v1.801.490 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package sites is your published site, live on the public web at <slug>.hanzo.app.

It is the host-routed edge that turns `<slug>.hanzo.app` into the static site a user deployed to OUR S3.

It is NOT a /v1 API. It owns the ROOT path space for requests whose Host is a site host (`<slug>.<apex>`, apex default hanzo.app). It is installed as the FIRST middleware in the compose root (serve.go), before identity/billing, so a public site GET never enters the authenticated API pipeline — a site is a public artifact, not a tenant API call.

Tenant isolation is the whole point (this is RED-reviewed). The org and the S3 prefix a request may read come ONLY from the store lookup keyed by the validated subdomain slug — NEVER from the request path, a client header, or the Host beyond the one validated label. One slug ⇒ exactly one `<org>/<slug>/` S3 prefix, hard-bounded, and the object key is rooted-clean so no `..`/encoded traversal can escape that prefix into another project or org. See resolveKey + its exhaustive test.

The resolver (slug → {org,bucket,prefix,status}) is the projects store, injected via SetResolver at mount. sites does NOT import projects (projects imports cloud, cloud imports sites — importing projects here would form a cycle); the store implements the tiny Resolver interface and registers itself.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CacheControlFor

func CacheControlFor(key, htmlOverride string) string

CacheControlFor is the ONE canonical cache policy by asset class, used both when WRITING an object at deploy (projects/blob.go) and when SERVING one here, so a site's TTL is identical on the site-server path and the direct-S3 path.

  • HTML documents → short browser TTL + long shared-cache TTL: a redeploy is seen fast (60s), while the CDN holds it for a day (purged instantly on redeploy by cache-tag). htmlOverride, when non-empty, replaces this for a project (the per-project cacheControl knob); it applies to documents only.
  • content-hashed / immutable assets → cache for a year (a new build changes the hash, so the URL is safe forever). NOT overridable — always correct.
  • everything else → a conservative middle TTL.

func CacheTag

func CacheTag(org, slug string) string

CacheTag is the ONE canonical edge cache-tag for a project's site objects. The site server (streamSite) emits it as the Cache-Tag response header on every served object; the Purger targets it on deploy, domain-bind, delete, and the dedicated POST .../purge. Both derive it from server-owned Org+Slug so the emitted tag and the purged tag never diverge.

func HasFallbackResolver added in v1.801.408

func HasFallbackResolver() bool

currentResolver prefers the in-process store and falls back to the plane. A process that owns the store never pays for a hop; one that does not can still answer, instead of silently serving the API for every customer's site. HasFallbackResolver reports whether a cross-process resolver is installed. It exists so the host can PROVE it wired the edge: the defect this guards was a middleware that ran nowhere, which no behavioural test in this package could have caught, because the package itself was always correct.

func IsReserved

func IsReserved(label string) bool

IsReserved reports whether a subdomain LABEL may NOT be a published site. The comparison is on the lowercased label.

It takes a label, never a hostname: the set it compares against holds bare labels, so a whole FQDN matches nothing. Ours is the predicate for a name whose shape is not known in advance.

func IsSelfHost

func IsSelfHost(host string) bool

IsSelfHost reports whether host is one of OUR registrable domains or anything beneath it. Those names are ours to assign and no DNS proof is even possible for them (a customer cannot publish a TXT record in a zone we run), so they are never claimable. The ONE self-domain predicate; serve and claim both call it.

func Ours added in v1.801.425

func Ours(name string) bool

Ours reports whether name is a name the PLATFORM holds rather than a tenant's to take. It is the ONE predicate the claim gate and the host table both ask, over the two shapes site_hosts actually stores:

a bare label  → the reserved-subdomain policy; it would publish as <label>.<apex>
a hostname    → the self-domain set; at or under a registrable domain we run

One question, two shapes, because the table holds both: a project's own bare slug (deploy.go siteHost) and its custom FQDNs (domains.go). The storage gate used to ask IsReserved with whichever it was given, so a HOSTNAME was compared against a set of bare labels and `login.hanzo.ai` matched nothing — the backstop that is supposed to make the serve-time gate a mere backstop contributed nothing at all for the FQDN half. Splitting on shape is what makes one predicate answer both without the label policy ever reaching a customer's own `www.example.com`.

func SetBaseHostHandler

func SetBaseHostHandler(h func(org string, c *zip.Ctx) error)

SetBaseHostHandler installs the per-org Base handler (see baseHostHandler).

func SetFallbackResolver added in v1.801.408

func SetFallbackResolver(r Resolver)

func SetReservedExtra

func SetReservedExtra(labels []string)

SetReservedExtra registers operator-supplied extra reserved labels (from CLOUD_SITES_RESERVED). It ADDS to baseReserved; it can never remove a baked-in reserved label. Called once at startup by New.

func SetResolver

func SetResolver(r Resolver)

SetResolver installs the slug→Site resolver. projects.Mount calls this once with its store — the no-hop answer when the edge and projects share a process.

func SetSelfDomains

func SetSelfDomains(domains []string)

SetSelfDomains registers the domains we operate, from the same list the serve gate is built with. Called once at startup by New, beside SetReservedExtra.

func VerifiedHost added in v1.801.490

func VerifiedHost(ctx context.Context, host string) (string, bool)

VerifiedHost reports the org that owns host as a VERIFIED public site host.

It is the SAME read the site edge serves from — Resolver.Resolve, which is Store.ResolveHost, which filters `status='verified'` — asked for the one fact a caller outside this package can need about a hostname: whose is it, and has the owner PROVED it. A host with only a pending claim resolves to nothing here, because a pending row holds its name against the PK but never routes; that filter is the hostname-hijack boundary, and asking through this function is what keeps every caller on the right side of it instead of growing a second lookup that could forget the status.

found=false on a miss AND on a resolver error, which is deliberate and is the difference between this and Resolve: the serve path must tell "no such site" (404) from "could not ask" (503), because serving a 404 for a live customer site during a transient failure looks exactly like deletion. A caller asking "is this host proven" has no such distinction to make — an answer we could not obtain is not a proof — so the error collapses into "no", and a caller cannot forget to check a second return.

Types

type Config

type Config struct {
	Apex     string
	Reserved []string
	// SelfDomains are the registrable domains of OUR OWN infrastructure (e.g.
	// hanzo.ai, hanzo.app). A Host at or under any of them is never a customer
	// custom-domain candidate, so the high-traffic api/console path is never
	// subjected to a per-request binding lookup, and a customer binding can never
	// shadow a real Hanzo host. The apex is always treated as self.
	SelfDomains []string
	// FirstPartyApex is a SelfDomain (e.g. hanzo.ai) on which we serve a small set
	// of OUR OWN first-party sites (cd/flow/gallery.hanzo.ai) — the brand's own
	// pages, not customer sites. It uses the OPPOSITE security model to Apex: Apex
	// is multi-tenant, so sites are the DEFAULT and a denylist (Reserved) carves out
	// app hosts; the brand apex carries api/console/iam/kms/… and CANNOT afford a
	// denylist gap (one missing label = a project shadowing a real host → the OAuth
	// account-takeover in reserved.go), so here sites are OPT-IN: ONLY a label in
	// FirstPartySites serves, everything else falls through to the normal pipeline,
	// protected by default. Empty = no first-party sites (the multi-tenant-only
	// default). The apex itself is unaffected (hanzo.app stays the site default).
	FirstPartyApex  string
	FirstPartySites []string
	// FirstPartyOrg is the org that OWNS the first-party sites (hanzo). A first-party
	// host resolves PINNED to this org — never unique-across-orgs — so a customer's
	// same-named project can never be served on our internal apex. Empty ⇒ the
	// first-party sites cannot resolve (fail-closed), so it must be set when
	// FirstPartyApex is.
	FirstPartyOrg string
}

Config configures the site host-router. Apex is the zone whose subdomains are site hosts (hanzo.app). Reserved is the set of subdomain labels that are NOT sites (they belong to real app hosts) and must fall through to the normal pipeline — the reserved-host exclusion that stops a site from shadowing a real hanzo.app app or api.

func ConfigFromEnv added in v1.801.413

func ConfigFromEnv(domain string) Config

The ONE resolution of the site-edge configuration.

Two processes mount this middleware: the light router that owns the public port (cmd/cloud) and cloud.Listen in every per-app child. Each used to resolve Config for itself, from two SPELLINGS of the first-party keys — CLOUD_SITES_FIRSTPARTY_* against CLOUD_SITES_FIRST_PARTY_* — and two sets of defaults, so which policy applied depended on which process a request happened to reach. The edge resolved no first-party apex and therefore no brand self-domain, leaving every hanzo.ai host a custom-domain candidate: the per-request binding lookup that the self-domain exclusion exists to keep off the api/console path ran on api.hanzo.ai itself. The key names and the defaults now live here, in the package that owns the type, and both call sites read this function.

domain is the deployment's primary API host (api.hanzo.ai) — the caller resolves it, because it is the deployment's fact and not this package's. It seeds the self-domain exclusion with its registrable domain (hanzo.ai), so a customer binding can never shadow a real Hanzo host. Empty ⇒ read CLOUD_DOMAIN, and failing that derive api.<brand apex> through the SAME brand.APIHost the root Config uses — this file used to spell the literal "api.hanzo.ai" for itself, which made the deployment's own host a fact stated in two places, brand-blind in both.

type PlaneSite added in v1.801.408

type PlaneSite struct {
	Found                bool   `json:"found"`
	Org                  string `json:"org"`
	Slug                 string `json:"slug"`
	Bucket               string `json:"bucket"`
	Prefix               string `json:"prefix"`
	Status               string `json:"status"`
	CrossOriginIsolation bool   `json:"crossOriginIsolation"`
}

SetFallbackResolver installs the resolver used when projects is NOT in this process. cloud's composition root sets it to a plane-backed client.

It exists because in production they are never in the same process: the pod boots ~25 single-app processes, so the registry above was written inside `projects` and read inside whichever process fronts :8000, where it is nil. A nil registry is a clean miss, not a fault — so every published site resolved as not-found with no error anywhere, fell through to the API pipeline, and <slug>.hanzo.app served the console SPA with the whole cloud API answering on the customer's own hostname. Measured at the pod, ingress bypassed.

The old comment here read "until it is set, every site request is an honest 404 (the projects subsystem is not mounted)". That premise was the bug: projects IS mounted, just somewhere else, and 404 is not honest when the site exists. PlaneSite is the wire shape of a resolved site, exported so a host that has no business importing the root package can still speak this call.

Found is explicit: the edge must tell "no such site" (an honest 404) from "could not ask" (503). Collapsing them serves 404s for live customer sites during any transient failure of the owning app, which is indistinguishable from the site being deleted.

type PlaneSiteIn added in v1.801.408

type PlaneSiteIn struct {
	Slug string `json:"slug"`
	Org  string `json:"org,omitempty"`
}

PlaneSiteIn names the site to resolve. Org is set only on the first-party path, which pins the lookup to one org.

type Purger

type Purger struct {
	// contains filtered or unexported fields
}

Purger purges the Cloudflare edge cache by cache-tag so a redeploy is instantly live at the edge. It is deliberately minimal: one POST to the zone purge API with the tags we stamped onto every site object (Cache-Tag: site-<org>-<slug>).

Credentials come ONLY from the environment the operator injects from KMS (CF_API_TOKEN, CF_ZONE_ID) — never hard-coded, never logged. When either is unset the Purger is a no-op that warns once per call: a missing CF token must degrade to "stale-until-TTL", never fail a deploy.

The Cloudflare account and its purge quota are SHARED BY EVERY TENANT, so an unthrottled purge is a cross-tenant hazard: one org driving purges in a loop would exhaust the quota and silently stop every OTHER org's redeploy from reaching the edge (purge failures are non-fatal by design, so they would serve stale content with no error). Two bounds prevent that, and both live HERE so every caller — deploy, release activation, delete — inherits them:

  • per-tag coalescing: at most two API calls per window per site (one on the leading edge so a normal single publish is instant, one trailing so the LAST change in a burst is always purged). Coalescing is correctness- preserving: purging tag T twice in a burst and purging it once after the burst invalidate exactly the same thing.
  • a process-wide ceiling on actual API calls per minute, which is what ultimately protects the shared quota from a tenant that spreads load across many sites. Exceeding it degrades to stale-until-TTL — the same documented failure mode as an unconfigured token — and says so at Warn.

func NewPurger

func NewPurger(log luxlog.Logger) *Purger

NewPurger reads CF_API_TOKEN + CF_ZONE_ID from the environment. The returned Purger is safe to hold for the process; Configured() reports whether it can actually reach Cloudflare. The coalescing window and the process-wide ceiling are operator knobs with honest defaults.

func (*Purger) AssertHTMLPassthrough

func (p *Purger) AssertHTMLPassthrough(ctx context.Context)

AssertHTMLPassthrough makes the zone deliver our HTML byte-for-byte: it reads each rewriting setting and PATCHes only the ones that drifted. Idempotent, so the startup path calls it unconditionally; it costs one GET per setting and a PATCH only on drift.

It degrades exactly like PurgeTags: unconfigured or unauthorized is a Warn, not a failure — a token that cannot read zone settings must never stop the site server from booting. The Warn names the setting and the value it needs, so an operator can fix it from the log line alone.

func (*Purger) Configured

func (p *Purger) Configured() bool

Configured reports whether both a token and a zone id are present.

func (*Purger) PurgeTags

func (p *Purger) PurgeTags(ctx context.Context, tags ...string) error

PurgeTags purges every listed cache-tag. A no-op (warn-only) when unconfigured, so callers invoke it unconditionally after a publish. A purge failure is returned so the caller can log it, but callers treat it as non-fatal: the new content still becomes visible when the short HTML TTL lapses.

A COALESCED call returns nil: being folded into the burst's trailing purge is not a failure, and the tag will be purged.

func (*Purger) Stop

func (p *Purger) Stop()

Stop cancels every scheduled trailing purge and makes further scheduling a no-op. It is idempotent, and the process shutdown path calls it so a pending timer can never outlive the subsystem that created it.

type Resolver

type Resolver interface {
	Resolve(ctx context.Context, slug string) (Site, bool, error)
	// ResolveOrg resolves a slug PINNED to org — the first-party-host path
	// (cd.hanzo.ai → org "hanzo"). It must NEVER fall back to unique-across-orgs, so
	// an internal host can only ever be served by OUR own project, never shadowed by
	// a customer who happens to name a project the same.
	ResolveOrg(ctx context.Context, org, slug string) (Site, bool, error)
}

Resolver maps a validated subdomain slug to its authoritative Site. It is the projects store (the ONE source of project truth). found=false ⇒ no such published subdomain (honest 404); err ⇒ a real store failure (honest 500).

func CurrentResolver added in v1.801.490

func CurrentResolver() Resolver

CurrentResolver is the resolver in force for this process — the in-process store when projects is co-resident, else the cross-process fallback. nil means nothing can answer "which release does this site serve", which is a wiring fault, not a miss.

It is exported because the site edge is no longer the only reader: the CONSOLE is a published site too (webui/release), and it must read the active-release pointer through this ONE registry. A second lookup path would be a second answer to "where do a site's bytes live", and the two would drift the first time one of them learned something — which is the defect SetFallbackResolver was added to close, one layer down.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the host-routed public site edge. It holds the S3 access path (s3admin, the SAME credentials as the deploy blob store) plus the apex. The reserved-label policy is the package-level shared source (reserved.go), so serve/create/bind never disagree. It reads the resolver at request time.

func New

func New(cfg Config, log luxlog.Logger) *Server

New builds the Server from Config. An empty apex defaults to hanzo.app. Operator-supplied Reserved labels are registered into the shared reserved source (ADDING to the baked-in defaults, never removing them), so createProject and BindHost enforce the exact same set the serve gate does.

func (*Server) Middleware

func (s *Server) Middleware() zip.Handler

type Site

type Site struct {
	Org    string
	Slug   string
	Bucket string
	Prefix string
	Status string
	// CrossOriginIsolation, when true, makes the site server emit the cross-origin
	// isolation headers (COOP/COEP on documents, CORP on assets) so a multithreaded
	// Unity/Unreal/Godot WebGL build can use SharedArrayBuffer. It is OPT-IN per
	// site — isolation blocks embedding third-party cross-origin content, so it is
	// NEVER global. Server-owned like every other field: the resolver sets it (from
	// the project's declared WebGL game-engine framework), never the request.
	CrossOriginIsolation bool
}

Site is the authoritative binding for a published subdomain: the tenant (Org), the S3 location (Bucket + Prefix), and the publish Status. Every field is server-owned — produced by the store from the validated slug, never from the request. Prefix is the hard tenant boundary for object reads.

func SiteOf added in v1.801.408

func SiteOf(out *PlaneSite) (Site, bool)

SiteOf projects a wire answer onto a Site. Exported for the same reason the types are: the caller lives outside this package and must not restate the mapping.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL