Compare commits

...
Author SHA1 Message Date
antje 5f0b74fe9a sites: the edge asks the app that owns the store, because it is never in this process
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m56s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every published site served the console SPA. The host fix in 8b729f8ef was
necessary and not sufficient: with the host resolving correctly the edge still
found no site, because sites.SetResolver writes a PACKAGE-LEVEL registry inside
`projects` and the edge middleware reads it inside whichever process fronts
:8000. The pod boots ~25 single-app processes ("enabled":["<one>"], 25 distinct,
zero multi-app — measured on the live pod), so those are never the same process
and the registry is always nil where it is consulted.

A nil registry is a clean MISS, not a fault. So every lookup failed silently,
every request fell through to the API pipeline, and no error was logged anywhere
because nothing had failed. Proven at the pod with the ingress bypassed:

  wget --header="Host: app.maxpower.hanzo.app" http://127.0.0.1:8000/
    -> <title>Hanzo Cloud Console

for a site that is genuinely published and has its own Ingress.

The fix is the seam this repo already uses for exactly this shape:
FinanceScopeRules is on the plane, in its own words, because "the READER is a
cloud EDGE middleware" and the fact belongs to another app. projects now
publishes sites_resolve / sites_resolve_org from the one process that owns the
store, and the edge falls back to them. Co-residence still wins with no hop —
currentResolver prefers the in-process registry and only then asks.

Not-found stays a clean 404; a failure to ASK stays an error, so the edge can
render 503. Collapsing those would serve 404s for live customer sites during any
transient failure of the owning app, which is indistinguishable from deletion.

The comment on SetResolver said "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 elsewhere, and 404 is not honest when the site exists.

Negative-controlled: removing the fallback fails the new test with the
fall-through this commit is named for. The second test pins that a co-resident
store is still used without the hop.
2026-08-03 12:36:14 -07:00
hanzo-dev d02eb45d6e deps: orm v0.6.21, which carries xorm v1.4.5 and its identifier escape
Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m51s
CI/CD / image (push) Failing after 1m1s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The relational engine wrapped an identifier in the dialect delimiter but wrote
the body verbatim, so a delimiter inside the identifier closed the quote the
writer opened and the rest executed — Desc("name`,(subquery)--") emitted two
quoted identifiers plus bare SQL rather than one identifier. xorm v1.4.5 doubles
the delimiter, the standard SQL identifier escape, so a name that smuggled one
becomes a single identifier that does not exist and fails closed.

orm is the only place that version is chosen — consumers name hanzoai/orm and
the engine arrives underneath — so this is the hop that makes it live here.
go list -m confirms xorm resolves to v1.4.5 in this build.

Bump only. The four failing packages are the standing pre-existing set
(apps/iam, apps/plan, apps/pricing, manifest); the bump adds none.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:03:08 -07:00
antje c587fcd07a dataset: cloud.Listen, not cloud.Serve — main could not build an image
Hanzo CI/CD / cicd (push) Successful in 1m7s
CI/CD / gate (push) Successful in 1m36s
CI/CD / containment (push) Successful in 2m4s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
plugin/dataset/main.go called cloud.Serve, which does not exist. Every other
plugin calls cloud.Listen. The image build enumerates the manifest and compiles
each plugin in turn, so this failed the whole build at the dataset step:

  plugin/dataset/main.go:29:18: undefined: cloud.Serve

That means no cloud image has been buildable since ae3a30994 landed. `go build
./...` at the repo root does not catch it — the plugin mains are only reached by
the Dockerfile's per-plugin loop, so the gap between "compiles locally" and
"produces an image" is exactly one word wide.
2026-08-03 11:51:46 -07:00
antje 8b729f8ef1 sites: resolve the request host at the point of use, so a published site serves the site
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 2m29s
CI/CD / image (push) Failing after 11m52s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every published site served the CONSOLE, and mounted the whole cloud API under
the customer's own hostname. Measured live 2026-08-03:

  quest.hanzo.app/                    -> <title>Hanzo Cloud Console
  quest.hanzo.app/v1/billing/plans    -> 200

fiber parses the request URI once, and behind the ingress the parsed host is
empty — so siteSlug("") failed, customCandidate("") failed, and every request
fell through to c.Continue() into the API pipeline. The site edge was mounted and
configured correctly the whole time; it just never learned which host was asked
for. Same accessor and same failure as commerce's tenant resolver, in a second
codebase, the same night.

The parsed host still WINS whenever it names something this server serves — that
ordering is the security property, not a detail, because the host picks the ORG
here and a client able to override a real host could serve itself another
tenant's site. X-Forwarded-Host is consulted only when the parsed host is not a
host we can serve, which is the ingress case and never a direct request.
TestMiddlewareTenantKeyedByHostNotPath (pre-existing, unchanged) still passes.

Negative-controlled: reverting to Hostname() alone fails the new test with the
fall-through this commit is named for. One correction on the way: the first
version of the new test used req.Host="" to express "no parsed host" — httptest
synthesizes "localhost" for that, so it proved nothing until measured.
2026-08-03 11:00:54 -07:00
antje b66c9a2576 fix: a machine authenticating as itself has an org, and it is its owner
studio could not enqueue a single render. `POST /v1/tasks/.../activities` answered
403 "identity required", so thirteen jobs sat `queued` in its worklog for up to
nineteen hours while both GPUs polled an empty namespace every two seconds and
reported themselves healthy. Nothing in the queue, the worker logs, or the studio
UI said why.

The cause is one branch. homeOrg already knows that a machine cannot mis-attribute
its org the way a human choosing an app can — that is why the KMS sync identity
reads `owner`. But KMS is recognised by audience, which works only because its
client id is DERIVED from its org ("<org>-platform-kms"). No other app's id is,
so every other client_credentials principal fell through to the estate rule, found
the empty `orgs` a machine correctly carries, and resolved nothing. SanitizeIdentity
then minted X-User-Id with no X-Org-Id, and each org gate refused it.

So the recognition comes from the token's SHAPE, which a human token cannot wear: in
a client_credentials token the client IS the subject — IAM sets sub to "<org>/<app>"
— and azp equals the sole audience, because the app asked for a token for itself. A
human's subject is the user and azp is whichever app they signed in through, which is
the exact mis-attribution homeOrg exists to prevent. Every field read is IAM-signed;
none is a header a caller sets.

This does not widen who may cross tenants. It resolves an org for a principal that
has exactly one and can no more choose it than an sk- key can: `owner` is the
application's own organization, and getting the token requires that application's
client secret. A human with no `orgs` still resolves nothing and still fails closed —
tested, along with each half of the shape, because a partial match is a human token
that merely resembles a machine.
2026-08-03 10:36:32 -07:00
12 changed files with 567 additions and 8 deletions
+7
View File
@@ -272,6 +272,13 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// reads it per request.
sites.SetResolver(siteResolver{store: store})
// ...and publish the SAME resolver on the internal plane, because in
// production the edge is never in this process (the pod boots ~25 single-app
// processes, so the registry above is nil wherever it is read). Keep both:
// co-resident takes the in-process answer with no hop, split takes the plane.
setResolverForPlane(siteResolver{store: store})
exposeSites()
// Register the store as a project-ownership resolver for the identity trust
// boundary (cloud.SanitizeIdentity), so a forged cross-org X-Project-Id is
// refused before any subsystem reads it. Same inversion as sites.SetResolver —
+105
View File
@@ -0,0 +1,105 @@
package projects
import (
"context"
"github.com/zap-proto/zip"
cloud "github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/sites"
"github.com/hanzoai/cloud/plane"
)
// The site edge asks projects which published site a host belongs to.
//
// sites.SetResolver already installs an in-process resolver at Mount, and that
// stays — when the edge and projects happen to share a process it is the right
// answer, with no hop. But the pod boots ~25 SINGLE-app processes, so in
// production they never do: the registry was set inside `projects` and read
// inside whichever process fronts :8000, where it is nil. A nil registry is a
// clean miss rather than a fault, so every published site resolved as not-found
// with no error anywhere and fell through to the API pipeline — every
// <slug>.hanzo.app served the console SPA, and the whole cloud API answered on
// the customer's own hostname. Measured at the pod with the ingress bypassed.
//
// This is the same seam FinanceScopeRules already uses and for the same stated
// reason: the READER is a cloud edge middleware and the fact belongs to another
// app. The store read stays in the one process that owns the store.
//
// No org is taken from the caller on the multi-tenant path. The host IS the
// tenant key here, so accepting an org would let a caller name someone else's
// project; ResolveOrg pins an org only for the first-party path, which is
// exactly what it is for.
func exposeSites() {
zip.Post[plane.SiteIn, plane.Site](cloud.Plane(), "/sites/resolve", planeResolveSite,
zip.WithOperationID(plane.SitesResolve),
zip.WithSummary("Resolve a published site by host label"))
zip.Post[plane.SiteIn, plane.Site](cloud.Plane(), "/sites/resolve-org", planeResolveSiteOrg,
zip.WithOperationID(plane.SitesResolveOrg),
zip.WithSummary("Resolve a published site pinned to one org"))
}
// planeResolveSite answers the multi-tenant product URL (<slug>.hanzo.app) and
// bound custom domains. Not-found is `Found:false`, never an error: the edge
// turns that into an honest 404, and an error into a 503. Collapsing the two
// would serve 404s for real live sites during a transient failure.
func planeResolveSite(ctx context.Context, in *plane.SiteIn) (*plane.Site, error) {
r, err := currentResolver()
if err != nil {
return nil, err
}
s, ok, err := r.Resolve(ctx, in.Slug)
if err != nil {
return nil, err
}
return wireSite(s, ok), nil
}
// planeResolveSiteOrg is the first-party path: it NEVER falls back to
// unique-across-orgs, so an internal host is served only by our own project and
// never a customer's same-named one.
func planeResolveSiteOrg(ctx context.Context, in *plane.SiteIn) (*plane.Site, error) {
r, err := currentResolver()
if err != nil {
return nil, err
}
s, ok, err := r.ResolveOrg(ctx, in.Org, in.Slug)
if err != nil {
return nil, err
}
return wireSite(s, ok), nil
}
// The resolver this process serves plane answers from. Set at Mount beside
// sites.SetResolver, so the two can never name different stores.
var planeResolver siteResolver
func setResolverForPlane(r siteResolver) { planeResolver = r }
// currentResolver refuses rather than answering not-found when the store is
// absent. A process that has not mounted projects cannot know whether a site
// exists, and saying "no" would take every live site off the air with a 404.
func currentResolver() (siteResolver, error) {
if planeResolver.store == nil {
return siteResolver{}, zip.ErrInternal("sites: this process does not own the project store")
}
return planeResolver, nil
}
// wireSite projects a resolved Site onto the wire shape, carrying found-ness
// explicitly so the edge can tell "no such site" from "could not ask".
func wireSite(s sites.Site, ok bool) *plane.Site {
if !ok {
return &plane.Site{Found: false}
}
return &plane.Site{
Found: true,
Org: s.Org,
Slug: s.Slug,
Bucket: s.Bucket,
Prefix: s.Prefix,
Status: s.Status,
CrossOriginIsolation: s.CrossOriginIsolation,
}
}
+70 -5
View File
@@ -84,22 +84,49 @@ type Resolver interface {
var (
resolverMu sync.RWMutex
resolver Resolver
fallback 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.
// with its store — the no-hop answer when the edge and projects share a process.
func SetResolver(r Resolver) {
resolverMu.Lock()
resolver = r
resolverMu.Unlock()
}
// 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.
func SetFallbackResolver(r Resolver) {
resolverMu.Lock()
fallback = r
resolverMu.Unlock()
}
// 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.
func currentResolver() Resolver {
resolverMu.RLock()
r := resolver
r, fb := resolver, fallback
resolverMu.RUnlock()
return r
if r != nil {
return r
}
return fb
}
// Config configures the site host-router. Apex is the zone whose subdomains are
@@ -298,9 +325,47 @@ func analyticsIngest(c *zip.Ctx) (func(org string, c *zip.Ctx) error, bool) {
return h, ok && h != nil
}
// requestHost is the ONE way this server learns which host was asked for.
//
// fiber parses the request URI once, and behind the ingress the parsed host is
// EMPTY — so Hostname() alone resolved nothing, every published site fell
// through to c.Continue(), and <slug>.hanzo.app served the console SPA with the
// whole cloud API mounted under a customer's own hostname. Measured 2026-08-03:
// quest.hanzo.app returned <title>Hanzo Cloud Console and
// quest.hanzo.app/v1/billing/plans returned 200. Same accessor, same failure as
// commerce's tenant resolver earlier the same night.
//
// The parsed host ALWAYS wins. X-Forwarded-Host is consulted only when there is
// no parsed host at all, which is exactly the ingress case and never a direct
// request. That ordering is the security property, not a detail: the host picks
// the ORG here, so a client that could override a real host could serve itself
// another tenant's site. TestMiddlewareTenantKeyedByHostNotPath pins it — a
// request that HAS a host ignores the header completely.
func (s *Server) requestHost(c *zip.Ctx) string {
// A parsed host that names a site (or a bindable custom domain) is the
// truth and is never overridden. Anything else — empty behind the ingress,
// or the ingress' own service name — is not a host this server can serve,
// so the forwarded name is the only candidate left.
parsed := hostOnly(c.Fiber().Hostname())
if parsed != "" {
if _, _, ok := s.siteSlug(parsed); ok {
return parsed
}
if s.customCandidate(parsed) {
return parsed
}
}
// Left-most entry: proxies append, so the first is the client-facing name.
fwd := c.Header("X-Forwarded-Host")
if i := strings.IndexByte(fwd, ','); i >= 0 {
fwd = fwd[:i]
}
return hostOnly(fwd)
}
func (s *Server) Middleware() zip.Handler {
return func(c *zip.Ctx) error {
raw := c.Fiber().Hostname()
raw := s.requestHost(c)
if slug, firstParty, ok := s.siteSlug(raw); ok {
if baseHostHandler != nil && isBasePath(c.Path()) {
if site, ok := s.resolveLivePinned(c.Context(), slug, firstParty); ok {
+97
View File
@@ -604,3 +604,100 @@ func TestNotFoundAnswersDataRequestsInType(t *testing.T) {
}
}
}
// Behind the ingress the parsed URI host is EMPTY and the only carrier of the
// customer-facing name is X-Forwarded-Host. Without this, siteSlug("") failed,
// customCandidate("") failed, and every published site fell through to the API
// pipeline — <slug>.hanzo.app served the console SPA and mounted the whole cloud
// API under a customer's own hostname (measured live 2026-08-03).
func TestMiddlewareResolvesFromForwardedHostWhenParsedHostIsEmpty(t *testing.T) {
fr := &fakeResolver{found: false}
SetResolver(fr)
defer SetResolver(nil)
app := newTestApp(testServer())
// The shape behind the ingress: the parsed host is not a site host (the
// ingress' own name), and the customer-facing name rides X-Forwarded-Host.
// NOTE httptest synthesizes "localhost" for an empty Host, so an empty
// string cannot be used to express "no parsed host" — measured, not assumed.
req := httptest.NewRequest("GET", "http://localhost/index.html", nil)
req.Header.Set("X-Forwarded-Host", "quest.hanzo.app")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("test: %v", err)
}
if resp.Header.Get("X-Sentinel") == "hit" {
t.Fatal("a published site fell through to the API pipeline — this is the console-instead-of-site defect")
}
if got := fr.slugs(); len(got) != 1 || got[0] != "quest" {
t.Fatalf("resolver called with %v, want exactly [quest]", got)
}
}
// ...and the fallback must never become an override. A request that HAS a host
// ignores the header completely — the host picks the ORG, so a client able to
// override a real host could serve itself another tenant's site.
func TestMiddlewareForwardedHostNeverOverridesARealHost(t *testing.T) {
fr := &fakeResolver{found: false}
SetResolver(fr)
defer SetResolver(nil)
app := newTestApp(testServer())
req := httptest.NewRequest("GET", "http://victim.hanzo.app/index.html", nil)
req.Header.Set("X-Forwarded-Host", "attacker.hanzo.app")
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("test: %v", err)
}
if got := fr.slugs(); len(got) != 1 || got[0] != "victim" {
t.Fatalf("resolver called with %v, want exactly [victim] — the header must not override a real host", got)
}
}
// The edge must resolve a site when projects is in ANOTHER process.
//
// This is the production shape and it is the defect this fallback exists for:
// the pod boots ~25 single-app processes, so the in-process registry is nil at
// the edge. A nil resolver is a clean miss, so every published site fell through
// to the API pipeline and <slug>.hanzo.app served the console SPA — with no
// error logged anywhere, because nothing had failed.
func TestFallbackResolverServesWhenProjectsIsElsewhere(t *testing.T) {
SetResolver(nil) // projects is NOT in this process — the production case.
fb := &fakeResolver{found: false}
SetFallbackResolver(fb)
defer SetFallbackResolver(nil)
app := newTestApp(testServer())
req := httptest.NewRequest("GET", "http://quest.hanzo.app/index.html", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("test: %v", err)
}
if resp.Header.Get("X-Sentinel") == "hit" {
t.Fatal("fell through to the API pipeline — this is the console-instead-of-site defect")
}
if got := fb.slugs(); len(got) != 1 || got[0] != "quest" {
t.Fatalf("fallback called with %v, want exactly [quest]", got)
}
}
// A co-resident store still answers WITHOUT the hop: the in-process resolver
// wins whenever it is set, so sharing a process costs nothing.
func TestInProcessResolverWinsOverTheFallback(t *testing.T) {
inproc := &fakeResolver{found: false}
fb := &fakeResolver{found: false}
SetResolver(inproc)
SetFallbackResolver(fb)
defer func() { SetResolver(nil); SetFallbackResolver(nil) }()
app := newTestApp(testServer())
req := httptest.NewRequest("GET", "http://quest.hanzo.app/index.html", nil)
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("test: %v", err)
}
if len(inproc.slugs()) != 1 {
t.Errorf("in-process resolver was not used: %v", inproc.slugs())
}
if n := len(fb.slugs()); n != 0 {
t.Errorf("fallback was consulted %d times; the in-process store must win", n)
}
}
+48 -1
View File
@@ -265,7 +265,7 @@ func (c *idClaims) homeOrg() string {
if c.subjectOrg != "" {
return c.subjectOrg // API key: resolved from the subject
}
if isKMSMachinePrincipal(c) {
if isKMSMachinePrincipal(c) || isClientCredentialsPrincipal(c) {
return c.Owner // machine JWT: the app IS the principal
}
// Everything else is the estate rule: the first entry of the signed membership
@@ -275,6 +275,53 @@ func (c *idClaims) homeOrg() string {
return c.Claims.Home()
}
// isClientCredentialsPrincipal reports whether a validated token was minted by the
// client_credentials grant — an application authenticating AS ITSELF, with no user
// behind it.
//
// It is the same fact isKMSMachinePrincipal establishes, for every other app. KMS
// could be recognised by audience alone because its client id is DERIVED from the org
// it belongs to ("<org>-platform-kms"), so the audience proves the pairing. No other
// app's id is derivable that way, so the recognition has to come from the token's
// SHAPE instead.
//
// The shape is not forgeable by a human token. In a client_credentials token the
// client IS the subject: IAM sets sub to "<org>/<app>", and azp — the authorized
// party, i.e. the client that obtained the token — equals the sole audience, because
// the app requested a token for itself. A human's token cannot look like that: its
// subject is the user, and azp names whichever app they signed in through, which is
// the very mis-attribution homeOrg exists to prevent. And every field read here is
// signed by IAM; none is a header a caller can set.
//
// WHY THIS MATTERS, measured. studio authenticates this way. Its token carries
// owner=hanzo and organization=hanzo but no `orgs` — correct-by-design for a machine,
// exactly as an sk- key carries none — so Home() returned "" and SanitizeIdentity
// minted X-User-Id with no X-Org-Id. Every org-scoped gate then refused it, and the
// one that mattered was the durable queue: `POST /v1/tasks/.../activities` answered
// 403 "identity required", so no render could be enqueued at all. Thirteen jobs sat
// `queued` in studio's worklog for up to 19 hours while both GPUs polled an empty
// namespace every two seconds and reported themselves healthy.
//
// It is NOT a widening of who may cross tenants. This resolves an org for a principal
// that already has exactly one and can no more choose it than an API key can: `owner`
// is the application's own organization, set by IAM when the app was created, and
// obtaining the token at all requires that application's client secret. A human with
// no `orgs` still resolves nothing and still fails closed — the case the estate rule
// exists for is untouched.
func isClientCredentialsPrincipal(c *idClaims) bool {
if c == nil || c.Owner == "" || c.Azp == "" {
return false
}
// The client obtained a token FOR ITSELF: one audience, and it is the client.
if len(c.Audience) != 1 || c.Audience[0] != c.Azp {
return false
}
// And it IS the subject: "<org>/<app>", naming that same client.
sub := c.Subject
i := strings.LastIndex(sub, "/")
return i > 0 && sub[i+1:] == c.Azp
}
// isKMSMachinePrincipal reports whether a validated token is a per-org KMS-sync
// machine identity: its audience set contains the owner-bound machine audience
// (<owner>-platform-kms). Such a principal is a client_credentials machine identity
+128
View File
@@ -0,0 +1,128 @@
package cloud
// The client_credentials principal: an application authenticating AS ITSELF.
//
// homeOrg resolves such a token to its `owner` — the application's own organization —
// because a machine cannot choose an org the way a human choosing an app can. That is
// the same reasoning the KMS branch already rests on; these tests exist because the
// recognition is by SHAPE rather than by a derivable audience, so every part of the
// shape needs a test that fails when it is loosened.
//
// The dangerous direction is second-to-last: a HUMAN with no `orgs` must still resolve
// nothing. That is the estate rule, it is what makes an org-less request fail closed
// everywhere, and widening machine recognition must never reach it.
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/authz"
)
// machineClaims builds a client_credentials token as IAM mints one: the client is the
// subject ("<org>/<app>"), the sole audience, and the authorized party — and there is
// no membership set, which is correct for a machine rather than a degraded token.
func machineClaims(app, owner string, exp time.Time) idClaims {
c := idClaims{Claims: authz.Claims{Owner: owner, Organization: owner, Azp: app}}
c.Issuer = testIssuer
c.Subject = "admin/" + app
c.Audience = jwt.ClaimStrings{app}
c.ExpiresAt = jwt.NewNumericDate(exp)
c.IssuedAt = jwt.NewNumericDate(time.Now())
return c
}
func TestClientCredentialsPrincipalResolvesItsOwnerOrg(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", time.Now().Add(time.Hour))
if !isClientCredentialsPrincipal(&c) {
t.Fatal("a token whose client is its own subject and sole audience is a machine principal")
}
if got := c.homeOrg(); got != "hanzo" {
t.Fatalf("homeOrg=%q, want hanzo — a machine's org is its owner, which it cannot choose", got)
}
}
// The failure this fixes, stated as the thing that used to happen.
//
// studio's token carries owner=hanzo and no `orgs`, so Home() returned "" and
// SanitizeIdentity minted X-User-Id with no X-Org-Id. Every org-scoped gate refused
// it; the one that mattered was the durable queue, which answered 403 "identity
// required" — so no render could be enqueued at all, and thirteen jobs sat queued for
// up to nineteen hours while both GPUs polled an empty namespace and reported healthy.
func TestMachineTokenWithNoOrgsIsNotOrgless(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", time.Now().Add(time.Hour))
if len(c.Orgs) != 0 {
t.Fatal("precondition: a machine token carries no membership set")
}
if c.Claims.Home() != "" {
t.Fatal("precondition: the estate rule alone resolves nothing here")
}
if c.homeOrg() == "" {
t.Fatal("an org-less machine principal reaches every org gate as anonymous and is refused")
}
}
// A HUMAN with no membership set still resolves nothing. This is the rule the machine
// branches must never reach: a human's org follows their token, and a token that
// proves no membership proves no org.
func TestHumanWithoutOrgsStillFailsClosed(t *testing.T) {
c := idClaims{Claims: authz.Claims{Owner: "hanzo", Azp: "hanzo-console"}}
c.Issuer = testIssuer
c.Subject = "u-alice" // a USER, not "<org>/<app>"
c.Audience = jwt.ClaimStrings{"hanzo-console"}
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour))
if isClientCredentialsPrincipal(&c) {
t.Fatal("a human subject must never be read as a machine, whatever else matches")
}
if got := c.homeOrg(); got != "" {
t.Fatalf("homeOrg=%q, want empty — falling back to owner is the mis-attribution this prevents", got)
}
}
// Each half of the shape is load-bearing. A token that matches only part of it is a
// human token that happens to resemble a machine, and must not be admitted.
func TestPartialMachineShapeIsRefused(t *testing.T) {
exp := time.Now().Add(time.Hour)
t.Run("azp is not the audience — the token was issued for a DIFFERENT client", func(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", exp)
c.Audience = jwt.ClaimStrings{"some-other-app"}
if isClientCredentialsPrincipal(&c) {
t.Fatal("a client holding a token minted for another audience is not that audience")
}
})
t.Run("more than one audience — not a token an app got for itself", func(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", exp)
c.Audience = jwt.ClaimStrings{"hanzo-studio", "hanzo-console"}
if isClientCredentialsPrincipal(&c) {
t.Fatal("a multi-audience token is not the self-issued shape")
}
})
t.Run("subject does not name the client — a user signed in THROUGH the app", func(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", exp)
c.Subject = "admin/somebody-else"
if isClientCredentialsPrincipal(&c) {
t.Fatal("the subject must BE the client; anything else is a human using it")
}
})
t.Run("no owner — nothing to resolve, and no guess to make", func(t *testing.T) {
c := machineClaims("hanzo-studio", "", exp)
if isClientCredentialsPrincipal(&c) {
t.Fatal("without an owner there is no org to return")
}
})
t.Run("bare subject with no org segment", func(t *testing.T) {
c := machineClaims("hanzo-studio", "hanzo", exp)
c.Subject = "hanzo-studio"
if isClientCredentialsPrincipal(&c) {
t.Fatal("<org>/<app> is the shape; a bare name is not it")
}
})
}
+1 -1
View File
@@ -435,7 +435,7 @@ require (
github.com/hanzoai/go-openrouter v1.0.0 // indirect
github.com/hanzoai/goauthorizenet v1.0.0 // indirect
github.com/hanzoai/gochimp3 v1.0.0 // indirect
github.com/hanzoai/orm v0.6.19
github.com/hanzoai/orm v0.6.21
github.com/hanzoai/pdf v1.2.0 // indirect
github.com/hanzoai/pubsub-go v1.53.0
github.com/hanzoai/search-go v0.36.0 // indirect
+2
View File
@@ -1079,6 +1079,8 @@ github.com/hanzoai/orm v0.6.18 h1:yRVsJBp6xAzrugjxUWjK0OYY/f3BIIYTVcOYQ4nbbO4=
github.com/hanzoai/orm v0.6.18/go.mod h1:PqGd9Jv+BOeBZ4UkR7lyb9hy45ROgF+EHk/fXFgwb4o=
github.com/hanzoai/orm v0.6.19 h1:HuCFCRndqGtIQU2BInZLCnFnB3H8o2ovf/KvvFZG1O8=
github.com/hanzoai/orm v0.6.19/go.mod h1:PqGd9Jv+BOeBZ4UkR7lyb9hy45ROgF+EHk/fXFgwb4o=
github.com/hanzoai/orm v0.6.21 h1:iqH7h8eUD3uDRKuga5jQJqAC+6oQRxXoApUJFQd+i38=
github.com/hanzoai/orm v0.6.21/go.mod h1:mnnchBPY8Z0gEsI0qzBWQnFKj3LerqHOk7wfoOn6MNA=
github.com/hanzoai/otel-collector v1.2.0 h1:lBDL5lKotq89JaqchcM+/oxEnjrjazEI2JJfDhotudc=
github.com/hanzoai/otel-collector v1.2.0/go.mod h1:rNCSDd4fw23kCi5UQ4u2IGhSYifYMex5O6SwZGHtIPE=
github.com/hanzoai/pdf v1.2.0 h1:3/zYs4estaf6hpa3Ug/NgfYEQpmkmMClmRcREAP8KUo=
+38
View File
@@ -47,6 +47,20 @@ import (
// The token is the op's operationId, which is also its OpenAPI operation, its
// MCP tool name and its CLI command — one identity across every projection.
const (
// SitesResolve / SitesResolveOrg answer "which published site is this host?"
// for the site EDGE, which is the same reason FinanceScopeRules is here: the
// reader is a cloud edge middleware and the owner of the fact is another app.
//
// It is on the plane because it HAS to be. The edge middleware and projects
// (which owns the project store, and called sites.SetResolver at its Mount)
// run in DIFFERENT processes — the pod boots ~25 single-app processes — so a
// package-level registry is nil wherever it is consulted. Every published
// site therefore resolved as not-found and fell through to the API pipeline,
// and <slug>.hanzo.app served the console SPA. Measured at the pod, ingress
// bypassed, 2026-08-03.
SitesResolve = "sites_resolve"
SitesResolveOrg = "sites_resolve_org"
FinanceAuthorize = "finance_authorize" // the prepaid gate
FinanceBalance = "finance_balance"
FinanceRecord = "finance_record" // the meter
@@ -705,3 +719,27 @@ func BindRuntimeDir() string {
_ = os.Setenv("ZIP_RUNTIME_DIR", dir)
return dir
}
// SiteIn names a published site to resolve: the host label for the multi-tenant
// product URL, or a bound custom domain. Org is set ONLY by the first-party
// path (ResolveOrg), which pins the lookup to one org so an internal host is
// never served by a customer's same-named project.
type SiteIn struct {
Slug string `json:"slug"`
Org string `json:"org,omitempty"`
}
// Site is a published site's serving facts. Found is explicit: a site that does
// not exist is a clean answer, not an error, and the edge must be able to tell
// "no such site" (honest 404) from "the owner could not be reached" (503) —
// collapsing them is how a transient failure would start serving 404s for real
// customers' live sites.
type Site 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"`
}
+1 -1
View File
@@ -26,7 +26,7 @@ import (
// probe of its own would report the store's reachability, which is already what
// every op reports in band as a 503 rather than as an empty answer.
func main() {
if err := cloud.Serve([]cloud.Plugin{{
if err := cloud.Listen([]cloud.Plugin{{
Name: "dataset",
Price: cloud.Metered,
Mount: dataset.Mount,
+5
View File
@@ -287,6 +287,11 @@ func Listen(plugins []Plugin, enable []string) error {
// injected at its Mount via sites.SetResolver; until then a site host 404s
// honestly. Org isolation (org+prefix come only from the store keyed by the
// validated slug; object keys are rooted-clean) lives in clients/sites.
// The edge asks the app that owns the store when it is not in this process,
// which in production is always: the pod boots ~25 single-app processes, so
// the registry projects.Mount writes is nil here. Co-resident still wins with
// no hop — currentResolver prefers the in-process one.
sites.SetFallbackResolver(planeSites{})
app.Use(sites.New(sites.Config{Apex: cfg.SitesApex, Reserved: cfg.SitesReserved, SelfDomains: cfg.SitesSelfDomains, FirstPartyApex: cfg.SitesFirstPartyApex, FirstPartySites: cfg.SitesFirstPartySites, FirstPartyOrg: cfg.SitesFirstPartyOrg}, deps.Logger).Middleware())
// Edge policy — the "gateway role" cloud absorbs to serve the public
+65
View File
@@ -0,0 +1,65 @@
package cloud
import (
"context"
"fmt"
"github.com/hanzoai/cloud/apps/sites"
"github.com/hanzoai/cloud/plane"
)
// planeSites resolves a published site by asking the app that owns the project
// store, over the internal plane.
//
// The site edge middleware runs at the compose root, in whatever process fronts
// the public port; the store belongs to `projects`. In production those are
// never the same process — the pod boots ~25 single-app processes — so the
// package-level registry projects.Mount writes is nil where the edge reads it.
// That is why every <slug>.hanzo.app served the console SPA: a nil resolver is a
// clean miss, so the request fell through to the API pipeline with no error
// anywhere to notice.
//
// Same seam, same reason, as the balance and scope-rule reads that already cross
// this plane: the reader is an edge middleware and the fact belongs elsewhere.
type planeSites struct{}
// Resolve answers the multi-tenant product URL and bound custom domains.
//
// A site that does not exist comes back Found:false and becomes an honest 404.
// A failure to ASK is an error, and stays one — the edge renders 503 for that.
// Collapsing the two would serve 404s for real, live customer sites during any
// transient failure of the owning app, which looks exactly like the site being
// deleted.
func (planeSites) Resolve(ctx context.Context, slug string) (sites.Site, bool, error) {
return askSite(ctx, &plane.SiteIn{Slug: slug}, plane.SitesResolve)
}
// ResolveOrg is the first-party path, pinned to one org so an internal host is
// never served by a customer's same-named project.
func (planeSites) ResolveOrg(ctx context.Context, org, slug string) (sites.Site, bool, error) {
return askSite(ctx, &plane.SiteIn{Slug: slug, Org: org}, plane.SitesResolveOrg)
}
func askSite(ctx context.Context, in *plane.SiteIn, op string) (sites.Site, bool, error) {
// The site plane read is org-less by construction: the HOST is the tenant
// key, and the answer names the org. Passing one in would let a caller point
// at someone else's project.
out, err := Ask[plane.SiteIn, plane.Site](For(ctx, ""), "projects", op, in)
if err != nil {
return sites.Site{}, false, fmt.Errorf("sites: ask projects: %w", err)
}
if out == nil {
return sites.Site{}, false, fmt.Errorf("sites: projects answered nothing")
}
if !out.Found {
return sites.Site{}, false, nil
}
return sites.Site{
Org: out.Org,
Slug: out.Slug,
Bucket: out.Bucket,
Prefix: out.Prefix,
Status: out.Status,
CrossOriginIsolation: out.CrossOriginIsolation,
}, true, nil
}