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 ¶
- func CacheControlFor(key, htmlOverride string) string
- func CacheTag(org, slug string) string
- func IsReserved(label string) bool
- func IsSelfHost(host string) bool
- func SetAnalyticsHost(h map[string]func(org string, c *zip.Ctx) error)
- func SetBaseHostHandler(h func(org string, c *zip.Ctx) error)
- func SetReservedExtra(labels []string)
- func SetResolver(r Resolver)
- func SetSelfDomains(domains []string)
- type Config
- type Purger
- type Resolver
- type Server
- type Site
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CacheControlFor ¶
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 ¶
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 IsReserved ¶
IsReserved reports whether a subdomain label may NOT be a published site. This is the ONE predicate every enforcement point calls, so serve/create/bind never drift. The comparison is on the lowercased label.
func IsSelfHost ¶
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 SetAnalyticsHost ¶
SetAnalyticsHost installs the site-host ingest carve (see analyticsHost): the ingest paths bound to their handlers. A nil or empty map disables the carve.
func SetBaseHostHandler ¶
SetBaseHostHandler installs the per-org Base handler (see baseHostHandler).
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. Until it is set, every site request is an honest 404 (the projects subsystem is not mounted), never a crash.
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.
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.
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 ¶
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 ¶
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 ¶
Configured reports whether both a token and a zone id are present.
func (*Purger) PurgeTags ¶
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.
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).
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 ¶
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 ¶
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.