Compare commits

...
Author SHA1 Message Date
antje e87012d765 answer: the model may choose a result's SHAPE, not its markup
CI/CD / image (push) Failing after 33m35s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m22s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 12s
The extension renders structured result widgets (comparison, steps, stats,
timeline, entity, definition) but nothing emitted them: /v1/ask's stream is
status|sources|text|follow_ups|done and the system prompts never mentioned the
format. The client was ready and the producer was missing.

widgetRule teaches the one format both modes share, so search and research
cannot drift apart. It says three things beyond the schema:

- Only when the question's SHAPE calls for one, at most two, most answers none.
  A widget on every answer is a worse document, not a better one.
- The PROSE MUST STAND ALONE. The client validates every block and drops
  anything malformed — a ragged table, an unknown kind, a field of the wrong
  type — keeping the prose. An answer that leaned on a widget to be complete
  would read as a hole exactly when validation refused one.
- DATA ONLY, NEVER HTML. This model reads the open web, so every page it
  fetches is a potential injection source. Markup it authored would be a path
  into the extension's origin, which holds the user's session. The renderer
  owns the shapes and escapes every field; the model owns only content.

widget_rule_test.go is the closest thing to a shared type across the two repos.
The renderer lives in the extension, so a typo here fails nothing: it produces
answers whose widgets silently never appear, which looks exactly like a model
choosing not to emit one. The test parses every example out of the prompt and
holds it to the shape the client accepts — one example per kind, every required
field present, the comparison example rectangular (the client drops ragged
tables rather than padding them), and both modes carrying the rule.

Negative control run: renaming the steps example's field to "items" fails with
`kind "steps" example omits required field "steps"`.

Pre-existing and unrelated: the root package does not build right now
(middleware_spend.go, another session's in-flight work). apps/answer builds and
tests clean.
2026-08-05 00:56:04 -07:00
zooqueenandhanzo-dev 11a6ce27a4 release: a KMS path has no org in it, and the guard that said so never ran
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
No release has pinned since v1.801.454. .455 and .457 built, published, and
were never referenced by anything — the fleet has been serving .454 while
three versions sat in the registry.

The rollout car reads UNIVERSE_PIN_TOKEN from
/v1/kms/orgs/<org>/secrets/deploy/... and the broker has no such route. The
store root is derived from the validated claim, which is exactly what makes
another tenant's secret unnameable rather than merely refused, so there is no
org in a KMS path and never was. Measured against kms.hanzo.ai:

  /v1/kms/auth/login                                 401  (route exists)
  /v1/kms/orgs/hanzo/secrets/deploy/UNIVERSE_PIN_TOKEN  404
  /v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN             403  (route exists)

fanout reads FLEET_DISPATCH_TOKEN through the same wrong shape and would have
failed the same way the moment rollout stopped failing first.

WHY IT WAS INVISIBLE, WHICH IS THE HALF WORTH FIXING. The step is
`set -euo pipefail` and the read is `TOKEN=$(curl -fsS ... | jq ...)`. curl
-f exits 22 on the 404, pipefail propagates it, set -e aborts the assignment —
so the authored `::error::UNIVERSE_PIN_TOKEN missing in KMS ...` is
unreachable code. Every one of these releases died with a bare `exitcode
'22': failure` and never printed the sentence written to explain it. Three
guards in this file were dead the same way; the two KMS logins are fixed here
too, and `receipt`'s create-or-update on a tag with no release yet is the
third (left alone — it is a different car and a different bug).

A guard that cannot run is worse than no guard: it reads as diagnosis
already handled.

KMS_ORG goes with the paths. The org is the credential's, so a variable that
named it was a knob that decided nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:42:42 -07:00
zooqueenandhanzo-dev 74b743065b catalog: ask projects too — the site half was reaching across the same gap
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The corpus has two sources and both were in-process globals the catalog
process cannot see. The index write was one (previous commit). This is the
other, and it failed more quietly still.

projects.LiveSites answers nil when its package is unmounted, on the stated
reasoning that a deployment which hosts no sites is not an error. That is
true of a DEPLOYMENT and false of a PROCESS: catalog, index and projects are
three separate apps and therefore three separate processes, so in the
catalog process nil never meant "nothing is serving" — it meant "you asked
the wrong half of the fleet". nil and empty are the same answer, so the
corpus was published with no sites in it and nothing anywhere said so.

What that costs is the whole `site` kind: every demo URL, the lineage that
files a remix under community instead of leaving it looking like one of our
starters, and the deployed demos the template lane is mostly made of. The
repos alone would have made a catalog of source with nothing live in it.

  plane      sites_live, and it takes no org — the same shape as
             sites_resolve beside it. This is THE cross-org read; the rule
             that makes it safe (public, live, not hidden) is applied in the
             query by the app that owns the store, so there is no tenant here
             for a caller to widen into.
  projects   Ready(), so a caller can finally tell "nothing is serving" from
             "ask the process that owns the store" — the distinction
             LiveSites cannot make, because it answers nil for both.
  catalog    serving(), the third seam with the same two legs as lexical and
             write.

Same test file, same reason: the split is what production runs, so the test
reaches a real peer over a real socket.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:36:56 -07:00
hanzo-dev 5baf8ca104 risk: the credit door is screened by the model, over the plane
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
cloud.SetRiskScorer has held the seam and the whole fail policy since it was
written and has never had a producer. It could not have one: the model is
in-process mutable state, so exactly one binary may hold it, and the pod forks
one process per app. Every gate in the fleet therefore read a nil scorer, took
the absent exemption, and allowed unscored — fleet-wide, silently. The
observability plane's event door was the same shape and learned it the
expensive way; obsevents.go is gone and apps/o11y/obs_rpc.go replaced it.

So the scorer is REACHED rather than linked. apps/risk publishes risk_decide on
its own socket, commerce installs a plane client as the seam's first producer,
and POST /v1/billing/topup/token — the one self-serve credit door, whose mint
authority is a settled card charge — asks it before the charge.

The tenant is minted from the PLANE CALLER, never from the body. RiskDecideIn
carries no org and cannot: a model is trained on one organisation's own
behaviour, so naming which model answers would be the only cross-tenant read
this plane has to offer. The HTTP mint (tenantOf) reads a principal parked on a
request, which a plane call does not have, so it would fail closed on every
call; planeTenant qualifies cloud.Who(ctx).Org with the deployment's own brand
instead, and qualify() refuses an empty org.

The gate states Privileged explicitly. cloud.Privileged() matches IAM and KMS
paths and not this one, so the default would be the fail-OPEN branch — a scorer
outage waving through the one route that mints spendable balance. With the bit
set, a scorer that is present and cannot answer refuses and the caller retries;
a scorer that is NOT DEPLOYED still allows, which is the exemption cloud's fail
policy already carries and the plane client is careful to preserve: over a
socket "not deployed" would otherwise arrive as a failed call and take the
closed branch, so the socket is probed, ErrNoPeer is read as absence, and the
lazy child is woken off the request path rather than inside a 150ms budget.

A REFUSAL CARRIES NO SCORE. The engine assigns one before it checks whether the
model has warmed, so a declining model returns a populated, meaningless number;
publishing it would turn "no opinion" into "this is fine". And an alert is a
REVIEW, never a block: cloud's own vocabulary says a statistical judgement may
reach review and no further on its own, and this model is exactly that.

IT SHIPS IN SHADOW. No org is armed, no regime is changed, and a model nobody
has reviewed is in shadow — where alert is forced false however high the score.
So every legitimate top-up proceeds today and every decision is on the record
with the shape and policy version that produced it. The subject kinds move to
the call contract for the same reason the signal names are there: two spellings
of "account" would not read as a disagreement, they would namespace one subject
into two.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:30:47 -07:00
zooqueenandhanzo-dev a8b5bd5337 catalog: tell the index, because the write never left this process
CI/CD / image (push) Failing after 26m11s
CI/CD / gate (push) Successful in 12s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / containment (push) Successful in 1m31s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The published catalog has been empty since catalog and index became two
plugin rows, and not because a sync failed to run. It ran every hour, read
GitHub and the sites table correctly, assembled the whole corpus — and then
handed it to index.Reconcile, which serves out of the index's own
process-level global. In the catalog process that global is nil and always
will be. So the corpus was never written a single time.

The READ leg was given a plane op when the split happened; the write leg was
deliberately left in-process, on the reasoning that the store has one writer
and it lives where the file lives. That property is right and the conclusion
was wrong: the writer is still one and still the index's, whether the corpus
reaches it through a function call or a socket. Handing it over does not add
a second writer — a second process OPENING that SQLite would, and neither
leg does.

Fixing the read alone therefore could not have shown a row. It turned a
503 into 200 {"data":[],"total":0}: from a page that said it was broken to
one that said the fleet had built nothing. hanzo.app's /community and
/templates have rendered that empty ever since.

  plane      index_reconcile, the mirror of index_query, with the corpus
             relayed verbatim for the same reason the read's rows are raw.
  index      publishes it beside the read; the swap still executes here,
             in the process that owns the file. query_rpc.go -> rpc.go, the
             name the other multi-op peers already use.
  catalog    write(), the mirror of lexical(): in-process first, then the
             plane. Both legs now go through the GENERATED index client, so
             the app name, the op and the In/Out pair are fixed to each
             other by the compiler instead of at run time.

The suite could not have caught this: every existing catalog test mounts the
index and the lens on ONE app, which is the topology this deployment stopped
having — index.Ready() is true there, so the plane leg was never executed by
a test at all. split_test.go models the split instead and reaches a real peer
over a real socket. It fails on the old code with "index: not mounted".

A silent write failure outlives a loud read failure, and it is the harder one
to see: an error names itself, an empty page looks like an answer.

Also commits plane/team, generator output for an op added without a
regenerate in c8b7c31.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:28:38 -07:00
zeekayandhanzo-dev d0e8867117 deps: commerce v1.50.5 — a payment rail can be turned on and off
CI/CD / image (push) Failing after 29m59s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m30s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Successful in 12s
The provider list that decides which rails pay.hanzo.ai offers was readable and
not writable, so card, Apple Pay, Google Pay, Cash App, ACH, wire and crypto were
whatever happened to be in the tenant row and moving one meant editing the
database. v1.50.5 adds the write verb, one rail per call — a whole-list PUT built
from what the admin read projection exposes would write every provider back with
an empty KMS path and silently disconnect all of them from their credentials.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:57:00 -07:00
zooqueenandhanzo-dev 8b89c877b7 commands: the ⌘K bar is the route table's fifth projection
CI/CD / image (push) Successful in 23m6s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m14s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / rollout (push) Failing after 8s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
GET /v1/commands serves zip.CommandsFromSpec over the rendered document —
the same function the CLI's tree is derived from, on the same bytes every
SDK is generated from. No new registry, no generator, no build step: a
route registered this morning is a command this afternoon.

serve() gains one line, so both document sources light up at once — Mount
for an app binary, MountFleet for the front door that answers api.hanzo.ai.

Total, and unfiltered by caller on purpose. zip has no per-op scope; it has
Authorizer, which decides on the DECODED INPUT of every op. Permission is a
fact about an input, so any list filtered here would be a second static
claim the Authorizer is free to contradict — wrong in the direction that
hides working functionality from people who have access.

Two things the design did not know, both measured here:

  The size. The projection is 2,344,651 bytes, 454,881 gzipped — 1.32x
  smaller than the document, not the 4.5x/107 KB the design quotes. That
  figure was taken from hanzoai/cli's spec/products.json, a different
  artifact with every description, summary and operationId stripped. This
  one keeps the prose, and the prose is most of it (Description alone is
  1,028,067 bytes). Recorded in command.go rather than fixed by forking
  zip.Command into a trimmed wire shape.

  The order. 41 of the 2,323 commands share zip's (Service, Name) sort key
  — `mq streams-delete` is claimed by three — and sortCommands is unstable,
  so the tie fell to a map walk. Two replicas weaving one document served
  identical content under different ETags, which is a full re-download on
  every conditional request that lands on a different pod. order() completes
  the key with (Method, Path).

openapi.Door names what serve registers. Three gates needed exactly that
fact and each had written the one literal that was true when it was written
— Complete skipping a description no app can own, cmd/cloud exempting the
doors from a scoped deployment's surface, manifest refusing an app row that
claims one. The second door made all three wrong the same afternoon.

Pins: every served command is a route the document carries; the served
bytes are CommandsFromSpec of openapi.yaml exactly; two mounts of one
document agree on bytes AND ETag; a conditional request gets 304 and no
body; the projection is smaller than what it projects. Plus the door itself
answering on the host's own spec() mount.

TestFleetIsTheWeaveOfItsApps and two cmd/cloud gates are red on this tree
already, over a duplicate operationId between GET /v1/billing/methods and
GET /v1/billing/portal/methods. Not touched, not absorbed: openapi.yaml
therefore does not yet carry /v1/commands, and the full-fleet version of
the door test belongs in the commit that fixes the weave.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:42:56 -07:00
zeekay ab7c3d0e6b Merge remote-tracking branch 'forge/main'
CI/CD / image (push) Successful in 20m47s
CI/CD / gate (push) Successful in 13s
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / containment (push) Successful in 1m35s
CI/CD / rollout (push) Failing after 7s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:26:47 -07:00
zeekayandhanzo-dev a94b25870a publish the commit to github before claiming a tag on it
The image job reserves a version by creating refs/tags/v<N> AT THE COMMIT on
github.com. A ref can only point at an object that is there, so for a commit
github has never seen the claim answers 404 — 'Object does not exist' — and
refuses to build.

It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical and
where the push lands; github is fed by a push mirror on an EIGHT-HOUR interval,
and the claim runs seconds after the push. The object the tag must name is
normally hours away, so the failure is not a permissions problem that looks like
a race — it is a race that looks like a permissions problem, and it is why four
days of releases stacked up behind one commit.

The commit is now published to github before the claim reads it, on a ref of its
own: main is the mirror's to move and the two lineages do diverge, while this
step's only job is to make the object exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:26:44 -07:00
antje 266e578e83 zip v1.25.1 — zero OTel packages behind the framework
Hanzo CI/CD / cicd (push) Successful in 15s
CI/CD / gate (push) Successful in 15s
CI/CD / containment (push) Successful in 1m41s
CI/CD / image (push) Failing after 16s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
metric v1.10.0 moved the OTel bridge out of its root package, and zip took
the bump, so the framework and everything that inherits it now link no
OpenTelemetry SDK at all. Cloud's own meter pipeline still imports the SDK
directly and deliberately — it feeds the status page until zip's rows are
proven where the gauges read, and it is deleted with that proof, not before.
2026-08-04 22:08:05 -07:00
zeekayandhanzo-dev 49b3651596 Merge remote-tracking branch 'forge/main'
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m42s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
# Conflicts:
#	Dockerfile

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:02:47 -07:00
zeekayandhanzo-dev a6d2d94665 not signed in is 401, and the billing app stops publishing an address it does not serve
Moving /v1/billing/methods in-process (it had been forwarded through a base URL
this deployment never sets, so a signed-in customer got 401 listing their own
cards) quietly changed what an ANONYMOUS caller gets: the proxy answered 401 and
the co-resident chain answers 403, because PinBillingSubject refused both of its
cases with ErrForbidden.

Those two cases are different answers and must not share a status. A service
token is a credential: presenting one and omitting X-Org-Id is an authenticated
request that names no scope, and 403 is right. Presenting nothing is not signed
in. The difference is load-bearing on the customer path — a browser
re-authenticates on 401 and merely reports 403 — so an expired session on the
saved-cards screen showed a permission error instead of sending the customer to
sign in. The billing app it moved from answered 401 on purpose; its test said so
in as many words, and that test was deleted with the proxy.

So the tests move with the route, which is how this surfaced at all:

  apps/commerce gains the assertion the billing app used to carry — anonymous
  GET and POST answer 401, and a 404 fails loudly rather than passing as
  "refused", since an unmounted route also declines every request.

  apps/billing loses seven tests that pinned a proxy that no longer exists, the
  three handlers behind them (paymentMethods, createPaymentMethod,
  deletePaymentMethod — defined, registered nowhere, dead since the move), and
  an openapi.Describe for DELETE /v1/billing/methods/{id}, an address this app
  published and did not serve. That last one is the exact publish/serve
  disagreement manifest.Apps exists to catch.

Also merges the GitHub lineage, which had diverged: 4 commits there (console
embed pinned by semver, platform forge sibling, the ai subscriber-scoring fix)
against 19 here. The version claim mints its tag on github.com, so a commit that
only ever reached the forge cannot be claimed — which is what the image job hit
after the containment fix let it run at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 22:01:31 -07:00
hanzo-dev c8b7c310da team: one identity seam, IAM lane beside the HS256 arm
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m10s
CI/CD / image (push) Failing after 15s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every team surface resolved its caller by decoding an HS256 token itself, so
"who is calling" was answered in six places against one signing key. This adds
identity (apps/team/account.go): ONE seam that resolves a caller, and the only
place a credential's algorithm is routed on. account, typed, files, billing,
collab and the transactor hold the seam instead of the secret, and three of them
stop importing the condemned package — account.go is the only reader left inside
apps/team.

Verification, tenancy and authorization are three questions, answered separately:

  - VERIFICATION is cloud's own IAM validator, narrowed. A signature from a
    trusted issuer says IAM minted the token, never that it was minted FOR this
    surface — IAM's signer emits the same claims into an access token and an
    id_token but for aud/tokenType/nonce. A session door must say which it
    means, so the lane takes access tokens whose audience this deployment NAMES.
    The boundary's no-audience-gate posture is right for an API door and wrong
    here; the divergence is stated at the pin.
  - TENANCY is the home org from the signed membership set, never `owner`.
    `owner` carries the application's org, so it is chosen by whichever app the
    caller authenticated through, and a lane reading it scopes every store query
    to an org the caller selected. No membership set means no home, which is
    also every machine credential — a team session is a person's.
  - IDENTITY is the `sub` claim, resolved through the store. The canonical user
    id falls back to preferred_username, and an account id derives from a UUID
    verbatim, so a token with no sub whose username is a colleague's account uuid
    resolved to the colleague. Subject-only, confirmed against the rows a login
    created.

An IAM credential never leaves the seam: it is an estate-wide bearer held in an
HttpOnly cookie so page JS cannot read it, and the account RPC echoes a caller's
token back to page JS.

Workspace authorization on the IAM lane is the membership rows (admit) — the
server decides, the caller signs nothing. The transactor keeps its path-borne
workspace token and gains no ambient lane: a WebSocket is exempt from CORS, so a
cookie-borne credential would make the Origin list the data plane's only access
control, and that list no longer carries a wildcard either.

The existing credential answers first on every carrier, so a client that has one
behaves exactly as it did and the new lane serves only a browser holding nothing
else. meet decides a room join and does not own the workspace rows, so team
publishes them on the internal plane and meet asks, off the boundary's own
attestation rather than off headers a client can set. analytics is untouched:
its trust order already resolves a validated IAM bearer ahead of the team token.

The HS256 arm is deleted when login mints IAM-only and front/love/
analytics-collector verify IAM; getWorkspaceInfo is the one surface that still
needs a client change first.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:55:46 -07:00
antje 4c1686e9e1 console pin → sha-f8d8325: reach-first Models, one-lineage main
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 14s
CI/CD / containment (push) Successful in 1m39s
CI/CD / image (push) Failing after 17s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Carries the converged console main: the Recent + Suggested strip on Models,
the topbar slimmed to navigation (environment, theme and alerts fold into
the account menu and drawer; production is the default environment), the
cross-app launcher restored, and the assistant defaulting to Enso. The pin
names the image GHCR serves — probed 200 with a negative control — and the
sha is the forge lineage's, which is the lineage that builds.
2026-08-04 21:54:46 -07:00
antje 12ae6f0dd9 zip v1.25.0 — spans and the request record ship over native ZAP
The framework boundary now exports a server span and a request record per
request to the o11y ears this binary already runs (planesink: 4317 spans,
4318 logs), shaped by zip against the collector's own receivers — no SDK,
nothing any plugin inherits. The address is the switch: O11Y_SPANS_ADDR and
O11Y_LOGS_ADDR turn each signal on, and O11Y_METRICS_ADDR — the name the
manifest already states — is now genuinely read (v1.24.x read a name the
manifest had stopped matching and reached the right ear by loopback
coincidence).
2026-08-04 21:54:46 -07:00
zeekay a7b3ccacfa Merge remote-tracking branch 'origin/main'
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:51:00 -07:00
zeekayandhanzo-dev 5a368bd261 our own modules resolve from our own forge, and the wire rail ships
Hanzo CI/CD / cicd (push) Successful in 10s
CI/CD / gate (push) Successful in 11s
CI/CD / containment (push) Successful in 1m45s
CI/CD / image (push) Failing after 21s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Every cloud release since 62590c3 — nine consecutive commits, four days — was
blocked in the `containment` job, and nothing about those commits caused it:

  go: github.com/hanzoai/zen@v1.4.10: invalid version: git ls-remote ...
      remote: Repository not found.

The token was valid and non-empty. hanzoai/zen simply has one collaborator where
its sibling modules — ai, commerce, orm, account — have fifteen, so the build
identity could read every private module in the graph except that one. GitHub
answers 404 for "private and not yours" and for "does not exist" alike, so the
error could not distinguish an ACL from a deletion. An ACL drifting beside the
code stopped the fleet, and the earlier green runs only hid it behind a warm
module cache.

So the build no longer asks GitHub for code that is ours. The module PATH stays
github.com/hanzoai/* — that is the package's name, not its address — while git
dials git.hanzo.ai, which is canonical anyway. go.sum is untouched and still
decides: the forge mirrors the same objects, the zip hashes to the committed h1:
line, and a forge serving different bytes would fail the build instead of
shipping them. Proved cold, with no GitHub credential present at all. GH_PAT
remains the fallback for anything the forge has not mirrored, and the four
modules it was missing (goauthorizenet, namespace, sendgrid-go, commerce) are
mirrored now.

Riding along, because it could not ship until the train moved: commerce v1.50.3
carries the wire rail reading the deployment's bank through the host's secret
plane, the payer reference on the wire memo, and the saved-card and tier guards.
Its work had landed on the lineage the history rewrite replaced, invisible to
the module path everything actually resolves; it is replayed onto the published
history, with thirdparty/square taken from the published side because fitKey()
is applied at all three Square boundaries there and carries tests.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:44:41 -07:00
hanzo-dev 8bdf63c6b9 build(console): pin the embed by semver, not sha
The pin named a sha, then a digest. Both say which bytes built the bundle
but not which console RELEASE it is, so "what console is in cloud
v1.801.N" needed a second lookup, and the last six bumps read like
receipts for nothing.

The builder already publishes both: sha-<sha7>-amd64 on every main push,
and the bare semver on a cut v* tag. v8.5.37 is the tag for console
origin/main's tip.

This SUPERSEDES the digest pinned by #383 rather than reverting it: that
pin shipped the anonymous-visitor entry fix, and v8.5.37 contains that
commit (b1a76fff2a, verified ancestor) plus the toast render-loop fix --
the OAuth return stacked twelve identical cards and now raises one. It is
a strict forward move, not a swap.

Both images were pulled in-cluster with the fleet's own credentials before
this was written, because pinning an image the registry cannot serve has no
rollback path: v8.5.37 is 2383695 bytes, the superseded digest 2375128.

The discipline shifts rather than disappears. A digest cannot be re-pushed
to different bytes; a semver tag can, and :v8.4.118 was. So the rule is now
that a cut tag is never re-pointed -- cut the next patch instead.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 21:41:58 -07:00
zeekayandhanzo-dev 29ac3d1a96 make compose: ask the kernel for a port instead of owning a namespace
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 11s
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
The recipe handed each app 41000+index*10 and that was accidental complexity.
The question is "does this binary compose". Answering it does not require owning
a port namespace, a stride, or an index.

It also answered WRONG. A second run inside sixty seconds collided with the first
run's sockets in TIME_WAIT — which `ss -lnt` does not show, so the ports looked
free — and reported up to SIXTEEN healthy apps as DIED. A check that invents
failures gets ignored exactly as fast as one that misses them, and this check
exists because fifteen plugins reached production without one.

:0 on all four listeners deletes the bookkeeping, the stride, the TIME_WAIT
window and the concurrency cap in one move. The kernel already allocates ports
correctly; the recipe just had to stop doing it by hand.

Measured on this tree, same command, minutes apart:
  before  >> compose FAILED: 16 of 120   (15 of them "address already in use")
  after   >> compose FAILED:  1 of 120

The one is `kafka`, which fail-closes when no broker answers at
nats://127.0.0.1:4222. That is the app being honest about a missing dependency,
it reproduces identically on unmodified main, and it is not a composition fault.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay 05c82f45c0 the running build states its own commit, on the health payload it already serves
v1.801.426 was pinned, rolled out and served traffic while the job meant to build
it sat Failed. The image had been built from OLDER source: two fixes reported as
shipped were not in the running binary, and nothing detected it, because there
was no way to ask the process what commit it was. /v1/health returned
{"status":"ok"} and nothing else, /v1/version was a 404, and no revision variable
existed in the Go source. Establishing the truth took exec-ing into the pod,
running the image's own /admin binary and reading its panic.

ONE fact, stamped ONCE, read through ONE function:

  -X github.com/hanzoai/cloud.revision=<40-hex>

reported on the health payload every one of these processes already serves —
/v1/health on the product API, and /healthz, /readyz, /health on the ops listener
(CLOUD_HEALTH_LISTEN, the unauthenticated in-cluster read). No new route, no new
port, no new env var. The two surfaces built their bodies separately — one a map,
one a fixed byte string — which is exactly how a field comes to exist on one and
not the other, so both now build from healthBody and a literal can no longer miss
an addition.

MEASURED, on plugin/base linked with the flags the Dockerfile passes:

  :18110 /v1/health  {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}
  :18112 /healthz    {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}
  :18112 /readyz     {"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062",...}

and, linked with no -X at all, {"revision":"unknown"} on all three.

UNKNOWN IS A VALUE, NOT A BLANK. Only a full 40-hex lowercase object name is
reported; an unexpanded ${REVISION}, a branch name, "dev", a short sha and the
empty string all read "unknown" — measured on a real binary stamped `main`, which
serves "unknown". A value that is NEARLY a commit is worse than none, because
someone acts on it. That rule is cloud.IsCommit, and it is the rule the BUILDER
already applied before passing build-arg:REVISION, so apps/platform's private
isCommitSHA copy is deleted and calls it: the two ends of that wire cannot drift
into disagreeing about what they hand each other.

THE STAMP NOW REACHES THE STAGE THAT SHIPS. `ARG REVISION` existed already — in
the FINAL stage, feeding the OCI label, invisible to `go build`, because an ARG
is per-stage. The wire was connected at one end. Worse, the one -X that did exist
reaches nothing: cmd/cloud does not link the root package, so
`-X …cloud.Version=` on /cloud has always been dropped — measured, the flag is in
that binary's `go version -m` record and the value is nowhere in its bytes. The
PLUGINS serve /v1/health and they carried no -X whatsoever, so stamping only the
entrypoint would have left the answering process mute. Both facts now go into one
GO_LDFLAGS used by every binary in the image.

`-X` on a symbol that does not resolve is SILENTLY DROPPED, and under the rule
above a dropped stamp reads as the legitimate "unknown" — invisible, exactly like
the image revision LABEL that has read `unknown` in this fleet with nobody
noticing. Two things close it: the image greps its own linked binaries for the sha
(`go version -m` is not a witness — it echoes the flag that was REQUESTED, present
even when the symbol was never set), and version_test.go LINKS a real binary and
asks the process. The first draft of that test read the child's EXIT CODE, and a
deliberately mis-named symbol sailed through it green, because a test that skips
itself exits 0 like one that passes; it reads the child's OUTPUT now, and the
mutation fails it.

The Dockerfile's ARG sits as late as it can, below `COPY . .` — everything from
there down is already re-keyed by any source change, so a per-commit value costs
nothing, while the same value in scope above would re-key `go mod download` and
turn every build into a full one.

`make` builds report "unknown" on a dirty tree, correctly: REVISION comes from
`git describe --always --abbrev=40 --match='' --dirty`, and `-dirty` is not a
40-hex name, so an uncommitted tree cannot name a commit whose source is not what
was built — the same lie, locally.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay a284ccf282 scope, plugins: the three composition failures become tests, and two more are named
Three defects shipped today because the only thing that could see them was a
running binary and nothing ran one. Each is now the smallest program that
reproduces it.

scope_group_test.go — the group, which broke twice in one day, in opposite
directions:

  - TestGroupWithLaterUseComposes. `g := app.Group(p); g.Use(mw)` while the
    routes register through ZipApp on the ROOT. Group returning the raw router
    hangs middleware on a node whose subtree is necessarily empty; zip refuses
    that at boot, which is what crash-looped fifteen plugins. Asserted with
    zip.App.Build — Listen minus the sockets, verdict returned rather than
    thrown. RED against the raw-router Group with the exact production text.

  - TestGroupPrefixesWhatRegistersThroughIt. The opposite kind: a program that
    composes perfectly and answers somewhere else. A child Group must prefix
    what registers through it down BOTH paths — the route methods and OpScope,
    because `zip.Get(app.Group("/v1"), "/bots", h)` is a real idiom here — and
    must leave an absolute path at the subsystem root alone. Asserted on the
    composed route table, since no compose check can see a route that merely
    MOVED. RED separately against each half of the fix.

  - TestGroupUseOutsideThePrefixesFailsTheMount / ...IsAllowed. Confinement
    through the door Group opened, and its limit: middleware at a prefix the
    subsystem does not own installs nothing and fails the mount, while a BARE
    group there is ordinary — a prefix is just a path.

plugin_surface_test.go — the two drifts, DERIVED from the specs the mains
declare (go/ast) and the routes the committed projections hold, so a subsystem
added tomorrow is checked tomorrow:

  - TestHealthOwnershipMatchesWhatIsRegistered. OwnsHealth is a claim with two
    halves. Claimed falsely, one address is declared twice and zip refuses the
    program — reverting plugin/authz/main.go reproduces the crash verbatim:
    `GET /v1/authz/health: declared by "authz" at serve.go:324 and by "authz" at
    serve/mount.go:31`. Claimed while owning nothing, the address silently 404s.
    The second half asks the manifest which health address is this app's rather
    than assuming /v1/<name>/health — plan answers /v1/plans, storage /v1/s3.

  - TestDeclaredPrefixesCoverTheSurface. A grant a main WRITES must cover the
    surface it serves. One-directional on purpose: containment, never equality,
    or deploy's 14-leaf row makes its own /v1/deploy bridge an escape. It checks
    the half a document can answer; middleware has no address, so the other half
    is `make compose`, which runs the binaries.

plugin/bot/main.go — bot declared OwnsHealth: true and registers no health
route anywhere, so the field's only effect was to suppress serve.go's generic
route and leave GET /v1/bot/health answering 404. Measured before: 404. After:
{"service":"bot","status":"ok"}. Nothing changes in the fleet, where /v1/bot/health
routes to `runtime`; this is the standalone binary's own liveness answer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b13196c728 make help: a target with a digit in its name is still a target
The awk class was [a-zA-Z_-]+, which excludes digits, so `make help` silently
omitted e2e and e2e-ui — two targets that have existed all along and that nobody
browsing help could discover. One character.

Found by running `make help` in console and reading the output against the file,
which is the only way this shows up: the recipe succeeds either way.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b42e7bf0ec make compose: concurrent, because an hour-long check is one nobody runs
The timeout is the cost and it is paid per app, so one-at-a-time made this most of
an hour for 120 apps. A check that takes an hour gets skipped, and a check that
gets skipped is how fifteen crash-looping plugins reached production in the first
place — the mechanism has to be cheap enough that it is actually used.

Each app already had its own data dir and its own port block, so nothing contends;
xargs -P just stops them queueing. Failures go to files rather than racing onto
stdout, and the summary names how many of how many failed. Measured: 120 apps in
minutes rather than ~50.

First full run on this tree: `>> compose: 120 apps boot`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay d79b53d396 make: dev and lint, the two fleet-wide names this file was missing
Aliases, not recipes: dev -> run, lint -> vet. Both targets already existed
and already did the right thing; only the names the rest of the fleet uses
were absent, so there is still exactly one recipe behind each.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
hanzo-devandzeekay b474e5d71d make compose: prove every app BOOTS, because go build cannot
zip refuses to compose a program whose middleware could never run, and it
refuses at BOOT. So on v1.801.425/.426 fifteen plugins compiled, linked, vetted
and passed unit tests, then crash-looped in production. Nothing in this Makefile
could have caught that: the only thing that finds a compose panic is running the
binary.

SURVIVAL is the signal, and it is the only honest one. A compose panic is fatal,
so a process still alive when the timeout kills it (rc 124) composed. Grepping
the log for a success line does NOT work — `"message":"zip new"` is printed
BEFORE composition, and reading it as a pass is exactly how a broken build got
reported as shipped twice in one day.

Each app gets a writable data dir and its OWN four ports, because without them it
dies on `mkdir /var/lib/cloud/orgs` or on binding :8080/:9653/:9090/:8081 long
before it reaches the router — and an early death looks like silence, which reads
as a pass. That mistake is why a "16 binaries, 0 panics" check was worthless: the
binaries had exited before composing.

It reuses `apps`, so there is one way to build an app binary and no second
mechanism. `clean` takes the scratch dir with it.

Proven both directions on this commit:
  GREEN  make compose APPS="admin prefs"   ->  >> compose: 2 apps boot
  RED    inject `ZipApp(app).Group("/v1/prefs/zzz").Use(Bridge())` into prefs
         ->  PANIC prefs
             zip: the group "/v1/prefs/zzz" declares middleware at
             prefs/prefs.go:147 and no routes anywhere beneath it
         ->  >> compose FAILED, make exits 1
(The injection is needed because every real call site is already fixed — deploy's
was corrected by bd2c284e, so it can no longer reproduce the fault.)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:39 -07:00
07ff50327a console: pin the embed that stops an anonymous visitor bouncing off a second landing (#383)
hanzoai/console d761fbc. Clicking "Sign in" on cloud.hanzo.ai appeared to do
nothing: console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page
wearing the byte-identical @hanzogui/shell header, so the click landed on a page
indistinguishable from the one it left and read as a re-render. Reaching hanzo.id
took three clicks, two of them through pages that only asked "did you mean it?".

The console is the application; the marketing face is cloud.hanzo.ai. `/` is no
longer a special surface — an anonymous visitor STARTS the authorize hop.

Pinned by digest, not the usual sha-<sha7>-amd64 tag: that build published to
:latest, which is the moving target this file already warns about. The digest
names these bytes and no other.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-08-04 20:21:49 -07:00
zeekayandhanzo-dev 058e434ac6 A peer call is a NAME, not a URL: generate the typed client from the one registry
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / gate (push) Successful in 12s
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
The billing gate died of a peer call that was an HTTP URL. COMMERCE_URL defaulted
to the public api.hanzo.ai edge — which is THIS binary — so the /v1/billing/*
forwarder re-entered itself, and apps/commerce/transport still carries the scar
tissue: a whole app republished as an http.Handler, every edge middleware re-run
per peer read, and a goroutine-keyed depth counter (maxDepth = 8) to stop the
recursion it cannot otherwise prevent. A call by name cannot express that mistake,
because there is no address to point at the wrong thing.

An app already declares everything a caller needs:

    zip.Post[plane.BalanceIn, plane.Balance](cloud.Plane(), "/finance/balance",
        planeBalance, zip.WithOperationID(plane.FinanceBalance))

— the app, the op, the request type and the response type, in one expression.
Cloud already projects that registry as OpenAPI, a CLI, an MCP tool list and a
routing declaration. A typed Go client for a peer call is ONE MORE PROJECTION of
it, which is why it is generated here rather than hand-written once per caller.

    commerce.FinanceBalance(ctx, &plane.BalanceIn{Currency: "usd"})

plane/gen emits one package per peer (14 apps, 28 ops) holding ONLY request and
response types and call stubs. What it buys is a check no care buys today: a
hand-written peer call is four independent facts that must agree at RUN time, and
nothing stops pairing commerce's op with iam's name, or BalanceIn with Txns. The
wrapper fixes all four to each other where they are declared.

THE CLIENT HALF MOVED TO THE LEAF, and that is what makes any of this possible.
package cloud is itself a caller — the edge rate-limiter reads finance_scope_rules
— so a client that imported cloud could never be imported BY cloud, and the one
call that most needed to stop being a URL is the one the mechanism could not have
expressed. Ask and everything under it now live in package plane; cloud keeps the
server half (Plane, ServePlane) because binding a socket reports itself to o11y.
cloud.Ask stays as a forwarder, so the 44 existing call sites do not move and
there is still exactly one implementation.

It does not drag the peer's tree: plane/commerce is 355 packages against
apps/commerce's 1231, and imports zero apps/ packages — one more than the leaf it
needs. An importable client that linked the implementation would have rebuilt the
problem with extra steps.

Generated FROM SOURCE, judged BY THE RUNNING REGISTRY. zipdoc already reads these
same call sites; reading source buys hermeticity a mount cannot (no store opened,
no boot order, no app that must come up before it can be described). zip's rule —
project from the live router, never the AST — is about a host discovering a plugin
it does not build, and it still binds: plane_registry_test.go mounts commerce and
asserts the generated surface IS the live plane registry, so the generator never
gets to be quietly wrong. Reading the AST's index expression alone had already
been quietly wrong once — treasury spells its registration with inferred type
arguments, so its only op was dropped; types.Info.Instances sees both spellings.

Proven against the real thing, not a fake. plane_client_test.go mounts commerce as
a plugin process does, binds its plane socket as Serve does, and calls the
generated function: commerce answers amount="0" currency="USD" over the socket. A
cold peer with no router answers ErrNoPeer naming the app — a named absence, never
a timeout a caller would have to guess at.

Three root-package call sites converted, including both money ops — the prepaid
gate and the meter now reach commerce as commerce.FinanceAuthorize and
commerce.FinanceRecord. Those are the imports that were structurally impossible
before, so they are the proof the direction is real.

Full suite: 133 failing test names / 26 packages, byte-identical to the same
measurement on origin/main. Regression set EMPTY. go vet ./... exit 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:21:45 -07:00
zeekayandhanzo-dev 2f322c4659 merge: explorer — chain indexing named for what it is, /v1/graph freed
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
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
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:19:27 -07:00
hanzo-devandzeekay 7d12120120 explorer: chain indexing is named for what it is, freeing "graph" for the graph layer
apps/graph was never a graph database. Its package doc says so: "chain data:
your block indexers and how far each has caught up, plus the on-chain price
feeds." It proxies luxfi/indexer (explorer REST) and luxfi/graph (GraphQL) --
"graph" here meant GraphQL, not a property graph.

Both upstreams already mount under /v1/explorer (client.go: graphd default
prefix), so the name follows the contract the app already speaks rather than
inventing one.

Wire unchanged: the app keeps /v1/indexers and /v1/oracles, keeps its frozen
mount position, and the woven document moves only x-app and the tag prose.
References to luxfi/graph -- GRAPH_URL, graphQLPath, the upstream log key --
stay, because those name the upstream, not this app.

/v1/graph is now free for the embedded per-org graph layer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:17:44 -07:00
zeekayandhanzo-dev dc84b46df5 platform: a deployment's own forge is a SIBLING of its API, not a child
The self-hosted-git allowance existed, was correct in intent, and could never
match. `selfGitHost` was `deps.Domain` verbatim — "api.hanzo.ai" — while
hostAllowed grants that host or a SUBDOMAIN of it. The forge is "git.hanzo.ai":
a sibling. So every native build was refused with

    repo.url host "git.hanzo.ai" is not an allowed git provider

and the estate fell back to building from GitHub, which is exactly what broke
when hanzoai/cloud moved to hanzo-inc/cloud and the build credential lost access.
The code's own comment already said APEX; only the assignment disagreed.

selfGitHost is now the registrable apex ("hanzo.ai"), so every sibling the
deployment owns — git., ci., cd. — is a trusted build source by construction,
for hanzo.ai, lux.network, zoo.network and any white-label domain, with no
per-brand list to maintain. publicsuffix rather than "last two labels" so a
multi-label suffix (co.uk) yields the registrable domain and not the suffix
itself, which would trust every domain under it.

Proven both directions: TestSelfForgeIsAllowedFromTheApex admits
git/ci/cd.hanzo.ai and still REFUSES git.evil.com;
TestOldDomainVerbatimRefusedTheForge pins the old behaviour as the defect, so
this cannot silently regress.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:29 -07:00
zeekayandhanzo-dev a8b952f473 bot: the control plane and the door to its executor are one product, not two
CI/CD / containment (push) Failing after 10s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
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
apps/runtime was never a second product. It was the transport to @hanzo/bot —
the TS service that executes a run — plus a relay of that service's own ops
paths at /v1/bot/*. What separated it from apps/bots was a LANGUAGE boundary,
Go surface and TS executor, wearing the shape of a product boundary. Both
answer for the same thing: a bot doing your work on a real desktop.

So they merge. runtime.go becomes transport.go, ops.go becomes relay.go,
bots.Mount mounts both faces, and the manifest holds one row for the pair.

THE WIRE DOES NOT MOVE. Every path this fleet serves is the path it served
before — the whole diff to openapi.yaml is `x-app: runtime` -> `x-app: bots`
on the seven relayed operations. No CLI, SDK, MCP tool or doc regenerates to
a different address, because none of them has a different address to go to.

Two things the merge had to earn rather than assume:

  - apps/coding also dispatches to that executor, so it followed the transport
    from apps/runtime to apps/bots. That is not new coupling wearing a new
    name: coding runs its tasks ON the bot runtime, which is what the import
    now says out loud.
  - the typed-or-named gate was TWO gates, one per old package, each blind to
    half of what is now one surface. They are one gate over the whole product,
    and mountWith mounts the relay so the gate can actually see it. Two gates
    stapled together would have kept passing while measuring nothing.

/v1/bot is still shared with apps/bot, whose three deeper prefixes win on it by
specificity. That sharing is the remaining defect and it is not this commit's
to fix: apps/bot's product is connected machines, not a bot, and it is the one
that has to vacate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:26 -07:00
zeekayandhanzo-dev b180a086ff zip v1.24.6 -> v1.24.7: the remote mount is called Proxy now
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
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
zip exported TWO things named Mount and they were unrelated:

  zip/remote.go   func Mount(prefix, addr string, decl ...Declaration) (*App, error)
  112 subsystems  func Mount(app cloud.Router, deps cloud.Deps) error

The second is this repo's registration contract, now enforced by the compiler
through cloud.MountFunc. The first was a leftover — one of five composition
verbs (Listen/Mount/Add/Graft/Use), the other four dissolved into Use, and this
one survived only by being the one that pointed at another process.

Renamed upstream to Proxy, which is what it BUILDS: every route it registers
runs one handler whose whole body is forward() to the given address. It is also
a noun, which a function returning a value should be. Not Remote — zip.Remote
already exists and is the CALLER's side; this is the SERVER's side, a stand-in
inside this program's own routing table.

Source-compatible here: cloud had no zip.Mount call site, only the one comment
in cmd/cloud, which now names the function that exists.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:11:33 -07:00
zeekayandhanzo-dev 633b692511 the store prologue that 33 apps copied, and the one that got it wrong
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
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
`type Store` is declared 40 times under apps/ and 35 of those are structurally
IDENTICAL — `struct { db *sql.DB }`. That number is a trap and I am not acting
on it: in Go, git.Store and bot.Store are different types because they are in
different packages, each carries its own app's schema in its own methods, and
"consolidating" a one-field handle wrapper would produce `type Store struct{
shared.DB }` — a rename that moves no code and removes no duplication. 35 of 40
share a shape; ~0 share a MEANING. Optimising that number would be pure damage.

The duplication the number was pointing at is one level down, in the
CONSTRUCTOR, and it is exact. 33 stores open themselves with byte-identical
code modulo their own name:

	db, err := cek.Open(namespace.System(), "<app>", dir)
	if err != nil { return nil, fmt.Errorf("open <app> store: %w", err) }
	sqlpool.Single(db)

Those three lines are a PAIR that nothing paired. sqlpool.Single's own doc says
a two-statement read-modify-write (tracker's per-project issue number, agents'
MAX(seq)+1) is atomic ONLY because no second connection can interleave — so the
cap is a correctness requirement, and it was a separate call every caller had to
remember. 33 remembered.

apps/framework did not. It opens a cek database through an engine OpenDB
callback and never capped it, so its DocType store has been running with an
uncapped pool. That is the defect, and it is the one a 33-way copied prologue
exists to produce: the rule holds until someone writes the 34th store.

sqlpool.Open is the rule moved INSIDE the opener, so there is nothing left to
forget — the same argument as cloud.App being the only way to obtain an app.
36 call sites converted, framework included; the stores that open differently
(git takes a *sql.DB from the OrgStore cache, others carry extra migrations)
still do, because they are different and the point is not uniformity.

Measured, not assumed: shapes compared by AST field set, not by name. Of the
other collision families the brief named, Result (9 decls) and Config (10) have
ZERO structurally identical pairs, and Client has exactly one family of 3
(`base string; http *http.Client; token string`). Those are Go's package
qualifier working, and they were left alone.

Regression set EMPTY by name: all 15 failures in the touched packages
(TestDocTypeAndDocumentRoundTrip, TestForkCreatesProjectFromTemplate, the Redeem
family, …) fail identically on forge/main.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:08:31 -07:00
zeekayandhanzo-dev caae99ef8a five apps stop demanding more of the framework than they use
CI/CD / containment (push) Failing after 7s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
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
All five diverged from the canonical Mount signature, and the divergence was
invisible because nothing checked it. Now MountFunc does (see the parent commit),
so these are what the compiler demanded.

  agent, ai, commerce    took *zip.App, so they had to be wired through the
                         old App field — which ALSO granted app-wide middleware.
                         They now take cloud.Router and reach the concrete app
                         through cloud.ZipApp, the named hole for it.

                         agent installs no middleware at all (hanzoai/agent
                         calls Use nowhere) and now mounts SCOPED: it had the
                         whole binary's gate to buy a typed-op registry.
                         ai and commerce genuinely do gate everything —
                         commerce wraps all of /v1 — and now DECLARE it as
                         Plugin.Global at their composition root, where a
                         capability belongs, instead of implying it with a
                         parameter type.

  dataroom               imported cloud as `hcloud`. The alias is cosmetic to
                         the compiler and fatal to every grep-based check —
                         which is how these five stayed invisible. One name.

  treasury/anchor        not a Mount, but the same disease one level down and
                         the cleanest Pike case in the repo: status() took
                         ledger.Backend — ELEVEN methods — to call Root and
                         nothing else. The parameter said the anchor could
                         accrue revenue, seed the reserve, debit a program and
                         rewrite the revenue-share policy. It can do none of
                         those. It now takes a one-method `rooted`, declared by
                         the consumer, which both backends satisfy without a
                         line of change.

RETRACTED: rollingcap's `func Mount(_ cloud.Router, _ cloud.Deps) error` was
reported to me as the fifth violation, possibly dead. It is neither. It installs
the rolling AI-spend cap reader — live, and load-bearing — and `_` is CORRECT Go
for parameters it genuinely does not read. It already IS a MountFunc; the
compiler says so. Naming those parameters to satisfy a text pattern would make
the code worse. Left exactly as it is.

Regression set EMPTY by name (148 failing tests before, the same 148 after).
apps/commerce's TestBalanceCents and TestInProcessClient fail identically on the
parent commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:54:38 -07:00
zeekayandhanzo-dev a6b174e4e4 one Mount signature, checked by the compiler; Router stops restating zip's
Two names in this file each meant two things, and both cost the fleet something
real.

1. Plugin had TWO mount fields with TWO signatures

    Mount MountFunc                    // func(Router, Deps) error
    App   func(*zip.App, Deps) error   // "for a subsystem that gates everything"

App braided a POLICY question (may this subsystem install middleware over the
whole binary) into a TYPE question (what shape is its entry point). Because the
grant carried its own signature, a subsystem could take the grant just to get
the concrete *zip.App — and three did. agent, ai and commerce each held app-wide
middleware authority they never asked for, purely because their Mount named a
concrete type. hanzoai/agent calls Use nowhere; agent's grant bought it nothing
and risked everything after it in the mount order.

Nothing reported this. The field's own doc claimed "apps.TestWireFrozen fails on
a new one"; no such test exists anywhere in this repo — grep it. A contract
stated in a comment and violated five times means nothing is checking it.

Unbraided: App becomes `Global bool`, and every subsystem — scoped or global —
goes through spec.Mount. The grant now decides only WHICH Router arrives (the
bare app, or a scope bound to declared prefixes). One shape, so MountFunc is the
whole enforcement and it is the compiler: at 123 composition roots a divergent
Mount cannot be assigned and cannot link. It found a sixth violator I had missed
by grep on the first build — cloud.MountMetrics, whose doc says "adapts
hanzoai/metrics into a MountFunc" while its signature took *zip.App. It is one
now, and mounts SCOPED (hanzoai/metrics installs no middleware either).

The concrete app stays reachable through ZipApp, the named hole that already
existed for exactly this and reports nil rather than pretending.

2. Router restated zip.Router instead of embedding it

Ten method lines were COPIED here. Copying an interface makes cloud a second
place zip's routing surface is defined, and the two agree only while someone
keeps them agreeing. When zip v1.23 widened one signature (Use took Component,
not Handler), every implementor that had spelled the methods out had to move in
lockstep — which is what stalled v1.19+ adoption across the fleet.

Embedded, a zip routing change costs this file zero edits, and Fiber() +
Plugins() are visibly what cloud ADDS rather than being buried among ten lines
cloud merely echoes. It carries zip.OpTarget along, which is a gain, not a
widening: scope, *zip.App and commerce's mintRouter all already have OpScope,
and a Router that IS an OpTarget is one zip.Get[In, Out] accepts directly.

Regression set EMPTY, compared BY NAME against a pristine worktree at the same
commit: 148 failing tests across 29 packages before, the identical 148 after
(comm -13 on the sorted name sets is empty in both directions).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:54:38 -07:00
zeekayandhanzo-dev e8c34bfa31 ai v1.832.29: paying subscribers on go/dev/max/team/business were scored FREE
commerceTierToLadder allow-listed only `starter`->trial and `pro`/`enterprise`
->paid, and folded EVERYTHING ELSE to free. The plans commerce actually sells
are go ($9), dev ($19), pro, max, team and business — so a subscriber on
go/dev/max/team/business was scored free, then refused every SKU carrying a
trial or paid floor AND throttled by the free-tier flash cap on top. We took
their money and shut the door.

v1.832.29 inverts the default: a plan is PAID unless it is explicitly free
("", "free", or a `*-free` suffix), with starter/trial the one middle rung.

That direction is safe because A TIER IS NOT A PAYMENT. filter_balance.go has
no exemptions and fails closed — "nothing runs on credit it has not been funded
for" — so an unrecognized plan reaching `paid` still cannot spend a cent it has
not been funded. The OLD default was the dangerous one: it silently cost
revenue every time anyone added a plan slug. Now, add a plan and it works.

Both tests had encoded the defect as intent ("developer"->free,
"mystery"->free) and were corrected to the real plan list. 22 packages ok,
0 FAIL at the module.

Also carries the zen seed fix (v1.832.24) and the object TestMain fix
(v1.832.27), which took that package from ZERO tests executing to 153 PASS —
TestMain called os.Exit(0) before m.Run(). That immediately surfaced a
crawl-storage default still naming the retired MinIO Service, a correction that
had been made twice and could never take effect.

Verified: `go build ./...` clean at this pin.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 19:43:38 -07:00
177 changed files with 9092 additions and 1699 deletions
+93 -17
View File
@@ -196,6 +196,7 @@ jobs:
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
# GOPRIVATE names exactly the namespace that is private. github.com/hanzoai/*
# is: ai, account, commerce, orm, xorm, beego, csqlite and ~30 more are
@@ -203,7 +204,30 @@ jobs:
# that cannot see them. Everything else stays on the public proxy + checksum
# db, which is what makes a module hash immutable: zap-proto (all 55 repos)
# and luxfi (all 37 deps here) are public and proxy-served.
#
# OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. The module PATH stays
# github.com/hanzoai/* — that is the package's name, not its address — but
# the address git dials is git.hanzo.ai, which is canonical anyway.
#
# This is not a preference. Every release for nine consecutive commits was
# blocked because hanzoai/zen's GitHub collaborator list drifted from its
# sibling modules': the token could read ai, commerce, orm and account, and
# answered `Repository not found` for zen alone. A private repo denies and
# a missing repo denies with the same 404, so the build could not even say
# which had happened. Nothing about zen changed; an ACL beside it did, and
# it stopped the fleet.
#
# A checksum makes the substitution safe rather than merely convenient: the
# forge mirrors the same objects, so the fetched zip hashes to the h1: line
# already committed in go.sum. A forge that served different bytes would
# fail the build, loudly, instead of shipping them.
#
# GH_PAT remains the fallback for a module the forge has not mirrored.
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -282,6 +306,47 @@ jobs:
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: The commit must exist on github before a tag can name it
env:
GH_PAT: ${{ secrets.GH_PAT }}
SHA: ${{ github.sha }}
# The claim below reserves a version by creating refs/tags/v<N> AT THIS
# COMMIT on github.com. A ref can only point at an object that is there,
# so the claim answers 404 — "Object does not exist" — for a commit github
# has never seen, and refuses to build.
#
# It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical
# and where the push lands; github is fed by a PUSH MIRROR on an 8-HOUR
# interval, and the claim runs seconds later. So the object the tag must
# name is normally hours away, and every release in that window fails on a
# 404 that reads like a permissions problem and is really a race. It cost
# the fleet four days of releases stacked behind one.
#
# Publishing the commit here closes the race at its cause: after this step
# github HAS the object, whatever the mirror's schedule. It goes to a ref
# of its own rather than to main, because main is the mirror's to move and
# the two lineages do diverge — this step's job is to make the object
# exist, not to decide what main is.
run: |
set -euo pipefail
api="https://api.github.com/repos/hanzoai/cloud"
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "commit ${SHA} is already on github — nothing to publish"
exit 0
fi
echo "commit ${SHA} is not on github yet (push mirror runs every 8h); publishing it now"
git push --force "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
"${SHA}:refs/heads/forge-head"
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "github now resolves ${SHA}"
exit 0
fi
sleep 3
done
echo "::error::pushed ${SHA} to github but it still does not resolve — the claim below would 404 on an object that is not there"
exit 1
- name: Claim a version — atomically, before anything is built
id: ver
env:
@@ -492,6 +557,7 @@ jobs:
org.opencontainers.image.source=https://github.com/hanzoai/cloud
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
FORGE_TOKEN=${{ secrets.FORGE_TOKEN }}
# build-push-action can exit 0 before the manifest resolves, so a green run
# could still mean a future ImagePullBackOff. Prove it pulls BEFORE the pin
@@ -621,7 +687,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
# pin.sh probes the registry before it moves anything; these let it read
# a private manifest instead of falling back to anonymous.
@@ -632,16 +697,21 @@ jobs:
VERSION="${{ needs.image.outputs.version }}"
# Secrets come from KMS, never from a file or a repo variable.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
PIN_TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV})"; exit 1; }
# THERE IS NO ORG IN A KMS PATH. The store root comes from the validated
# claim, so the org is the credential's, not the URL's — that is what
# makes another tenant's secret unnameable rather than merely refused.
# This asked for /v1/kms/orgs/<org>/secrets/... which is not a route the
# broker has, so it 404'd on every release since the car was written.
PIN_TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty' || true)
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at deploy/ (env ${KMS_SECRET_ENV}, org from the KMS credential)"; exit 1; }
echo "::add-mask::${PIN_TOKEN}"
git clone --quiet --depth 1 \
@@ -706,13 +776,19 @@ jobs:
with:
go-version-file: go.mod
# Same private-module contract as the containment job: github.com/hanzoai/*
# is private, so it must resolve direct+authenticated and skip a sumdb that
# cannot see it, while everything else stays on the public proxy.
# Same private-module contract as the containment job, including the forge
# substitution: our own modules resolve from git.hanzo.ai (the canonical
# address) with GitHub as the fallback, so one drifted GitHub ACL cannot
# stop a release. go.sum still decides whether the bytes were right.
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -775,7 +851,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
run: |
set -euo pipefail
@@ -798,17 +873,18 @@ jobs:
# naming the exact KMS path to create, instead of quietly shipping a
# cloud nobody's client knows about. That silent version is what the
# fleet has been living in.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
# Same route correction as the pin above: no org in a KMS path.
TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty' || true)
if [ -z "$TOKEN" ]; then
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV}). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at deploy/ (env ${KMS_SECRET_ENV}, org from the KMS credential). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
exit 1
fi
echo "::add-mask::${TOKEN}"
+89 -5
View File
@@ -44,7 +44,23 @@
# BUMP: when a console/skills change must reach production, move its pin here in
# the same commit that claims it. That is what makes a cloud release
# reproducible and makes "what console is in v1.801.N" answerable from git.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:sha-a0a4899-amd64
#
# CONSOLE IS PINNED BY SEMVER, not by sha. `sha-<sha7>-amd64` is what the builder
# publishes on every main push; `v<X.Y.Z>` is what it publishes on a cut v* tag,
# and that is the one to name here — the pin then says which RELEASE of the
# console a cloud image carries, which a sha cannot.
#
# The tradeoff is real and the discipline changes to match: a sha tag cannot be
# re-pushed to different bytes, whereas a semver tag CAN be moved (`:v8.4.118`
# was, in this fleet). So the rule that keeps this reproducible is now a rule
# about tags, not about tag SHAPE: a cut tag is never re-pointed. Cut the next
# patch instead — that is cheap, and it keeps "which console is in v1.801.N"
# answerable from git alone.
# 8.5.48 IS sha-f8d8325 (both tags resolve to sha256:c16431e21c8e): the console
# carrying reach-first Models, named by its release rather than by the commit that
# happened to build it. Pinning the sha would have shipped the same bytes and left
# 'which console is in this image' answerable only by cross-referencing a build.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:8.5.48
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:sha-b931a11-amd64
# ── toolchain base images: the golang + alpine FROMs below pull from our own
@@ -131,8 +147,20 @@ COPY go.mod go.sum ./
# and resolves fine from a clean cache. That is exactly what wedged the release
# on otel-collector v0.144.10. BUMP THE SUFFIX (-v4 -> -v5) to force a cold
# module cache the next time a phantom pin poisons it.
# FORGE_TOKEN, when supplied, points our OWN modules at git.hanzo.ai. The module
# path stays github.com/hanzoai/* — a name, not an address — and git dials the
# canonical forge instead. The longer prefix wins in git, so only hanzoai/* is
# redirected and every other github.com module still goes to GitHub. go.sum is
# unchanged and still authoritative: the forge mirrors the same objects, so the
# zip hashes to the committed h1: line, and a forge serving different bytes fails
# the build rather than shipping them. Both secrets are optional; absent either,
# this falls back to exactly the previous behaviour.
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=secret,id=FORGE_TOKEN \
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
if [ -s /run/secrets/FORGE_TOKEN ]; then \
git config --global url."https://x:$(cat /run/secrets/FORGE_TOKEN)@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"; \
fi && \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
@@ -201,6 +229,37 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
go generate -run zipdoc ./...
# The commit this image is built FROM, handed in by the SAME builder that already
# feeds it to the OCI label in the final stage (apps/platform buildFrontendCmdRev,
# `--opt build-arg:REVISION=<sha>`; the other lane passes github.sha).
#
# `ARG REVISION` already existed — but ONLY in that final stage, and an ARG is
# per-stage, so it was never in scope where `go build` runs and no binary in this
# image could name its commit. The wire was connected at one end.
#
# Do not "fix" it by trusting the label. A label is read by whoever thinks to open
# the registry; the PROCESS is read by whoever is holding the outage — and this
# fleet's revision label has itself read `unknown` on natively-built images
# without anyone noticing, which is what a label is worth.
#
# DECLARED HERE, AS LATE AS POSSIBLE, and deliberately not beside ARG VERSION at
# the top of the stage: everything below `COPY . .` is already re-keyed by any
# source change, so a per-commit value costs nothing from this line down. The same
# value in scope ABOVE would re-key `go mod download` and turn every build into a
# full one.
ARG REVISION=unknown
# ONE flag string for EVERY binary in this image. This is a build-stage variable —
# the final stage does not inherit it and nothing reads it at run time; it exists
# so the stamp cannot reach some binaries and miss others.
#
# It has to reach the PLUGINS. cmd/cloud is a router that links zip and the
# manifest, not the package these symbols live in, so `-X github.com/hanzoai/
# cloud.Version=` on /cloud has always been silently dropped — measured: the flag
# shows up in the binary's `go version -m` build record and the value is nowhere
# in the linked bytes. The plugins are what serve /v1/health, and they carried no
# -X whatsoever, so stamping only the entrypoint would have left the process that
# answers the question mute.
ENV GO_LDFLAGS="-s -w -X github.com/hanzoai/cloud.Version=${VERSION} -X github.com/hanzoai/cloud.revision=${REVISION}"
# THE LIGHT HOST (cmd/cloud) — ~400 packages, pure Go, no codec and no subsystem
# (it links zip + the manifest + the light webui console embed, and nothing else).
# It is the ENTRYPOINT. It knows only where each app lives and what path it
@@ -209,14 +268,13 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
# together, so no build in this image is the mega link that once dominated it.
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build \
-ldflags="-s -w -X github.com/hanzoai/cloud.Version=${VERSION}" -o /cloud ./cmd/cloud
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /cloud ./cmd/cloud
# The functional smoke prober (plugin/smoke) — a stdlib-only static binary shipped
# alongside the host so the release gate can `docker exec` it against the freshly-
# built image (and any deployment can be smoked via `docker run --entrypoint /smoke`).
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /smoke ./plugin/smoke
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /smoke ./plugin/smoke
# EVERY subsystem, each as its OWN binary in /plugins beside the host. The host
# fork/execs a sibling <dir>/<name> (manifest.App.Plugin) on the first request that
# reaches its prefix, so the binary must be in the image or the mount aborts:
@@ -260,8 +318,34 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
for p in $names; do \
[ -d "./plugin/$p" ] || { echo "FATAL: manifest app '$p' has no plugin/$p — run 'make generate' and commit"; exit 1; }; \
echo "building plugin $p"; \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="-s -w" -o "/plugins/$p" "./plugin/$p"; \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
done
# THE STAMP LANDED — asked of the ARTIFACT, not of the flag string.
#
# `-X` naming a path or symbol the linker cannot resolve is not an error: it is
# dropped, the build succeeds, and every binary then reports the entirely
# legitimate-looking "unknown" forever. A renamed package or variable would fail
# in exactly the one way nobody looks at, which is how this started.
#
# `go version -m` is NOT a witness — it echoes the -ldflags string that was
# REQUESTED, and that string is present even when the symbol was never set
# (measured on /cloud, whose Version stamp has been dropped all along). Only the
# linked bytes answer.
#
# strings|grep rather than a bare grep: grep treats binary input as non-text and
# its exit status there is not portable across implementations, so a plain
# `grep -qF` can report no match on a binary that demonstrably contains the sha.
# strings normalises to text lines first; binutils is already installed above.
#
# An image built with no REVISION is not a failure — it is a build that cannot
# name its commit, and it says so here and on every health response it serves.
RUN set -eu; \
if [ "$REVISION" = "unknown" ]; then \
echo ">> no REVISION build-arg: this image cannot name its commit, and every health response it serves will report revision=unknown"; \
else \
strings -a /plugins/base | grep -qF "$REVISION" || { echo "FATAL: -X did not reach /plugins/base — github.com/hanzoai/cloud.revision was not resolved, so it was dropped and every health response would report 'unknown'"; exit 1; }; \
echo ">> revision $REVISION linked into the plugins"; \
fi
# Prove a SHIPPED sqlite-backed plugin binds sqlite3_* to libsqlcipher, not a
# plaintext libsqlite3. /plugins/base opens per-org stores under the SAME CGO=1 +
# libsqlite3 build every plugin above got, so it is a real witness for the set.
+43 -2
View File
@@ -1897,11 +1897,11 @@ finding is not in the count:
large-int precision. Wire preservation wins; the fix is the one `reflect.Interface`
case in zip's `schemaOf` (an unconstrained element is `{}`, not an object).
- Three `cloud.Request` entries were added and each is a request FACT, not a
tenant: graph FORWARDS the caller's `Authorization` to the indexer/graph when no
tenant: explorer FORWARDS the caller's `Authorization` to the indexer/graph when no
service token is configured, prefs' isolation key is the qualified
`<owner>/<name>` rather than the org, and admission's `?host=` default is the
request's own Host. All three fail closed off the HTTP path. Two of them had NO
test at all before — `apps/graph`'s forwarding and `apps/admission`'s Host
test at all before — `apps/explorer`'s forwarding and `apps/admission`'s Host
fallback both would have degraded silently (a 200 with an anonymous upstream
read; a 200 with `known:false` for every guard that omits the query).
@@ -3597,6 +3597,47 @@ The org an inbound webhook belongs to comes from the App INSTALLATION id via the
acked `200 {"ignored":"unknown installation"}` and silently does nothing — a 200 on
that path is not evidence it worked; check for sync/build activity.
### Ask the PROCESS which commit it is — `revision` on the health payload
```
curl -s https://api.hanzo.ai/v1/health
{"revision":"d25b0f5e70f79bfb04ec60b1f535f20db9a62062","status":"ok"}
```
Same field on the ops listener's `/healthz`, `/readyz` and `/health`
(`CLOUD_HEALTH_LISTEN`, default `:9090`) — unauthenticated, which is the
in-cluster read a rollout check makes. One field, one builder (`healthBody` in
serve.go), so no surface can carry it while its siblings stay mute.
A build that cannot name its commit answers `"unknown"` — never blank, never a
branch name, never a short sha. `cloud.IsCommit` is that rule, and the BUILDER
applies the same one before it will pass `build-arg:REVISION` at all
(apps/platform), so the two ends of that wire cannot drift.
**The version is NOT this answer.** `x-api-version` is the image TAG — an
operator's label that `CLOUD_VERSION` can restate on a pod running any image.
v1.801.426 was pinned, rolled out and served traffic while the job meant to build
it sat `Failed`: the tag was right, the image was built from older source, both
fixes it claimed were missing, and establishing that took exec-ing into the pod to
read a panic out of a second binary. `revision` is written by the linker only
(Dockerfile `GO_LDFLAGS`) and is deliberately unreadable from the environment,
because an env var can name a commit it was never built from.
The OCI `image.revision` label is fed the same build-arg and is NOT a substitute —
a label is read by whoever thinks to open the registry, and this fleet's has read
`unknown` without anyone noticing.
Two things worth knowing when reading this:
- `-X` on a symbol the linker cannot resolve is dropped SILENTLY, and a dropped
stamp reads as the legitimate `"unknown"`. The image build therefore greps its
own linked binaries for the sha after linking them, and `version_test.go` links
a real binary and asks the process rather than setting the variable itself.
- `cmd/cloud` does not link the root package, so `-X …cloud.Version=` on `/cloud`
has always been a no-op — the flag appears in that binary's `go version -m`
record and the value is nowhere in its bytes. The PLUGINS serve `/v1/health`,
and they are stamped.
## The `hanzo` name is TWO binaries — the Rust CLI, and cmd/hanzo's control half
The `hanzo` fabric CLI is the RUST binary at `~/work/hanzo/cli`. Its control-plane
+76 -5
View File
@@ -28,6 +28,22 @@ LDFLAGS ?= -s -w
# .git has nothing to describe. Empty stamps nothing, and cmd/hanzo's
# resolveVersion then answers from the metadata the toolchain embeds by itself.
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
# The commit those same bytes were built FROM — the other half of the question,
# from the same command, in the same -X idiom. `--abbrev=40 --match=''` makes
# describe report the full object name and nothing else.
#
# `--dirty` is load-bearing rather than decorative: on an uncommitted tree it
# appends `-dirty`, which is not a 40-hex name, so cloud.Revision reports
# "unknown" instead of naming a commit whose source is NOT what was built. That
# lie is the one this whole change exists to remove, so the local build must not
# tell it either. Honest by construction, with no second rule to keep in step.
REVISION ?= $(shell git describe --always --abbrev=40 --match='' --dirty 2>/dev/null)
# What a build says about itself, written ONCE: the tag it was published under
# and the commit it came from. Appended per-target for the reason above — `make
# LDFLAGS=...` keeps overriding exactly what it always did — and shared by the
# host and the plugins, because three copies of a stamp is three chances to
# stamp one binary and forget the one that answers /v1/health.
STAMP = -X github.com/hanzoai/cloud.Version=$(VERSION) -X github.com/hanzoai/cloud.revision=$(REVISION)
# Path to a hanzoai/console checkout used to build the embedded console bundle.
CONSOLE_DIR ?= ../console
# Path to a hanzoai/openapi checkout — the SOT the agent-skills catalog is generated from.
@@ -64,10 +80,10 @@ APPS := $(shell sed -n 's/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)
# them in parallel and build exactly the one you ask for.
APP_BINS := $(addprefix bin/,$(APPS))
.PHONY: help webui deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run smoke test test-fast test-cgo test-codec vet tidy docker docker-push clean e2e
.PHONY: help webui deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
webui: ## Build the real console static bundle into webui/dist (go:embed source). CONSOLE_DIR=<path to console>.
@command -v npm >/dev/null 2>&1 || { echo "npm is required to build the console bundle"; exit 1; }
@@ -121,7 +137,7 @@ build: cloud ## FAST PATH (default): build the light host into ./bin/cloud. Then
# named cloud — it IS the one real binary, and its ENTRYPOINT the image ships.
cloud: ## Build the light host into ./bin/cloud (links zip + the manifest, none of the apps).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) -X github.com/hanzoai/cloud.Version=$(VERSION)" -o bin/$@ ./cmd/$@
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o bin/$@ ./cmd/$@
@echo ">> bin/cloud — $$(CGO_ENABLED=$(CGO_ENABLED) $(GO) list -deps ./cmd/cloud | wc -l) packages, $$(du -h bin/cloud | cut -f1)"
# THE RELEASE LAYOUT: the light host plus one dedicated binary per app, all in
@@ -147,7 +163,7 @@ apps: $(APP_BINS) ## Build every app binary into ./bin. Parallelise: make -j app
$(APP_BINS): bin/%:
@test -d plugin/$* || { echo "no plugin/$* — run 'make generate', or check the name against 'make plugin' with no APP"; exit 1; }
@mkdir -p bin
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o $@ ./plugin/$*
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o $@ ./plugin/$*
plugin: ## Build ONE app into ./bin: make plugin APP=wallets.
@test -n "$(APP)" || { echo "usage: make plugin APP=<name>"; echo "apps: $(APPS)"; exit 1; }
@@ -191,6 +207,11 @@ run: cloud ## Run the host, building the plugins in RUN_PLUGINS (iam,base,kms,ga
@for a in $$(echo $(RUN_PLUGINS) | tr ',' ' '); do $(MAKE) --no-print-directory plugin APP=$$a; done
./bin/cloud
# dev and lint are the names every repo in the fleet answers to. They are ALIASES
# of the two targets that already do the work, never copies of them, so each of
# those two things still has exactly one recipe.
dev: run ## Alias for run.
smoke: ## Build and run the smoke prober (mount-time integration check).
$(GO) run ./plugin/smoke
@@ -320,6 +341,8 @@ test-codec: ## Run the suite against the engine the image ships (cgo + a real li
vet: ## go vet across the module.
CGO_ENABLED=$(CGO_ENABLED) $(GO) vet ./...
lint: vet ## Alias for vet.
# Not part of `test`: it rewrites source, so it runs deliberately, alone. It is how a
# new assertion earns its place — break the property, watch the test go RED. An anchor
# that no longer matches is a hard FAILURE here, never a skip, so a refactor that
@@ -337,5 +360,53 @@ docker: ## Build the Docker image (uses repo Dockerfile, scratch final stage).
docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
# COMPOSE is the check the v1.801.425/.426 outage needed and nobody had. zip
# refuses to compose a program whose middleware could never run, and it refuses at
# BOOT — so fifteen plugins built, linked, passed vet and unit tests, and then
# crash-looped in production. `go build` cannot see it; only running the binary can.
#
# SURVIVAL is the signal, and it is the only honest one. A compose panic is fatal,
# so a process still alive when the timeout kills it (rc 124) composed. Grepping
# the log for a success line does NOT work: `"message":"zip new"` is printed
# BEFORE composition, and reading it as a pass is exactly how a broken build was
# twice reported shipped.
#
# Each app gets a writable data dir and PORT ZERO on all four listeners. Without a
# data dir it dies on `mkdir /var/lib/cloud/orgs`, and without free ports it dies on
# binding :8080/:9653/:9090/:8081 — either way long before it reaches the router, and
# an early death looks like silence, which reads as a pass.
#
# :0 RATHER THAN A COMPUTED PORT BLOCK. This handed out 41000+index*10 and it was
# accidental complexity: the question is "does this binary compose", and answering it
# does not require owning a port namespace. Worse, it answered WRONG — a second run
# inside sixty seconds collided with the first run's sockets in TIME_WAIT, which
# `ss -lnt` does not show, and reported up to 16 healthy apps as DIED. A check that
# invents failures gets ignored exactly as fast as one that misses them. The kernel
# already allocates ports correctly; asking it removes the bookkeeping, the stride,
# the TIME_WAIT window and the cap on concurrency in one move.
#
# CONCURRENT, because the timeout is the cost and it is paid per app: one at a time,
# $(words $(APPS)) apps take most of an hour, and a check nobody runs is how all of
# this reached production. Failures go to files rather than racing onto stdout.
COMPOSE_DIR ?= .compose
COMPOSE_JOBS ?= 8
compose: apps ## Prove every app binary BOOTS — the compose check `go build` cannot do.
@rm -rf $(COMPOSE_DIR) && mkdir -p $(COMPOSE_DIR)
@printf '%s\n' $(APPS) | xargs -P$(COMPOSE_JOBS) -n1 sh -c '\
a=$$0; d=$(COMPOSE_DIR)/$$0; mkdir -p $$d/rt; \
out=$$(CLOUD_DATA_DIR=$$d ZIP_RUNTIME_DIR=$$d/rt \
CLOUD_LISTEN=:0 CLOUD_ZAP_LISTEN=:0 \
CLOUD_HEALTH_LISTEN=:0 CLOUD_ADMIN_LISTEN=:0 \
timeout 25 ./bin/$$a 2>&1); rc=$$?; \
if printf "%s" "$$out" | grep -q "does not compose"; then \
{ echo "PANIC $$a"; printf "%s\n" "$$out" | grep -E "zip: (the group|GET|POST|PUT|PATCH|DELETE)" | sed "s/^/ /" | head -4; } > $$d.fail; \
elif [ $$rc -ne 124 ]; then \
echo "DIED $$a (rc=$$rc): $$(printf "%s" "$$out" | tail -1 | cut -c1-140)" > $$d.fail; \
fi'
@set -- $(COMPOSE_DIR)/*.fail; \
if [ -e "$$1" ]; then cat $(COMPOSE_DIR)/*.fail; n=$$(ls $(COMPOSE_DIR)/*.fail | wc -l); \
rm -rf $(COMPOSE_DIR); echo ">> compose FAILED: $$n of $(words $(APPS)) apps"; exit 1; \
else rm -rf $(COMPOSE_DIR); echo ">> compose: $(words $(APPS)) apps boot"; fi
clean: ## Remove built artifacts.
rm -rf bin
rm -rf bin $(COMPOSE_DIR)
+18 -3
View File
@@ -62,10 +62,25 @@ func PinBillingSubject() zip.Handler {
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
// names its own org (same admission billingData makes), leaving its query
// untouched. Everything else is refused before the read runs.
if s2sBillingCall(c) && c.Org() != "" {
return c.Next()
//
// The two refusals are DIFFERENT answers and must not share a status. A
// service token is a credential: presenting one and omitting X-Org-Id is
// an authenticated request that names no scope, which is 403. Presenting
// nothing is not signed in, which is 401 — and the difference is load
// bearing on the customer path, because a browser re-authenticates on 401
// and merely reports 403. These routes moved here from cloud's billing
// app, which answered 401 deliberately ("a customer's own billing action,
// so no identity is 401 sign in, never the wildcard's admin 403"); serving
// them in-process silently made every one of them 403, so an expired
// session on the saved-cards screen showed a permission error instead of
// sending the customer to sign in.
if s2sBillingCall(c) {
if c.Org() != "" {
return c.Next()
}
return zip.ErrForbidden("X-Org-Id is required to scope a service-token billing read")
}
return zip.ErrForbidden("sign in to view billing")
return zip.ErrUnauthorized("sign in to view billing")
}
subject := account.Payer(account.Credential{
+4 -2
View File
@@ -122,8 +122,10 @@ func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
map[string]string{"X-Org-Id": "victim"}, "")
if code != http.StatusForbidden {
t.Fatalf("unvalidated caller: want 403, got %d", code)
// 401, not 403: no credential was presented at all, and a browser only
// re-authenticates on 401. A forged X-Org-Id is not a credential.
if code != http.StatusUnauthorized {
t.Fatalf("unvalidated caller: want 401, got %d", code)
}
}
+5 -7
View File
@@ -6,11 +6,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -33,11 +32,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "ads", dir)
db, err := sqlpool.Open("ads", dir)
if err != nil {
return nil, fmt.Errorf("open ads store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -6
View File
@@ -9,11 +9,11 @@ import (
"fmt"
"strings"
// cek is the ONE opener; the ONE Hanzo SQLite driver registers "sqlite".
// sqlpool.Open is the ONE opener (cek + the single-connection cap); the ONE
// Hanzo SQLite driver registers "sqlite".
// Mirrors clients/referrals / clients/crm — one storage pattern.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -182,11 +182,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "affiliates", dir)
db, err := sqlpool.Open("affiliates", dir)
if err != nil {
return nil, fmt.Errorf("open affiliates store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+11 -2
View File
@@ -118,11 +118,20 @@ func init() {
// Mount wires POST /v1/agent (+ reads) into cloud, injecting the ai completion and
// the tool plane. The caller identity comes from cloud's validated principal.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("agent.Mount: nil app")
}
_, err := hz.Mount(app, hz.Deps{
// hanzoai/agent registers TYPED ops, and the op registry lives on the concrete
// App — so this is the named hole (cloud.ZipApp), not a widened parameter. The
// signature stays the fleet's one MountFunc, and agent installs no app-wide
// middleware (hanzoai/agent calls Use nowhere), so it mounts SCOPED: taking the
// concrete type used to cost it the whole binary's middleware grant.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("agent.Mount: router is not a zip app — the typed op registry is unreachable")
}
_, err := hz.Mount(zapp, hz.Deps{
Logger: deps.Logger,
DataDir: deps.DataDir,
Brand: deps.Brand,
+11 -4
View File
@@ -28,7 +28,6 @@ import (
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// The MODEL API IS THE DOOR'S REGISTRY, and it is asked rather than described.
@@ -93,7 +92,15 @@ func aiProse() map[string]openapi.Said {
// Mount installs the money, ingest and telemetry wiring, then mounts ai. A nil
// callback is left alone — cloud leaves one nil exactly when that subsystem
// isn't co-resident, and the module's own fallback applies.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
// The typed MCP op and hanzoai/ai's own mount both register on the concrete
// App, which cloud.ZipApp is the named hole for. ai's app-wide reach is
// DECLARED as Plugin.Global at its composition root — it is a policy fact, not
// something a parameter type should be able to grant on its own.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("ai.Mount: router is not a zip app — the typed op registry is unreachable")
}
// One provider, one wire. cloud.Listen installed the process-global tracer
// provider before MountAll; DECLARE it to ai here so ai emits every gen_ai span
// through THAT provider instead of forking its own. Without this ai's
@@ -249,7 +256,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// The MCP door's inventory, registered BEFORE the wildcard below so the
// reading order is the routing order (see mcp.go — the router would pick the
// static path over All("/v1/*") either way).
mountMCP(app)
mountMCP(zapp)
// The door: ONE `app.All("/v1/*")` (hanzoai/ai mount.go) adapting the legacy
// beego ControllerRegister through zip.AdaptNetHTTP, so ai's ~200 real routes —
// /v1/chat/completions, /v1/models, /v1/messages and the rest — reach the wire
@@ -262,5 +269,5 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// projects routers.App's own table through it, so the published surface is ai's
// 192 paths rather than one wildcard. Typed request and response schemas for them
// are still work in github.com/hanzoai/ai, where those handlers live.
return aimod.Mount(app, deps)
return aimod.Mount(zapp, deps)
}
+34 -2
View File
@@ -105,14 +105,46 @@ const (
"Lead with the answer; be concise, factual, and well structured (short paragraphs, bullets where they help). " +
"Cite inline as Markdown links [source title](url) immediately after the claim each source supports, and cite generously. " +
"Do NOT add a References or Sources section, footnote markers, or bare URLs — citations are inline links only. " +
"If the sources conflict or are insufficient, say so plainly and answer from general knowledge while noting the uncertainty. Never fabricate facts or URLs."
"If the sources conflict or are insufficient, say so plainly and answer from general knowledge while noting the uncertainty. Never fabricate facts or URLs." +
widgetRule
// widgetRule teaches the model the ONE structured-result format the answer
// surfaces render. It is shared by every mode so the shapes cannot drift apart
// between search and research.
//
// A widget is an ENHANCEMENT, and the instruction says so explicitly: the
// client validates every block and DROPS anything malformed — a ragged table,
// an unknown kind, a field of the wrong type — keeping the prose. So an answer
// whose prose depends on a widget to make sense would read as a hole whenever
// validation refused one. The prose must stand alone; the widget makes it
// faster to read.
//
// The model supplies DATA, never markup: the renderer holds the shapes and
// escapes every field. That is deliberate — this model reads the open web, so
// any page it fetches is a potential injection source, and markup it authored
// would be a path into the extension's origin.
widgetRule = "\n\nWhen the question's SHAPE calls for one — a comparison, a procedure, " +
"key figures, a chronology, a single entity, or a term to define — ALSO emit exactly one " +
"structured result block, fenced as ```hanzo-widget containing only JSON. Use at most two " +
"per answer, and only when the shape genuinely fits; most answers need none. " +
"The prose must stand on its own without the block. Emit DATA only, never HTML. " +
"The kinds and their exact fields are:\n" +
`{"kind":"comparison","title":"...","columns":["","A","B"],"rows":[["Row label","A value","B value"]]}` + "\n" +
`{"kind":"steps","title":"...","steps":["first","second"]}` + "\n" +
`{"kind":"stats","title":"...","stats":[{"label":"...","value":"..."}]}` + "\n" +
`{"kind":"timeline","title":"...","events":[{"when":"2019","what":"..."}]}` + "\n" +
`{"kind":"entity","title":"...","subtitle":"...","facts":[{"label":"...","value":"..."}]}` + "\n" +
`{"kind":"definition","term":"...","meaning":"...","example":"..."}` + "\n" +
"Every value must be a plain string. In a comparison, every row must have exactly as many " +
"cells as there are columns, and the first column is the row label."
researchSystem = "You are Hanzo Deep Research. Synthesize a thorough, well-organized report answering the question from the numbered web sources. " +
"Write a structured report with section headings, compare sources, and surface the strongest evidence. " +
"Cite at least three distinct sources per section. " +
"Place each [title](url) immediately after the claim it supports; never a bare URL, never a period after a link, " +
"never a trailing References or Sources section and no footnote markers. " +
"Note gaps or disagreements between sources. Never fabricate facts or URLs."
"Note gaps or disagreements between sources. Never fabricate facts or URLs." +
widgetRule
)
// feeCents resolves the per-answer price in cents for a mode, most specific
+114
View File
@@ -0,0 +1,114 @@
// Copyright © 2026 Hanzo AI. MIT License.
package answer
import (
"encoding/json"
"regexp"
"strings"
"testing"
)
// THE PROMPT TEACHES A FORMAT SOMETHING ELSE PARSES, AND THE TWO CANNOT BE CHECKED
// BY THE COMPILER.
//
// The renderer lives in the extension (packages/browser/src/answer/widget.ts): it
// validates every block and DROPS anything it does not recognise. So a typo here —
// a renamed field, a kind the client has no branch for, a number where a string
// belongs — does not fail anything. It produces answers whose widgets silently never
// appear, which looks exactly like a model that chose not to emit one.
//
// These assertions are the closest thing to a shared type. They hold the examples in
// the prompt to the shape the client actually accepts, so drift is red HERE, in the
// repo that can fix it.
func TestWidgetRuleTeachesShapesTheClientAccepts(t *testing.T) {
// The kinds the client has a branch for, and the fields each one requires.
required := map[string][]string{
"comparison": {"columns", "rows"},
"steps": {"steps"},
"stats": {"stats"},
"timeline": {"events"},
"entity": {"title", "facts"},
"definition": {"term", "meaning"},
}
objects := regexp.MustCompile(`\{"kind":.*`).FindAllString(widgetRule, -1)
if len(objects) != len(required) {
t.Fatalf("prompt carries %d example objects, want one per kind (%d): the model is "+
"taught a set the client does not mirror", len(objects), len(required))
}
seen := map[string]bool{}
for _, raw := range objects {
var got map[string]any
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Errorf("example is not valid JSON, so it teaches a format nothing can parse: %v\n%s", err, raw)
continue
}
kind, _ := got["kind"].(string)
fields, ok := required[kind]
if !ok {
t.Errorf("example names kind %q, which the client has no branch for and will drop", kind)
continue
}
seen[kind] = true
for _, f := range fields {
if _, present := got[f]; !present {
t.Errorf("kind %q example omits required field %q — the client refuses a block without it", kind, f)
}
}
}
for kind := range required {
if !seen[kind] {
t.Errorf("kind %q is never shown to the model, so it will never be emitted", kind)
}
}
}
// A comparison row must have exactly as many cells as there are columns. The client
// refuses a ragged table rather than padding it (a padded grid renders broken), so an
// example that models raggedness would teach the one mistake guaranteed to be dropped.
func TestWidgetRuleComparisonExampleIsRectangular(t *testing.T) {
raw := regexp.MustCompile(`\{"kind":"comparison".*`).FindString(widgetRule)
if raw == "" {
t.Fatal("no comparison example in the prompt")
}
var got struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatalf("comparison example does not parse: %v", err)
}
for i, row := range got.Rows {
if len(row) != len(got.Columns) {
t.Errorf("example row %d has %d cells against %d columns — the client drops ragged tables",
i, len(row), len(got.Columns))
}
}
}
// Both modes must carry the rule. They are separate constants, so appending it to
// one and not the other is a one-line omission that nothing else would notice —
// research would simply never produce a widget.
func TestEveryModeCarriesTheWidgetRule(t *testing.T) {
for name, m := range modes {
if !strings.Contains(m.system, "hanzo-widget") {
t.Errorf("mode %q has a system prompt with no widget rule, so it can never emit one", name)
}
}
}
// The rule must say the prose stands alone. The client drops an invalid block and
// keeps the prose, so an answer that leaned on a widget to be complete would read as
// a hole exactly when validation refused one.
func TestWidgetRuleRequiresSelfSufficientProse(t *testing.T) {
if !strings.Contains(widgetRule, "stand on its own") {
t.Error("the rule does not tell the model the prose must stand alone; a dropped " +
"widget would then leave the answer incomplete")
}
if !strings.Contains(widgetRule, "never HTML") {
t.Error("the rule does not forbid HTML; the model reads the open web, so markup it " +
"authored is an injection path into the client's origin")
}
}
+5 -6
View File
@@ -10,11 +10,11 @@ import (
"net/url"
"strings"
// cek is the ONE opener; the ONE Hanzo SQLite driver registers "sqlite".
// sqlpool.Open is the ONE opener (cek + the single-connection cap); the ONE
// Hanzo SQLite driver registers "sqlite".
// Mirrors clients/affiliates / clients/referrals — one storage pattern.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -176,11 +176,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "authors", dir)
db, err := sqlpool.Open("authors", dir)
if err != nil {
return nil, fmt.Errorf("open authors store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+6 -7
View File
@@ -8,14 +8,14 @@ import (
"fmt"
"time"
// cek is the ONE opener: it renders this subsystem's path from the
// namespace and opens it under the key cek derives for that name.
// sqlpool.Open is the ONE opener: it renders this subsystem's path from the
// system namespace, opens it under the key cek derives for that name, and
// applies the single-connection cap.
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver; the blank import
// registers the "sqlite" database/sql name. Mirrors clients/crm exactly — the
// ONE storage pattern.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -36,11 +36,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "automations", dir)
db, err := sqlpool.Open("automations", dir)
if err != nil {
return nil, fmt.Errorf("open automations store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
-90
View File
@@ -336,22 +336,6 @@ func init() {
"holds 18-decimal USD, so the cents asked for are the cents taken.\n\n"+
"401 without a validated principal — a customer charging its OWN wallet, so an absent "+
"identity is not signed in, never not authorized.")
openapi.Describe("/v1/billing/methods/:id", http.MethodDelete,
"Remove a saved card from the caller's org",
"Detaches a card on file: the stored reference is removed here AND withdrawn from the "+
"processor's vault, so nothing is left that a later charge could bill.\n\n"+
"The id is resolved INSIDE the caller's own org, so it can only ever name a card "+
"this org can list. Another tenant's id does not resolve and answers 404 — not 403, "+
"because a status that separates 'not yours' from 'not there' turns an id into "+
"something worth guessing.\n\n"+
"Removing the card an auto-recharge or a running GPU lease bills leaves that "+
"arrangement with nothing to charge; it is the customer's call to make, and this "+
"makes it rather than refusing on their behalf.\n\n"+
"401 without a validated principal — the org is the validated owner claim, never a "+
"client-supplied field, so this cannot be pointed at another tenant.")
}
// billingSubjectKeys — every query/body param through which a commerce billing endpoint
@@ -574,80 +558,6 @@ func gpuEligibility(s *cloud.Service[state], c *zip.Ctx) error {
return proxy(s, c, "/v1/billing/gpu/eligibility", "amountCents", "minPrepaidCents", "currency")
}
// paymentMethods → commerce GET /v1/billing/portal/methods: the org's saved cards
// as the masked descriptor commerce returns (brand + last4 + expiry — never a PAN/CVV/
// token). The console requests the same-origin /v1/billing/methods (mounted here);
// this proxies to commerce's admin-group PORTAL read, which filters CustomerId on the
// pinned subject (commerce 400s without a customerId — proxy always pins it), so a caller
// sees ONLY its OWN org's methods. Backs the launch gate's card-on-file check.
func paymentMethods(s *cloud.Service[state], c *zip.Ctx) error {
return proxy(s, c, "/v1/billing/portal/methods")
}
// createPaymentMethod → commerce POST /v1/billing/methods: vault the Square
// card token the browser produced as a card-on-file. Same discipline as gpuCharge —
// the billing SUBJECT is pinned server-side to the caller's OWN org, so a forged body
// can never attach a card to another tenant — and commerce's status is forwarded
// VERBATIM (a 402 decline keeps its reason) rather than 500-masked.
func createPaymentMethod(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to save a card")
}
if !s.State.commerce.configured() {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
body, status, err := s.State.commerce.post(c.Context(), "/v1/billing/methods", org, pinSubjectBody(c.Body(), org), "")
if err != nil {
s.Log.Warn("commerce save card failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
}
c.SetHeader("Content-Type", "application/json")
c.SetHeader("Cache-Control", "no-store")
return c.Bytes(status, body)
}
// deletePaymentMethod → commerce DELETE /v1/billing/portal/methods/{id}: remove a
// saved card. The twin of paymentMethods, at the twin address and for the same
// reason — this app OWNS /v1/billing/methods, so it cannot forward there without
// re-entering itself, and commerce publishes the portal family as the face a host
// may proxy to.
//
// TENANT SCOPE. The org is the VALIDATED principal (principal.Org), never
// readerOrg: readerOrg additionally admits the trusted in-proc service token, which
// is right for a READ the ai gate makes on its own behalf and wrong for a MUTATION
// — the same rule createPaymentMethod and gpuCharge already follow, and the reason
// they do. The org then rides X-Org-Id, which selects commerce's per-org namespace,
// so `id` is resolved INSIDE the caller's own tenant: another org's card id is a
// not-found miss there and comes back 404 (never 403 — an id must not be probeable).
// A caller can therefore only ever delete a method its own org can list, which is
// exactly the scope paymentMethods reads.
//
// The id is a caller-supplied path segment, so it is percent-escaped into the
// upstream URL rather than concatenated raw: the value names a resource, it does
// not get to name a route.
func deletePaymentMethod(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in to remove a card")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("a payment method id is required")
}
if !s.State.commerce.configured() {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
body, status, err := s.State.commerce.del(c.Context(), "/v1/billing/portal/methods/"+url.PathEscape(id), org, scopedBillingQuery(c, org))
if err != nil {
s.Log.Warn("commerce remove card failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
}
c.SetHeader("Content-Type", "application/json")
c.SetHeader("Cache-Control", "no-store")
return c.Bytes(status, body)
}
// pinSubjectBody overwrites every commerce billing-subject key on a top-level JSON object
// with subject (the caller's OWN org), so a POST body can NEVER act on another tenant's
// wallet. The keys mirror commerce's edge-auth billing-subject set {user,userId,customerId}
-155
View File
@@ -287,26 +287,6 @@ func TestGPUEligibility_ClientCannotWidenScope(t *testing.T) {
}
}
func TestPaymentMethods_ProxiesPortal_Scoped(t *testing.T) {
f := &fakeCommerce{status: 200, body: `[{"id":"pm_1","brand":"visa","last4":"4242","isDefault":true}]`}
app := mountApp(t, f.server(t).URL, "svc-token")
// The console requests the same-origin /v1/billing/methods (mounted on cloud);
// cloud proxies it to commerce's admin-group PORTAL read.
code, body := call(t, app, http.MethodGet, "/v1/billing/methods", "maxpower/dave", "maxpower")
if code != 200 || string(body) != f.body {
t.Fatalf("payment-methods: want 200 verbatim, got %d (%s)", code, body)
}
if f.gotPath != "/v1/billing/portal/methods" {
t.Fatalf("commerce path: want /v1/billing/portal/methods, got %q", f.gotPath)
}
// PortalPaymentMethods 400s without a customerId; the proxy pins it (and user/userId)
// to the caller's own org, so the list is scoped to the caller's cards — never widenable.
if f.gotOrg != "maxpower" || f.gotQuery.Get("customerId") != "maxpower" || f.gotQuery.Get("user") != "maxpower" {
t.Fatalf("payment-methods scope: org=%q customerId=%q user=%q", f.gotOrg, f.gotQuery.Get("customerId"), f.gotQuery.Get("user"))
}
}
func TestGPUCharge_PinsSubjectInBody_ForwardsStatus(t *testing.T) {
// commerce answers 402 card_required — the money verdict must be forwarded verbatim.
f := &fakeCommerce{status: 402, body: `{"error":{"code":"card_required","message":"Add a card on file before launching a GPU"}}`}
@@ -384,138 +364,3 @@ func TestPinSubjectBody(t *testing.T) {
t.Fatalf("empty body must still pin the subject: %v", m)
}
}
// TestCreatePaymentMethod_PostReachesCommerce_PinsSubject pins the save-card route
// that did not exist: the GET-only registration made POST answer 405 on the specific
// route (shadowing the wildcard), so the console's save-card call — and auto-recharge
// behind it, which charges the vaulted card — were dead. It must reach commerce as a
// POST, with the subject pinned to the caller's own org, forwarding status verbatim.
func TestCreatePaymentMethod_PostReachesCommerce_PinsSubject(t *testing.T) {
f := &fakeCommerce{status: 402, body: `{"error":{"code":"card_declined","message":"Card was declined"}}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := callBody(t, app, http.MethodPost, "/v1/billing/methods",
"maxpower/dave", "maxpower",
`{"user":"victim","customerId":"victim","providerRef":"cnon:card-nonce","brand":"visa"}`)
if code != 402 || string(body) != f.body {
t.Fatalf("save card: want the 402 decline verbatim, got %d (%s)", code, body)
}
if f.gotMethod != http.MethodPost || f.gotPath != "/v1/billing/methods" {
t.Fatalf("commerce call: want POST /v1/billing/methods, got %s %q", f.gotMethod, f.gotPath)
}
var got map[string]any
if err := json.Unmarshal(f.gotBody, &got); err != nil {
t.Fatalf("commerce body not JSON: %v (%s)", err, f.gotBody)
}
for _, k := range []string{"user", "customerId"} {
if got[k] != "maxpower" {
t.Fatalf("body %q must be pinned to the caller's org, got %v", k, got[k])
}
}
if got["providerRef"] != "cnon:card-nonce" {
t.Fatalf("the card token must survive verbatim, got %v", got["providerRef"])
}
}
// TestCreatePaymentMethod_Unauthenticated401 — saving a card is a customer's own
// billing action, so no identity is 401 "sign in", never the wildcard's admin 403.
func TestCreatePaymentMethod_Unauthenticated401(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{}`}
app := mountApp(t, f.server(t).URL, "svc-token")
if code, _ := callBody(t, app, http.MethodPost, "/v1/billing/methods", "", "", `{}`); code != 401 {
t.Fatalf("unauth save card: want 401, got %d", code)
}
}
// TestDeletePaymentMethod_ProxiesPortal_Scoped — removing a card reaches commerce
// at the PORTAL sub-resource (never /v1/billing/methods/{id}, which this app owns
// and would re-enter), carries the caller's OWN org as the trusted selector, and
// forwards commerce's body and status verbatim.
func TestDeletePaymentMethod_ProxiesPortal_Scoped(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{"deleted":true,"id":"pm_1"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := call(t, app, http.MethodDelete, "/v1/billing/methods/pm_1", "maxpower/dave", "maxpower")
if code != 200 || string(body) != f.body {
t.Fatalf("delete method: want 200 verbatim, got %d (%s)", code, body)
}
if f.gotMethod != http.MethodDelete || f.gotPath != "/v1/billing/portal/methods/pm_1" {
t.Fatalf("commerce call: want DELETE /v1/billing/portal/methods/pm_1, got %s %q", f.gotMethod, f.gotPath)
}
if f.gotOrg != "maxpower" {
t.Fatalf("X-Org-Id must be the caller's own org, got %q", f.gotOrg)
}
}
// TestDeletePaymentMethod_TenantIsolation is the RED-focus test: everything a
// caller can say about WHOSE card this is gets overwritten with the caller's own
// org before the request leaves this process.
//
// X-Org-Id is the only tenant selector commerce honours on the S2S seam, and it is
// taken from the VALIDATED principal — so org A deleting with a forged ?org=orgb,
// a forged subject, or org B's own id can never reach org B's namespace, and the
// id it names is resolved inside org A's, where a foreign card is not found.
// (commerce/api/billing/payment_methods_tenant_test.go proves the far side: org B
// handed org A's id gets 404 and the card survives.)
func TestDeletePaymentMethod_TenantIsolation(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{"deleted":true,"id":"pm_victim"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
// org A ("maxpower") aims a delete at org B ("victimorg") every way the wire allows.
code, _ := call(t, app, http.MethodDelete,
"/v1/billing/methods/pm_victim?org=victimorg&customerId=victimorg&user=victimorg&userId=victimorg",
"maxpower/dave", "maxpower")
if code != 200 {
t.Fatalf("want 200, got %d", code)
}
if f.gotOrg != "maxpower" {
t.Fatalf("CROSS-TENANT: X-Org-Id reached commerce as %q, want the caller's own org", f.gotOrg)
}
if f.gotQuery.Has("org") {
t.Fatalf("CROSS-TENANT: client-forged org reached commerce as %q", f.gotQuery.Get("org"))
}
for _, k := range []string{"customerId", "user", "userId"} {
if got := f.gotQuery.Get(k); got != "maxpower" {
t.Fatalf("CROSS-TENANT: forged %s reached commerce as %q, want the caller's own org", k, got)
}
}
}
// TestDeletePaymentMethod_Unauthenticated401 — removing a card is a MUTATION, so
// it resolves the org with principal.Org (the validated principal ONLY) and not
// readerOrg, which additionally admits the in-proc service token for reads. No
// identity is 401 "sign in", the same answer saving a card gives.
func TestDeletePaymentMethod_Unauthenticated401(t *testing.T) {
f := &fakeCommerce{status: 200, body: `{}`}
app := mountApp(t, f.server(t).URL, "svc-token")
if code, _ := call(t, app, http.MethodDelete, "/v1/billing/methods/pm_1", "", ""); code != 401 {
t.Fatalf("unauth delete card: want 401, got %d", code)
}
// The service token alone is not a customer: a READ would be admitted here
// (readerOrg), a mutation must not be.
if f.gotPath != "" {
t.Fatalf("an unauthenticated delete must not reach commerce at all, got %q", f.gotPath)
}
}
// TestDeletePaymentMethod_IDIsAValueNotARoute — the id is a caller-supplied path
// segment, and the router hands it over STILL PERCENT-ENCODED, so it is escaped
// again on the way out. An id carrying encoded separators must name a (missing)
// resource inside the portal sub-resource, never steer the S2S call to a different
// commerce address — /v1/billing/deposit being the one that would matter, since
// this process holds the service token that satisfies commerce's mint gate.
func TestDeletePaymentMethod_IDIsAValueNotARoute(t *testing.T) {
f := &fakeCommerce{status: 404, body: `{"error":"payment method not found"}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := call(t, app, http.MethodDelete, "/v1/billing/methods/pm%2f..%2f..%2fbilling%2fdeposit",
"maxpower/dave", "maxpower")
if code != 404 || string(body) != f.body {
t.Fatalf("want commerce's 404 forwarded verbatim, got %d (%s)", code, body)
}
const prefix = "/v1/billing/portal/methods/"
rest, ok := strings.CutPrefix(f.gotPath, prefix)
if !ok || strings.Contains(rest, "/") {
t.Fatalf("the id must stay ONE segment under %s, got %q", prefix, f.gotPath)
}
}
+48 -22
View File
@@ -1,32 +1,52 @@
// Package bots is a bot doing your work on a real desktop, live, while you watch.
//
// It is the CONTROL PLANE for a bot run: a task the bot runtime executes on a
// surface a desktop or terminal sandbox it drives — with a LIVE session (the
// URL the hanzo.app /vnc panel embeds to watch/attach).
// It is the whole cloud side of the headless bot: the CONTROL PLANE for a bot run
// — a task executed on a surface (a desktop or terminal sandbox the bot drives)
// with a LIVE session, the URL the hanzo.app /vnc panel embeds to watch or attach
// — TOGETHER WITH the door to the service that executes it, @hanzo/bot.
//
// A bot run is ONE value with ONE home. It is not the bot MACHINE that hosts a
// runtime (visor's /v1/compute/bots — a machine you rent), and it is not the
// runtime service itself (apps/runtime — the transport to the executor).
// One product, not two. The control plane and the transport to the executor were
// separate apps once (apps/runtime), which made a LANGUAGE boundary look like a
// product boundary: the surface is Go, the executor is TS, and nothing else
// distinguished them. They answer for the same thing and now live in one place.
//
// CLOUD OWNS POLICY, THE RUNTIME OWNS THE RUN. The sandbox lives in the runtime,
// keyed in the runtime's own store under the tenant that started it; that store is
// the only thing that knows whether a run is alive. So this package keeps no
// second copy of it. It owns what a control plane owns — who you are, which org
// you are, and whether you may — and then asks the runtime, which IS the registry.
// Copying that state into cloud would create a second id space agreeing with
// nothing: listing runs that do not exist and stopping runs never started.
// CLOUD OWNS POLICY, THE EXECUTOR OWNS THE RUN. The sandbox lives in @hanzo/bot,
// keyed in its own store under the tenant that started it; that store is the only
// thing that knows whether a run is alive. So this package keeps no second copy of
// it. It owns what a control plane owns — who you are, which org you are, and
// whether you may — and then asks the executor, which IS the registry. Copying
// that state into cloud would create a second id space agreeing with nothing:
// listing runs that do not exist and stopping runs never started.
//
// A bot run is ONE value with ONE home. It is not the bot MACHINE that hosts an
// executor (visor's /v1/compute/bots — a machine you rent).
//
// Isolation: the org is the gateway-minted X-Org-Id (HIP-0026) resolved via
// principal.Org, NEVER a request field, and it is what cloud sends the runtime,
// principal.Org, NEVER a request field, and it is what cloud sends the executor,
// which keys every run under tenants/{org}/. A caller cannot name another tenant's
// org, so it cannot read or stop another tenant's runs; a foreign run id resolves
// under the CALLER's org, where it does not exist, and answers 404.
//
// Surface (org-scoped; the console BotsApi and the CLI `hanzo bot run` call it):
// Two faces, and the split between them is what a tenant can ACT on:
//
// POST /v1/bots/run -> 501: no runtime launch operation exists yet
// GET /v1/bots -> {bots:[{runId,task,surface,status,sessionUrl,startedAt}]}
// POST /v1/bots/:runId/stop -> {runId, status}
// - NATIVE + TYPED, the run control plane (org-scoped; the console BotsApi and
// the CLI `hanzo bot run` call it):
//
// POST /v1/bots/run -> 501: no executor launch operation exists yet
// GET /v1/bots -> {bots:[{runId,task,surface,status,sessionUrl,startedAt}]}
// POST /v1/bots/:runId/stop -> {runId, status}
//
// - RELAYED, the executor's own operational paths at /v1/bot/* (relay.go). A
// liveness probe is not a tenant-scoped resource, so it stays a relay rather
// than being reimplemented in Go.
//
// The transport itself (transport.go) knows how to MOVE BYTES and nothing about
// what they mean: a caller states WHAT it wants done (a Call) and gets back a
// domain-shaped outcome — never an *http.Response, a status code, or a framing
// detail. Today those bytes move over HTTP; per HIP-0106/HIP-0120 they should move
// over ZAP, and that swap is meant to be a change to transport.go plus each
// caller's one stub, not a rewrite. apps/coding dispatches its coding tasks to the
// same executor and uses the same Call.
package bots
import (
@@ -40,7 +60,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/runtime"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
@@ -56,7 +76,7 @@ const (
// gatewayURLEnv configures the browser-facing bot VNC gateway base — the public
// origin the TS bot service serves /vnc?nodeId=<id> from, which the hanzo.app
// /vnc panel embeds. It is DISTINCT from the runtime's in-cluster address
// (clients/runtime's BOT_GATEWAY_URL, a pod-internal DNS name a browser cannot
// (transport.go's BOT_GATEWAY_URL, a pod-internal DNS name a browser cannot
// reach): a session URL must be publicly embeddable, so it carries its own knob.
gatewayURLEnv = "CLOUD_BOT_GATEWAY_URL"
defaultGatewayURL = "https://bot.hanzo.ai"
@@ -163,6 +183,12 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
State: state{gateway: gatewayBase(), runtime: wire{}},
}
routes(app, s)
// The executor's ops face, on the same product: /v1/bot/* relayed verbatim.
// It mounts SECOND because the native control plane above is what a tenant
// acts on, and a relay must never be able to shadow it.
if err := mountRelay(app, deps); err != nil {
return err
}
s.Log.Info("bots surface mounted", "gateway", s.State.gateway, "brand", deps.Brand)
return nil
}
@@ -317,9 +343,9 @@ func (o ops) stop(ctx context.Context, in *stopBotIn) (*BotStopped, error) {
case err == nil:
o.s.Log.Info("bot stopped", "org", org, "run", runID)
return &BotStopped{RunID: runID, Status: statusStopped}, nil
case errors.Is(err, runtime.ErrNotFound):
case errors.Is(err, ErrNotFound):
return nil, zip.ErrNotFound("no such bot for this org")
case errors.Is(err, runtime.ErrNotServed):
case errors.Is(err, ErrNotServed):
return nil, zip.Errorf(http.StatusBadGateway,
"bots: the runtime does not serve stop, so this run's state is unknown — it was NOT stopped")
default:
+9 -3
View File
@@ -11,7 +11,7 @@ import (
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/runtime"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -69,7 +69,7 @@ func (f *fakeRuntime) Stop(_ context.Context, org, runID string) error {
k := runKey{org, runID}
if _, ok := f.rows[k]; !ok {
// The real runtime resolves under tenants/{org}/ and ANSWERS absent.
return runtime.ErrNotFound
return ErrNotFound
}
delete(f.rows, k)
return nil
@@ -114,6 +114,12 @@ func mountWith(t *testing.T, rt Runtime) *zip.App {
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
compose(app)
routes(app, s)
// The relay is the same product's second face, so the surface every gate reads
// is the WHOLE product — otherwise the typed-or-named gate goes blind on half
// of it. /v1/bot/* cannot shadow /v1/bots: the wildcard needs the slash.
if err := mountRelay(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
t.Fatalf("mountRelay: %v", err)
}
return app
}
@@ -330,7 +336,7 @@ func TestStopHaltsTheRun(t *testing.T) {
func TestStopFailsClosedWhenTheRuntimeDoesNotServeStop(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Status: "running"})
rt.stopErr = runtime.ErrNotServed
rt.stopErr = ErrNotServed
app := mountWith(t, rt)
code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme")
+21 -20
View File
@@ -1,19 +1,19 @@
// ops.go mounts /v1/bot/* — the runtime's OWN operational paths (health, and the
// relay.go mounts /v1/bot/* — @hanzo/bot's OWN operational paths (health, and the
// surfaces the console Bot module links out to), relayed verbatim. It is the
// runtime's ops face, not a control plane: a liveness probe is not a
// executor's ops face, not a control plane: a liveness probe is not a
// tenant-scoped resource, so it stays a relay rather than being reimplemented in
// Go. Everything a tenant can ACT on is native and lives in its own domain —
// /v1/bots is the run control plane (apps/bots).
// Go. Everything a tenant can ACT on is native and typed beside it — /v1/bots is
// the run control plane (bots.go).
//
// Path mapping: the runtime serves bare paths (/health, /v1/chat/completions),
// Path mapping: the executor serves bare paths (/health, /v1/chat/completions),
// NOT the /v1/bot/* prefix — the edge strips it. So this face strips /v1/bot too:
// /v1/bot/<rest> → {runtime}/<rest> (e.g. /v1/bot/health → /health).
// /v1/bot/<rest> → {executor}/<rest> (e.g. /v1/bot/health → /health).
//
// Order 143 — binds /v1/bot/* before the AI subsystem's /v1/* catch-all (150).
//
// The package doc lives once, in runtime.go.
// The package doc lives once, in bots.go.
package runtime
package bots
import (
"bytes"
@@ -69,23 +69,24 @@ var identityHeaders = []string{
"Authorization", "X-Org-Id", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
}
type service struct {
target string // runtime base, no trailing slash
type relay struct {
target string // executor base, no trailing slash
log luxlog.Logger
cc *http.Client
}
// Mount registers the /v1/bot/* surface on app per HIP-0106.
func Mount(app cloud.Router, deps cloud.Deps) error {
// mountRelay registers the /v1/bot/* surface on app per HIP-0106. Mount (bots.go)
// calls it: one product, one entry point, two faces.
func mountRelay(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("runtime.Mount: nil app")
return fmt.Errorf("bots.mountRelay: nil app")
}
if deps.Logger == nil {
return fmt.Errorf("runtime.Mount: nil deps.Logger")
return fmt.Errorf("bots.mountRelay: nil deps.Logger")
}
s := &service{
target: url(),
log: deps.Logger.New("subsystem", "runtime"),
s := &relay{
target: executorURL(),
log: deps.Logger.New("subsystem", "bots"),
cc: &http.Client{Timeout: 60 * time.Second},
}
// UNTYPED BY DESIGN — and it is the only route here, so this whole subsystem
@@ -105,13 +106,13 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// serialises its Out as JSON, so both move.
//
// The tenant-actionable surface is native and typed elsewhere: /v1/bots is the
// run control plane (clients/bots). This face is ops, and it stays a relay.
// run control plane (bots.go). This face is ops, and it stays a relay.
app.All("/v1/bot/*", s.proxy)
s.log.Info("runtime ops surface mounted", "target", s.target, "brand", deps.Brand)
s.log.Info("bots relay surface mounted", "target", s.target, "brand", deps.Brand)
return nil
}
func (s *service) proxy(c *zip.Ctx) error {
func (s *relay) proxy(c *zip.Ctx) error {
// Gate on a validated principal before forwarding X-Org-Id to the runtime,
// which trusts these headers as the gateway-minted tenant context. Off-gateway,
// the identity middleware restores a forged X-Org-Id but leaves X-User-Id empty;
@@ -1,4 +1,4 @@
package runtime
package bots
import (
"net/http"
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/runtime"
"github.com/hanzoai/cloud/apps/visor"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
@@ -85,8 +85,8 @@ func mountFleet(t *testing.T, rt *stubRuntime) *zip.App {
if err := visor.Mount(app, deps); err != nil { // Wire order: visor first — the shadowing mount
t.Fatalf("visor.Mount: %v", err)
}
if err := runtime.Mount(app, deps); err != nil {
t.Fatalf("runtime.Mount: %v", err)
if err := mountRelay(app, deps); err != nil {
t.Fatalf("mountRelay: %v", err)
}
if err := Mount(app, deps); err != nil { // …bots last, as in Wire
t.Fatalf("bots.Mount: %v", err)
@@ -1,25 +1,23 @@
// Package runtime is the transport to the bot runtime service — the TS bot that
// executes channels and skills — and it relays that service's own ops paths at
// /v1/bot/* (ops.go), the only routes it serves.
// transport.go is the transport to @hanzo/bot — the TS service that executes
// channels and skills — and it is the ONE place that resolves the base address,
// mints the server-originated identity, frames the stream, bounds a call, and
// decides whether a cleartext hop is allowed.
//
// It knows how to MOVE BYTES to that service and nothing about what they mean.
// There is no run here, no coding task, no tenant policy: the domains own their
// own wire contracts (apps/bots' stop, apps/coding's task) and express them
// as a Call. So exactly ONE place resolves the base address, mints the
// server-originated identity, frames the stream, bounds a call, and decides
// whether a cleartext hop is allowed.
// There is no run here and no coding task: each caller owns its own wire contract
// (this package's stop in wire.go, apps/coding's task) and expresses it as a Call.
// Nothing in this file may learn what a run is — the moment it does, it has
// stopped being a transport and the swap below stops being local.
//
// The edge is transport-agnostic on purpose. A caller states WHAT it wants done
// (Call) and gets back a domain-shaped outcome — never an *http.Response, a
// status code, a header map, or a framing detail. Today those bytes move over
// HTTP; per HIP-0106/HIP-0120 they should move over ZAP, and that swap is meant
// to be a change to THIS package's internals plus each domain's one stub file,
// not a rewrite of the domains.
// to be a change to THIS FILE plus each caller's one stub, not a rewrite.
//
// Dependencies point one way: bots -> runtime, coding -> runtime. runtime imports
// neither, and must not: the moment it knows what a run is, it has stopped being
// a transport.
package runtime
// The package doc lives once, in bots.go.
package bots
import (
"bufio"
@@ -187,7 +185,7 @@ func send(ctx context.Context, c Call, method, accept string) (*http.Response, e
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, url()+c.Op, body)
req, err := http.NewRequestWithContext(ctx, method, executorURL()+c.Op, body)
if err != nil {
return nil, fmt.Errorf("runtime: build call: %w", err)
}
@@ -264,8 +262,8 @@ func ErrBody(resp *http.Response) string {
return strings.TrimSpace(string(b))
}
// url resolves the runtime base, no trailing slash.
func url() string {
// executorURL resolves the executor base, no trailing slash.
func executorURL() string {
if v := getenv(urlEnv); v != "" {
return strings.TrimRight(v, "/")
}
@@ -282,7 +280,7 @@ func url() string {
// entry point pins X25519MLKEM768 and refuses a classical-only peer structurally,
// which is what makes this check unnecessary rather than merely satisfied.
func requireSecure() error {
u := url()
u := executorURL()
if strings.HasPrefix(u, "https://") || getenv(plaintextEnv) == "1" {
return nil
}
+30
View File
@@ -42,8 +42,38 @@ var untypedByDesign = map[string]string{
"POST /v1/bots/run": "answers 501 unconditionally — a typed op publishes a SUCCESS response it can " +
"never send, and mints an MCP tool and CLI command for an operation that cannot succeed; it is also " +
"body-tolerant, which op.invoke's unconditional 400 on an unparseable body cannot express.",
// The relay face. All seven ARE one registration — app.All("/v1/bot/*",
// s.proxy) in relay.go — so they share one reason.
"DELETE /v1/bot/{wildcard1}": reasonProxy,
"GET /v1/bot/{wildcard1}": reasonProxy,
"OPTIONS /v1/bot/{wildcard1}": reasonProxy,
"PATCH /v1/bot/{wildcard1}": reasonProxy,
"POST /v1/bot/{wildcard1}": reasonProxy,
"PUT /v1/bot/{wildcard1}": reasonProxy,
"TRACE /v1/bot/{wildcard1}": reasonProxy,
}
// reasonProxy is the one reason the seven relay operations share. Three wire facts
// each independently forbid a typed op:
//
// - ONE registration, EVERY method. zip's typed registrars are per-method and
// there is no All[In, Out].
// - a GREEDY wildcard whose value the proxy RE-MOUNTS on the executor
// (Params("*") → target). fiber names it `*1` and the document `{wildcard1}`,
// and a whole sub-path is not a scalar zip's bindURL can set on an In field.
// - a VERBATIM response. proxy answers c.Bytes(resp.StatusCode, rb) under the
// executor's own Content-Type, which is frequently not JSON at all. A typed op
// can only answer c.JSON(out) under the status it DECLARED, so both move.
//
// The seven publish no MCP tool and no CLI command. They DO carry prose:
// openapi.Describe declares it beside the wire fact in relay.go, which is the seam
// for exactly an operation the wire refuses to type.
const reasonProxy = "proxy. One All() registration for every method, over a greedy wildcard the proxy " +
"re-mounts on the runtime, relaying the runtime's own status code and Content-Type verbatim. zip has " +
"no All[In, Out], no In field can bind a whole sub-path, and a typed op can only answer c.JSON(out) " +
"under its declared status (zip v1.18.12 typed.go:302-311) — method, path and response all move."
// botOps reads BOTH projections of the live router at their one shared address
// form: what the document says is served, and which of those carry a typed registry
// entry. EVERY served operation counts, so a route mounted at an address nobody
+2 -4
View File
@@ -3,8 +3,6 @@ package bots
import (
"context"
"net/url"
"github.com/hanzoai/cloud/apps/runtime"
)
// wire.go is bots' WIRE CONTRACT with the bot runtime — the stub behind the
@@ -44,7 +42,7 @@ func (wire) List(ctx context.Context, org string) ([]Run, error) {
var answer struct {
Bots []runRow `json:"bots"`
}
if err := runtime.Read(ctx, runtime.Call{Op: listOp, Org: org}, &answer); err != nil {
if err := Read(ctx, Call{Op: listOp, Org: org}, &answer); err != nil {
return nil, err
}
out := make([]Run, 0, len(answer.Bots))
@@ -64,5 +62,5 @@ func (wire) List(ctx context.Context, org string) ([]Run, error) {
// unserved, or a failure — so the handler decides what each MEANS rather than this
// stub deciding for it.
func (wire) Stop(ctx context.Context, org, runID string) error {
return runtime.Do(ctx, runtime.Call{Op: stopOp(runID), Org: org})
return Do(ctx, Call{Op: stopOp(runID), Org: org})
}
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -89,11 +88,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "campaign", dir)
db, err := sqlpool.Open("campaign", dir)
if err != nil {
return nil, fmt.Errorf("open campaign store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+106 -12
View File
@@ -25,8 +25,11 @@
// A customer's private project is a row in their own org's `catalog` index. It
// cannot appear in another tenant's results because the query that would return
// it is never run for them. Nothing PUBLISHES over HTTP either: the published
// corpus is reconciled in-process from sources that are public by construction
// (sync.go), so no credential exists that could promote a tenant row into it.
// corpus is reconciled from sources that are public by construction (sync.go),
// so no credential exists that could promote a tenant row into it. The swap
// itself is a call on the internal plane — a socket the edge router does not
// carry, reachable only from inside this deployment — so "no write route" stays
// literally true of every surface a caller can reach.
//
// Surface:
//
@@ -61,6 +64,12 @@ import (
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/projects"
"github.com/hanzoai/cloud/plane"
// The GENERATED client for the index peer — the one typed way to call it, with
// the app name, the op and the In/Out pair already fixed to each other. Aliased
// because the app package this file also imports is the SAME word: one is the
// index in this process, the other is how to reach it in another.
indexpeer "github.com/hanzoai/cloud/plane/index"
projectspeer "github.com/hanzoai/cloud/plane/projects"
"github.com/zap-proto/zip"
)
@@ -345,9 +354,7 @@ func lexical(ctx context.Context, org, q string) ([]json.RawMessage, error) {
if c, ok := cloud.Request(ctx); ok {
call = cloud.As(c, org)
}
out, err := cloud.Ask[plane.IndexQueryIn, plane.IndexQueryOut](
call, "index", plane.IndexQuery,
&plane.IndexQueryIn{UID: uid, Q: q, Limit: scan})
out, err := indexpeer.IndexQuery(call, &plane.IndexQueryIn{UID: uid, Q: q, Limit: scan})
if err != nil {
return nil, err
}
@@ -357,6 +364,59 @@ func lexical(ctx context.Context, org, q string) ([]json.RawMessage, error) {
return out.Rows, nil
}
// write hands the assembled corpus to the index, wherever the index happens to
// be. It is the exact mirror of lexical, and it was missing for the exact reason
// lexical needed writing: Reconcile serves out of the index's own process-level
// global, so in THIS process it has always answered "index: not mounted".
//
// That is why the catalog was empty. Not a wiped store, not an expired GitHub
// token, not a sync that never ran — the sync ran every hour, read both sources
// correctly, assembled the whole corpus, and then had nowhere to put it. When
// catalog and index became two plugin rows the READ was given a plane op and the
// write was deliberately left in-process ("one writer, in the process that owns
// the file"), which is the right property and the wrong conclusion: the writer is
// still one and still the index's, whether the corpus reaches it through a
// function call or a socket.
//
// Fixing the read alone turned a 503 into {"data":[],"total":0} — it began
// succeeding against a store nothing had ever written to. A page of nothing is a
// worse bug than an error, because it looks like an answer.
//
// IN-PROCESS FIRST, then the plane, both legs real, for the same reason lexical
// takes them in that order: a fused binary that mounted both apps has the index
// right here, and the deployed fleet does not.
func write(ctx context.Context, org string, docs []json.RawMessage) (int, int, error) {
if index.Ready() {
rows := make([]map[string]any, 0, len(docs))
for _, raw := range docs {
var d map[string]any
if err := json.Unmarshal(raw, &d); err != nil {
continue
}
rows = append(rows, d)
}
return index.Reconcile(ctx, org, uid, pk, rows)
}
// WHICH TENANT THE CORPUS IS WRITTEN AS, and why For() is right here where
// lexical needs As().
//
// lexical runs inside a request, and zip's forwardIdentity says an inbound
// request always wins over a stated caller — so it has an identity to displace.
// This runs in the sync goroutine off a background context: there is no request
// to lose to, and the tenant is simply stated. That is the form plane's own
// contract names for a background job, and the published corpus is written as
// PublicOrg by exactly this call.
out, err := indexpeer.IndexReconcile(cloud.For(ctx, org),
&plane.IndexReconcileIn{UID: uid, PrimaryKey: pk, Docs: docs})
if err != nil {
return 0, 0, err
}
if out == nil {
return 0, 0, nil
}
return out.Kept, out.Removed, nil
}
// filter applies the exact-match browse axes. An absent param is not a filter.
// Every dimension `facet` counts is filterable here and vice versa: a facet a
// caller can see but cannot act on is a rail that lies about being clickable.
@@ -457,20 +517,54 @@ func intQuery(raw string, def int) int {
// is testable without a live GitHub and the site source without a store.
var (
reconcile = func(ctx context.Context, org string, rows []Entry) (int, int, error) {
docs := make([]map[string]any, 0, len(rows))
docs := make([]json.RawMessage, 0, len(rows))
for _, e := range rows {
if e.ID == "" || e.Org == "" {
continue // an unkeyed row is one the next swap could never prune
}
e.Scope = "" // provenance is stamped on READ; storing it would freeze it
var doc map[string]any
raw, _ := json.Marshal(e)
_ = json.Unmarshal(raw, &doc)
docs = append(docs, doc)
raw, err := json.Marshal(e)
if err != nil {
continue
}
docs = append(docs, raw)
}
return index.Reconcile(ctx, org, uid, pk, docs)
return write(ctx, org, docs)
}
// The corpus's two sources, one seam each: what we BUILT and what is LIVE.
fromOrgs = orgRepos
liveSites = projects.LiveSites
liveSites = serving
)
// serving is what is LIVE, wherever the projects store happens to be — the same
// two legs as lexical and write, for the third source that was reaching for an
// in-process global across a process boundary.
//
// This one failed the most quietly of the three. projects.LiveSites reports nil
// when its package is unmounted, because a deployment that hosts no sites is not
// an error — true of a deployment, and false of a PROCESS. In the catalog process
// it meant "you asked the wrong half of the fleet", and nil and empty are the
// same answer, so the corpus simply had no sites in it and nothing anywhere said
// so. That is the whole `site` kind, every demo URL, and the deployed starters
// the template lane is mostly made of.
func serving(ctx context.Context) ([]projects.LiveSite, error) {
if projects.Ready() {
return projects.LiveSites(ctx)
}
out, err := projectspeer.SitesLive(ctx, &plane.LiveSitesIn{})
if err != nil {
return nil, err
}
if out == nil {
return nil, nil
}
live := make([]projects.LiveSite, 0, len(out.Sites))
for _, s := range out.Sites {
live = append(live, projects.LiveSite{
Org: s.Org, Slug: s.Slug, Name: s.Name, URL: s.URL,
Repo: s.Repo, ForkedFrom: s.ForkedFrom, UpdatedAt: s.UpdatedAt,
Upstream: s.Upstream, License: s.License,
})
}
return live, nil
}
+262
View File
@@ -0,0 +1,262 @@
package catalog
// split_test.go — the catalog as it is actually DEPLOYED: its own process, with
// the index in another one.
//
// The rest of this suite mounts the index and the lens on ONE app (catalog_test.go
// `mount`). That was the whole fleet once, and it is the topology this deployment
// stopped having. In it index.Ready() is true, so every read and every write takes
// the in-process leg and the plane leg — the ONLY leg production runs — was never
// executed by a test at all.
//
// That gap is the entire bug. catalog's reconcile called index.Reconcile, which
// serves out of the index's own process-level global; in the catalog process that
// global is nil and always will be. So the hourly sync assembled the corpus
// correctly and then dropped it on the floor with "index: not mounted", the
// published catalog was never written a single time, and GET /v1/catalog answered
// 200 with {"data":[],"total":0} — a page that reads as a platform on which
// nobody has built anything. The suite stayed green throughout, because the suite
// was the fused binary.
//
// So these tests refuse the in-process leg on purpose and go over a real unix
// socket to a real peer, which is the only arrangement that can prove the corpus
// reaches the store.
import (
"context"
"encoding/json"
"errors"
"net"
"os"
"sync"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/index"
"github.com/hanzoai/cloud/apps/projects"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// runDir points this test's plane at a directory of its own, and it is
// deliberately not t.TempDir(): that name carries the TEST's name, and a unix
// socket path is capped near 104 bytes. Over the cap the failure is
// "connect: invalid argument" — an errno that reads like a broken call rather
// than a long name, and it arrives identically whether a peer is there or not.
// The plane's own suite keeps a short dir for exactly this reason.
func runDir(t *testing.T) {
t.Helper()
dir, err := os.MkdirTemp("", "cx")
if err != nil {
t.Fatalf("run dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
t.Setenv("ZIP_RUNTIME_DIR", dir)
plane.Unbind()
t.Cleanup(plane.Unbind)
}
// standIn is the index peer as another PROCESS presents it: one socket, the
// reconcile op, and a record of what arrived. It records the TENANT too, because
// the org rides the caller and never the argument — so capturing it here is what
// proves the corpus was published as the public org rather than as nobody.
type standIn struct {
mu sync.Mutex
org string
uid string
pk string
docs []map[string]any
}
func (s *standIn) seen() (string, string, string, []map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
return s.org, s.uid, s.pk, s.docs
}
// peer serves the index's reconcile op on the index's canonical socket, and
// first insists that this process has NO index of its own — without that the
// write takes the in-process leg and the test silently checks nothing, which is
// exactly how the original defect survived a green suite.
func peer(t *testing.T) *standIn {
t.Helper()
if index.Ready() {
t.Fatal("split test: an index is mounted in this process, so the plane leg cannot be reached")
}
runDir(t)
s := &standIn{}
app := zip.New(zip.Config{AppName: "index", DisableStartupMessage: true})
zip.Post[plane.IndexReconcileIn, plane.IndexReconcileOut](app, "/index/reconcile",
func(ctx context.Context, in *plane.IndexReconcileIn) (*plane.IndexReconcileOut, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.org, s.uid, s.pk = cloud.Who(ctx).Org, in.UID, in.PrimaryKey
for _, raw := range in.Docs {
var d map[string]any
if err := json.Unmarshal(raw, &d); err != nil {
return nil, err
}
s.docs = append(s.docs, d)
}
return &plane.IndexReconcileOut{Kept: len(in.Docs), Removed: 0}, nil
}, zip.WithOperationID(plane.IndexReconcile))
go func() { _ = app.Listen(zip.SocketPath("index")) }()
t.Cleanup(func() { _ = app.Shutdown() })
for i := 0; i < 200; i++ {
if c, err := net.Dial("unix", zip.SocketPath("index")); err == nil {
_ = c.Close()
return s
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("index stand-in never began listening at %s", zip.SocketPath("index"))
return nil
}
// TestWriteReachesTheIndexProcess is the regression, and it fails on the code
// this replaces with "index: not mounted" — the error the deployed fleet returned
// every hour while the catalog read as empty.
func TestWriteReachesTheIndexProcess(t *testing.T) {
s := peer(t)
kept, removed, err := reconcile(context.Background(), PublicOrg, []Entry{
{ID: "hanzo/ui", Org: "hanzo", Name: "ui", Kind: "repo", Origin: OriginProduct, Forkable: true},
{ID: "zoo/gym", Org: "zoo", Name: "gym", Kind: "site", Origin: OriginCommunity},
})
if err != nil {
t.Fatalf("reconcile over the plane: %v", err)
}
if kept != 2 || removed != 0 {
t.Fatalf("reconcile reported kept=%d removed=%d, want 2 and 0", kept, removed)
}
org, uid, pk, docs := s.seen()
// The corpus is published as the public org, which is the whole tenancy rule:
// a name no principal can mint, stated by a background job that has no request
// behind it to be overridden by.
if org != PublicOrg {
t.Errorf("the index was asked as %q, want %q", org, PublicOrg)
}
if uid != "catalog" || pk != "id" {
t.Errorf("index addressed as uid=%q pk=%q, want catalog and id", uid, pk)
}
if len(docs) != 2 {
t.Fatalf("the peer received %d documents, want 2", len(docs))
}
if got := docs[0]["id"]; got != "hanzo/ui" {
t.Errorf("first document id %v, want hanzo/ui", got)
}
// Provenance is stamped on READ, so a stored row must not CLAIM one: a row
// frozen as "public" would keep saying so after it was read out of an org's
// private corpus. The key itself survives the wire — Scope is not omitempty,
// because the read contract has it present on every row it returns — so the
// invariant to hold is that it crosses empty.
if got := docs[0]["scope"]; got != "" {
t.Errorf("scope crossed as %q; provenance belongs to the read, not the corpus", got)
}
// forkable=false is an ANSWER, so it has to survive the wire. Dropped, a
// client cannot tell "you may not fork this" from "nobody said".
if got, ok := docs[1]["forkable"]; !ok || got != false {
t.Errorf("second document forkable=%v (present=%v), want false and present", got, ok)
}
}
// TestUnkeyedRowsNeverReachTheIndex holds the one filter the write applies. A row
// with no id could never be pruned by a later swap, so it would sit in the corpus
// forever — the one way this reconcile could leak rows it can no longer see.
func TestUnkeyedRowsNeverReachTheIndex(t *testing.T) {
s := peer(t)
if _, _, err := reconcile(context.Background(), PublicOrg, []Entry{
{ID: "", Org: "hanzo", Name: "nameless"},
{ID: "hanzo/real", Org: "", Name: "orgless"},
{ID: "hanzo/keeper", Org: "hanzo", Name: "keeper"},
}); err != nil {
t.Fatalf("reconcile over the plane: %v", err)
}
_, _, _, docs := s.seen()
if len(docs) != 1 {
t.Fatalf("the peer received %d documents, want only the keyed one", len(docs))
}
if got := docs[0]["id"]; got != "hanzo/keeper" {
t.Errorf("document id %v, want hanzo/keeper", got)
}
}
// TestLiveSitesCrossToTheProjectsProcess is the corpus's OTHER source, and it
// failed even more quietly than the write: projects.LiveSites answers nil when
// its package is unmounted, so in the catalog process "ask the process that owns
// the store" and "nothing is serving" were the same answer, and the corpus lost
// every site with no error to read anywhere.
func TestLiveSitesCrossToTheProjectsProcess(t *testing.T) {
if projects.Ready() {
t.Fatal("split test: the projects store is in this process")
}
runDir(t)
app := zip.New(zip.Config{AppName: "projects", DisableStartupMessage: true})
zip.Post[plane.LiveSitesIn, plane.LiveSitesOut](app, "/sites/live",
func(context.Context, *plane.LiveSitesIn) (*plane.LiveSitesOut, error) {
return &plane.LiveSitesOut{Sites: []plane.LiveSite{
{Org: "hanzo", Slug: "folio", Name: "Folio", URL: "https://folio.hanzo.app",
Repo: "https://git.hanzo.ai/hanzo-apps/folio", UpdatedAt: 1750000000},
{Org: "maxpower", Slug: "dave", URL: "https://dave.hanzo.app", ForkedFrom: "hanzo/folio"},
}}, nil
}, zip.WithOperationID(plane.SitesLive))
go func() { _ = app.Listen(zip.SocketPath("projects")) }()
t.Cleanup(func() { _ = app.Shutdown() })
for i := 0; i < 200; i++ {
if c, err := net.Dial("unix", zip.SocketPath("projects")); err == nil {
_ = c.Close()
break
}
time.Sleep(10 * time.Millisecond)
}
live, err := liveSites(context.Background())
if err != nil {
t.Fatalf("liveSites over the plane: %v", err)
}
if len(live) != 2 {
t.Fatalf("read %d live sites, want 2", len(live))
}
if live[0].Org != "hanzo" || live[0].Slug != "folio" || live[0].URL != "https://folio.hanzo.app" {
t.Errorf("first site came back as %+v", live[0])
}
// Lineage has to survive the wire: it is what files a remix in the community
// lane instead of leaving it looking like one of our own starters.
if live[1].ForkedFrom != "hanzo/folio" {
t.Errorf("forked-from came back as %q, want hanzo/folio", live[1].ForkedFrom)
}
// The trace back out of a demo. A live URL with no repo beside it is a
// screenshot, and this is the field that keeps it from being one.
if live[0].Repo == "" {
t.Error("the source repo did not survive the wire")
}
}
// TestNoIndexAnywhereIsAPeerFault separates the two failures a write can have, so
// an operator reading a log can act on it. "There is no index in this deployment"
// is ErrNoPeer; it is NOT the in-process ErrNotMounted, which in the split fleet
// was never a real fact about the deployment at all — only about the wrong half
// of it being asked.
func TestNoIndexAnywhereIsAPeerFault(t *testing.T) {
if index.Ready() {
t.Fatal("split test: an index is mounted in this process")
}
runDir(t) // an empty run dir: no peer serves here
_, _, err := reconcile(context.Background(), PublicOrg,
[]Entry{{ID: "hanzo/ui", Org: "hanzo", Name: "ui"}})
if err == nil {
t.Fatal("a write with no index anywhere reported success")
}
if !errors.Is(err, plane.ErrNoPeer) {
t.Errorf("write failed with %v, want ErrNoPeer", err)
}
if errors.Is(err, index.ErrNotMounted) {
t.Error("write reported the in-process ErrNotMounted, so it never took the plane leg")
}
}
+6 -8
View File
@@ -4,13 +4,12 @@ import (
"context"
"database/sql"
"errors"
"fmt"
// cek is the ONE opener: it renders this subsystem's path from the
// namespace and opens it under the key cek derives for that name.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: it renders this subsystem's path from the
// system namespace, opens it under the key cek derives for that name, and
// applies the single-connection cap.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both build tags). Blank import registers
@@ -41,11 +40,10 @@ type store struct {
}
func openStore(dir string) (*store, error) {
db, err := cek.Open(namespace.System(), "channels", dir)
db, err := sqlpool.Open("channels", dir)
if err != nil {
return nil, fmt.Errorf("open channels store: %w", err)
return nil, err
}
sqlpool.Single(db)
st := &store{db: db}
if err := st.migrate(); err != nil {
_ = db.Close()
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/apps/runtime"
"github.com/hanzoai/cloud/apps/bots"
)
// task.go is coding's WIRE CONTRACT with the bot runtime — the stub behind the
@@ -73,7 +73,7 @@ type runner struct{}
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
var out RunResult
var terminal bool
err := runtime.Stream(ctx, runtime.Call{
err := bots.Stream(ctx, bots.Call{
Op: taskOp,
Org: org,
User: userID,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
// These pin coding's wire contract with the runtime through the REAL transport
// (clients/runtime) against a stub server — so the credential-custody and
// (apps/bots' transport) against a stub server — so the credential-custody and
// fail-closed properties are proven end to end over the seam, not against a fake
// of it.
+86
View File
@@ -0,0 +1,86 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
import (
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
accountclient "github.com/hanzoai/cloud/apps/account"
commercebilling "github.com/hanzoai/commerce/api/billing"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
)
// The saved-card address, /v1/billing/methods, moved from cloud's billing app —
// where it was FORWARDED to commerce over HTTP through a base URL this
// deployment never sets — to here, where commerce is already in the process.
// The symptom of the hop was a customer getting 401 while listing their own
// cards, and a checkout that could not prefill.
//
// Its tests had to move with it, and they were the reason to write these: the
// billing app's versions asserted the PROXY (which commerce path was called,
// which query the forwarder pinned). None of that exists anymore, and deleting
// them without replacement would have taken the two facts that still matter
// with it. Both survive the move because they are properties of the route, not
// of the transport:
//
// a caller must be authenticated — saving a card is a customer's own act
// the subject is PINNED server-side — never read from what the caller sent
//
// The second is what keeps one customer out of another's cards, so it is
// asserted against a hostile request rather than a well-formed one.
// methodsApp mounts the customer saved-card routes exactly as Mount does — same
// middleware, same order. A test that assembles a different chain proves only
// that the chain it invented behaves.
func methodsApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{})
app.Get("/v1/billing/methods",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.ListPaymentMethods,
)
app.Post("/v1/billing/methods",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercebilling.CreatePaymentMethod,
)
return app
}
// TestMethods_Unauthenticated401 — an anonymous caller must not reach the
// handler at all. The failure this guards is not a leak but a 404: when the
// route is not mounted, an unauthenticated request gets "no such address",
// which reads like a missing feature and hides that the gate never ran.
func TestMethods_Unauthenticated401(t *testing.T) {
app := methodsApp(t)
for _, tc := range []struct{ method, path, body string }{
{"GET", "/v1/billing/methods", ""},
{"POST", "/v1/billing/methods", `{"sourceId":"cnon:fake"}`},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 404 {
t.Fatalf("%s %s answered 404 — the route is not mounted, so the auth gate never ran", tc.method, tc.path)
}
if resp.StatusCode != 401 {
t.Fatalf("%s %s: want 401 for an anonymous caller, got %d (%s)", tc.method, tc.path, resp.StatusCode, body)
}
}
}
+36 -4
View File
@@ -175,7 +175,7 @@ func commerceMasterKey(master []byte, lg log.Logger) []byte {
// checkout SPA root catch-all, Listen) are skipped by the SharedApp contract.
// This adapter registers the remaining wire-contract families with commerce's
// own gate chains (see Prefixes).
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
// The ledger lives here, so the methods that read and move it are published
// here: balance, the prepaid gate, the debit and the credit.
exposeBalance()
@@ -188,10 +188,25 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("commerce: nil app")
}
// The embedded hanzoai/commerce module and the two typed surfaces below
// register on the concrete App — the named hole, cloud.ZipApp, not a widened
// Mount signature. commerce's app-wide reach (it wraps ALL of /v1, below) is
// DECLARED as Plugin.Global at its composition root instead of being implied
// by a parameter type nobody was checking.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("commerce: router is not a zip app — the embedded module has nothing to register on")
}
if deps.Logger == nil {
return fmt.Errorf("commerce: nil deps.Logger")
}
lg := deps.Logger.New("subsystem", "commerce")
// The other direction of the same idea as the ops above: those publish what
// this process OWNS, and this reaches for the one thing it does not. The credit
// door below screens against a model that can only live in one binary, so the
// scorer is installed as a plane client — cloud.SetRiskScorer's first producer
// (risk.go).
installRiskScorer(lg)
if deps.Payments == nil {
lg.Warn("commerce: deps.Payments is nil — payment intent paths will fail; tenant config + admin still served")
}
@@ -205,8 +220,8 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// what decides whether an agent can take a payment. They share commerce's ONE
// charge core with the browser's card top-up (payments.go), so registering
// them here adds a door, never a second money path.
exposePayments(app)
exposeInvoices(app)
exposePayments(zapp)
exposeInvoices(zapp)
// Native zip health endpoint — registered FIRST so probes answer even when
// the embed fails below.
@@ -231,7 +246,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
RequireIdentity: false,
// THE native co-residence contract: commerce registers its routes on
// cloud's own app — no second engine, no net/http adaptation.
App: app,
App: zapp,
// ONE LEDGER: commerce's POST /v1/billing/credit mints into cloud's native
// finance ledger (the SAME per-org account the AI spend-gate reads), so a
// granted credit is immediately spendable. commercemod.Embed calls
@@ -239,6 +254,14 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// its own datastore (standalone), but in this unified binary finance is
// co-resident, so we inject the finance-backed ledger adapter.
Ledger: ledger{},
// THE HOST'S SECRET PLANE, in-process. deps.KMS is the embedded KMS this
// binary already runs (the logs say so at boot: "deps.KMS -> the kms app
// over the internal plane"), so commerce reads a deployment secret by
// asking its host rather than through an env fan-out — KMS to a k8s
// Secret to a pod variable, three places to go stale and a restart to
// pick up a rotation. nil when this build has no KMS, which commerce
// treats as "fall back", never as an error.
Secrets: deps.KMS,
})
if err != nil {
lg.Error("commerce embed failed — serving fail-closed 503 (cloud stays up)", "err", err)
@@ -648,11 +671,20 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// boundary stays exactly where the bridge put it.
// The card PAN never touches this binary: TopupWithToken charges the Square nonce only,
// and the settled charge itself is the mint authority (mintauth.WithAuthorized).
// riskGate — that mint authority is exactly why this route is screened.
// A settled charge on a stolen card IS spendable balance, so
// HANZO RISK judges the payer at the last moment before the
// charge, as a PRIVILEGED grant: a scorer that is present and
// cannot answer refuses rather than proceeds. It sits after
// PinBillingSubject so the subject it judges is the subject the
// charge credits, and before the handler so a refusal costs no
// card authorization. See risk.go.
app.Post("/v1/billing/topup/token",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
riskGate(lg),
commercebilling.TopupWithToken,
)
+300
View File
@@ -0,0 +1,300 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
// risk.go — the credit door is SCREENED, and the scorer that screens it lives in
// another process.
//
// Two halves of one seam, and they belong in one file because neither is
// intelligible without the other:
//
// the CLIENT — cloud.SetRiskScorer's first producer. Package cloud has held
// the seam and the fail policy since it was written and has never had anyone
// to ask: the model is in-process mutable state, so exactly one binary may
// hold it, and the pod forks one process per app. Every gate in the fleet
// therefore read a nil scorer and allowed, unscored. This installs one that
// reaches the risk child over its socket.
//
// the GATE — the one place that asks. POST /v1/billing/topup/token is the
// self-serve CREDIT DOOR: a settled card charge is its mint authority, so a
// stolen card that clears is money in an account, and the account is what buys
// inference. It is the sharpest lifecycle moment this binary owns.
//
// THE GATE IS PRIVILEGED AND SAYS SO IN SO MANY WORDS. cloud.Privileged() reads a
// list of grant PATHS and this route is on none of them, so the default would be
// the fail-OPEN branch — a scorer outage would wave every top-up through, on the
// one route where waving one through mints spendable balance. The bit is set here
// rather than added to that list because the list describes IAM and KMS surfaces
// and this is neither; the gate that knows what it is guarding states it.
//
// IT SHIPS IN SHADOW, and that is a property of the MODEL rather than of this
// code. A model nobody has reviewed is in shadow (apps/risk policy), shadow forces
// its alert false however high the score, and this gate turns a non-alert into an
// allow. So today every legitimate top-up proceeds and every decision is on the
// record with what the model WOULD have said. What still refuses is a scorer that
// is HERE and cannot answer — the fail-closed branch this bit exists to select.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync/atomic"
log "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
accountclient "github.com/hanzoai/cloud/apps/account"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/plane"
riskpeer "github.com/hanzoai/cloud/plane/risk"
)
// installRiskScorer publishes the ONE scorer to package cloud's seam. Mount calls
// it, beside the ledger ops, because both are this process telling the fleet what
// it can reach through it.
//
// It is installed unconditionally: the seam's own fail policy already answers for
// a scorer that cannot be reached, and installing conditionally would mean asking
// at mount time a question whose answer changes every time the risk child starts
// or stops.
func installRiskScorer(lg log.Logger) {
cloud.SetRiskScorer(func(ctx context.Context, org string, q cloud.RiskQuery) (cloud.RiskVerdict, error) {
return scoreOverPlane(ctx, lg, org, q)
})
}
// scoreOverPlane asks the risk child, and translates the two vocabularies.
//
// THE TENANT IS STATED, NOT FORWARDED. cloud.For on a context with no request
// behind it is the one place zip reads a stated caller; on a request-derived
// context zip prefers the gateway's assertion and returns before it looks, so the
// org resolved by the gate — which for a SuperAdmin acting in another org is not
// the inbound assertion — would be silently replaced by the inbound one. Same
// correction apps/x402's peerCtx makes, for the same reason: the org that PAYS is
// not always the org that asked.
//
// THE BUDGET IS THE SEAM'S. cloud.Decide answers at RiskBudget whatever this
// returns, so a hop bounded any longer would only hold one of the seam's 256
// slots past the point where its answer could still be used.
func scoreOverPlane(ctx context.Context, lg log.Logger, org string, q cloud.RiskQuery) (cloud.RiskVerdict, error) {
// NOT LISTENING IS NOT AN OUTAGE, and telling the two apart is the whole
// reason this is a probe and not just a call.
//
// The fleet starts 106 of its apps lazily: an app is brought up by a request
// reaching its prefix, and nothing reaches risk's. So the steady state of a
// fresh pod is a scorer that is not there yet — which, asked synchronously,
// costs a child's whole startup inside a 150ms budget, times out, and (being
// privileged) REFUSES THE TOP-UP. Every cold start would take the credit door
// down for the first customer to reach it.
//
// A socket with no listener is exactly cloud's ABSENT fact — no scorer here,
// allow and say so — so it is answered as one, and the child is brought up off
// the request path for the next caller. A socket that IS there and does not
// answer stays an outage and still denies, which is the fact this gate exists
// to fail closed on.
if !scorerUp() {
wakeScorer(lg)
return cloud.RiskUnavailable(q, cloud.RefusalAbsent), nil
}
cctx, cancel := context.WithTimeout(cloud.For(context.Background(), org), cloud.RiskBudget)
defer cancel()
out, err := riskpeer.RiskDecide(cctx, &plane.RiskDecideIn{
Stage: q.Stage,
Kind: q.Subject.Kind,
Subject: q.Subject.ID,
Signals: signalsOf(q.Signals),
})
switch {
case errors.Is(err, cloud.ErrNoPeer):
// The router owns the manifest and it says this fleet runs no risk app.
// That is the absent fact again, decided by the only thing that can decide
// it — never guessed from a failed call.
return cloud.RiskUnavailable(q, cloud.RefusalAbsent), nil
case err != nil:
return cloud.RiskVerdict{}, fmt.Errorf("risk: decide over the plane: %w", err)
case out == nil:
// A void reply from the only thing that can judge is not a judgement.
return cloud.RiskVerdict{}, fmt.Errorf("risk: the scorer answered nothing")
}
// The AGENCY is not carried in either direction: this scorer has no opinion on
// which lane a caller is in, so the asking gate's own lane stands. An empty
// Agency here is what says that.
return cloud.RiskVerdict{
Action: out.Action,
Score: out.Score,
Cause: out.Cause,
Refusal: out.Refusal,
Shape: out.Shape,
Policy: out.Policy,
}, nil
}
// signalsOf puts the gate's observations on the wire. A map cannot cross the
// plane at all — zapenc refuses one at encode — so they travel as a list, SORTED,
// because an unordered wire is one that cannot be compared with itself.
func signalsOf(facts map[string]string) []plane.Signal {
if len(facts) == 0 {
return nil
}
out := make([]plane.Signal, 0, len(facts))
for name, value := range facts {
out = append(out, plane.Signal{Name: name, Value: value})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// scorerUp reports whether the risk child's socket has a LISTENER behind it right
// now. It connects, because the file does not answer the question: a socket left
// by a dead pod outlives it, and for a lazily started app that difference is the
// whole answer.
func scorerUp() bool {
plane.Bind()
up, err := plane.Listening(zip.SocketPath(riskpeer.App))
return err == nil && up
}
// waking holds the ONE start request in flight. The host single-flights the start
// itself, so this bounds the goroutines rather than the starts: without it a burst
// of top-ups against a cold scorer would spawn one waiter each.
var waking atomic.Bool
// wakeScorer brings the risk child up OFF the request path. The caller has
// already been answered — absent, allowed, on the record — so this exists only to
// make the NEXT decision a real one.
func wakeScorer(lg log.Logger) {
if !waking.CompareAndSwap(false, true) {
return
}
go func() {
defer waking.Store(false)
// Reach applies the host's own plugin-start budget. A fleet that runs no
// risk app answers ErrNoPeer immediately and this is a no-op that repeats
// at most once per decision, one at a time.
if err := plane.Reach(context.Background(), riskpeer.App); err != nil {
lg.Debug("commerce: the risk scorer could not be started", "err", err)
}
}()
}
// ── the gate ─────────────────────────────────────────────────────────────────
// riskGate screens one credit-door request and refuses what the scorer will not
// have.
//
// WHERE IT SITS IS PART OF WHAT IT IS. It runs AFTER PinBillingSubject, which is
// what makes the subject it judges the subject the charge will credit: that
// middleware refuses every caller that is neither a validated customer nor the
// trusted service token, and pins the credited subject to the caller's own. A
// screen placed before it would judge a subject a later middleware could still
// change, which is a control on a value rather than on an act.
//
// It refuses in TWO different sentences because they are two different facts, and
// a customer can act on only one of them:
//
// the scorer is here and could not answer — 503, retry. The model exists, this
// question went unanswered, and a privileged grant waits rather than proceeds.
// It is an operational fact, so saying it is honest and useful.
//
// the model DECIDED against it — 403, and nothing more. A risk reason handed
// back to whoever triggered it is a feedback channel for tuning the next
// attempt. The reason is written to the log with the shape and policy version
// that produced it, which is where an operator reads it.
//
// It is never a 402. Out of funds is what a 402 means at this door and this is
// not that — the whole point of the door is that the caller has no funds yet.
func riskGate(lg log.Logger) zip.Handler {
return func(c *zip.Ctx) error {
org := payerOrg(c)
v := cloud.Decide(c.Context(), org, cloud.RiskQuery{
Stage: cloud.StagePayment,
Subject: cloud.RiskSubject{Kind: plane.KindAccount, ID: principal.Subject(c, org)},
// STATED, never derived. cloud.Privileged() does not match this route
// and the default is fail-open, so an unset bit here is a scorer outage
// minting balance.
Privileged: true,
Signals: cloud.Facts(topupSignals(c)),
})
// EVERY decision is recorded, including the allows — an unscored allow and a
// clean one are different rows, and the refusal is what tells them apart.
lg.Info("credit door screened",
"org", org, "action", v.Action, "scored", v.Scored(), "refusal", v.Refusal,
"cause", v.Cause, "score", v.Score, "shape", v.Shape, "policy", v.Policy)
if v.Allowed() {
return c.Next()
}
if v.Refusal != "" {
return zip.Errorf(http.StatusServiceUnavailable,
"the payment screen could not answer (%s) — try again in a moment", v.Refusal)
}
// Block, challenge and restrict all land here. This door has no way to
// present a challenge and no reduced ceiling to fall back to, so anything
// short of "proceed" is a refusal — never a quiet proceed.
return zip.ErrForbidden("this top-up was not authorised")
}
}
// payerOrg is the org whose ledger this top-up will credit, and therefore the
// organisation whose model judges it.
//
// The two lanes are exactly the two PinBillingSubject admits, and no others reach
// this gate:
//
// a validated customer — principal.Ledger, the SELECTED org that pays. It is
// the same key the balance read and the spend gate use.
// the trusted service token — a verified COMMERCE_SERVICE_TOKEN naming its own
// org. It carries no validated user, so there is no ledger to resolve and the
// org it named is the one being credited.
//
// Anything else resolves to "" and the scorer refuses to mint a tenant for it, so
// a request that reaches the credit door naming no organisation is denied by the
// privileged branch rather than screened against nobody.
func payerOrg(c *zip.Ctx) string {
if org := principal.Ledger(c); org != "" {
return org
}
if accountclient.IsServiceToken(c) {
return strings.TrimSpace(c.Org())
}
return ""
}
// topupSignals is what this gate SAW, in the scorer's own vocabulary.
//
// The amount is the fact that matters at a credit door — value velocity is the
// axis a stolen card moves — and it is the one signal the model reads as a
// coordinate. The rest are the gate's record of why it asked.
func topupSignals(c *zip.Ctx) map[string]string {
var body struct {
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency"`
}
// A body that does not decode is not this middleware's refusal to make: the
// handler behind it validates its own wire and answers 400 in its own words.
// Here it simply means the amount was not observed.
_ = json.Unmarshal(c.Body(), &body)
signals := map[string]string{
"ip": cloud.ClientIP(c),
"currency": strings.ToLower(strings.TrimSpace(body.Currency)),
}
// NANO IS USD. A minor unit in another currency converted as though it were
// cents would be a number the value features read as a different amount of
// money, so an amount this gate cannot state in USD is not stated at all —
// absent, blind, and counted as blind on the org's own model state.
if body.AmountCents > 0 && (signals["currency"] == "" || signals["currency"] == "usd") {
signals[plane.SignalNano] = strconv.FormatInt(body.AmountCents*nanoPerCent, 10)
}
return signals
}
// nanoPerCent converts the wire's minor unit to the model's. A cent is 10^-2 USD
// and a nano is 10^-9, so one cent is 10^7 nano.
const nanoPerCent = 10_000_000
+325
View File
@@ -0,0 +1,325 @@
package commerce
// risk_test.go — the credit door's screen, held to the one property that decides
// whether shipping it is safe: WHICH WAY IT FAILS.
//
// The two failures are not the same failure and must not have the same answer:
//
// the scorer is NOT DEPLOYED — the top-up proceeds. A control that is not
// installed must not be able to take the product down, and this is the
// NEGATIVE CONTROL of the whole change: if this test can be made to fail by
// the same code that makes the next one pass, the gate is an outage.
//
// the scorer IS here and cannot answer — the top-up waits. That is what
// Privileged selects, and it is the reason the bit is stated at this gate.
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/account"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
const (
gateOrg = "acme"
gateUser = "u_412"
// A $42.00 top-up, in the wire's own minor unit.
gateBody = `{"sourceId":"cnon:card-nonce-ok","amountCents":4200,"currency":"USD"}`
)
// charged is the handler the gate stands in front of. Reaching it IS the allow.
func charged(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]string{"status": "charged"})
}
// gateApp is the credit door reduced to the two middlewares that decide the
// outcome: the screen, and the thing it guards.
func gateApp(t *testing.T) *zip.App {
t.Helper()
// The scorer's socket is resolved under a directory that has none, so
// "not deployed" is the state of the world unless a test installs a scorer.
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
plane.Unbind()
t.Cleanup(plane.Unbind)
t.Cleanup(func() { cloud.SetRiskScorer(nil) })
app := zip.New(zip.Config{Logger: luxlog.New("gatetest"), DisableStartupMessage: true})
app.Post("/v1/billing/topup/token", riskGate(luxlog.New("gatetest")), charged)
return app
}
// topup posts the credit door's own body as a validated customer.
func topup(t *testing.T, app *zip.App) (int, string) {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/v1/billing/topup/token", strings.NewReader(gateBody))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Org-Id", gateOrg)
r.Header.Set("X-User-Id", gateUser)
resp, err := app.Test(r)
if err != nil {
t.Fatalf("topup: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// TestRiskGate_ANotDeployedScorerDoesNotCloseTheCreditDoor — THE NEGATIVE
// CONTROL.
//
// A fleet that does not run the risk app must still take money. cloud's fail
// policy exempts exactly this case ("refusing to mint a credential because a
// component is not deployed is not security, it is a product that cannot be
// operated") and the plane client is what preserves it across a process boundary:
// over a socket, "not deployed" would otherwise arrive as a failed call and take
// the fail-CLOSED branch.
//
// Mutation proof: return an error instead of the absent verdict in
// scoreOverPlane, or drop the scorerUp probe, and this fails while
// [TestRiskGate_APresentScorerThatCannotAnswerMakesTheGrantWait] still passes.
func TestRiskGate_ANotDeployedScorerDoesNotCloseTheCreditDoor(t *testing.T) {
app := gateApp(t)
// No producer at all — the state of every process before this change.
cloud.SetRiskScorer(nil)
if code, body := topup(t, app); code != http.StatusOK {
t.Fatalf("no scorer installed: %d %s, want 200 — an uninstalled control must not refuse a payment", code, body)
}
// The producer this change installs, against a fleet with no risk app: the
// socket has no listener, so the answer is ABSENT and the door stays open.
installRiskScorer(luxlog.New("gatetest"))
if code, body := topup(t, app); code != http.StatusOK {
t.Fatalf("scorer installed, peer not deployed: %d %s, want 200 — an unreachable model is absent, not a denial", code, body)
}
}
// TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage is the same
// property one layer down, where the two facts are actually told apart — and it
// asserts the SHAPE of the answer, which the status code above cannot see.
func TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage(t *testing.T) {
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
plane.Unbind()
t.Cleanup(plane.Unbind)
q := cloud.RiskQuery{
Stage: cloud.StagePayment,
Subject: cloud.RiskSubject{Kind: plane.KindAccount, ID: "acme/u_412"},
Privileged: true,
}
v, err := scoreOverPlane(context.Background(), luxlog.New("gatetest"), gateOrg, q)
if err != nil {
t.Fatalf("an undeployed peer reported an error: %v — an error is an outage, and this is an absence", err)
}
if v.Refusal != cloud.RefusalAbsent {
t.Errorf("refusal %q, want %q — the answer must name why it is not a scored one", v.Refusal, cloud.RefusalAbsent)
}
if v.Action != cloud.ActionAllow {
t.Errorf("action %q, want %q even though the query is privileged", v.Action, cloud.ActionAllow)
}
if v.Scored() {
t.Error("an absent scorer produced a SCORED verdict — an unscored allow must never read as a clean one")
}
}
// TestRiskGate_APresentScorerThatCannotAnswerMakesTheGrantWait is the other half
// of the fail policy, and the reason Privileged is stated at this gate at all:
// cloud.Privileged() does not match this route, so without the explicit bit a
// scorer outage would mint balance.
//
// Mutation proof: drop `Privileged: true` from the query and this returns 200.
func TestRiskGate_APresentScorerThatCannotAnswerMakesTheGrantWait(t *testing.T) {
app := gateApp(t)
cloud.SetRiskScorer(func(context.Context, string, cloud.RiskQuery) (cloud.RiskVerdict, error) {
return cloud.RiskVerdict{}, errors.New("the model plane is unavailable")
})
code, body := topup(t, app)
if code != http.StatusServiceUnavailable {
t.Fatalf("%d %s, want 503 — a judge that is here and did not answer makes a privileged grant wait", code, body)
}
if code == http.StatusPaymentRequired {
t.Error("the refusal was a 402 — that means out of funds, which is the one thing this door exists to fix")
}
if !strings.Contains(body, cloud.RefusalError) {
t.Errorf("the refusal does not name the operational fact a customer can act on: %s", body)
}
}
// TestRiskGate_AVerdictDecidesTheOutcome — the whole action vocabulary at this
// door, including the two it has no way to honour.
func TestRiskGate_AVerdictDecidesTheOutcome(t *testing.T) {
for _, tc := range []struct {
action string
want int
}{
{cloud.ActionAllow, http.StatusOK},
// Review PROCEEDS. It summons a person; it does not stop traffic.
{cloud.ActionReview, http.StatusOK},
{cloud.ActionBlock, http.StatusForbidden},
// This door has no challenge to present and no reduced ceiling to fall
// back to, so anything short of "proceed" is a refusal — never a quiet
// proceed.
{cloud.ActionChallenge, http.StatusForbidden},
{cloud.ActionRestrict, http.StatusForbidden},
} {
t.Run(tc.action, func(t *testing.T) {
app := gateApp(t)
cloud.SetRiskScorer(func(context.Context, string, cloud.RiskQuery) (cloud.RiskVerdict, error) {
return cloud.RiskVerdict{Action: tc.action, Score: 0.9, Cause: "above the cut"}, nil
})
if code, body := topup(t, app); code != tc.want {
t.Errorf("%s: %d %s, want %d", tc.action, code, body, tc.want)
}
})
}
}
// TestRiskGate_JudgesThePayerThatWillBeCredited pins the QUESTION rather than the
// answer: the moment, the subject, the amount, and the bit that decides which way
// silence falls.
func TestRiskGate_JudgesThePayerThatWillBeCredited(t *testing.T) {
app := gateApp(t)
var asked cloud.RiskQuery
var forOrg string
cloud.SetRiskScorer(func(_ context.Context, org string, q cloud.RiskQuery) (cloud.RiskVerdict, error) {
forOrg, asked = org, q
return cloud.RiskVerdict{Action: cloud.ActionAllow}, nil
})
if code, body := topup(t, app); code != http.StatusOK {
t.Fatalf("%d %s, want 200", code, body)
}
if forOrg != gateOrg {
t.Errorf("the model asked was %q's, want %q's — the tenant is the org whose ledger is credited", forOrg, gateOrg)
}
if asked.Stage != cloud.StagePayment {
t.Errorf("stage %q, want %q", asked.Stage, cloud.StagePayment)
}
if asked.Subject.Kind != plane.KindAccount {
t.Errorf("kind %q, want %q — a top-up moves an ACCOUNT's spend, and the kind namespaces the subject",
asked.Subject.Kind, plane.KindAccount)
}
// The ONE subject rule, stated here independently of the gate: the wallet key
// the balance read, the spend gate and the credit all address.
want := account.Payer(account.Credential{Owner: gateOrg, Name: gateUser}).Subject()
if asked.Subject.ID != want {
t.Errorf("subject %q, want %q — the screen must judge the subject the charge credits", asked.Subject.ID, want)
}
if !asked.Privileged {
t.Error("the query is not privileged — a scorer outage would mint spendable balance, " +
"and cloud.Privileged() does not match this route")
}
if got := asked.Signals[plane.SignalNano]; got != "42000000000" {
t.Errorf("nano %q, want %q — $42.00 is 4200 cents is 42e9 nano", got, "42000000000")
}
if got := asked.Signals["currency"]; got != "usd" {
t.Errorf("currency %q, want %q", got, "usd")
}
// EVERY STATED SIGNAL IS A FACT. cloud.Facts drops the empties, because an
// empty string is a VALUE: a scorer keying velocity on an address it never
// received would group every such caller into one very busy one. The address is
// absent HERE for exactly that reason and correctly — app.Test has no socket
// peer, and 0.0.0.0 is not a client address — so what this asserts is the rule
// rather than the fixture.
for name, value := range asked.Signals {
if value == "" {
t.Errorf("signal %q travelled empty — a fact we do not have must be ABSENT, not the empty string", name)
}
}
}
// TestTopupSignals_AnAmountThatIsNotUSDIsNotStated. Nano is nano-USD, so a minor
// unit in another currency read as cents is a different amount of money. Absent
// beats wrong: the value features read blind, and blind is counted.
func TestTopupSignals_AnAmountThatIsNotUSDIsNotStated(t *testing.T) {
for _, tc := range []struct{ name, body, nano string }{
{"usd", `{"amountCents":4200,"currency":"USD"}`, "42000000000"},
{"no currency is usd", `{"amountCents":500}`, "5000000000"},
{"eur is not converted", `{"amountCents":4200,"currency":"eur"}`, ""},
{"zero is not an amount", `{"amountCents":0,"currency":"usd"}`, ""},
{"a negative is not an amount", `{"amountCents":-4200,"currency":"usd"}`, ""},
{"an unreadable body observes nothing", `not json at all`, ""},
{"no body observes nothing", ``, ""},
} {
t.Run(tc.name, func(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("gatetest"), DisableStartupMessage: true})
var got map[string]string
app.Post("/probe", func(c *zip.Ctx) error {
got = cloud.Facts(topupSignals(c))
return c.JSON(http.StatusOK, "ok")
})
r := httptest.NewRequest(http.MethodPost, "/probe", strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
if _, err := app.Test(r); err != nil {
t.Fatalf("probe: %v", err)
}
if got[plane.SignalNano] != tc.nano {
t.Errorf("nano %q, want %q", got[plane.SignalNano], tc.nano)
}
})
}
}
// TestSignalsOf_CrossesAsASortedList. A map cannot cross this plane at all —
// zapenc refuses one at encode, which is a failure INSIDE the call and not a
// rejected field — and an unordered wire is one that cannot be compared with
// itself.
func TestSignalsOf_CrossesAsASortedList(t *testing.T) {
got := signalsOf(map[string]string{"ip": "203.0.113.7", "currency": "usd", plane.SignalNano: "1"})
if len(got) != 3 {
t.Fatalf("%d signals, want 3", len(got))
}
for i := 1; i < len(got); i++ {
if got[i-1].Name >= got[i].Name {
t.Errorf("signal %d (%q) is not after %q — the wire is unordered", i, got[i].Name, got[i-1].Name)
}
}
if signalsOf(nil) != nil {
t.Error("no facts must travel as no signals, never as an empty list")
}
}
// TestPayerOrg_ResolvesOnlyTheTwoLanesThatReachThisGate. Everything else is "",
// which the scorer refuses to mint a tenant for — so a request that reaches the
// credit door naming no organisation is denied by the privileged branch rather
// than screened against nobody.
func TestPayerOrg_ResolvesOnlyTheTwoLanesThatReachThisGate(t *testing.T) {
for _, tc := range []struct{ name, org, user, want string }{
{"a validated customer pays from its own ledger", gateOrg, gateUser, gateOrg},
{"an org header with no validated user is the forged case", gateOrg, "", ""},
{"no identity at all", "", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("gatetest"), DisableStartupMessage: true})
var got string
app.Get("/probe", func(c *zip.Ctx) error {
got = payerOrg(c)
return c.JSON(http.StatusOK, "ok")
})
r := httptest.NewRequest(http.MethodGet, "/probe", nil)
if tc.org != "" {
r.Header.Set("X-Org-Id", tc.org)
}
if tc.user != "" {
r.Header.Set("X-User-Id", tc.user)
}
if _, err := app.Test(r); err != nil {
t.Fatalf("probe: %v", err)
}
if got != tc.want {
t.Errorf("payerOrg = %q, want %q", got, tc.want)
}
})
}
}
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -32,11 +31,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "company", dir)
db, err := sqlpool.Open("company", dir)
if err != nil {
return nil, fmt.Errorf("open company store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+2 -5
View File
@@ -6,10 +6,8 @@ import (
"errors"
"fmt"
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/apps/idv"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -27,11 +25,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "compliance", dir)
db, err := sqlpool.Open("compliance", dir)
if err != nil {
return nil, fmt.Errorf("open compliance store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+6 -7
View File
@@ -6,16 +6,16 @@ import (
"errors"
"fmt"
// cek is the ONE opener: it renders this subsystem's path from the
// namespace and opens it under the key cek derives for that name.
// sqlpool.Open is the ONE opener: it renders this subsystem's path from the
// system namespace, opens it under the key cek derives for that name, and
// applies the single-connection cap.
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags (cgo →
// mattn+SQLCipher, encrypted at rest; !cgo → pure-Go modernc). Importing
// modernc directly instead would double-register "sqlite" under CGO and
// panic at init. Blank import registers the driver.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -38,11 +38,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "crm", dir)
db, err := sqlpool.Open("crm", dir)
if err != nil {
return nil, fmt.Errorf("open crm store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+15 -15
View File
@@ -53,7 +53,7 @@ import (
"golang.org/x/crypto/bcrypt"
hcloud "github.com/hanzoai/cloud"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/goja"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
@@ -85,10 +85,10 @@ type state struct {
}
// mounted is the active service so Shutdown can release the per-tenant stores.
var mounted *hcloud.Service[state]
var mounted *cloud.Service[state]
// Mount wires the /v1/dataroom/* surface onto app per HIP-0106.
func Mount(app hcloud.Router, deps hcloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("dataroom.Mount: nil app")
}
@@ -130,7 +130,7 @@ func Mount(app hcloud.Router, deps hcloud.Deps) error {
return nil
}
s := &hcloud.Service[state]{Base: hcloud.NewBase(deps, "dataroom"), State: state{host: host, index: index, blob: deps.VFS}}
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "dataroom"), State: state{host: host, index: index, blob: deps.VFS}}
mounted = s
routes(app, s)
@@ -160,14 +160,14 @@ func Mount(app hcloud.Router, deps hcloud.Deps) error {
// validated org a typed op could read, and they are the surface a visitor's
// browser drives rather than one an agent calls. Health is native and answers
// before any of this exists.
func routes(app hcloud.Router, s *hcloud.Service[state]) {
func routes(app cloud.Router, s *cloud.Service[state]) {
g := app.Group("/v1/dataroom")
// Bridge FIRST: a typed op receives only a context, so the validated org
// reaches it by being parked there — never as an In field, which is
// caller-supplied and would be a cross-tenant read the caller asserted for
// itself. fiber runs middleware in registration order, so this must precede
// the leaves below.
g.Use(hcloud.Bridge())
g.Use(cloud.Bridge())
// Then the bundle's own envelope: a typed op that must answer the bundle's
// {"error": …} returns a goja.BundleErr, and this writes those bytes back
// verbatim. Also before the leaves, for the same registration-order reason.
@@ -194,8 +194,8 @@ func routes(app hcloud.Router, s *hcloud.Service[state]) {
// --- admin surface, untyped: the bytes ------------------------------------
// The file IS the body on the way in and a stream on the way out; there is no
// In/Out pair for that, and inventing a base64 envelope would change the wire.
g.Post("/documents", hcloud.Handle(s, uploadDocument))
g.Get("/documents/:id/file", hcloud.Handle(s, adminDownload))
g.Post("/documents", cloud.Handle(s, uploadDocument))
g.Get("/documents/:id/file", cloud.Handle(s, adminDownload))
// --- viewer surface (public; org resolved from the link index) -----------
// No principal reaches these: the visitor is whoever holds the link id, and
@@ -203,7 +203,7 @@ func routes(app hcloud.Router, s *hcloud.Service[state]) {
g.Get("/view/:linkId", viewer(s, "view.link", false))
g.Post("/view/:linkId/authenticate", viewer(s, "view.authenticate", true))
g.Post("/view/:linkId/pageview", viewer(s, "view.recordPage", true))
g.Get("/view/:linkId/document/:documentId/file", hcloud.Handle(s, viewerDownload))
g.Get("/view/:linkId/document/:documentId/file", cloud.Handle(s, viewerDownload))
}
// The prose for the routes that are NOT typed ops — the upload, the two file
@@ -313,7 +313,7 @@ func init() {
// === viewer dispatch (public; org via the link index) ========================
func viewer(s *hcloud.Service[state], route string, readBody bool) zip.Handler {
func viewer(s *cloud.Service[state], route string, readBody bool) zip.Handler {
return func(c *zip.Ctx) error {
linkID := c.Param("linkId")
org, ok, err := s.State.index.org(linkID)
@@ -337,7 +337,7 @@ func viewer(s *hcloud.Service[state], route string, readBody bool) zip.Handler {
// uploadDocument stores the request body (the file bytes) on the object-storage
// seam, then records the metadata row via the bundle. The file is the raw request
// body; ?name= names it, Content-Type carries the mime type, ?numPages= is optional.
func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
func uploadDocument(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
@@ -379,7 +379,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
}
// adminDownload streams a document's bytes to an authenticated owner.
func adminDownload(s *hcloud.Service[state], c *zip.Ctx) error {
func adminDownload(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
@@ -394,7 +394,7 @@ func adminDownload(s *hcloud.Service[state], c *zip.Ctx) error {
}
// viewerDownload streams a document's bytes to an authorised viewer.
func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
func viewerDownload(s *cloud.Service[state], c *zip.Ctx) error {
linkID := c.Param("linkId")
org, ok, err := s.State.index.org(linkID)
if err != nil || !ok {
@@ -413,7 +413,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
// streamFile turns a {fileKey,contentType,name} bundle result into a byte stream
// from object storage. A non-200 bundle result (404/403) passes through as JSON.
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *goja.Response) error {
func streamFile(s *cloud.Service[state], c *zip.Ctx, resp *goja.Response) error {
if resp.Status != http.StatusOK {
c.SetHeader("Content-Type", "application/json")
return c.Bytes(resp.Status, resp.Body)
@@ -441,7 +441,7 @@ func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *goja.Response) error
// write dispatches one bundle route on the tenant's Base store (one transaction
// per request) and writes {status, body}.
func write(s *hcloud.Service[state], c *zip.Ctx, org, route string, params, query map[string]string, body any) error {
func write(s *cloud.Service[state], c *zip.Ctx, org, route string, params, query map[string]string, body any) error {
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
Route: route, Params: params, Query: query, Body: body,
})
+2 -2
View File
@@ -51,7 +51,7 @@ import (
"encoding/json"
"net/http"
hcloud "github.com/hanzoai/cloud"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/goja"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
@@ -60,7 +60,7 @@ import (
// ops binds the service to the typed dataroom ops. A TypedHandler takes no
// service parameter, so the service arrives as a RECEIVER and every op is a
// method value — also the only bound form cmd/zipdoc can lift prose from.
type ops struct{ s *hcloud.Service[state] }
type ops struct{ s *cloud.Service[state] }
// noInput is the In of an op addressed entirely by the caller's principal: it
// takes nothing off the wire. The dataroom collection reads are org-scoped, so
+5 -7
View File
@@ -8,11 +8,10 @@ import (
"fmt"
"time"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -31,11 +30,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "destinations", dir)
db, err := sqlpool.Open("destinations", dir)
if err != nil {
return nil, fmt.Errorf("open destinations store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -6,11 +6,10 @@ import (
"fmt"
"sort"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both cgo and pure-Go build tags). Blank
@@ -45,11 +44,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "entitlements", dir)
db, err := sqlpool.Open("entitlements", dir)
if err != nil {
return nil, fmt.Errorf("open entitlements store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+3 -5
View File
@@ -10,9 +10,8 @@ import (
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags. Importing modernc
@@ -122,11 +121,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "evals", dir)
db, err := sqlpool.Open("evals", dir)
if err != nil {
return nil, fmt.Errorf("open evals store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
@@ -4,5 +4,5 @@
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := graph
APPS := explorer
include ../../mk/plugin.mk
@@ -1,5 +1,5 @@
// client.go is the ONE HTTP path from this subsystem to the Lux chain-data plane.
// Every handler in graph.go routes through this client, so the wire contract (base
// Every handler in explorer.go routes through this client, so the wire contract (base
// URLs, read-only auth, JSON/GraphQL decoding, error mapping) lives once here and can
// never drift between hand-rolled fetches.
//
@@ -21,7 +21,7 @@
// non-2xx HTTP status → that status, and a GraphQL {errors} envelope → 502 with the
// upstream message. It never masks an upstream failure as success or fabricates data.
package graph
package explorer
import (
"bytes"
@@ -119,23 +119,23 @@ func (cl *client) latestBlock(ctx context.Context, auth string) (map[string]any,
func (cl *client) getJSON(ctx context.Context, auth, url string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, zip.Errorf(http.StatusInternalServerError, "graph: build request: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "explorer: build request: %v", err)
}
req.Header.Set("Accept", "application/json")
authorize(req, auth)
resp, err := cl.cc.Do(req)
if err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "graph: indexer unreachable: %v", err)
return nil, zip.Errorf(http.StatusBadGateway, "explorer: indexer unreachable: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, zip.Errorf(resp.StatusCode, "graph: indexer %d: %s", resp.StatusCode, snippet(raw))
return nil, zip.Errorf(resp.StatusCode, "explorer: indexer %d: %s", resp.StatusCode, snippet(raw))
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "graph: decode indexer response: %v", err)
return nil, zip.Errorf(http.StatusBadGateway, "explorer: decode indexer response: %v", err)
}
return out, nil
}
@@ -151,7 +151,7 @@ func (cl *client) priceFeeds(ctx context.Context, auth string) ([]map[string]any
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cl.graph+graphQLPath, bytes.NewReader(body))
if err != nil {
return nil, zip.Errorf(http.StatusInternalServerError, "graph: build query: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "explorer: build query: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
@@ -159,12 +159,12 @@ func (cl *client) priceFeeds(ctx context.Context, auth string) ([]map[string]any
resp, err := cl.cc.Do(req)
if err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "graph: graph unreachable: %v", err)
return nil, zip.Errorf(http.StatusBadGateway, "explorer: graph unreachable: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, zip.Errorf(resp.StatusCode, "graph: graph %d: %s", resp.StatusCode, snippet(raw))
return nil, zip.Errorf(resp.StatusCode, "explorer: graph %d: %s", resp.StatusCode, snippet(raw))
}
var out struct {
@@ -176,10 +176,10 @@ func (cl *client) priceFeeds(ctx context.Context, auth string) ([]map[string]any
} `json:"errors"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, zip.Errorf(http.StatusBadGateway, "graph: decode graphql response: %v", err)
return nil, zip.Errorf(http.StatusBadGateway, "explorer: decode graphql response: %v", err)
}
if len(out.Errors) > 0 {
return nil, zip.Errorf(http.StatusBadGateway, "graph: %s", firstNonEmpty(out.Errors[0].Message, "graphql error"))
return nil, zip.Errorf(http.StatusBadGateway, "explorer: %s", firstNonEmpty(out.Errors[0].Message, "graphql error"))
}
return out.Data.PriceFeeds, nil
}
@@ -1,4 +1,4 @@
// Package graph is chain data: your block indexers and how far each has caught
// Package explorer is chain data: your block indexers and how far each has caught
// up, plus the on-chain price feeds.
//
// It serves them at /v1/indexers and /v1/oracles — read from the Lux chain-data
@@ -39,7 +39,7 @@
// list (200) — the same graceful fold as visor/clusters, NOT a 502 that surfaces as a
// console error for every org without an indexer/graph deployed. A reachable-but-empty
// upstream likewise returns an empty list — it NEVER fabricates an indexer or oracle row.
package graph
package explorer
import (
"context"
@@ -49,7 +49,7 @@ import (
"github.com/zap-proto/zip"
)
// state is graph's own data: the chain-data upstream client. The shared deps live
// state is explorer's own data: the chain-data upstream client. The shared deps live
// in the embedded cloud.Base — brand (s.Brand) is the chain family surfaced and env
// (s.Env) is the network tier reported as an indexer's `network`.
type state struct {
@@ -58,14 +58,14 @@ type state struct {
// Mount wires the chain-data surface onto app per HIP-0106.
func Mount(app cloud.Router, deps cloud.Deps) error {
return cloud.Mount(app, deps, "graph", build, routes)
return cloud.Mount(app, deps, "explorer", build, routes)
}
// build dials the chain-data upstreams (INDEXER_URL / GRAPH_URL from env) and
// records the informative mount line.
func build(b cloud.Base) (state, error) {
st := state{cl: newClient()}
b.Log.Info("graph chain-data surface mounted",
b.Log.Info("explorer chain-data surface mounted",
"indexer", st.cl.indexer, "graph", st.cl.graph, "brand", b.Brand, "env", b.Env)
return st, nil
}
@@ -85,18 +85,18 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// before any subsystem registers a route — an order only the composer can
// hold.
// TYPED ops. graph owns two top-level nouns rather than one prefix, so each is
// TYPED ops. explorer owns two top-level nouns rather than one prefix, so each is
// declared at its whole path on the app's registry — the identity every
// projection (document, MCP tool, CLI command, SDK method) keys on.
o := graphOps{s: s}
o := ops{s: s}
zapp := cloud.ZipApp(app)
zip.Get(zapp, "/v1/indexers", o.listIndexers)
zip.Get(zapp, "/v1/oracles", o.listOracles)
}
// graphOps is the receiver the chain-data ops hang off. A method value is the only
// ops is the receiver the chain-data ops hang off. A method value is the only
// bound form cmd/zipdoc can lift prose from, so ops are methods and not closures.
type graphOps struct{ s *cloud.Service[state] }
type ops struct{ s *cloud.Service[state] }
// noInput is the input of an op the URL fully addresses.
type noInput struct{}
@@ -141,7 +141,7 @@ type indexersOut struct {
// reaches the indexer; when the indexer is entirely unreachable the answer degrades
// to an honest-EMPTY list at 200, not a 502. No chain HEAD is exposed by the indexer
// REST, so `lag` is honestly omitted rather than fabricated.
func (o graphOps) listIndexers(ctx context.Context, _ *noInput) (*indexersOut, error) {
func (o ops) listIndexers(ctx context.Context, _ *noInput) (*indexersOut, error) {
if err := gate(ctx); err != nil {
return nil, err
}
@@ -173,7 +173,7 @@ type oraclesOut struct {
// PriceFeed registry. A reachable graph with no feeds answers an honest empty list;
// an unreachable or erroring graph likewise degrades to an empty list at 200 rather
// than a 502, so the console never error-toasts. No feed is ever fabricated.
func (o graphOps) listOracles(ctx context.Context, _ *noInput) (*oraclesOut, error) {
func (o ops) listOracles(ctx context.Context, _ *noInput) (*oraclesOut, error) {
if err := gate(ctx); err != nil {
return nil, err
}
@@ -1,4 +1,4 @@
package graph
package explorer
import (
"encoding/json"
@@ -10,7 +10,7 @@
// not carry — the chain HEAD (hence true indexing lag) — is left off so the UI renders
// "—", never a fabricated 0.
package graph
package explorer
import (
"fmt"
@@ -1,6 +1,6 @@
// Code generated by zipdoc; DO NOT EDIT.
package graph
package explorer
import (
"github.com/zap-proto/zip"
+6 -4
View File
@@ -39,12 +39,11 @@ import (
"strings"
"time"
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/sqlpool"
engine "github.com/hanzoai/framework"
"github.com/hanzoai/namespace"
"github.com/zap-proto/zip"
)
@@ -80,8 +79,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// down from a library cannot be. A per-org DocType store would come through
// OrgDB, which names its owner.
eng, err := engine.Open(engine.Config{
Dir: deps.DataDir,
OpenDB: func(string) (*sql.DB, error) { return cek.Open(namespace.System(), "framework", deps.DataDir) },
Dir: deps.DataDir,
// sqlpool.Open, not a bare cek.Open: this handle needs the single-connection
// cap like every other store in the binary, and opening it by hand is how it
// went without one. The opener applies it now, so there is nothing to forget.
OpenDB: func(string) (*sql.DB, error) { return sqlpool.Open("framework", deps.DataDir) },
Logger: log,
})
if err != nil {
+5 -4
View File
@@ -116,10 +116,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
mounted = s
routes(app, s)
// The lexical read, published for the processes that do NOT own this store —
// `catalog` is one, and without this its browse is a permanent 503
// (query_rpc.go).
exposeQuery()
// The read AND the write, published for the processes that do NOT own this
// store. `catalog` is one, and it needs both: without the read its browse is a
// permanent 503, and without the write there is nothing for the browse to find
// (rpc.go).
expose()
b.Log.Info("index mounted", "brand", deps.Brand)
return nil
-66
View File
@@ -1,66 +0,0 @@
package index
// The lexical index's read, published on the internal plane.
//
// WHY THIS EXISTS. Query() serves out of `mounted`, a package-level global set by
// Mount — so it answers only in the binary that mounted the index. That was true
// and harmless when everything was one fused process. It stopped being harmless
// when the fleet became one process per app: `catalog` guards its browse on
// Ready(), and in the catalog process the global is nil and always will be.
//
// So GET /v1/catalog answered {"status":503,"error":"catalog: index not
// mounted"} on every request, and hanzo.app's Community page rendered
// "ERROR: CATALOG: 503" under an otherwise fully-drawn page. Nothing was
// misconfigured and nothing had crashed; an in-process dependency had simply
// survived a process split, and the only symptom was a status code.
//
// The index is ASKED, not opened. Its store is one encrypted SQLite with a
// single writer (store.go: MaxOpenConns(1) against the single-writer file, keyed
// through cek), so a second process opening the same file to read it is the
// collision, not the cure — the same reasoning tasks/activities and commerce's
// ledger already rest on.
//
// READ ONLY. Reconcile and the rest of the write side stay exactly where they
// are: one writer, in the process that owns the file.
import (
"context"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// exposeQuery publishes the org-scoped lexical read on the internal plane.
// Mount calls it.
func exposeQuery() {
zip.Post[plane.IndexQueryIn, plane.IndexQueryOut](cloud.Plane(), "/index/query", planeQuery,
zip.WithOperationID(plane.IndexQuery),
zip.WithSummary("Search one index in the caller's org"))
}
// planeQuery reads out of the index THIS process owns, so an app that has no
// index can still search what was written here.
//
// The org is the CALLER's, taken from the call and never from the input — the
// same tenancy rule every op on this plane follows. A caller reaches the public
// catalog by asking as the public org, which is a different call, not a wider
// one.
func planeQuery(ctx context.Context, in *plane.IndexQueryIn) (*plane.IndexQueryOut, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrForbidden("index: no org on the call")
}
if !Ready() {
// The process that serves this op is the one that mounted the index, so
// this is a real fault here — not the routine "not in my binary" the
// caller used to get.
return nil, zip.ErrInternal("index: no index in the process that owns it")
}
rows, err := Query(ctx, org, in.UID, in.Q, in.Limit, in.Offset)
if err != nil {
return nil, err
}
return &plane.IndexQueryOut{Rows: rows}, nil
}
+128
View File
@@ -0,0 +1,128 @@
package index
// The lexical index, published on the internal plane: the org-scoped read, and
// the corpus swap that fills it.
//
// WHY THIS EXISTS. Query and Reconcile both serve out of `mounted`, a
// package-level global set by Mount — so they answer only in the binary that
// mounted the index. That was true and harmless when everything was one fused
// process. It stopped being harmless when the fleet became one process per app,
// because the app that OWNS a corpus is not the app that owns the store.
//
// Both halves broke, and they broke differently. The read announced itself: GET
// /v1/catalog answered {"status":503,"error":"catalog: index not mounted"} on
// every request, and hanzo.app's Community page rendered "ERROR: CATALOG: 503"
// under an otherwise fully-drawn page. The write said nothing at all — the
// hourly reconcile assembled the corpus correctly, handed it to a global that
// was nil in that process, logged a warning nobody was reading, and left the
// store empty. So when the read was fixed it began succeeding against a corpus
// that had never been written, and /v1/catalog went from a 503 to a clean
// {"data":[],"total":0}: from a page that said it was broken to one that said
// the fleet had built nothing.
//
// A silent write failure outlives a loud read failure. That is the lesson worth
// keeping here.
//
// The index is ASKED, not opened — on BOTH legs. Its store is one encrypted
// SQLite with a single writer (store.go: MaxOpenConns(1) against the
// single-writer file, keyed through cek), so a second process opening that file
// is the collision, not the cure — the same reasoning tasks/activities and
// commerce's ledger already rest on. Publishing the write here does not add a
// second writer: the swap still executes in this process, the one that holds the
// file. Only the request for it crosses the boundary.
import (
"context"
"encoding/json"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// expose publishes the index's whole plane surface. Mount calls it.
func expose() {
zip.Post[plane.IndexQueryIn, plane.IndexQueryOut](cloud.Plane(), "/index/query", planeQuery,
zip.WithOperationID(plane.IndexQuery),
zip.WithSummary("Search one index in the caller's org"))
zip.Post[plane.IndexReconcileIn, plane.IndexReconcileOut](cloud.Plane(), "/index/reconcile", planeReconcile,
zip.WithOperationID(plane.IndexReconcile),
zip.WithSummary("Replace one index's whole corpus in the caller's org"))
}
// planeQuery reads out of the index THIS process owns, so an app that has no
// index can still search what was written here.
//
// The org is the CALLER's, taken from the call and never from the input — the
// same tenancy rule every op on this plane follows. A caller reaches the public
// catalog by asking as the public org, which is a different call, not a wider
// one.
func planeQuery(ctx context.Context, in *plane.IndexQueryIn) (*plane.IndexQueryOut, error) {
org, err := owner(ctx)
if err != nil {
return nil, err
}
rows, err := Query(ctx, org, in.UID, in.Q, in.Limit, in.Offset)
if err != nil {
return nil, err
}
return &plane.IndexQueryOut{Rows: rows}, nil
}
// planeReconcile swaps one corpus into the index THIS process owns, so the app
// that ASSEMBLES a corpus does not have to be the app that stores it.
//
// The org is the caller's by exactly the same rule as the read, which is what
// makes the write no wider than the read it feeds: a call can only ever replace
// the corpus of the tenant it was made as. The published catalog is written by
// asking as "~catalog" — a name no principal can mint, so the only callers who
// can state it are the ones already inside this deployment, on a socket the edge
// router does not carry.
//
// An empty Docs is a legitimate request and is passed through: "the upstream
// truth is now nothing" is a real answer, and a transport that second-guessed it
// would be a second copy of a decision that belongs to the corpus's owner. The
// owner already makes it — catalog's sync refuses to reconcile a pass whose
// sources all failed, precisely so a GitHub outage cannot prune the catalog.
func planeReconcile(ctx context.Context, in *plane.IndexReconcileIn) (*plane.IndexReconcileOut, error) {
org, err := owner(ctx)
if err != nil {
return nil, err
}
docs := make([]map[string]any, 0, len(in.Docs))
for _, raw := range in.Docs {
var d map[string]any
if err := json.Unmarshal(raw, &d); err != nil {
// One malformed document fails the whole swap rather than being
// dropped: a partial corpus would be reconciled as if it were the
// complete one, and the prune would delete every key the dropped
// documents held. Silent partial truth is how a sync deletes things.
return nil, zip.ErrBadRequest("index: undecodable document")
}
docs = append(docs, d)
}
kept, removed, err := Reconcile(ctx, org, in.UID, in.PrimaryKey, docs)
if err != nil {
return nil, err
}
return &plane.IndexReconcileOut{Kept: kept, Removed: removed}, nil
}
// owner is the tenant a plane call acts for, refused when absent or when this
// process has no index behind it. Both ops answer it identically, so it is one
// function: a read and a write that disagreed about who the caller is would be
// two tenancy rules for one store.
func owner(ctx context.Context) (string, error) {
org := cloud.Who(ctx).Org
if org == "" {
return "", zip.ErrForbidden("index: no org on the call")
}
if !Ready() {
// The process that serves these ops is the one that mounted the index, so
// this is a real fault here — not the routine "not in my binary" the
// caller used to get.
return "", zip.ErrInternal("index: no index in the process that owns it")
}
return org, nil
}
+5 -7
View File
@@ -13,11 +13,10 @@ import (
"unicode"
"unicode/utf8"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -65,11 +64,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "index", dir)
db, err := sqlpool.Open("index", dir)
if err != nil {
return nil, fmt.Errorf("open index store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"fmt"
"strings"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both build tags). Same driver
@@ -45,11 +44,10 @@ type Store struct {
var ErrHostTaken = errors.New("host already claimed by another route")
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "ingress", dir)
db, err := sqlpool.Open("ingress", dir)
if err != nil {
return nil, fmt.Errorf("open ingress store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -8,11 +8,10 @@ import (
"strings"
"time"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both build tags). Importing modernc
@@ -69,11 +68,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "integrations", dir)
db, err := sqlpool.Open("integrations", dir)
if err != nil {
return nil, fmt.Errorf("open integrations store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -22,11 +22,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite" // registers the "sqlite" database/sql driver
)
@@ -58,11 +57,10 @@ type optinStore struct {
}
func openOptinStore(dir string) (*optinStore, error) {
db, err := cek.Open(namespace.System(), "leaderboard", dir)
db, err := sqlpool.Open("leaderboard", dir)
if err != nil {
return nil, fmt.Errorf("open leaderboard store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &optinStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -28,11 +27,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "legal", dir)
db, err := sqlpool.Open("legal", dir)
if err != nil {
return nil, fmt.Errorf("open legal store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"fmt"
"sync"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" name under both build tags).
@@ -38,11 +37,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "link", dir)
db, err := sqlpool.Open("link", dir)
if err != nil {
return nil, fmt.Errorf("open link store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"fmt"
"strings"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -40,11 +39,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "marketing", dir)
db, err := sqlpool.Open("marketing", dir)
if err != nil {
return nil, fmt.Errorf("open marketing store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+95 -19
View File
@@ -56,8 +56,10 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
"gopkg.in/yaml.v3"
)
@@ -342,16 +344,17 @@ func mint(s *cloud.Service[state], c *zip.Ctx) error {
if room == "" {
return zip.ErrBadRequest("roomName required")
}
// Say what was actually checked. meet performs NO membership lookup — it has
// no members table, no store, and makes no call to IAM. Membership was decided
// upstream, at the IAM login that minted this session, and is already signed
// into the token as `workspace`. All that happens here is a refusal to WIDEN
// that: the room asked for must belong to the workspace the token already
// names. Claiming "not a member" describes a determination this code never
// makes, and reads as a second authorization system where there is none.
t, ok := st.admits(room, c.Header("Authorization"))
// Say what was actually checked, and it is now one of two things. On the HS256
// arm meet performs no membership lookup: membership was decided upstream at
// the login that minted the session and is signed into the token as
// `workspace`, and all that happens here is a refusal to WIDEN it. On the IAM
// lane there is no such claim, so the workspace rows are asked directly — and
// then "not a member" IS the determination being made. One message covers both
// because it names the fact, not the mechanism: this caller is not admitted to
// this room.
j, ok := st.admits(c, room)
if !ok {
return zip.Errorf(http.StatusUnauthorized, "token workspace does not match this room")
return zip.Errorf(http.StatusUnauthorized, "not admitted to this room")
}
// THE IDENTITY IS THE TOKEN'S, NOT THE BODY'S. LiveKit uses `sub` as the
// participant identity and EJECTS an existing participant on a duplicate — so
@@ -364,7 +367,7 @@ func mint(s *cloud.Service[state], c *zip.Ctx) error {
// checked: verifying it belongs to the caller would need the person<->account
// mapping from apps/team, whereas the token already carries an identity that IS
// the caller. One fewer seam, and no lookup to get wrong.
identity := strings.TrimSpace(t.Account)
identity := j.account
if identity == "" {
return zip.Errorf(http.StatusUnauthorized, "token carries no account")
}
@@ -384,8 +387,45 @@ func workspace(room string) string {
return ws
}
// admits decides whether the bearer may join room. Every clause is a refusal; there
// is no branch that admits by default.
// joiner is who may join, once a lane has decided it: the account LiveKit takes as
// the participant identity, and nothing else. A lane that cannot fill it does not
// admit anyone.
type joiner struct{ account string }
// admits decides whether the caller may join room, on either of two lanes. Every
// clause is a refusal; there is no branch that admits by default.
//
// IAM LANE, selected on the boundary's OWN ATTESTATION (principal.Minted) and on
// nothing else. It used to select on `c.Org() != "" && c.User() != ""`, and both
// disjuncts are forgeable — the same defect agency.go names and fixed: X-Org-Id
// survives the boundary on the anonymous path by design, and in a process where
// the boundary is not installed at all (a hand-written plugin main, which is
// exactly what this app has) NOTHING strips either header, so both are the
// client's. Here that bought a LiveKit seat under a chosen identity, and LiveKit
// EVICTS an existing participant on a duplicate `sub` — so a forged header ejected
// a colleague from a live call and impersonated them to the room. The attestation
// is absent when no boundary ran, which falls through to the HS256 arm and refuses
// rather than admitting whatever was typed.
//
// The org and the SUBJECT are read from that attestation, never off c.Org()/
// c.User() and never off the body. p.Subject is the `sub` claim verbatim: p.User
// falls back to preferred_username, so two identities can present the same User and
// a lookup keyed on it can be handed one token and address another's row.
//
// A MACHINE CREDENTIAL IS NOT A PERSON, and the subject requirement is what
// excludes one. The boundary stamps an org and a user for an sk- API key too, so
// "has an org and a user" would have put a machine on the lane whose whole question
// is "which human is in this room" — and LiveKit would then seat it under whatever
// identity the account lookup returned. A key principal carries no `sub`, so
// requiring one refuses it structurally rather than by naming credential kinds.
//
// The verdict says nothing about a workspace, so the workspace ROWS decide: apps/team
// owns them and answers over the internal plane (plane.TeamMember) with the caller's
// role and the account id it joined the subject to. A caller with no row, or one
// whose role is not privileged, is refused, and so is a peer that cannot answer —
// an unreachable authority is a refusal, never an assumption.
//
// HS256 ARM, unchanged, and deleted with the rest of the second bearer authority:
//
// - the token must VERIFY against SERVER_SECRET (signature, exp, nbf) — so a forged
// or stale session is not a member;
@@ -401,22 +441,58 @@ func workspace(room string) string {
// check was inert and every guest was admitted. selectWorkspace now signs the real
// workspace role, and an ABSENT role is unprivileged, so a token that has not
// proven a role is refused rather than assumed to be a member.
func (s state) admits(room, auth string) (*token.Token, bool) {
raw := bearer(auth)
func (s state) admits(c *zip.Ctx, room string) (joiner, bool) {
if p, ok := principal.Minted(c); ok && p.Subject != "" && p.Org != "" {
return s.admitsMember(c, room, p)
}
raw := bearer(c.Header("Authorization"))
if raw == "" {
return nil, false
return joiner{}, false
}
t, err := token.Decode(raw, s.teamSecret, true)
if err != nil {
return nil, false
return joiner{}, false
}
if t.Workspace == "" || t.Workspace != workspace(room) {
return nil, false
return joiner{}, false
}
if !t.Privileged() {
return nil, false
return joiner{}, false
}
return joiner{account: strings.TrimSpace(t.Account)}, true
}
// admitsMember is the IAM lane's authorization: ask the process that owns the
// workspace rows. Both halves of the question come from the ATTESTED principal —
// the org rides the caller (never an argument, so a caller cannot ask about another
// tenant's workspace) and the subject is the attested `sub`.
func (s state) admitsMember(c *zip.Ctx, room string, p principal.Principal) (joiner, bool) {
ws := workspace(room)
if ws == "" {
return joiner{}, false
}
m, err := cloud.Ask[plane.MemberIn, plane.Member](cloud.As(c, p.Org), "team", plane.TeamMember,
&plane.MemberIn{Workspace: ws, Subject: p.Subject})
if err != nil || m == nil || !m.Member {
return joiner{}, false
}
if !privileged(m.Role) {
return joiner{}, false
}
return joiner{account: strings.TrimSpace(m.Account)}, true
}
// privileged is token.Privileged over a role the SERVER read rather than one a
// token signed. Same vocabulary, same fail-closed shape: an unknown or absent role
// is not privileged, so a role added to the invite set tomorrow starts without a
// seat in a colleague's meeting instead of silently holding one.
func privileged(role string) bool {
switch strings.TrimSpace(role) {
case token.RoleOwner, token.RoleAdmin, token.RoleMember:
return true
default:
return false
}
return t, true
}
// bearer extracts the token from an "Authorization: Bearer <t>" header (scheme
+176 -6
View File
@@ -22,7 +22,9 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/internal/iamtest"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -593,6 +595,54 @@ func TestSigningUsesTheFilesSecretVerbatim(t *testing.T) {
// ── the tenant boundary, at the level that enforces it ───────────────────────
// admitsOn drives one request through THE REAL IDENTITY BOUNDARY and runs
// st.admits against its live context — which is pooled and recycled the moment the
// handler returns, so the call has to happen inside it.
//
// boundary selects what a test is modelling, and the distinction is the whole
// point of these cases:
//
// - true — cloud.IdentityMiddleware installed, exactly as Serve installs it.
// Client-sent identity headers are STRIPPED and the attestation is minted from
// the token or not at all.
// - false — no boundary, which is what apps/meet's own plugin main actually runs.
// Nothing strips anything, so every identity header on the wire is the
// client's. A lane that reads one here is reading whatever was typed.
func admitsOn(t *testing.T, st state, room, auth string, headers map[string]string, boundary bool) (j joiner, ok bool) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if boundary {
app.Use(cloud.IdentityMiddleware(&cloud.Config{IAMIssuer: iamtest.Issuer, JWKSURL: jwksURL}))
}
app.Use(cloud.Bridge())
app.Get("/probe", func(c *zip.Ctx) error {
j, ok = st.admits(c, room)
return c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
for k, v := range headers {
req.Header.Set(k, v)
}
if _, err := app.Test(req); err != nil {
t.Fatalf("probe: %v", err)
}
return j, ok
}
// jwksURL is where the per-test issuer publishes; set by iamIssuer below.
var jwksURL string
// iamIssuer stands up the signing issuer these tests mint IAM tokens with.
func iamIssuer(t *testing.T) *iamtest.Issuer0 {
t.Helper()
iss := iamtest.New(t)
jwksURL = iss.URL
return iss
}
// TestAdmitsBindsRoomToTheSignedWorkspace tests `admits`, NOT the workspace() helper.
// That distinction is the whole point: TestWorkspaceOfRoom pins the parse in isolation
// and constrains nothing about how admits USES it, so mutating the comparison from
@@ -614,16 +664,16 @@ func TestAdmitsBindsRoomToTheSignedWorkspace(t *testing.T) {
workspaceA + "-evil_standup_1", // suffixed segment
workspaceA + workspaceA + "_standup_1", // segment 0 starts with the real uuid
} {
if _, ok := st.admits(room, member(workspaceA)); ok {
if _, ok := admitsOn(t, st, room, member(workspaceA), nil, false); ok {
t.Errorf("admitted room %q for workspace %q — segment 0 is not an exact match", room, workspaceA)
}
}
// The exact segment is admitted, so the test discriminates rather than always failing.
if _, ok := st.admits(roomIn(workspaceA), member(workspaceA)); !ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), member(workspaceA), nil, false); !ok {
t.Fatal("refused the exact-workspace room; the check is not discriminating")
}
// And the converse direction: a member of A cannot enter B's room.
if _, ok := st.admits(roomIn(workspaceB), member(workspaceA)); ok {
if _, ok := admitsOn(t, st, roomIn(workspaceB), member(workspaceA), nil, false); ok {
t.Error("a member of workspace A was admitted to a workspace B room")
}
}
@@ -637,16 +687,16 @@ func TestAdmitsRefusesUnboundSession(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
unbound := "Bearer " + session(t, "", teamSecret, nil, hour)
for _, room := range []string{"_standup_1", "_", "_anything"} {
if _, ok := st.admits(room, unbound); ok {
if _, ok := admitsOn(t, st, room, unbound, nil, false); ok {
t.Errorf("an unbound session was admitted to %q", room)
}
}
// It is also refused for a normal room, and a BOUND session is admitted — so the
// refusal is about the empty claim, not about rooms in general.
if _, ok := st.admits(roomIn(workspaceA), unbound); ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), unbound, nil, false); ok {
t.Error("an unbound session was admitted to a real workspace room")
}
if _, ok := st.admits(roomIn(workspaceA), "Bearer "+session(t, workspaceA, teamSecret, nil, hour)); !ok {
if _, ok := admitsOn(t, st, roomIn(workspaceA), "Bearer "+session(t, workspaceA, teamSecret, nil, hour), nil, false); !ok {
t.Fatal("a bound member was refused; the test is not discriminating")
}
}
@@ -839,3 +889,123 @@ func TestHealthLeaksNothingUnauthenticated(t *testing.T) {
})
}
}
// TestForgedIdentityHeadersBuyNothing is the F1 regression, and it is the reason
// these tests run the real boundary.
//
// meet used to select its IAM lane on `c.Org() != "" && c.User() != ""` — two
// HEADERS. In a process with no identity boundary installed nothing strips them,
// and apps/meet's own plugin main is exactly such a process. So any caller could
// name themselves, take the lane, and be issued a LiveKit seat under a chosen
// identity — and LiveKit EVICTS an existing participant on a duplicate `sub`, so
// the forgery ejected a colleague from a live call and impersonated them to the
// room.
//
// The lane now selects on the boundary's own attestation, which no header can
// create. Both shapes are pinned: with no boundary the headers are inert, and with
// the boundary they are stripped before anything reads them.
func TestForgedIdentityHeadersBuyNothing(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iamIssuer(t)
forged := map[string]string{
"X-Org-Id": "acme",
"X-User-Id": "11111111-2222-4333-8444-555555555555",
}
for _, boundary := range []bool{false, true} {
if j, ok := admitsOn(t, st, roomIn(workspaceA), "", forged, boundary); ok {
t.Fatalf("SECURITY (boundary=%v): forged identity headers bought a seat as %q", boundary, j.account)
}
}
// And they do not upgrade a caller who holds nothing else, nor downgrade one who
// holds a real HS256 session: the headers are simply not an input.
hour := time.Now().Add(time.Hour).Unix()
good := "Bearer " + session(t, workspaceA, teamSecret, nil, hour)
if _, ok := admitsOn(t, st, roomIn(workspaceA), good, forged, false); !ok {
t.Fatal("forged headers displaced a valid HS256 session")
}
}
// TestIAMLaneTakesTheAttestedPrincipalAndFailsClosed proves the other half: a REAL
// IAM access token, through the REAL boundary, does take the IAM lane — and on that
// lane the authority is apps/team over the internal plane. There is no team peer
// here, so the ask cannot be answered, and an authority that cannot answer is a
// refusal rather than an assumption of membership.
func TestIAMLaneTakesTheAttestedPrincipalAndFailsClosed(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iss := iamIssuer(t)
hour := time.Now().Add(time.Hour).Unix()
// A token that WOULD be admitted on the HS256 arm, presented by the same caller,
// so the refusal below is about the lane and not about the credential.
good := "Bearer " + session(t, workspaceA, teamSecret, nil, hour)
if _, ok := admitsOn(t, st, roomIn(workspaceA), good, nil, true); !ok {
t.Fatal("the HS256 arm refused a bound member; the test is not discriminating")
}
iamTok := "Bearer " + iss.Sign(t, iamtest.Claims{Sub: "11111111-2222-4333-8444-555555555555", Owner: "acme"})
if _, ok := admitsOn(t, st, roomIn(workspaceA), iamTok, nil, true); ok {
t.Fatal("the IAM lane admitted a caller with no answer from the workspace rows")
}
// A room that names no workspace is refused before anything is asked.
if _, ok := admitsOn(t, st, "no-separator", iamTok, nil, true); ok {
t.Fatal("the IAM lane admitted a room that names no workspace")
}
}
// TestPrivilegedIsFailClosed pins the role predicate the IAM lane grants on. It is
// the same vocabulary token.Privileged reads, over a role the SERVER read: a guest
// is reduced, and an absent or unrecognised role is not privileged, so a role added
// to the invite set tomorrow starts without a seat in a colleague's meeting.
func TestPrivilegedIsFailClosed(t *testing.T) {
for _, role := range []string{token.RoleOwner, token.RoleAdmin, token.RoleMember, " owner "} {
if !privileged(role) {
t.Errorf("privileged(%q) = false, want true", role)
}
}
for _, role := range []string{"guest", "", " ", "GUEST", "Owner", "auditor"} {
if privileged(role) {
t.Errorf("privileged(%q) = true — an unproven role must not confer a seat", role)
}
}
}
// TestMachineCredentialIsNotAPerson is the F6/F7 regression.
//
// The identity boundary stamps an org AND a user for an sk- API key, so a lane
// selected on "has an org and a user" put a MACHINE on the lane whose whole
// question is which human is in this room — and LiveKit seats a participant under
// whatever identity it is handed, evicting the live one on a duplicate. A key
// principal carries no `sub`, so requiring one refuses it structurally rather than
// by trying to enumerate credential kinds.
func TestMachineCredentialIsNotAPerson(t *testing.T) {
st := state{teamSecret: teamSecret, apiKey: apiKey, apiSecret: apiSecret}
iamIssuer(t)
// The shape a key principal has after the boundary: an attested org and user,
// and no subject.
for _, p := range []principal.Principal{
{Org: "acme", User: "sk-key-user"}, // API key: no sub
{Org: "acme", User: "hanzo/robot", Subject: ""}, // client_credentials
{Org: "", User: "u", Subject: "has-a-sub"}, // no tenant
} {
if _, ok := admitsWithPrincipal(t, st, roomIn(workspaceA), p); ok {
t.Fatalf("SECURITY: a principal with no human subject was admitted: %+v", p)
}
}
}
// admitsWithPrincipal mints an attestation directly — the one way to model what the
// boundary produces for a credential kind this test cannot mint (an API key is
// resolved against IAM, not signed). principal.Mint is the boundary's own call, so
// this exercises exactly the value admits() reads.
func admitsWithPrincipal(t *testing.T, st state, room string, p principal.Principal) (j joiner, ok bool) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/probe", func(c *zip.Ctx) error {
principal.Mint(c, p)
j, ok = st.admits(c, room)
return c.String(http.StatusOK, "ok")
})
if _, err := app.Test(httptest.NewRequest(http.MethodGet, "/probe", nil)); err != nil {
t.Fatalf("probe: %v", err)
}
return j, ok
}
+3 -5
View File
@@ -11,9 +11,8 @@ import (
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -75,11 +74,10 @@ type annStore struct {
}
func openAnnStore(dir string) (*annStore, error) {
db, err := cek.Open(namespace.System(), "o11y_annotations", dir)
db, err := sqlpool.Open("o11y_annotations", dir)
if err != nil {
return nil, fmt.Errorf("open o11y_annotations store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &annStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+13 -20
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/k8s"
"github.com/hanzoai/namespace"
@@ -872,23 +873,6 @@ func buildFrontendCmd(buildCtx, dockerfile, image string) []any {
return buildFrontendCmdRev(buildCtx, dockerfile, image, "")
}
// isCommitSHA reports whether ref is a full 40-hex commit id. A build context may
// name a BRANCH, and a branch is not a revision: stamping "main" into
// org.opencontainers.image.revision would make the label look populated while
// answering a different question than the one anybody reads it for, which is
// strictly worse than the honest empty it replaces.
func isCommitSHA(ref string) bool {
if len(ref) != 40 {
return false
}
for _, c := range ref {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return false
}
}
return true
}
// buildFrontendCmdRev is buildFrontendCmd plus the commit being built, which is
// the difference between an image that can be traced back to source and one that
// cannot.
@@ -939,9 +923,18 @@ func buildFrontendCmdRev(buildCtx, dockerfile, image, revision string) []any {
cmd = append(cmd, "--opt", "build-arg:VERSION="+tag)
cmd = append(cmd, "--opt", "build-arg:GIT_VERSION="+strings.TrimPrefix(tag, "v"))
}
// Empty only for callers that genuinely have no commit (a context that is not
// a git ref); a Dockerfile with no `ARG REVISION` ignores it either way.
if isCommitSHA(revision) {
// A build context may name a BRANCH, and a branch is not a revision: stamping
// "main" here would make the label and the binary look populated while
// answering a different question than the one anybody reads them for. Empty
// only for callers that genuinely have no commit; a Dockerfile with no
// `ARG REVISION` ignores it either way.
//
// cloud.IsCommit, not a private copy, because this is one END of a wire whose
// other end applies the same rule to decide what it will REPORT (see
// cloud.Revision). Two copies of "what is a commit" that drift apart would let
// the builder pass a value the process silently downgrades to "unknown" — the
// same silent failure, one layer over.
if cloud.IsCommit(revision) {
cmd = append(cmd, "--opt", "build-arg:REVISION="+revision)
}
// REGISTRY LAYER CACHE, both directions. Every build job is a fresh pod with an
+11 -1
View File
@@ -152,7 +152,17 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// The cloud's own embedded-git apex is a trusted build source (clients/git
// serves repos at this host), so a self-hosted-git app builds with no env.
selfGitHost = strings.ToLower(strings.TrimSpace(deps.Domain))
//
// The APEX, not deps.Domain verbatim. deps.Domain is this deployment's own
// host — "api.hanzo.ai" — and the forge is "git.hanzo.ai": a SIBLING, not a
// child. hostAllowed matches selfGitHost or a subdomain OF it, so handing it
// the API host made the self-hosted-git allowance unreachable: every native
// build was refused with `host "git.hanzo.ai" is not an allowed git
// provider`, and the estate fell back to GitHub. Taking the registrable apex
// ("hanzo.ai") admits every sibling the deployment owns — git., ci., cd. —
// for hanzo.ai, lux.network, zoo.network and any white-label domain alike,
// with no list to maintain per brand.
selfGitHost = apexOf(deps.Domain)
// git-push-to-deploy: a push landed on the embedded git server (clients/git)
// triggers a build for every app tracking that repo+branch. Inverted so git
+5 -7
View File
@@ -7,11 +7,10 @@ import (
"fmt"
"strings"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags. Importing modernc
@@ -129,11 +128,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "platform", dir)
db, err := sqlpool.Open("platform", dir)
if err != nil {
return nil, fmt.Errorf("open platform store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+24
View File
@@ -25,6 +25,7 @@ package platform
import (
"fmt"
"golang.org/x/net/publicsuffix"
"net/url"
"regexp"
"strconv"
@@ -196,6 +197,29 @@ func validateRepoURL(raw string) (string, error) {
// hostAllowed reports whether host exactly matches, or is a subdomain of, the
// cloud's own embedded-git apex or an allowlisted external git provider apex.
// apexOf returns the registrable apex of a host: the domain one label below the
// public suffix ("api.hanzo.ai" -> "hanzo.ai", "hanzo.ai" -> "hanzo.ai"). It is
// what makes a deployment's SIBLING hosts — its forge, its CI — its own, since
// hostAllowed grants a host and its subdomains and siblings are neither.
//
// publicsuffix is used rather than "last two labels" so a multi-label suffix
// (co.uk, com.au) yields the registrable domain and not the suffix itself, which
// would trust every domain under it.
func apexOf(raw string) string {
h := strings.ToLower(strings.TrimSpace(raw))
if h == "" {
return ""
}
if i := strings.IndexByte(h, ':'); i >= 0 { // tolerate host:port
h = h[:i]
}
apex, err := publicsuffix.EffectiveTLDPlusOne(h)
if err != nil {
return h // not a registrable name (localhost, an IP): trust it verbatim
}
return apex
}
func hostAllowed(host string) bool {
if selfGitHost != "" && (host == selfGitHost || strings.HasSuffix(host, "."+selfGitHost)) {
return true
+39
View File
@@ -0,0 +1,39 @@
package platform
import "testing"
// The defect this fixes: selfGitHost was deps.Domain verbatim ("api.hanzo.ai"),
// and the forge is a SIBLING ("git.hanzo.ai"), so hostAllowed never matched and
// every native build was refused.
func TestSelfForgeIsAllowedFromTheApex(t *testing.T) {
saved := selfGitHost
defer func() { selfGitHost = saved }()
selfGitHost = apexOf("api.hanzo.ai")
if selfGitHost != "hanzo.ai" {
t.Fatalf("apexOf(api.hanzo.ai) = %q, want hanzo.ai", selfGitHost)
}
for _, h := range []string{"git.hanzo.ai", "ci.hanzo.ai", "cd.hanzo.ai", "hanzo.ai"} {
if !hostAllowed(h) {
t.Errorf("hostAllowed(%q) = false — a deployment's own sibling must be a trusted build source", h)
}
}
if hostAllowed("git.evil.com") {
t.Error("hostAllowed(git.evil.com) = true — the apex must not widen trust beyond the deployment's own domain")
}
}
func TestApexOfMultiLabelSuffix(t *testing.T) {
if got := apexOf("api.example.co.uk"); got != "example.co.uk" {
t.Errorf("apexOf = %q, want example.co.uk (never the bare suffix)", got)
}
}
// RED proof: the OLD assignment (deps.Domain verbatim) refuses the forge.
func TestOldDomainVerbatimRefusedTheForge(t *testing.T) {
saved := selfGitHost
defer func() { selfGitHost = saved }()
selfGitHost = "api.hanzo.ai" // what the code did before apexOf
if hostAllowed("git.hanzo.ai") {
t.Fatal("expected the old behaviour to REFUSE git.hanzo.ai — if this passes, the bug never existed")
}
}
+5 -7
View File
@@ -6,11 +6,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both cgo and pure-Go build tags). Blank
@@ -46,11 +45,10 @@ type Prefs struct {
var errNotFound = errors.New("prefs: not found")
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "prefs", dir)
db, err := sqlpool.Open("prefs", dir)
if err != nil {
return nil, fmt.Errorf("open prefs store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+3 -5
View File
@@ -26,9 +26,8 @@ import (
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags. Importing modernc
@@ -385,11 +384,10 @@ type catalog struct {
// openCatalog opens (creating if needed) the overlay DB under dir and migrates
// it. MaxOpenConns(1) serializes writes against the file lock without retry.
func openCatalog(dir string) (*catalog, error) {
db, err := cek.Open(namespace.System(), "catalog", dir)
db, err := sqlpool.Open("catalog", dir)
if err != nil {
return nil, fmt.Errorf("open catalog store: %w", err)
return nil, err
}
sqlpool.Single(db)
c := &catalog{db: db}
if err := c.migrate(); err != nil {
_ = db.Close()
+11 -3
View File
@@ -148,6 +148,13 @@ func OrgOf(user, org string) (string, bool) {
type Principal struct {
Org string
User string
// Subject is the token's `sub` VERBATIM. User is the canonical id and falls
// back to preferred_username when a token carries no sub, which makes it an
// attribution key rather than an identity key: two subjects can present the
// same User. A consumer that RESOLVES A RECORD from the caller — an account
// row, a membership — keys on this and refuses it empty, so it can never be
// handed one identity's token and address another's row.
Subject string
}
// mintedSlot names the request-local slot the boundary parks its attestation in.
@@ -169,7 +176,7 @@ type mintedSlot struct{}
// must not depend on its position asks THIS instead — a fact only the boundary
// can state, absent when the boundary did not run, which fails closed to
// anonymous rather than open to forged.
// Both fields are CLONED. A value read off a request is a zero-copy view into
// EVERY field is CLONED. A value read off a request is a zero-copy view into
// the reused fasthttp buffer, and this one is retained past the read — it becomes
// a map key in the edge sensor and a column in a meter — so an un-owned copy
// would mutate into unrelated bytes on the next request through that worker.
@@ -177,8 +184,9 @@ type mintedSlot struct{}
// clones for exactly this reason.)
func Mint(c *zip.Ctx, p Principal) {
c.Fiber().Locals(mintedSlot{}, Principal{
Org: strings.Clone(p.Org),
User: strings.Clone(p.User),
Org: strings.Clone(p.Org),
User: strings.Clone(p.User),
Subject: strings.Clone(p.Subject),
})
}
+13 -1
View File
@@ -44,8 +44,20 @@ type LiveSite struct {
Upstream, License string
}
// Ready reports whether the projects store is in THIS binary, so a caller can
// tell "nothing is serving" from "ask the process that owns the store".
//
// LiveSites cannot make that distinction itself: it answers nil for both, which
// is the right answer for a deployment that hosts nothing and the wrong one for
// a process that simply is not the host. The catalog read it as the former for
// as long as the two apps have been split, and published a corpus with no sites
// in it. A caller that can ask this question first can take the other leg
// (apps/catalog serving()).
func Ready() bool { return mounted != nil && mounted.State.store != nil }
// LiveSites returns every project currently serving at its site host, newest
// first. Unmounted ⇒ no sites (a deployment that does not host is not an error).
// first. Unmounted ⇒ no sites (a deployment that does not host is not an error)
// — see Ready above before treating that as a fact about the FLEET.
func LiveSites(ctx context.Context) ([]LiveSite, error) {
s := mounted
if s == nil || s.State.store == nil {
-1
View File
@@ -506,7 +506,6 @@ func (o ops) completeDeployment(ctx context.Context, in *projectsComplete) (*pro
return &out, nil
}
// failureOwnsProject reports whether a FAILED deployment is entitled to mark the
// whole project broken. Only the deployment a project is actually pointing at can
// — plus the case where it points at nothing, because a first deploy that fails
+33
View File
@@ -38,6 +38,39 @@ func exposeSites() {
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"))
zip.Post[plane.LiveSitesIn, plane.LiveSitesOut](cloud.Plane(), "/sites/live", planeLiveSites,
zip.WithOperationID(plane.SitesLive),
zip.WithSummary("Every serving site, across orgs"))
}
// planeLiveSites answers the cross-org directory read for the process that
// assembles the catalog, which is never this one.
//
// LiveSites returns nil when this package is unmounted, on the reasoning that a
// deployment which hosts nothing is not a fault. That reads correctly here — the
// process that owns the store is the one answering — and read WRONG in the
// catalog process, where nil meant "ask somewhere else" and was silently
// published as "nothing is live". Every demo URL, the whole `site` kind, and the
// template lane's deployed starters left the corpus without an error anywhere.
//
// No org, on purpose, exactly like the resolve above. This is the one cross-org
// read in the package and the visibility rule that makes it safe lives in its
// query, not in its caller.
func planeLiveSites(ctx context.Context, _ *plane.LiveSitesIn) (*plane.LiveSitesOut, error) {
live, err := LiveSites(ctx)
if err != nil {
return nil, err
}
out := &plane.LiveSitesOut{Sites: make([]plane.LiveSite, 0, len(live))}
for _, s := range live {
out.Sites = append(out.Sites, plane.LiveSite{
Org: s.Org, Slug: s.Slug, Name: s.Name, URL: s.URL,
Repo: s.Repo, ForkedFrom: s.ForkedFrom, UpdatedAt: s.UpdatedAt,
Upstream: s.Upstream, License: s.License,
})
}
return out, nil
}
// planeResolveSite answers the multi-tenant product URL (<slug>.hanzo.app) and
+5 -7
View File
@@ -10,10 +10,9 @@ import (
"github.com/hanzoai/cloud/apps/sites"
"github.com/hanzoai/cloud/sqlpool"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
"github.com/hanzoai/namespace"
// sqlpool.Open is the ONE opener: it renders this subsystem's path from the
// system namespace, opens it under the key cek derives for that name, and
// applies the single-connection cap.
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags. Importing modernc
@@ -200,11 +199,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "projects", dir)
db, err := sqlpool.Open("projects", dir)
if err != nil {
return nil, fmt.Errorf("open projects store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -8,11 +8,10 @@ import (
"fmt"
"strings"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags. Importing modernc
@@ -63,11 +62,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "prompts", dir)
db, err := sqlpool.Open("prompts", dir)
if err != nil {
return nil, fmt.Errorf("open prompts store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+5 -7
View File
@@ -9,11 +9,10 @@ import (
"fmt"
"strings"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE Hanzo SQLite driver (registers "sqlite" under both build tags).
// Mirrors clients/crm / clients/prompts — one storage pattern.
@@ -65,11 +64,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "referrals", dir)
db, err := sqlpool.Open("referrals", dir)
if err != nil {
return nil, fmt.Errorf("open referrals store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+11 -3
View File
@@ -57,6 +57,7 @@ import (
"github.com/hanzoai/cloud/apps/datastore"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/brand"
contract "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
@@ -189,16 +190,23 @@ func tenantOf(ctx context.Context, brandID string) (tenant, error) {
// Subject kinds. A feature is meaningless without saying WHOSE, and each kind
// below is a column the event surface actually carries — not a category invented
// here and then read as empty.
//
// The VALUES come from the call contract (package plane) because a kind crosses
// the process boundary: a gate in another binary states one on every decision it
// asks for. Two spellings of "account" would not read as a disagreement, they
// would namespace one subject into two — so there is one spelling, in the package
// both halves import, and the prose that says what each one MEANS stays here with
// the surface that computes it.
const (
// kindPerson is the identified end user across the product surface:
// person_id, else distinct_id, else anonymous_id.
kindPerson = "person"
kindPerson = contract.KindPerson
// kindSession is one session of that surface.
kindSession = "session"
kindSession = contract.KindSession
// kindAccount is the org's own user in the metered plane
// (hanzo.cloud_usage.user_id) — the subject whose spend velocity is what
// pay-as-you-go abuse moves.
kindAccount = "account"
kindAccount = contract.KindAccount
)
// kinds is the closed set, in one place, so a rollup cannot write a kind a read
+20 -10
View File
@@ -99,7 +99,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
s.State.plane = p
}
mount(s, app)
mounted = s.State.plane
mounted = s
// The internal scorer, published AFTER the state is built and the surface is
// registered, so a peer that can reach the socket can reach a working model.
// It is what arms every gate in every OTHER process — see risk_rpc.go.
exposeDecide()
s.Log.Info("risk model plane mounted", "brand", deps.Brand, "env", deps.Env, "plane", s.State.plane != nil)
return nil
}
@@ -244,19 +248,25 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
// composition root builds it with the deployment's own budget in it, so the plane
// has no business inventing a bound of its own — or, as it did, waiting with none.
func Shutdown(ctx context.Context) error {
if mounted == nil {
s := mounted
mounted = nil
if s == nil || s.State.plane == nil {
return nil
}
err := mounted.close(ctx)
mounted = nil
return err
return s.State.plane.close(ctx)
}
// mounted is the plane this binary mounted, so the composition root's Shutdown
// can reach it. Mount and Shutdown are two independent functions the plugin wires
// separately, so the value they share is held here rather than smuggled through a
// closure — one binary, one mount, one plane. Same shape as apps/dataroom.
var mounted *plane
// mounted is the service this binary mounted, so the two entry points that are
// not Mount can reach what Mount built: the composition root's Shutdown, which
// snapshots every resident model, and the internal scorer (risk_rpc.go), which
// needs the plane AND the brand its tenant key is qualified by. Mount, Shutdown
// and the plane op are wired separately by the plugin, so the value they share is
// held here rather than smuggled through a closure — one binary, one mount, one
// service. Same shape as apps/dataroom.
//
// It is the SERVICE and not the plane because two globals for one mount is two
// facts that can disagree about whether this process holds a model.
var mounted *cloud.Service[state]
// residents is how many tenants' models are held right now, how many residencies
// have been BUILT, how many have been evicted to hold that bound, and how many of
+241
View File
@@ -0,0 +1,241 @@
package risk
// risk_rpc.go — the scorer, for a gate in ANOTHER PROCESS.
//
// cloud.SetRiskScorer is an in-process handoff and it has never had a producer,
// for a reason the seam states plainly: the model is in-process mutable state, so
// one binary learns and scores, and a scorer installed here would arm this child
// and nothing else. The pod forks one process per app — /cloud, /billing,
// /commerce, /risk are separate pids — so every gate outside this one read nil,
// took the absent exemption, and allowed unscored. The observability plane's
// event door was the same shape and learned it the expensive way; obsevents.go is
// gone and apps/o11y/obs_rpc.go is what replaced it. This is that.
//
// So the model is ASKED, not linked. One op, on this app's own socket, answering
// the one question a gate has: what should I do with this subject, right now.
//
// WHAT IT DOES NOT DO, and both are deliberate:
//
// IT DOES NOT LEARN. Score is pure — it moves no counter and writes no row —
// so screening a payment cannot teach the model that the payment was normal.
// Learning is [ops.learn]'s, from the caller's own events, in its own call.
//
// IT DOES NOT CHARGE. Every HTTP op on this surface gates on the caller's own
// balance first ([ops.gate]), and that rule cannot cross to this one: the
// gate that asks is the CREDIT DOOR, so the balance it would be charged
// against is empty exactly when the customer is trying to fill it. A screen
// that refuses a top-up because the account has no money is a control that
// fires only on the customers it must not fire on. The per-tenant in-flight
// slot still applies — that is a bound on this process, not a price.
import (
"context"
"strconv"
"time"
"github.com/hanzoai/cloud"
contract "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// The short reasons a scored verdict carries. Three values, closed, in one place:
// a gate writes them to its own record and an operator reads them there, so a
// sentence that drifted between two call sites would be two facts in the log.
const (
// causeAboveCut — the event sat above the threshold in force and the model is
// DECIDING, so it is evidence.
causeAboveCut = "above the cut"
// causeShadowCut — the event sat above the threshold and the model is testing
// rather than deciding, so the outcome is unchanged and the fact is reported.
// This is the whole value of a shadow deployment: what the model WOULD have
// done, said out loud, on real traffic, changing nothing.
causeShadowCut = "above the cut, in shadow"
// causeWithinAppetite — the event sat at or below the threshold. Strictly
// below-or-equal is inside the stated appetite (learn.go's cut is the upper
// edge of the bucket that exhausts the review budget).
causeWithinAppetite = "within appetite"
)
// exposeDecide publishes the scorer on the internal plane. Mount calls it.
func exposeDecide() {
zip.Post[contract.RiskDecideIn, contract.RiskDecided](cloud.Plane(), "/risk/decide", planeDecide,
zip.WithOperationID(contract.RiskDecide),
zip.WithSummary("Judge one subject against the calling organisation's own model"))
}
// Judges one subject against the CALLING organisation's own model and answers
// what to do about it. It learns nothing, records nothing and moves no counter:
// the numbers it reads are that organisation's history as it stands.
//
// The organisation is the CALLER's, minted from the plane principal and never
// from this body — there is no field here that could name one. A model is trained
// on one organisation's own behaviour, so choosing which model answers would be
// the only cross-tenant read this plane has to offer.
//
// A model still WARMING declines with a reason and no score. That is the whole
// contract of the answer: the engine computes a score before it checks whether it
// has learned enough to have an opinion, so a refusal carries a populated number
// that means nothing, and publishing it would turn "no opinion" into "this is
// fine". Read `refusal` first; `scored` in the HTTP twin of this op says the same
// thing.
//
// A named handler, not a closure, so zipdoc can lift this prose into the registry.
func planeDecide(ctx context.Context, in *contract.RiskDecideIn) (*contract.RiskDecided, error) {
s := mounted
if s == nil {
// The op is registered by Mount, so reaching it with no service is a boot
// order that changed, never a tenant's problem.
return nil, zip.Errorf(503, "risk: this process serves the plane without having mounted the model")
}
o := ops{s: s}
p, err := o.plane()
if err != nil {
return nil, err
}
if !knownStage(in.Stage) {
return nil, zip.ErrBadRequest("'stage' must be one of " +
cloud.StageSignup + ", " + cloud.StageUsage + ", " + cloud.StagePayment)
}
t, err := planeTenant(ctx, s.Brand)
if err != nil {
return nil, err
}
obs, err := decideEvent(in).observation(time.Now())
if err != nil {
return nil, err
}
// The SAME per-tenant in-flight slot every HTTP op takes through [ops.admit] —
// the plane is a second door onto one model, so it cannot be a door with no
// bound on it. admit itself is not reused because the ONE thing that differs is
// the line above it: where the tenant comes from.
if err := p.enter(t); err != nil {
return nil, err
}
defer p.leave(t)
d, err := p.score(t, obs)
if err != nil {
return nil, wrap(err)
}
return answer(d), nil
}
// planeTenant mints the tenant a PLANE call acts for: the org the CALLER stated,
// qualified by this deployment's own brand.
//
// It is [tenantOf]'s sibling and deliberately not tenantOf itself. tenantOf reads
// the principal cloud.Bridge parks on a REQUEST — the right source for the HTTP
// surface, and absent on this one, where there is no request at all. A plane op
// that called it would fail closed on every call. The two mints agree on
// everything that matters: the brand is the deployment's, the org comes from a
// server-resolved identity, and neither can be named in a body.
//
// An empty org is refused by [qualify] — "no org, so the request acts for no
// tenant" — so a peer that states nothing gets no model.
func planeTenant(ctx context.Context, brandID string) (tenant, error) {
t, err := qualify(brandID, cloud.Who(ctx).Org)
if err != nil {
return "", zip.ErrForbidden(err.Error())
}
return t, nil
}
// knownStage reports whether the moment is one cloud declares. The set is read
// from the seam rather than restated here, so a fourth stage is added in one
// place.
func knownStage(stage string) bool {
switch stage {
case cloud.StageSignup, cloud.StageUsage, cloud.StagePayment:
return true
}
return false
}
// decideEvent projects one plane question onto the model's own event.
//
// It mints NO id, and the observation constructor gives it a random one. An id is
// what a recorded event deduplicates on, and this call records nothing — handing
// it a stable id would suggest a convergence that has nothing to converge.
func decideEvent(in *contract.RiskDecideIn) riskEvent {
return riskEvent{
Kind: in.Kind,
Subject: in.Subject,
Nano: nanoOf(signal(in.Signals, contract.SignalNano)),
Peer: signal(in.Signals, contract.SignalPeer),
Device: signal(in.Signals, contract.SignalDevice),
At: signal(in.Signals, contract.SignalAt),
}
}
// signal reads one observation out of what the gate stated. A linear read of a
// short list rather than a map, because the four names this scorer knows are
// fixed and a map would allocate one per decision to answer four questions.
func signal(list []contract.Signal, name string) string {
for _, s := range list {
if s.Name == name {
return s.Value
}
}
return ""
}
// nanoOf reads the value moved. A value that is absent OR unreadable is ABSENT:
// the value features then read BLIND rather than being told the amount was zero,
// which is [cloud.Facts]' rule applied to the one signal that is a number.
//
// It does not refuse. A gate that spells an amount wrong has a bug, and the
// bug's blast radius must not be a refused payment — this op answers a
// privileged gate, so an error here is a denial. The cost is visible instead of
// silent: a blind coordinate is counted per dimension and reported on the
// organisation's own model state.
func nanoOf(v string) int64 {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0
}
return n
}
// answer projects one verdict onto the wire, and it is where the model's
// vocabulary becomes the fleet's. It is [verdict]'s twin — that one answers the
// HTTP surface, this one answers a gate — and both read the same [decided].
//
// TWO RULES, and both are the difference between a control and a rumour.
//
// A REFUSAL CARRIES NO SCORE. The engine assigns the score before it checks
// whether the model has warmed, so a declining model returns a populated number
// that means nothing. Publishing it would let a caller read a refusal as a clean
// result, which is the exact inversion this surface exists to prevent.
//
// AN ALERT IS A REVIEW, NEVER A BLOCK. cloud's own vocabulary says it: "a
// statistical judgement may reach here and no further on its own". This model is
// exactly that — a density estimate over one organisation's own behaviour — so
// the furthest it may take a decision by itself is to summon a person. Block is
// reserved for a determination that is not purely statistical, and nothing here
// makes one. Review still PROCEEDS ([cloud.RiskVerdict.Allowed]), so the customer
// is served and the decision is on the record.
//
// In shadow an above-the-cut event is an ALLOW that says so, because shadow is
// where a model earns the right to be trusted and a shadow that changed outcomes
// would not be one.
func answer(d decided) *contract.RiskDecided {
a := d.A
out := &contract.RiskDecided{
Action: cloud.ActionAllow,
Refusal: a.Reason,
Shape: d.Shape,
Policy: d.Version,
}
if !a.Scored {
return out
}
out.Score = a.Score
switch {
case a.Alert:
out.Action, out.Cause = cloud.ActionReview, causeAboveCut
case a.Score > a.Cut:
out.Cause = causeShadowCut
default:
out.Cause = causeWithinAppetite
}
return out
}
+300
View File
@@ -0,0 +1,300 @@
package risk
// risk_rpc_test.go — the internal scorer, held to the three properties that make
// it safe to put a payment behind it.
//
// THE TENANT IS THE CALLER'S. Not a field, not a subject that looks like a key,
// not the HTTP principal (there is no request here at all).
// A REFUSAL CARRIES NO SCORE. The engine populates one before it decides it has
// no opinion, and publishing that number reads as a clean bill of health.
// AN ALERT IS A REVIEW. A density estimate may summon a person and may not
// block by itself, and in shadow it changes nothing at all.
import (
"context"
"reflect"
"strings"
"testing"
"github.com/luxfi/aml/pkg/anomaly"
"github.com/hanzoai/cloud"
contract "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// asPeer is a plane call: a context with NO request behind it, carrying the org
// the caller states. It is the one place zip reads a stated caller, and it is how
// every peer reaches this op.
func asPeer(org string) context.Context { return cloud.For(context.Background(), org) }
// TestPlaneTenant_IsMintedFromTheCallerAndNothingElse.
//
// Mutation proof: read the org from anywhere but cloud.Who and the second case
// stops failing closed.
func TestPlaneTenant_IsMintedFromTheCallerAndNothingElse(t *testing.T) {
got, err := planeTenant(asPeer(orgA), brandA)
if err != nil {
t.Fatalf("planeTenant: %v", err)
}
if want := key(t, brandA, orgA); got != want {
t.Errorf("tenant %q, want %q — the mint must qualify the CALLER's org with this deployment's brand", got, want)
}
// No caller is no tenant. A peer that states nothing gets no model, rather
// than the deployment's brand over an empty org.
if _, err := planeTenant(context.Background(), brandA); err == nil {
t.Error("a call with no stated caller resolved a tenant — an unidentified peer must reach no model")
}
// The BRAND half is the deployment's. Two organisations with the same name
// under two brands are two tenants, and this is where that holds.
other, err := planeTenant(asPeer(orgA), brandB)
if err != nil {
t.Fatalf("planeTenant(brandB): %v", err)
}
if other == got {
t.Error("the same org under two brands minted one tenant — the brand half is not in the key")
}
}
// TestRiskDecideIn_CannotNameAnOrg is the STRUCTURAL half of the isolation: a
// caller cannot spoof a tenant it cannot spell. The contract carries no org, no
// tenant and no brand, so there is no field for a handler to be tempted by and no
// wire for one to arrive on.
//
// Mutation proof: add an Org field to plane.RiskDecideIn and this names it.
func TestRiskDecideIn_CannotNameAnOrg(t *testing.T) {
rt := reflect.TypeOf(contract.RiskDecideIn{})
for i := 0; i < rt.NumField(); i++ {
name := strings.ToLower(rt.Field(i).Name)
for _, banned := range []string{"org", "tenant", "brand", "owner"} {
if strings.Contains(name, banned) {
t.Errorf("plane.RiskDecideIn.%s names the tenant — the organisation whose model answers "+
"rides the caller, never the argument", rt.Field(i).Name)
}
}
}
}
// TestPlaneDecide_ASpoofedSubjectCannotCrossTenants is the BEHAVIOURAL half.
//
// The subject is the one string a caller does control, so the attack is to spell
// another tenant's key in it. It buys nothing: the subject is an identifier
// WITHIN the caller's tenant, and the only residency the call creates is the
// caller's own.
//
// Mutation proof: mint the tenant from in.Subject and the residency check names
// the wrong key.
func TestPlaneDecide_ASpoofedSubjectCannotCrossTenants(t *testing.T) {
probe.reset(true)
mountApp(t)
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
Stage: cloud.StagePayment, Kind: contract.KindAccount,
// Every shape of "be someone else" a subject can carry.
Subject: brandB + "/" + orgB,
})
if err != nil {
t.Fatalf("planeDecide: %v", err)
}
if out.Action != cloud.ActionAllow {
t.Errorf("action %q, want %q — a warming model changes no outcome", out.Action, cloud.ActionAllow)
}
p := mounted.State.plane
p.mu.Lock()
defer p.mu.Unlock()
if len(p.res) != 1 {
t.Fatalf("%d residencies after one call, want 1", len(p.res))
}
if _, ok := p.res[key(t, brandA, orgA)]; !ok {
var held []string
for k := range p.res {
held = append(held, string(k))
}
t.Errorf("the call landed in %v, not in the CALLER's tenant — a subject named the model", held)
}
}
// TestPlaneDecide_AWarmingModelDeclinesWithoutAScore is the trap this op exists
// not to fall into: the engine assigns a score BEFORE it checks whether it has
// learned enough to have one, so a fresh tenant's refusal carries a populated,
// meaningless number.
//
// Mutation proof: copy the score onto the answer unconditionally and this fails.
func TestPlaneDecide_AWarmingModelDeclinesWithoutAScore(t *testing.T) {
probe.reset(true)
mountApp(t)
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_412",
Signals: []contract.Signal{{Name: contract.SignalNano, Value: "420000000"}},
})
if err != nil {
t.Fatalf("planeDecide: %v", err)
}
if out.Refusal == "" {
t.Fatal("a model that has learned nothing answered without a refusal — silence reads as innocence")
}
if out.Score != 0 {
t.Errorf("a refusal carried score %v — a declining model's score is arithmetic, not an opinion", out.Score)
}
if out.Action != cloud.ActionAllow {
t.Errorf("action %q, want %q — an absent opinion must not change an outcome", out.Action, cloud.ActionAllow)
}
}
// TestPlaneDecide_RefusesAMomentItDoesNotModel. The stage is a closed set both
// ends read from cloud, and a scorer that judged an unrecognised moment would be
// answering a question neither end had agreed on.
func TestPlaneDecide_RefusesAMomentItDoesNotModel(t *testing.T) {
probe.reset(true)
mountApp(t)
for _, stage := range []string{"", "checkout", "PAYMENT"} {
_, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
Stage: stage, Kind: contract.KindAccount, Subject: "u_412",
})
var he *zip.HTTPError
if !asHTTP(err, &he) || he.Status != 400 {
t.Errorf("stage %q: err %v, want a 400 — an unrecognised moment is a refusal, not a verdict", stage, err)
}
}
// And the three cloud declares are all accepted.
for _, stage := range []string{cloud.StageSignup, cloud.StageUsage, cloud.StagePayment} {
if !knownStage(stage) {
t.Errorf("stage %q is declared by cloud and refused here — the two ends read one set", stage)
}
}
}
// TestPlaneDecide_RefusesAKindItCannotPlace: the kind namespaces the subject, so
// an unknown one would judge a different entity than the caller meant. It is the
// observation constructor's own bound, reached through this door too.
func TestPlaneDecide_RefusesAKindItCannotPlace(t *testing.T) {
probe.reset(true)
mountApp(t)
if _, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
Stage: cloud.StagePayment, Kind: "acount", Subject: "u_412",
}); err == nil {
t.Error("a misspelled kind was judged — a subject in no namespace is a verdict about nobody")
}
// The three the contract publishes are exactly the three this model places.
for _, kind := range []string{contract.KindPerson, contract.KindSession, contract.KindAccount} {
if !known(kind) {
t.Errorf("the contract publishes kind %q and this model cannot place it — one set, two spellings", kind)
}
}
}
// TestAnswer_ARefusalCarriesNoScore, on the projection itself, over the three
// refusals the engine declares.
func TestAnswer_ARefusalCarriesNoScore(t *testing.T) {
for _, reason := range []string{anomaly.ReasonWarming, anomaly.ReasonUnusable, anomaly.ReasonUnidentified} {
got := answer(decided{
// Scored false with a populated score is exactly what the engine returns.
A: anomaly.Assessment{Scored: false, Reason: reason, Score: 0.93, Cut: 0.5},
Version: 7, Shape: "halfspace:abc",
})
switch {
case got.Refusal != reason:
t.Errorf("refusal %q, want %q", got.Refusal, reason)
case got.Score != 0:
t.Errorf("%s: score %v travelled with a refusal", reason, got.Score)
case got.Action != cloud.ActionAllow:
t.Errorf("%s: action %q, want allow — a model with no opinion changes no outcome", reason, got.Action)
case got.Shape != "halfspace:abc" || got.Policy != 7:
t.Errorf("%s: the answer lost the shape/policy that pins it to a model", reason)
}
}
}
// TestAnswer_AnAlertIsAReviewAndShadowChangesNothing — the whole action mapping,
// in one table.
//
// BLOCK IS ABSENT ON PURPOSE. cloud's own vocabulary says a statistical judgement
// "may reach [review] and no further on its own", and this model is exactly one.
//
// Mutation proof: return ActionBlock on an alert and the first row fails; drop
// the shadow row's cause and the second stops reporting what the model would have
// done.
func TestAnswer_AnAlertIsAReviewAndShadowChangesNothing(t *testing.T) {
for _, tc := range []struct {
name string
a anomaly.Assessment
action, why string
wantTheScore float64
}{
{
name: "live, above the cut — a person is summoned and the request proceeds",
a: anomaly.Assessment{Scored: true, Score: 0.9, Cut: 0.5, Alert: true},
action: cloud.ActionReview, why: causeAboveCut, wantTheScore: 0.9,
},
{
name: "shadow, above the cut — nothing changes and the fact is stated",
a: anomaly.Assessment{Scored: true, Score: 0.9, Cut: 0.5, Shadow: true},
action: cloud.ActionAllow, why: causeShadowCut, wantTheScore: 0.9,
},
{
name: "at the cut — inside the stated appetite",
a: anomaly.Assessment{Scored: true, Score: 0.5, Cut: 0.5},
action: cloud.ActionAllow, why: causeWithinAppetite, wantTheScore: 0.5,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := answer(decided{A: tc.a, Version: 3, Shape: "halfspace:def"})
if got.Action != tc.action {
t.Errorf("action %q, want %q", got.Action, tc.action)
}
if got.Cause != tc.why {
t.Errorf("cause %q, want %q", got.Cause, tc.why)
}
if got.Score != tc.wantTheScore {
t.Errorf("score %v, want %v — a scored answer carries its own number", got.Score, tc.wantTheScore)
}
if got.Refusal != "" {
t.Errorf("a scored answer carried refusal %q", got.Refusal)
}
})
}
}
// TestDecideEvent_ReadsTheSignalsItKnowsAndBlindsWhatItCannot.
//
// An amount it cannot read is ABSENT rather than zero: the value features then
// read blind, which is a fact the model state reports, instead of being told the
// payment moved nothing.
func TestDecideEvent_ReadsTheSignalsItKnowsAndBlindsWhatItCannot(t *testing.T) {
ev := decideEvent(&contract.RiskDecideIn{
Kind: contract.KindAccount, Subject: "u_1",
Signals: []contract.Signal{
{Name: contract.SignalNano, Value: "420000000"},
{Name: contract.SignalPeer, Value: "mer_7"},
{Name: contract.SignalDevice, Value: "dev_9"},
{Name: "ip", Value: "203.0.113.7"}, // a name this scorer does not read
},
})
if ev.Nano != 420_000_000 || ev.Peer != "mer_7" || ev.Device != "dev_9" {
t.Errorf("the event lost a signal it knows: %+v", ev)
}
if ev.At != "" {
t.Errorf("'at' was invented as %q — an unstated time is now, decided downstream", ev.At)
}
for _, bad := range []string{"", "4.2e8", "420000000 ", "0x10", "nine"} {
if got := nanoOf(bad); got != 0 {
t.Errorf("nanoOf(%q) = %d, want 0 — an amount we cannot read is one we do not have", bad, got)
}
}
}
// asHTTP is errors.As for zip's HTTP error, kept local so the assertions above
// read as one line.
func asHTTP(err error, target **zip.HTTPError) bool {
he, ok := err.(*zip.HTTPError)
if ok {
*target = he
}
return ok
}
+15
View File
@@ -144,6 +144,21 @@ func init() {
"riskSurface.window": "Window is the lookback the fold covered.",
},
})
zip.Describe("POST /risk/decide", zip.Doc{
Description: "Judges one subject against the CALLING organisation's own model and answers\nwhat to do about it. It learns nothing, records nothing and moves no counter:\nthe numbers it reads are that organisation's history as it stands.\n\nThe organisation is the CALLER's, minted from the plane principal and never\nfrom this body — there is no field here that could name one. A model is trained\non one organisation's own behaviour, so choosing which model answers would be\nthe only cross-tenant read this plane has to offer.\n\nA model still WARMING declines with a reason and no score. That is the whole\ncontract of the answer: the engine computes a score before it checks whether it\nhas learned enough to have an opinion, so a refusal carries a populated number\nthat means nothing, and publishing it would turn \"no opinion\" into \"this is\nfine\". Read `refusal` first; `scored` in the HTTP twin of this op says the same\nthing.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
Fields: map[string]string{
"RiskDecideIn.kind": "Kind is whose behaviour this is — person, session or account. It namespaces\nthe subject, so a person and an account sharing an identifier stay two\nsubjects.",
"RiskDecideIn.signals": "Signals are the facts the gate observed. The scorer reads the names above;\nthe rest are the asking gate's own record of why it asked.",
"RiskDecideIn.stage": "Stage is the lifecycle moment, from cloud's closed set: signup, usage or\npayment. The scorer REFUSES a stage it does not recognise rather than\njudging a moment it does not model — the two ends of this call must agree on\nwhat is being asked before the answer means anything.",
"RiskDecideIn.subject": "Subject is the identifier on that kind, within the caller's own tenant.",
"RiskDecided.action": "Action is what to do, from cloud's action vocabulary: allow, review,\nchallenge, restrict or block.",
"RiskDecided.cause": "Cause is the scorer's short reason, for the record the gate writes.",
"RiskDecided.policy": "Policy is the version of that organisation's decision regime the verdict was\nreached under. Zero means no regime was ever stated and the default posture —\nshadow — was in force.",
"RiskDecided.refusal": "Refusal names why this is NOT a scored answer — warming, unusable or\nunidentified — and is empty when it is one. None of them is a clean bill of\nhealth.",
"RiskDecided.score": "Score is where the event sat in that organisation's own density, in [0,1].\nPresent only on a scored answer.",
"RiskDecided.shape": "Shape is the model SPACE the verdict was reached in, `<family>:<digest>`. It\nis what pins an adverse decision to a model: a score is only meaningful\nagainst the space that produced it.",
},
})
zip.Describe("POST /v1/risk/learn", zip.Doc{
Description: "Learn records a batch of events into the caller organisation's own aggregates\nand lets its model learn from them. It answers how many it learned from.\n\nIT DOES NOT SCORE, AND THAT IS THE POINT. An observation is a value you record;\nlearning is a transformation over observations; a verdict is a query against the\nresult. This op is the first two. [ops.score] is the third, it is pure, and it\nis the ONE door to a verdict. They were one call, which meant you could not\nrecord without training and could not train without being answered — and the\nmodel ran twice over every event to produce a verdict the response carried and\nno caller read.\n\nTO OBSERVE AND JUDGE, COMPOSE THE TWO, and mind the order. Score FIRST, then\nlearn: the score is then the model's opinion of an event it has not yet learned\nfrom, which is the question worth asking. The other order answers for a model\nthat has already absorbed the event it is judging.\n\nThis is the training path, and there is no job behind it: the model IS a set of\nmass counters over half-space trees, so learning is an increment and the model\nis current the instant the last event lands. Nothing from any other\norganisation is in it, and nothing from this organisation leaves it.\n\nA RETRY IS INERT. The record deduplicates on the event id you send, and an event\nalready in it moves nothing, costs nothing and is not counted — so a client that\ntimed out can send the same batch again and its model holds what it holds.\nWithout an id of your own there is nothing to converge on: two identical bodies\nare two events.",
Fields: map[string]string{
-8
View File
@@ -1,8 +0,0 @@
# Generated by plugin/gen-app-cmds. DO NOT EDIT.
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := runtime
include ../../mk/plugin.mk
-116
View File
@@ -1,116 +0,0 @@
package runtime
import (
"sort"
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/openapi"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// This file makes the /v1/bot ops face's untyped-ness a MEASURED fact rather than
// a sentence in Mount's comment. The refusal is real (see untypedByDesign below),
// but a refusal nobody can re-check is how a convertible route stays untyped
// forever — and how a reason that has stopped being true keeps being believed.
//
// The consequence being pinned: /v1/bot publishes seven operations at one greedy
// wildcard and NOT ONE carries an MCP tool or a CLI command. (They do carry a
// summary and a description — openapi.Describe declares those beside the wire
// fact in ops.go, which is the seam for an operation the wire refuses to type.)
// The tenant-actionable surface is native and typed elsewhere (/v1/bots,
// clients/bots); what stays here is ops, and it stays a relay. This gate is where
// that stops being deliberate the moment someone adds a route that need not be.
// untypedByDesign is the closed list of operations that are NOT typed ops, each
// with the WIRE FACT that typing it would move.
var untypedByDesign = map[string]string{
"DELETE /v1/bot/{wildcard1}": reasonProxy,
"GET /v1/bot/{wildcard1}": reasonProxy,
"OPTIONS /v1/bot/{wildcard1}": reasonProxy,
"PATCH /v1/bot/{wildcard1}": reasonProxy,
"POST /v1/bot/{wildcard1}": reasonProxy,
"PUT /v1/bot/{wildcard1}": reasonProxy,
"TRACE /v1/bot/{wildcard1}": reasonProxy,
}
// reasonProxy is the one reason all seven share, because all seven ARE one
// registration: `app.All("/v1/bot/*", s.proxy)` (ops.go). Three wire facts each
// independently forbid a typed op, all three verified against zip v1.18.12:
//
// - ONE registration, EVERY method. zip's typed registrars are per-method and
// there is no All[In, Out].
// - a GREEDY wildcard whose value the proxy RE-MOUNTS on the runtime
// (Params("*") → target). fiber names it `*1` and the document
// `{wildcard1}`, and a whole sub-path is not a scalar zip's bindURL
// (typed.go setScalar) can set on an In field.
// - a VERBATIM response. proxy answers c.Bytes(resp.StatusCode, rb) under the
// runtime's own Content-Type, which is frequently not JSON at all. A typed op
// can only answer c.JSON(out) under the status it DECLARED (zip v1.18.12
// typed.go:302-311), so both move.
const reasonProxy = "proxy. One All() registration for every method, over a greedy wildcard the proxy " +
"re-mounts on the runtime, relaying the runtime's own status code and Content-Type verbatim. zip has " +
"no All[In, Out], no In field can bind a whole sub-path, and a typed op can only answer c.JSON(out) " +
"under its declared status (zip v1.18.12 typed.go:302-311) — method, path and response all move."
// TestEveryRouteIsTypedOrNamed fails when a /v1/bot operation is neither a typed
// op nor named above, so the next route added here is typed BY DEFAULT. It also
// fails on a stale reason naming a route this face no longer serves.
func TestEveryRouteIsTypedOrNamed(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
t.Fatalf("Mount: %v", err)
}
doc, err := openapi.Spec(app, openapi.Info{Title: "runtime", Version: "v1"})
if err != nil {
t.Fatalf("spec: %v", err)
}
reg, err := openapi.Typed(app)
if err != nil {
t.Fatalf("typed registry: %v", err)
}
served := map[string]bool{}
for path, item := range doc.Paths {
if !strings.HasPrefix(path, "/v1/bot") {
continue
}
for method := range item {
served[strings.ToUpper(method)+" "+path] = true
}
}
if len(served) == 0 {
t.Fatal("the /v1/bot face serves nothing at all — the router moved and this gate is now blind")
}
typed := map[string]bool{}
for key := range reg.Ops {
if i := strings.Index(key, " "); i > 0 && strings.HasPrefix(key[i+1:], "/v1/bot") {
typed[key] = true
}
}
var untyped []string
for key := range served {
if typed[key] || untypedByDesign[key] != "" {
continue
}
untyped = append(untyped, key)
}
if len(untyped) > 0 {
sort.Strings(untyped)
t.Errorf("untyped and unnamed: %s\nAn untyped route projects to NOTHING — no prose, no MCP tool, "+
"no CLI command, no typed SDK method. Convert it (zip.Get/Post/... on the app), or add it to "+
"untypedByDesign with the WIRE FACT that typing it would move.", strings.Join(untyped, ", "))
}
for key := range untypedByDesign {
if !served[key] {
t.Errorf("untypedByDesign names %s, which this face does not serve — a stale reason nobody can re-check", key)
}
if typed[key] {
t.Errorf("untypedByDesign names %s, which IS a typed op — remove the reason", key)
}
}
if len(typed)+len(untypedByDesign) != len(served) {
t.Errorf("%d typed + %d named != %d served", len(typed), len(untypedByDesign), len(served))
}
}
+2 -5
View File
@@ -7,10 +7,8 @@ import (
"fmt"
"strings"
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/apps/security/detect"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
// the "sqlite" database/sql name under both build tags (cgo →
@@ -64,11 +62,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "security", dir)
db, err := sqlpool.Open("security", dir)
if err != nil {
return nil, fmt.Errorf("open security store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+2 -5
View File
@@ -6,9 +6,7 @@ import (
"errors"
"fmt"
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
// "sqlite" database/sql name under both cgo and pure-Go build tags). Blank
@@ -53,11 +51,10 @@ type SettingsStore struct {
}
func openSettingsStore(dir string) (*SettingsStore, error) {
db, err := cek.Open(namespace.System(), "settings", dir)
db, err := sqlpool.Open("settings", dir)
if err != nil {
return nil, fmt.Errorf("open settings store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &SettingsStore{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+2 -5
View File
@@ -8,9 +8,7 @@ import (
"fmt"
"strings"
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE Hanzo "sqlite" driver; blank import registers it.
_ "github.com/hanzoai/sqlite"
@@ -33,11 +31,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "social", dir)
db, err := sqlpool.Open("social", dir)
if err != nil {
return nil, fmt.Errorf("open social store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+401 -57
View File
@@ -19,6 +19,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -88,10 +89,12 @@ type api struct {
trans *transServer
cfg config
log luxlog.Logger
// verify is cloud's RS256/JWKS IAM token validator (cloud.NewTokenValidator —
// the SAME trust anchor as the identity boundary). The OAuth callback derives
// the tenant from ITS verdict, never from unverified claims.
verify func(string) (cloud.VerifiedIdentity, error)
// ident is the identity seam every team surface resolves its caller through:
// cloud's RS256/JWKS IAM validator (the SAME trust anchor as the identity
// boundary), the HS256 secret, and the membership rows. The OAuth callback
// derives its tenant from that validator's verdict, never from unverified
// claims — one validator, not a second copy beside the seam holding it.
ident *identity
// commerce answers CheckEntitlement(org, "team") at workspace select — nil
// (not co-resident) is an infra absence and never blocks login.
commerce types.CommerceClient
@@ -494,7 +497,7 @@ func (g *api) establishSession(ctx context.Context, access string) (account, tok
}
// AccountUuid = the IAM sub (derived to a stable UUID when the sub is not one).
account = accountID(sub)
id, err := g.verify(access)
id, err := g.ident.verify(access)
if err != nil {
return "", "", "org_failed", err
}
@@ -564,17 +567,30 @@ func (g *api) establishSession(ctx context.Context, access string) (account, tok
// map so token.Generate's JSON marshal is stable and the decode side
// (orgsFromExtra) reads it back with no SDK dependency in the token layer.
func orgsClaim(orgs []model.OrgRef, home string) []map[string]any {
out := make([]map[string]any, 0, len(orgs)+1)
refs := homeOrgs(orgs, home)
out := make([]map[string]any, 0, len(refs))
for _, o := range refs {
out = append(out, map[string]any{"org": o.Org, "role": o.Role})
}
return out
}
// homeOrgs is that rule itself: the verified membership set, deduped, with the home
// tenant guaranteed present. It is shared by the login mint above and by the IAM
// lane's caller (identity.iam), so a person enumerates the SAME orgs whichever
// credential they arrive with.
func homeOrgs(orgs []model.OrgRef, home string) []model.OrgRef {
out := make([]model.OrgRef, 0, len(orgs)+1)
seen := map[string]bool{}
for _, o := range orgs {
if o.Org == "" || seen[o.Org] {
continue
}
seen[o.Org] = true
out = append(out, map[string]any{"org": o.Org, "role": o.Role})
out = append(out, o)
}
if home != "" && !seen[home] {
out = append(out, map[string]any{"org": home, "role": "admin"})
out = append(out, model.OrgRef{Org: home, Role: "admin"})
}
return out
}
@@ -640,7 +656,13 @@ func (g *api) setCookie(c *zip.Ctx) error {
// cookie. Storing an unverified, caller-supplied value is a login-CSRF /
// session-fixation vector — an attacker could pin a cookie the victim's browser
// then presents as authenticated. Only a token THIS service signed is accepted.
if _, err := token.Decode(body.Token, g.cfg.serverSecret, true); err != nil {
//
// The HS256 arm ONLY, deliberately: this writes account-token, and the IAM
// cookie beside it is minted by the OAuth callback out of a code exchange the
// browser itself started. Accepting a caller-supplied IAM token here would add a
// second, caller-driven writer for that cookie — a session-fixation surface the
// callback does not have.
if _, err := g.ident.hs256(body.Token); err != nil {
return zip.ErrUnauthorized("invalid session token")
}
g.setSessionCookie(c, authCookie, body.Token, int(sessionTokenTTL.Seconds()))
@@ -864,25 +886,25 @@ func (g *api) resolveWorkspace(ctx context.Context, orgs []model.OrgRef, account
}
}
// getWorkspaceInfo returns info for THE workspace the caller is scoped to — the
// one selectWorkspace already minted into the session token's `workspace` claim,
// resolved owner_org-scoped by (org, uuid). It NEVER falls back to the caller's
// first workspace: a token with no workspace claim (an account/login token that
// has not selected a workspace yet) is a clean WorkspaceNotFound, so the client is
// forced through the explicit selectWorkspace step rather than being silently
// handed an arbitrary one.
// getWorkspaceInfo returns info for THE workspace the caller's CREDENTIAL is
// scoped to — the one selectWorkspace already minted into the workspace token's
// `workspace` claim, resolved owner_org-scoped by (org, uuid). It NEVER falls back
// to the caller's first workspace: a credential that pins no workspace (an
// account/login token that has not selected one, and every IAM caller, which pins
// nothing by construction) is a clean WorkspaceNotFound, so the client is forced
// through the explicit selectWorkspace step rather than being silently handed an
// arbitrary one.
func (g *api) getWorkspaceInfo(c *zip.Ctx) error {
t, _, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return g.fail(c, statusUnauthorized(err.Error()))
}
if t.Workspace == "" {
if cl.workspace == "" {
return g.fail(c, statusWorkspaceNotFound(""))
}
org := t.Org()
ws, err := g.accounts.WorkspaceByUUID(c.Context(), org, t.Workspace)
ws, err := g.accounts.WorkspaceByUUID(c.Context(), cl.org, cl.workspace)
if err != nil {
return g.fail(c, statusWorkspaceNotFound(t.Workspace))
return g.fail(c, statusWorkspaceNotFound(cl.workspace))
}
return g.ok(c, toWorkspaceInfo(ws))
}
@@ -908,58 +930,380 @@ func (g *api) getSocialIds(c *zip.Ctx) error {
}})
}
// ── helpers ───────────────────────────────────────────────────────────────────
// ── the identity seam ─────────────────────────────────────────────────────────
// sessionToken decodes AND verifies (signature + expiry) the request's HS256
// bearer/cookie token — the one this service minted. bearer takes precedence over
// the cookie. It is the ONE place a team session token is turned into a principal,
// shared by the account RPC and the files plane.
//
// The SPA sends OUR HS256 token, not an IAM RS256 JWT: an IAM bearer would simply
// fail the HMAC check (ErrSignature) and be rejected here, so there is no separate
// algorithm routing to maintain (why token.Alg was removed). token.Decode with
// verify=true also enforces `exp`/`nbf`, so a captured expired token is refused.
func sessionToken(c *zip.Ctx, secret string) (*token.Token, string, error) {
raw := bearer(c)
if raw == "" {
raw = c.Fiber().Req().Cookies(authCookie)
}
if raw == "" {
return nil, "", fmt.Errorf("no token")
}
t, err := token.Decode(raw, secret, true)
if err != nil {
return nil, "", err
}
if t.Account == "" {
return nil, "", fmt.Errorf("token has no account")
}
return t, raw, nil
// identity is what every team surface turns a credential into a caller with, and
// the ONE place a credential's algorithm is routed on. It composes three answers
// and braids none of them: VERIFICATION (an IAM access token against the IAM JWKS,
// or the HS256 signature), ACCOUNT RESOLUTION (accountID over the IAM subject —
// the join establishSession stores the account's rows under), and WORKSPACE
// AUTHORIZATION (the membership rows, admit).
type identity struct {
// verify is cloud's RS256/JWKS IAM validator (cloud.NewTokenValidator) — the
// SAME trust anchor as the identity boundary and as the OAuth callback's tenant
// derivation, so a token any one of them accepts is a token all three accept.
verify func(string) (cloud.VerifiedIdentity, error)
// secret is SERVER_SECRET, the key of the HS256 arm.
secret string
// accounts is the membership authority. On the IAM lane nothing about a
// workspace is signed, so these rows ARE the authorization.
accounts *accountStore
// audience is the set of IAM apps whose access tokens this deployment accepts
// as a TEAM SESSION. See identity.iam for why team gates on it when the
// identity boundary deliberately does not.
audience map[string]bool
}
// account resolves (AccountUuid, org, token) from the request's verified session
// token. The org is the token's SIGNED extra.org claim — the tenant key for every
// account-store query — never a client header.
// caller is who a team surface is talking to. It is the WHOLE answer: no surface
// reads a claim off a credential for itself, so no surface can disagree with this
// one about who is calling.
type caller struct {
// account is the team AccountUuid.
account string
// org is the IAM tenant every account-store query is scoped to.
org string
// orgs is the home-safe membership set the cross-org surfaces enumerate.
orgs []model.OrgRef
// user is the IAM `<owner>/<name>` id, the key IAM's get-user takes for a
// mid-session membership refresh. Empty when the credential names no username.
user string
// workspace is the workspace the CREDENTIAL pinned itself to. Empty on the IAM
// lane, which pins nothing: what an IAM caller may touch is decided per request
// by admit against the rows, never by a claim the caller carries.
workspace string
// raw is the HS256 credential exactly as presented, and it is EMPTY ON THE IAM
// LANE — deliberately, structurally, and not as a rule each caller remembers.
//
// The account RPC echoes this back to the SPA as its session token, and the SPA
// is page JS. An IAM access token is an estate-wide RS256 bearer that reaches
// the gateway, KMS and every other service; the login flow puts it in an
// HttpOnly cookie precisely so script can never read it. Echoing it here would
// hand it straight back to the script the cookie flag exists to keep it from —
// one unauthenticated-looking RPC, and the caller's whole platform credential is
// in a variable. So the IAM lane carries no credential OUT of this file at all,
// and a future echo site cannot reintroduce the leak by forgetting.
raw string
// iam reports which lane resolved this caller. It exists so a surface can grant
// on rows instead of on a signed workspace claim, not so it can re-derive trust.
iam bool
}
// who resolves the caller of a team surface, on either of two lanes.
//
// THE IAM LANE is an IAM access token — Authorization: Bearer, else the
// hanzo_iam_token cookie the login flow already set — verified against the IAM
// JWKS, narrowed to this deployment's own audience, and resolved to a team account
// through the store by its SUBJECT (identity.iam).
//
// THE HS256 ARM is the token this service minted, semantics unchanged: bearer
// first and the account-token cookie after, signature and exp/nbf enforced. It is
// deleted when login mints IAM-only and front/love/analytics-collector verify IAM.
//
// ONE SURFACE IS NOT DUAL-READ YET, and it blocks that deletion: getWorkspaceInfo
// answers for the workspace the CREDENTIAL pins, and the IAM lane pins none by
// construction — only selectWorkspace's HS256 mint does. So the workspace a client
// is "in" still has to travel as a claim. Deleting the arm means the front NAMING
// the workspace on that call (as it already does for selectWorkspace) and this
// authorizing it through admit, the same way the transactor and files planes
// already do. That is a client change, which is why it is a later phase and not
// this one.
//
// THE ORDER IS WHAT MAKES THIS PHASE INERT, and it is the existing credential
// first on BOTH carriers:
//
// - Authorization is answered by the bearer alone. A signed-in browser carries an
// IAM cookie beside its HS256 bearer, so consulting the cookie for a request
// that already presented a bearer would move every current client onto the new
// lane at once.
// - with no bearer, account-token is read BEFORE hanzo_iam_token, and an
// account-token that is PRESENT answers alone — a stale one is refused rather
// than falling through. The two cookies coexist for the whole overlap and are
// not interchangeable: the HS256 one can PIN A WORKSPACE and the IAM one
// cannot, so preferring the IAM cookie silently widened the collaborator planes
// from "the workspace this token names" to "any workspace you are a member of",
// and made getWorkspaceInfo answer WorkspaceNotFound where the pin used to
// answer. Falling through on expiry would be the same widening on a timer: a
// session that used to end in a 401 would quietly continue with a different
// reach.
//
// So the rule is one sentence for every carrier: THE FIRST CREDENTIAL THE REQUEST
// PRESENTS, IN CARRIER ORDER, IS THE ONE THAT ANSWERS. The IAM cookie is reached by
// a browser holding nothing else, which is exactly the post-cutover client and
// nobody today — which is what makes this phase inert. Within a carrier IAM wins: a
// header that verifies as IAM is never re-read as HS256.
func (id *identity) who(c *zip.Ctx) (caller, error) {
if id == nil {
return caller{}, fmt.Errorf("no identity seam")
}
ctx := c.Context()
if raw := bearer(c); raw != "" {
return id.verified(ctx, raw)
}
if raw := c.Fiber().Req().Cookies(authCookie); raw != "" {
return id.hs256(raw)
}
return id.iam(ctx, c.Fiber().Req().Cookies(iamTokenCookie))
}
// verified is who() over ONE presented credential rather than over a request's
// carriers — the same two lanes in the same order, for the surfaces that carry the
// credential in a body or a path segment instead of a header. The HS256 error is
// the one reported: both arms fail closed, so the caller learns why the credential
// it actually holds was refused rather than why the other lane did not claim it.
func (id *identity) verified(ctx context.Context, raw string) (caller, error) {
if cl, err := id.iam(ctx, raw); err == nil {
return cl, nil
}
return id.hs256(raw)
}
// iam turns a VERIFIED IAM ACCESS token into a caller. Fails closed on every
// path: no validator, no store to resolve against, an unverifiable token, one that
// is not an access token, one whose owner claim is empty (there is no tenant to
// scope to), and one whose SUBJECT names no account in that tenant.
//
// THE SUBJECT, NEVER THE CANONICAL USER ID. VerifiedIdentity.User falls back sub →
// preferred_username → name, so a token carrying no sub presents its USERNAME
// there — and accountID returns a UUID-shaped input verbatim, so a username set to
// a colleague's account uuid resolved to the colleague, and admit() then granted
// every workspace the two share. Subject-only closes it; the account itself comes
// from the store (AccountForSubject), which confirms the row a login created
// rather than asserting an id no row has to match.
//
// TYPE, NOT JUST SIGNATURE. IAM's signer emits the same claim set into the access
// token and the id_token but for aud/tokenType/nonce (middleware_identity.go), so
// a valid signature from a trusted issuer does not say WHICH of them arrived — and
// the id_token is the one handed to a browser SPA to read. A session credential
// must be the access token, so the type is checked here.
//
// AUDIENCE IS CHECKED HERE, and it is checked here BECAUSE the identity boundary
// deliberately does not. That posture was decided for the boundary, whose job is
// "did IAM mint this for one of its own apps" — for an API call, aud only names
// which app, and cloud kept no mirror of IAM's registry because the mirror drifted
// and silently 401'd every new first-party app. A SESSION is a different question.
// This lane turns a bearer into a signed-in person on hanzo.team, and a token the
// user obtained for a DIFFERENT app — chat, the console, any OIDC client they ever
// clicked through — is not consent to that. Without the gate, one app's token is
// every app's session, which is the confused-deputy shape the estate closes
// elsewhere by narrowing at the resource server rather than at the door.
//
// The set is this deployment's OWN client id and nothing else by default, so it
// cannot drift into a registry mirror: it is one value team already has to know to
// run its OAuth flow, and the browser's hanzo_iam_token is the token that flow
// exchanged, so it carries exactly this audience. Additional first-party SPAs are
// named explicitly by an operator (TEAM_IAM_AUDIENCES) rather than admitted by a
// pattern — an audience allowlist that grows by rule is the mirror again.
//
// TENANT IS THE HOME ORG, NEVER `owner`. v.Owner carries the APPLICATION's org, so
// it is chosen by whichever app the caller authenticated through; a token with
// owner="lux" and a membership set naming hanzo would otherwise scope every team
// store query to "lux". The boundary refuses to derive a tenant from that claim
// (idClaims.homeOrg) and so does this. An empty home is a refusal, which also
// excludes every MACHINE credential — a client_credentials app or an API key is a
// member of nothing, and a team session is a person's.
func (id *identity) iam(ctx context.Context, raw string) (caller, error) {
if id == nil || id.verify == nil {
return caller{}, fmt.Errorf("no iam validator")
}
if id.accounts == nil {
return caller{}, fmt.Errorf("no account store to resolve a subject against")
}
if raw == "" {
return caller{}, fmt.Errorf("no token")
}
v, err := id.verify(raw)
if err != nil {
return caller{}, err
}
if !isAccessToken(v.TokenType) {
return caller{}, fmt.Errorf("not an access token: tokenType %q", v.TokenType)
}
if !id.forThisDeployment(v.Audience) {
return caller{}, fmt.Errorf("token audience %v is not a team session audience", v.Audience)
}
org := v.Home()
if org == "" {
return caller{}, fmt.Errorf("verified token names no home org")
}
if v.Subject == "" {
return caller{}, fmt.Errorf("verified token carries no subject")
}
account, ok := id.accounts.AccountForSubject(ctx, org, v.Subject)
if !ok {
return caller{}, fmt.Errorf("verified subject holds no account in %q", org)
}
user := ""
if v.Username != "" {
user = org + "/" + v.Username
}
// NO raw: an IAM credential never leaves this function. See caller.raw.
return caller{
account: account,
org: org,
orgs: homeOrgs(v.Orgs, org),
user: user,
iam: true,
}, nil
}
// forThisDeployment reports whether a token was minted for an app whose session
// this deployment is. Empty audience is REFUSED: a session credential that names
// no app is one nobody consented to hand here.
//
// THE INCOMING CLAIM IS MATCHED EXACTLY — no trim, no fold, no normalisation.
// Normalising it here would make the comparison non-injective: "hanzo-team " and
// "hanzo-team" are DISTINCT IAM applications (IAM refuses only an exact name
// collision, so the padded one is registrable), and trimming collapses them onto
// one key, handing every session of the real app to whoever registered the
// lookalike. This is the rule OrgHasUnsafeRune states for orgs — an injective
// boundary must never fold two distinct identifiers into one — applied to the
// identifier this door happens to compare.
//
// Whitespace is dealt with once, on the way IN, where the set is BUILT
// (sessionAudience): an operator's config entry is theirs to tidy, a signed claim
// is not ours to rewrite.
func (id *identity) forThisDeployment(aud []string) bool {
for _, a := range aud {
if id.audience[a] {
return true
}
}
return false
}
// sessionAudience is the set of IAM apps whose access tokens this deployment
// accepts as a team session: its OWN client id, plus any explicitly named by the
// operator in TEAM_IAM_AUDIENCES (comma-separated).
//
// The default is one value — the client id team already needs to run its OAuth
// flow, and therefore the audience of the very token that flow puts in the
// browser's cookie. Extra entries are NAMED, never matched by a pattern: an
// audience set that grows by rule is the IAM app-registry mirror the estate
// deleted, arriving one wildcard at a time.
//
// Trimming happens HERE and only here — an operator's config entry is theirs to
// tidy, while the signed claim this set is compared against is matched exactly
// (see identity.forThisDeployment for why folding it is a hole).
//
// Phase-2 precondition: if the hanzo-team IAM app is IsShared, seed
// clientID+"-org-"+<org> here — a shared app's access tokens carry the per-org
// audience form.
func sessionAudience(cfg config) map[string]bool {
out := map[string]bool{}
if id := strings.TrimSpace(cfg.iamClientID); id != "" {
out[id] = true
}
for _, a := range strings.Split(os.Getenv("TEAM_IAM_AUDIENCES"), ",") {
if a = strings.TrimSpace(a); a != "" {
out[a] = true
}
}
return out
}
// isAccessToken reports whether IAM's `tokenType` names the token a bearer session
// may be built on.
//
// The comparison is case-insensitive and treats an ABSENT type as an access token,
// which is the one permissive branch here and is deliberate: IAM has minted tokens
// without the claim, and refusing those would sign every one of those users out at
// deploy rather than at expiry. It is safe in the direction that matters — the
// id_token this exists to exclude is exactly the one that DOES carry a type, so an
// omitted claim is never an id_token being waved through. It stops being reached as
// tokens roll over, rather than needing a flag day.
func isAccessToken(t string) bool {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "access-token", "access_token", "bearer":
return true
default:
return false
}
}
// hs256 decodes AND verifies (signature + expiry) the HS256 session or workspace
// token this service minted. The tenant, the membership set and the workspace all
// come from its SIGNED claims.
func (id *identity) hs256(raw string) (caller, error) {
if id == nil {
return caller{}, fmt.Errorf("no identity seam")
}
if raw == "" {
return caller{}, fmt.Errorf("no token")
}
t, err := token.Decode(raw, id.secret, true)
if err != nil {
return caller{}, err
}
if t.Account == "" {
return caller{}, fmt.Errorf("token has no account")
}
user, _ := t.Extra["user"].(string)
return caller{
account: t.Account,
org: t.Org(),
orgs: orgsFromExtra(t.Extra),
user: user,
workspace: t.Workspace,
raw: raw,
}, nil
}
// admit authorizes cl for the workspace the REQUEST named and returns its row.
// Membership IS the authorization — the server reads the rows, the caller signs
// nothing — which is why it is the one gate both lanes pass through wherever a
// workspace is named. Every failure answers the same errNoWorkspace, so an unknown
// workspace, another tenant's, and one the caller is not in are indistinguishable.
func (id *identity) admit(ctx context.Context, cl caller, wsUUID string) (workspace, error) {
if id == nil || id.accounts == nil {
return workspace{}, errNoWorkspace
}
// A caller with no tenant, or none with an account, names nothing to be a member
// of — and an empty org is a value the owner_org scoping would happily match a
// row against. Refused here, once, so every surface inherits the same floor.
if cl.org == "" || cl.account == "" {
return workspace{}, errNoWorkspace
}
w, err := id.accounts.WorkspaceByUUID(ctx, cl.org, strings.TrimSpace(wsUUID))
if err != nil {
return workspace{}, errNoWorkspace
}
if _, ok := id.accounts.Membership(ctx, w.ID, cl.account); !ok {
return workspace{}, errNoWorkspace
}
return w, nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
// account resolves (AccountUuid, org, token) from the request's verified caller.
// The org is the IAM tenant — the HOME org of a verified access token, or the HS256
// token's SIGNED extra.org claim — and is the key for every account-store query,
// never a client header.
//
// The token is EMPTY for an IAM caller, and that is the answer rather than a gap:
// it is echoed to the SPA as its session token, and an IAM caller's credential is
// an estate-wide bearer held in an HttpOnly cookie that script must never see (see
// caller.raw). Such a caller already holds the credential it authenticated with, so
// there is nothing it needs handed back.
func (g *api) account(c *zip.Ctx) (account, org, tok string, err error) {
t, raw, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return "", "", "", err
}
org = t.Org()
return t.Account, org, raw, nil
return cl.account, cl.org, cl.raw, nil
}
// accountOrgs resolves (AccountUuid, membership set, token) from the verified
// session token. The set is the SIGNED extra.orgs claim (home + every team org),
// read back home-safe by orgsFromExtra — the tenant SET the cross-org surfaces
// caller. The set is home-safe — the verified `orgs` claim on the IAM lane, the
// SIGNED extra.orgs on the HS256 one — and is the tenant SET the cross-org surfaces
// (getUserWorkspaces union, selectWorkspace resolution) enumerate. Never a client
// header. Empty account fails closed, exactly like account().
func (g *api) accountOrgs(c *zip.Ctx) (account string, orgs []model.OrgRef, tok string, err error) {
t, raw, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return "", nil, "", err
}
return t.Account, orgsFromExtra(t.Extra), raw, nil
return cl.account, cl.orgs, cl.raw, nil
}
// callbackOrigin is the ORIGIN the OAuth redirect_uri is built from — the SAME
+37
View File
@@ -349,6 +349,43 @@ func (s *accountStore) Membership(ctx context.Context, workspaceID, account stri
return role, true
}
// AccountForSubject is the ONE answer to "which team account is this IAM
// identity?", and the store is deliberately the one that gives it.
//
// The subject is the `sub` claim VERBATIM and nothing else. It is NOT the
// canonical user id: that one falls back sub → preferred_username → name, so a
// token carrying no sub presents its USERNAME there — and accountID returns a
// UUID-shaped input verbatim, so a username that is a colleague's account uuid
// would have resolved to the colleague. Subject-only closes that, and an empty
// subject is refused exactly as the OAuth callback's userinfo() refuses one.
//
// It then CONFIRMS the derived id against the rows instead of asserting it. The
// derivation (accountID) is the same function establishSession stores the rows
// under — one derivation, not two — but a login is what CREATES those rows, so an
// id that matches none is an identity this deployment has never seen, and the
// honest answer is "no account" rather than an account-shaped string every later
// query would then scope by. That is what makes the caller's refusal true rather
// than merely documented.
//
// The existence check is org-scoped: a member row is only this org's if its
// workspace is. So a subject known in org A resolves to nothing in org B, and the
// answer cannot be used to probe another tenant.
func (s *accountStore) AccountForSubject(ctx context.Context, org, subject string) (string, bool) {
account := accountID(strings.TrimSpace(subject))
if account == "" || strings.TrimSpace(org) == "" {
return "", false
}
var found string
err := s.db.Select("m.user_id").From("members m").
InnerJoin("workspaces w", query.NewExp("w.id = m.workspace_id")).
Where(query.HashExp{"w.owner_org": org, "m.user_id": account}).
Limit(1).WithContext(ctx).Row(&found)
if err != nil || found == "" {
return "", false
}
return found, true
}
// MembersForWorkspaceUUID returns the member rows of a workspace, resolved by
// (org, workspace uuid) so a foreign tenant's uuid returns nothing. This is the
// human half of the roster reconcile.
+11 -12
View File
@@ -77,7 +77,7 @@ type billingService struct {
accounts *accountStore
commerce types.CommerceClient
planEnt func(context.Context, string) (map[string]any, error)
secret string
ident *identity
degraded bool
}
@@ -128,7 +128,7 @@ func (b *billingService) readPlan(ctx context.Context, _ *none) (*planInfo, erro
if b.degraded {
return nil, unavailable()
}
_, org, err := sessionOf(ctx, b.secret)
_, org, err := sessionOf(ctx, b.ident)
if err != nil {
return nil, zip.ErrUnauthorized("sign in to view billing")
}
@@ -161,7 +161,7 @@ func (b *billingService) readPlan(ctx context.Context, _ *none) (*planInfo, erro
// index.html (the SPA shell). Session-gated — an anonymous caller gets 401,
// never the page. Fingerprinted assets/ cache hard; the shell never caches.
func (b *billingService) ui(c *zip.Ctx) error {
if _, _, err := orgPrincipal(c, b.secret); err != nil {
if _, _, err := orgPrincipal(c, b.ident); err != nil {
return zip.ErrUnauthorized("sign in to view billing")
}
root := wallet.FS()
@@ -196,18 +196,17 @@ func walletContentType(name string) string {
return "application/octet-stream"
}
// orgPrincipal resolves (account, org) from the request's VERIFIED session or
// workspace token (bearer or the HttpOnly account cookie), refusing a token
// that carries no org — the ONE token→tenant resolution the files and billing
// planes share.
func orgPrincipal(c *zip.Ctx, secret string) (account, org string, err error) {
t, _, err := sessionToken(c, secret)
// orgPrincipal resolves (account, org) from the request's VERIFIED caller
// (identity.who — an IAM access token, else team's own HS256 token, on a header or
// a cookie), refusing one that carries no org — the ONE credential→tenant
// resolution the files and billing planes share.
func orgPrincipal(c *zip.Ctx, id *identity) (account, org string, err error) {
cl, err := id.who(c)
if err != nil {
return "", "", err
}
org = t.Org()
if org == "" {
if cl.org == "" {
return "", "", errNoOrg
}
return t.Account, org, nil
return cl.account, cl.org, nil
}
+1 -1
View File
@@ -33,7 +33,7 @@ func billingApp(t *testing.T, commerce types.CommerceClient, planEnt func(contex
t.Fatalf("openAccountStore: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
b := &billingService{accounts: store, commerce: commerce, planEnt: planEnt, secret: testSecret}
b := &billingService{accounts: store, commerce: commerce, planEnt: planEnt, ident: testIdent(store)}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// The SAME bridge the composer installs at its root. The plan read is a typed
// op, and a typed op receives only a context — the request its session token
+826
View File
@@ -0,0 +1,826 @@
package team
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/internal/iamtest"
"github.com/hanzoai/cloud/plane"
)
// The IAM subject the fake validator answers for, and the account it must resolve
// to. accountID is the join establishSession stores rows under, so a lane that
// resolved anything else would address an account that flow never created.
const (
iamSub = "11111111-2222-4333-8444-555555555555"
iamOtherSub = "99999999-2222-4333-8444-555555555555"
)
// openTestStore opens an isolated account store for one test.
func openTestStore(t *testing.T) *accountStore {
t.Helper()
s, err := openAccountStore(t.TempDir())
if err != nil {
t.Fatalf("openAccountStore: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
// testIdent is the seam a test drives, with NO IAM validator: the HS256 arm alone,
// which is what the services that never see an IAM token are built with.
func testIdent(accounts *accountStore) *identity {
return &identity{secret: testSecret, accounts: accounts}
}
// identFor is the seam with both lanes live, verifying against a REAL issuer: the
// tokens are signed, the JWKS is fetched, and the claim mapping under test is
// cloud's own. Nothing between a signed token and a caller is faked.
func identFor(t *testing.T, accounts *accountStore) (*identity, *iamtest.Issuer0) {
t.Helper()
iam := iamtest.New(t)
verify := cloud.NewTokenValidator(iamtest.Issuer).Validate
return &identity{
verify: verify, secret: testSecret, accounts: accounts,
audience: map[string]bool{iamtest.Audience: true},
}, iam
}
// orgsOf is the signed membership set naming org as HOME — the first entry, which
// is the tenant rule the estate states once in idClaims.homeOrg.
func orgsOf(org string) []map[string]any {
return []map[string]any{{"org": org, "role": "admin"}}
}
// homeIn is the ordinary token: this subject, at home in this org.
func homeIn(org, sub string) iamtest.Claims {
return iamtest.Claims{Sub: sub, Owner: org, Orgs: orgsOf(org)}
}
// enrolled creates the account rows a login creates, and returns the account id
// the store will answer for that subject. The IAM lane resolves an account only
// when a login already made one — so a test about resolution has to enrol first.
func enrolled(t *testing.T, store *accountStore, org, subject, name string) string {
t.Helper()
if _, err := store.EnsureWorkspace(context.Background(), org, accountID(subject), name); err != nil {
t.Fatalf("enrol %s in %s: %v", subject, org, err)
}
return accountID(subject)
}
// withReq drives one request carrying whichever credentials the case is about and
// runs fn against its live context. The context is pooled and recycled the moment
// the handler returns, so the assertion has to happen inside it.
func withReq(t *testing.T, bearerTok, iamCookie, acctCookie string, fn func(*zip.Ctx)) {
t.Helper()
app := zip.New(zip.Config{})
ran := false
app.Get("/probe", func(c *zip.Ctx) error {
ran = true
fn(c)
return c.String(http.StatusOK, "ok")
})
r := httptest.NewRequest(http.MethodGet, "/probe", nil)
if bearerTok != "" {
r.Header.Set("Authorization", "Bearer "+bearerTok)
}
if iamCookie != "" {
r.AddCookie(&http.Cookie{Name: iamTokenCookie, Value: iamCookie})
}
if acctCookie != "" {
r.AddCookie(&http.Cookie{Name: authCookie, Value: acctCookie})
}
if _, err := app.Test(r); err != nil {
t.Fatalf("probe: %v", err)
}
if !ran {
t.Fatal("probe handler never ran")
}
}
// whoOn resolves a caller off a request carrying those credentials.
func whoOn(t *testing.T, id *identity, bearerTok, iamCookie, acctCookie string) (cl caller, err error) {
t.Helper()
withReq(t, bearerTok, iamCookie, acctCookie, func(c *zip.Ctx) { cl, err = id.who(c) })
return cl, err
}
// hsToken mints an HS256 token exactly as this service does.
func hsToken(t *testing.T, account, workspace, org string) string {
t.Helper()
tok, err := token.Generate(account, workspace, map[string]any{"org": org}, expUnix(sessionTokenTTL), testSecret)
if err != nil {
t.Fatalf("token.Generate: %v", err)
}
return tok
}
// TestIAMLaneResolvesTheAccount proves the IAM lane addresses the account the
// OAuth callback's join creates, resolved through the STORE, with the tenant taken
// from the verified owner claim and NEVER from anything the caller wrote, on both
// carriers. The token is really signed and really verified.
func TestIAMLaneResolvesTheAccount(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
want := enrolled(t, store, "acme", iamSub, "Ada")
raw := iam.Sign(t, iamtest.Claims{
Sub: iamSub, PreferredUsername: "ada", Owner: "acme",
Orgs: []map[string]any{{"org": "acme", "role": "admin"}, {"org": "beta", "role": "member"}},
})
for name, carrier := range map[string][2]string{
"bearer": {raw, ""},
"cookie": {"", raw},
} {
cl, err := whoOn(t, id, carrier[0], carrier[1], "")
if err != nil {
t.Fatalf("%s: who: %v", name, err)
}
if !cl.iam {
t.Fatalf("%s: resolved on the HS256 arm, want the IAM lane", name)
}
if cl.account != want {
t.Fatalf("%s: account = %q, want the enrolled account %q", name, cl.account, want)
}
if cl.org != "acme" {
t.Fatalf("%s: org = %q, want the verified owner", name, cl.org)
}
if cl.user != "acme/ada" {
t.Fatalf("%s: user = %q, want <owner>/<name>", name, cl.user)
}
// The IAM lane pins NO workspace: what it may touch is decided per request
// against the rows, never by a claim it carries.
if cl.workspace != "" {
t.Fatalf("%s: workspace = %q, want the IAM lane to pin none", name, cl.workspace)
}
// Home-safe: the verified membership set plus the home tenant.
if len(cl.orgs) != 2 || cl.orgs[0].Org != "acme" || cl.orgs[1].Org != "beta" {
t.Fatalf("%s: orgs = %v, want [acme beta]", name, cl.orgs)
}
// An IAM credential NEVER leaves the seam: raw is empty on this lane, so no
// echo site can hand a platform bearer back to page JS.
if cl.raw != "" {
t.Fatalf("%s: caller.raw carries the IAM credential", name)
}
}
}
// TestIAMLaneKeysOnTheSubjectNotTheUsername is the F2 regression, and it is the
// reason these tests sign real tokens.
//
// VerifiedIdentity.User falls back sub → preferred_username → name, and accountID
// returns a UUID-shaped input VERBATIM. So a token with NO `sub` whose
// preferred_username is a colleague's account uuid used to resolve to that
// colleague — and admit() then granted every workspace the two share. Nothing
// about that token is forged: IAM signs it, the issuer is trusted, the signature
// verifies. Only the claim the lane READS decides who it is.
//
// The attacker needs a token IAM will mint with no sub and a chosen username, so
// this is a privilege escalation gated on an IAM-side condition rather than an open
// door — which is exactly the kind that survives review by being called impossible.
func TestIAMLaneKeysOnTheSubjectNotTheUsername(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
victim := enrolled(t, store, "acme", iamSub, "Ada")
// The victim's ACCOUNT UUID worn as a username, on a token carrying no subject.
attack := iam.Sign(t, iamtest.Claims{PreferredUsername: victim, Name: victim, Owner: "acme", Orgs: orgsOf("acme")})
// The canonical user id really does resolve to the victim — the fallback fires,
// so this test observes the actual hazard rather than assuming it away.
v, err := id.verify(attack)
if err != nil {
t.Fatalf("the attack token did not verify, so this test proves nothing: %v", err)
}
if v.User != victim {
t.Fatalf("setup: User = %q, want the fallback to resolve it to the victim %q", v.User, victim)
}
if v.Subject != "" {
t.Fatalf("setup: Subject = %q, want no subject on this token", v.Subject)
}
// And the lane refuses it outright rather than resolving it to that account.
cl, err := id.iam(context.Background(), attack)
if err == nil {
t.Fatalf("SECURITY: a token with no subject resolved to account %q — the victim's", cl.account)
}
if _, err := whoOn(t, id, attack, "", ""); err == nil {
t.Fatal("SECURITY: the seam admitted a subject-less token")
}
// The same token, now WITH its own subject, is a different person entirely and
// resolves to no account here — so the refusal above is about the missing
// subject, not about the token being unusable in general.
own := iam.Sign(t, iamtest.Claims{Sub: iamOtherSub, PreferredUsername: victim, Owner: "acme", Orgs: orgsOf("acme")})
if _, err := id.iam(context.Background(), own); err == nil {
t.Fatal("SECURITY: a subject with no account row resolved to one anyway")
}
// And the victim's own token still works, so the gate discriminates.
good := iam.Sign(t, homeIn("acme", iamSub))
cl, err = id.iam(context.Background(), good)
if err != nil || cl.account != victim {
t.Fatalf("the victim's own token resolved (%+v, %v)", cl, err)
}
}
// TestIAMLaneRefusesAnIDToken is the F4 regression. IAM's signer emits the same
// claim set into the access token and the id_token but for aud/tokenType/nonce, so
// signature and issuer cannot tell them apart — and the id_token is the one handed
// to a browser SPA to read. A session credential must be the access token.
func TestIAMLaneRefusesAnIDToken(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
idToken := iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), TokenType: "id-token"})
if _, err := id.iam(context.Background(), idToken); err == nil {
t.Fatal("SECURITY: an id_token was accepted as a team session credential")
}
if _, err := whoOn(t, id, idToken, "", ""); err == nil {
t.Fatal("SECURITY: the seam admitted an id_token")
}
// An access token is admitted, and so is a token minted before IAM emitted the
// claim at all — the one permissive branch, which must not sign existing users
// out at deploy.
for _, tt := range []string{"access-token", "-"} {
if _, err := id.iam(context.Background(), iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), TokenType: tt})); err != nil {
t.Fatalf("tokenType %q was refused: %v", tt, err)
}
}
}
// TestIAMLaneRefusesWhatDoesNotVerify proves every failure of the IAM lane is a
// refusal, not a downgrade to a partially-trusted caller: a forged signature, an
// expired token, a verified one with no tenant to scope to, and one naming no
// subject. Each is a real signed token, so each failure is the real code path.
func TestIAMLaneRefusesWhatDoesNotVerify(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
// A DIFFERENT issuer, publishing a key this validator never fetches. It reuses
// the same kid on purpose: the token names a key the validator does have, and
// still fails, so the refusal is the signature check and not a missing key.
other := iamtest.New(t)
bad := map[string]string{
"forged": other.Sign(t, homeIn("acme", iamSub)),
"expired": iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme", Orgs: orgsOf("acme"), Exp: time.Now().Add(-time.Hour)}),
"no home org": iam.Sign(t, iamtest.Claims{Sub: iamSub, Owner: "acme"}),
"no subject": iam.Sign(t, iamtest.Claims{Owner: "acme", Orgs: orgsOf("acme")}),
"garbage": "not.a.token",
}
for name, raw := range bad {
if _, err := id.iam(context.Background(), raw); err == nil {
t.Fatalf("iam(%s) admitted a caller it must refuse", name)
}
// And through the whole seam, with no HS256 credential to fall back to.
if _, err := whoOn(t, id, raw, "", ""); err == nil {
t.Fatalf("who(bearer=%s) admitted a caller it must refuse", name)
}
}
if _, err := whoOn(t, id, iam.Sign(t, homeIn("acme", iamSub)), "", ""); err != nil {
t.Fatalf("who(valid IAM bearer): %v", err)
}
}
// TestIAMLaneWorkspaceIsMembership proves the IAM lane grants a workspace ONLY
// from the rows: a member is admitted, a non-member and a foreign tenant's
// workspace are refused, and the two refusals are indistinguishable.
func TestIAMLaneWorkspaceIsMembership(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
member := accountID(iamSub)
stranger := accountID(iamOtherSub)
ws, err := store.EnsureWorkspace(ctx, "acme", member, "Ada")
if err != nil {
t.Fatal(err)
}
// A workspace in ANOTHER tenant, which the caller is a member of THERE.
other, err := store.EnsureWorkspace(ctx, "rival", member, "Ada")
if err != nil {
t.Fatal(err)
}
id, iam := identFor(t, store)
// The stranger holds an account in the SAME org (they logged in) but no row in
// this workspace — the case membership has to answer, not existence.
if _, err := store.EnsureWorkspace(ctx, "acme", stranger, "Bob"); err != nil {
t.Fatal(err)
}
memberCl, err := id.iam(ctx, iam.Sign(t, homeIn("acme", iamSub)))
if err != nil {
t.Fatal(err)
}
strangerCl, err := id.iam(ctx, iam.Sign(t, homeIn("acme", iamOtherSub)))
if err != nil {
t.Fatal(err)
}
if _, err := id.admit(ctx, memberCl, ws.UUID); err != nil {
t.Fatalf("admit(member, own workspace): %v", err)
}
if _, err := id.admit(ctx, strangerCl, ws.UUID); err == nil {
t.Fatalf("admit(non-member) was granted — membership is the authorization")
}
// Same person, same account row, a workspace they ARE a member of — but their
// token names tenant acme, so the rival-owned workspace is not theirs to open
// on this credential.
if _, err := id.admit(ctx, memberCl, other.UUID); err == nil {
t.Fatalf("admit crossed the tenant boundary into a foreign org's workspace")
}
if _, err := id.admit(ctx, memberCl, uuid.NewString()); err == nil {
t.Fatalf("admit granted a workspace that does not exist")
}
// A caller with no tenant names nothing to be a member of.
if _, err := id.admit(ctx, caller{account: member}, ws.UUID); err == nil {
t.Fatalf("admit granted a caller carrying no org")
}
if _, err := id.admit(ctx, caller{org: "acme"}, ws.UUID); err == nil {
t.Fatalf("admit granted a caller carrying no account")
}
if stranger == member {
t.Fatal("test setup: the two subjects must resolve to different accounts")
}
}
// TestHS256ArmIsUnchanged proves the fallback arm answers exactly what the
// pre-cutover decode answered, for the same fixtures, on both carriers and in the
// same precedence: bearer before cookie, an account claim required, expiry
// enforced, and the tenant + workspace read from the SIGNED claims.
func TestHS256ArmIsUnchanged(t *testing.T) {
const acct = "550e8400-e29b-41d4-a716-446655440000"
wsUUID := uuid.NewString()
session := hsToken(t, acct, "", "acme")
workspace := hsToken(t, acct, wsUUID, "acme")
id := testIdent(nil)
// Bearer.
cl, err := whoOn(t, id, workspace, "", "")
if err != nil {
t.Fatalf("who(hs256 bearer): %v", err)
}
if cl.iam {
t.Fatal("an HS256 token resolved on the IAM lane")
}
if cl.account != acct || cl.org != "acme" || cl.workspace != wsUUID || cl.raw != workspace {
t.Fatalf("hs256 bearer = %+v", cl)
}
// Cookie, and the bearer still wins over it — the pre-cutover precedence.
cl, err = whoOn(t, id, workspace, "", session)
if err != nil {
t.Fatalf("who(bearer + account cookie): %v", err)
}
if cl.workspace != wsUUID {
t.Fatal("the account cookie displaced the bearer")
}
cl, err = whoOn(t, id, "", "", session)
if err != nil {
t.Fatalf("who(account cookie): %v", err)
}
if cl.account != acct || cl.workspace != "" {
t.Fatalf("hs256 cookie = %+v", cl)
}
// No credential, a forged one, and one carrying no account are all refused.
if _, err := whoOn(t, id, "", "", ""); err == nil {
t.Fatal("who admitted a request carrying no credential")
}
if _, err := whoOn(t, id, session+"x", "", ""); err == nil {
t.Fatal("who admitted a token whose signature does not check out")
}
expired, err := token.Generate(acct, "", map[string]any{"org": "acme"}, 1, testSecret)
if err != nil {
t.Fatal(err)
}
if _, err := whoOn(t, id, expired, "", ""); err == nil {
t.Fatal("who admitted an expired token")
}
}
// TestLanePrecedence pins the rule the whole cutover rests on: THE EXISTING
// CREDENTIAL IS ANSWERED FIRST, on both carriers, so this phase changes nothing
// for a client that has one.
//
// The two credentials are NOT interchangeable — an HS256 workspace token pins a
// workspace and an IAM token cannot — so preferring the IAM cookie did not merely
// pick a different lane, it silently WIDENED the collaborator planes from "the
// workspace this token names" to "any workspace you are a member of", and made
// getWorkspaceInfo answer WorkspaceNotFound where the pin used to answer. A phase
// that is supposed to be inert cannot do that, so the order is: bearer alone if
// there is a bearer; then account-token; then hanzo_iam_token for the browser that
// holds nothing else, which is exactly the post-cutover client.
func TestLanePrecedence(t *testing.T) {
const acct = "550e8400-e29b-41d4-a716-446655440000"
wsUUID := uuid.NewString()
hs := hsToken(t, acct, wsUUID, "acme")
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "iam-org", iamSub, "Ada")
iamTok := iam.Sign(t, homeIn("iam-org", iamSub))
// BOTH cookies — the state every signed-in browser is in today. The HS256 one
// wins, and it keeps its workspace pin.
cl, err := whoOn(t, id, "", iamTok, hs)
if err != nil {
t.Fatalf("who(both cookies): %v", err)
}
if cl.iam {
t.Fatal("the IAM cookie displaced a live account-token cookie — the phase is not inert")
}
if cl.org != "acme" || cl.workspace != wsUUID {
t.Fatalf("both cookies resolved %+v, want the HS256 caller with its workspace pin", cl)
}
// A live HS256 bearer beside an IAM cookie stays on the HS256 arm too.
cl, err = whoOn(t, id, hs, iamTok, "")
if err != nil {
t.Fatalf("who(hs256 bearer + iam cookie): %v", err)
}
if cl.iam || cl.org != "acme" || cl.workspace != wsUUID {
t.Fatalf("hs256 bearer resolved %+v", cl)
}
// The IAM cookie alone — the post-cutover browser — resolves on the IAM lane.
cl, err = whoOn(t, id, "", iamTok, "")
if err != nil {
t.Fatalf("who(iam cookie only): %v", err)
}
if !cl.iam || cl.org != "iam-org" {
t.Fatalf("iam cookie alone resolved %+v, want the IAM lane", cl)
}
// An IAM bearer wins over the HS256 arm on the SAME carrier: a header that
// verifies as IAM is never re-read as HS256.
cl, err = whoOn(t, id, iamTok, "", hs)
if err != nil {
t.Fatalf("who(iam bearer): %v", err)
}
if !cl.iam {
t.Fatal("an IAM bearer was not read as IAM")
}
// A stale IAM cookie beside a live account-token is simply never reached.
cl, err = whoOn(t, id, "", "stale.iam.cookie", hs)
if err != nil {
t.Fatalf("who(stale iam cookie + account cookie): %v", err)
}
if cl.iam || cl.account != acct {
t.Fatalf("stale IAM cookie did not fall through: %+v", cl)
}
// And the converse: a STALE account-token answers alone rather than falling
// through to a live IAM cookie. Falling through would extend a session that used
// to end in a 401, with a different reach — the widening this order exists to
// prevent, arriving on a timer instead of on a deploy.
stale, err := token.Generate(acct, wsUUID, map[string]any{"org": "acme"}, 1, testSecret)
if err != nil {
t.Fatal(err)
}
if _, err := whoOn(t, id, "", iamTok, stale); err == nil {
t.Fatal("an expired account-token fell through to the IAM cookie — a session silently continued")
}
}
// TestIAMCredentialNeverReachesTheWire is the C1 regression.
//
// getLoginInfoByToken echoes the caller's token back to the SPA as its session
// token, and the SPA is page JS. On the IAM lane that credential is the raw
// estate-wide RS256 bearer out of an HttpOnly cookie — HttpOnly precisely so script
// cannot read it. Echoing it hands it back to the script the flag exists to stop:
// one RPC with no bearer at all, and the caller's whole platform credential is in a
// variable. caller.raw is therefore empty on that lane structurally, so no echo
// site can reintroduce the leak by forgetting.
func TestIAMCredentialNeverReachesTheWire(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
iamTok := iam.Sign(t, homeIn("acme", iamSub))
for name, carrier := range map[string][2]string{
"bearer": {iamTok, ""},
"cookie": {"", iamTok},
} {
cl, err := whoOn(t, id, carrier[0], carrier[1], "")
if err != nil {
t.Fatalf("%s: who: %v", name, err)
}
if cl.raw != "" {
t.Fatalf("SECURITY (%s): caller.raw carries the IAM credential, which every echo site returns to page JS", name)
}
if strings.Contains(cl.raw, iamTok) {
t.Fatalf("SECURITY (%s): the IAM credential leaked into the caller", name)
}
}
// The HS256 arm still echoes its own token — that one IS the SPA's session
// token, and the SPA is the party that presented it.
hs := hsToken(t, "550e8400-e29b-41d4-a716-446655440000", "", "acme")
cl, err := whoOn(t, id, hs, "", "")
if err != nil || cl.raw != hs {
t.Fatalf("the HS256 arm stopped echoing its own token: (%q, %v)", cl.raw, err)
}
}
// TestIAMLaneTenantIsTheHomeOrgNotOwner is the F4 regression.
//
// `owner` carries the APPLICATION's org, so it is chosen by whichever app the
// caller authenticated through — the identity boundary refuses to derive a tenant
// from it for exactly that reason (idClaims.homeOrg). A lane that reads it scopes
// every store query to an org the caller SELECTED: sign in through an app owned by
// "lux" and team files your workspaces, blobs and billing under lux.
func TestIAMLaneTenantIsTheHomeOrgNotOwner(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
home := enrolled(t, store, "hanzo", iamSub, "Ada")
// owner names one org, the SIGNED membership set names another as home.
crossed := iam.Sign(t, iamtest.Claims{
Sub: iamSub, Owner: "lux",
Orgs: []map[string]any{{"org": "hanzo", "role": "admin"}},
})
cl, err := id.iam(context.Background(), crossed)
if err != nil {
t.Fatalf("a token whose owner differs from its home org was refused outright: %v", err)
}
if cl.org != "hanzo" {
t.Fatalf("SECURITY: tenant = %q, want the home org \"hanzo\" — `owner` is caller-selectable", cl.org)
}
if cl.account != home {
t.Fatalf("account = %q, want the home-org account %q", cl.account, home)
}
// A token with NO membership set has no home, and that is a refusal rather than
// a fallback to owner — which is also every MACHINE credential (a
// client_credentials app or an API key is a member of nothing).
machine := iam.Sign(t, iamtest.Claims{Sub: "svc/robot", Owner: "hanzo"})
if _, err := id.iam(context.Background(), machine); err == nil {
t.Fatal("SECURITY: a token carrying no membership set was given a tenant")
}
}
// TestIAMLaneRefusesAForeignAudience is the C2 regression.
//
// The identity boundary deliberately does not gate audience: for an API call, a
// valid signature from a trusted issuer already proves IAM minted the token for one
// of its own apps, and cloud kept no mirror of IAM's registry. A SESSION is a
// different question — this lane turns a bearer into a signed-in person on
// hanzo.team, and a token the user obtained for chat or the console is not consent
// to that. Without the gate, one app's token is every app's session.
func TestIAMLaneRefusesAForeignAudience(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
for _, aud := range []string{"hanzo-chat", "hanzo-console", "hanzo-cloud", ""} {
c := homeIn("acme", iamSub)
c.Aud = aud
if aud == "" {
c.Aud = " " // an audience that names no app
}
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err == nil {
t.Fatalf("SECURITY: a token minted for %q was accepted as a team session", aud)
}
}
// Team's own audience is admitted, so the gate discriminates.
if _, err := id.iam(context.Background(), iam.Sign(t, homeIn("acme", iamSub))); err != nil {
t.Fatalf("team's own audience was refused: %v", err)
}
// And an operator-named additional SPA is admitted, because it was NAMED.
id.audience["hanzo-front"] = true
c := homeIn("acme", iamSub)
c.Aud = "hanzo-front"
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err != nil {
t.Fatalf("an explicitly named audience was refused: %v", err)
}
}
// TestTransactorTakesNothingAmbient pins the decision that the transactor socket
// has NO IAM lane in this phase.
//
// A WebSocket is exempt from CORS, so a cookie-borne credential would make the
// Origin header the only access control on the entire workspace data plane — one
// permissive entry in that allowlist, or one first-party page running attacker
// script, and the stream is readable and writable. The credential therefore stays
// the path-borne workspace token, which a foreign page cannot produce, until the
// client can send it in-band the way collabws.go already does.
func TestTransactorTakesNothingAmbient(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
member := accountID(iamSub)
ws, err := store.EnsureWorkspace(ctx, "acme", member, "Ada")
if err != nil {
t.Fatal(err)
}
id, iam := identFor(t, store)
srv := &transServer{ident: id}
// The workspace token is the credential, as it has always been.
wsTok := hsToken(t, member, ws.UUID, "acme")
cl, got, err := srv.admitWS(wsTok)
if err != nil || got != ws.UUID || cl.iam {
t.Fatalf("admitWS(workspace token) = (%+v, %q, %v)", cl, got, err)
}
// A bare workspace UUID authorizes NOTHING, whatever the caller holds elsewhere:
// it is not a credential, and there is no ambient lane to pair it with.
if _, _, err := srv.admitWS(ws.UUID); err == nil {
t.Fatal("SECURITY: a bare workspace uuid opened a socket")
}
// Nor does a valid IAM access token in that position — an estate-wide bearer
// does not belong in a URL, so it is simply not a workspace token.
if _, _, err := srv.admitWS(iam.Sign(t, homeIn("acme", iamSub))); err == nil {
t.Fatal("SECURITY: an IAM access token was accepted as a workspace token")
}
// A session token (no workspace claim) resolves but names no workspace, which is
// what lets the statistics read answer it with an empty session map while
// serveWS refuses it.
if _, got, err := srv.admitWS(hsToken(t, member, "", "acme")); err != nil || got != "" {
t.Fatalf("admitWS(session token) = (%q, %v)", got, err)
}
}
// TestOriginAllowlistHasNoWildcard pins the socket's Origin gate as a NAMED set.
//
// It used to admit any *.hanzo.ai host. Because a WebSocket is exempt from CORS,
// that check is the access control rather than a hint about it, so a wildcard over
// the registrable domain put every first-party host inside the workspace data
// plane's trust boundary.
func TestOriginAllowlistHasNoWildcard(t *testing.T) {
const host = "api.hanzo.ai"
for _, origin := range []string{
"https://chat.hanzo.ai", "https://preview.hanzo.ai", "https://anything.hanzo.ai",
"https://evil.com", "https://hanzo.ai.evil.com", "https://team.hanzo.ai.evil.com",
} {
if originAllowed(origin, host) {
t.Errorf("SECURITY: origin %q was admitted to the workspace socket", origin)
}
}
// The named team surfaces, the request's own host, and a non-browser client
// (which sends no Origin, and which a browser cannot imitate) still pass.
for _, origin := range []string{
"", "https://hanzo.team", "https://team.hanzo.ai", "https://api.hanzo.team",
"https://hanzo.ai", "http://localhost:3000", "https://" + host,
} {
if !originAllowed(origin, host) {
t.Errorf("origin %q was refused; the gate is not discriminating", origin)
}
}
}
// TestMemberPlaneOpScopesToTheCaller proves the membership answer a PEER process
// gets is scoped to the org on the CALL and to nothing the caller wrote: the same
// (workspace, subject) pair answers "member" for the owning tenant and "not a
// member" for any other, so a peer cannot probe a foreign roster one workspace at
// a time. It also proves the IAM subject → account join stays here, where the rows
// were created: the peer sends a subject and is told the account.
func TestMemberPlaneOpScopesToTheCaller(t *testing.T) {
store := openTestStore(t)
ctx := context.Background()
ws, err := store.EnsureWorkspace(ctx, "acme", accountID(iamSub), "Ada")
if err != nil {
t.Fatal(err)
}
in := &plane.MemberIn{Workspace: ws.UUID, Subject: iamSub}
got, err := memberOf(cloud.For(ctx, "acme"), store, in)
if err != nil {
t.Fatalf("memberOf(own org): %v", err)
}
if !got.Member || got.Role == "" {
t.Fatalf("memberOf(own org) = %+v, want a member with a role", got)
}
if got.Account != accountID(iamSub) {
t.Fatalf("account = %q, want accountID(subject) = %q", got.Account, accountID(iamSub))
}
// Another tenant asking about the SAME workspace uuid learns nothing.
got, err = memberOf(cloud.For(ctx, "rival"), store, in)
if err != nil {
t.Fatalf("memberOf(foreign org): %v", err)
}
if got.Member || got.Role != "" || got.Account != "" {
t.Fatalf("memberOf(foreign org) = %+v, want an empty answer", got)
}
// A stranger in the owning org is not a member either.
got, err = memberOf(cloud.For(ctx, "acme"), store, &plane.MemberIn{Workspace: ws.UUID, Subject: iamOtherSub})
if err != nil {
t.Fatalf("memberOf(stranger): %v", err)
}
if got.Member {
t.Fatal("memberOf admitted a subject with no row")
}
// A call carrying NO org is refused, not answered — a refusal is a fault an
// operator sees, a false negative is a join that silently stops working.
if _, err := memberOf(ctx, store, in); err == nil {
t.Fatal("memberOf answered a call carrying no org")
}
// And a store this process does not have open fails closed.
if _, err := memberOf(cloud.For(ctx, "acme"), nil, in); err == nil {
t.Fatal("memberOf answered with no store open")
}
}
// TestSessionAudienceIsNamedNotPatterned pins the SHAPE of the audience policy,
// which is the half a behavioural test cannot hold.
//
// The estate's boundary deliberately does not gate audience, and that posture was
// decided for an API door: a valid signature from a trusted issuer proves IAM
// minted the token for one of its own apps, and cloud kept no mirror of IAM's
// registry because the mirror drifted and 401'd every new first-party app. Team is
// a SESSION door and diverges — but the way that divergence rots is by growing back
// into the mirror, one pattern at a time ("any *-team app", "anything from our
// org"). So the set is enumerated: this deployment's own client id, plus entries an
// operator NAMED, and matching is exact.
func TestSessionAudienceIsNamedNotPatterned(t *testing.T) {
aud := sessionAudience(config{iamClientID: "hanzo-team"})
if len(aud) != 1 || !aud["hanzo-team"] {
t.Fatalf("default audience = %v, want exactly this deployment's own client id", aud)
}
t.Setenv("TEAM_IAM_AUDIENCES", "hanzo-front, hanzo-desktop ,,")
aud = sessionAudience(config{iamClientID: "hanzo-team"})
for _, want := range []string{"hanzo-team", "hanzo-front", "hanzo-desktop"} {
if !aud[want] {
t.Fatalf("audience %v is missing the named entry %q", aud, want)
}
}
if len(aud) != 3 {
t.Fatalf("audience = %v, want exactly the three named entries", aud)
}
// Matching is EXACT. Nothing here may admit an audience by resemblance — a
// prefix, a suffix, or a wildcard — because that is the registry mirror
// returning under another name.
id := &identity{audience: aud}
for _, foreign := range []string{
"hanzo-teamx", "xhanzo-team", "hanzo", "hanzo-team-staging",
"*", "", " ", "HANZO-TEAM",
} {
if id.forThisDeployment([]string{foreign}) {
t.Errorf("SECURITY: audience %q was admitted by resemblance", foreign)
}
}
if !id.forThisDeployment([]string{"other", "hanzo-team"}) {
t.Fatal("a token naming several audiences, one of them ours, was refused")
}
// A deployment with NO audience configured admits nothing, rather than
// everything: an empty allowlist is a closed door.
empty := &identity{audience: sessionAudience(config{})}
if empty.forThisDeployment([]string{"hanzo-team"}) {
t.Fatal("SECURITY: an unconfigured audience set admitted a token")
}
}
// TestAudienceIsMatchedExactlyNotFolded is the NEW-1 regression.
//
// The audience gate used to TrimSpace the incoming `aud` claim before looking it
// up. That made the comparison non-injective: "hanzo-team " and "hanzo-team" are
// DISTINCT IAM applications — IAM refuses only an exact name collision, so the
// padded one is registrable by anyone through /v1/iam/add-application — and
// trimming collapses them onto one key. An attacker registers the lookalike, signs
// their own users in through it, and IAM hands them tokens this door accepts as
// sessions of the real app.
//
// It is the estate's identifier rule, which OrgHasUnsafeRune states for orgs:
// trimming would collapse "acme " onto "acme", and an injective boundary must
// never fold two distinct identifiers into one. The claim is signed, so it is not
// ours to rewrite; whitespace is settled where the SET is built instead.
func TestAudienceIsMatchedExactlyNotFolded(t *testing.T) {
store := openTestStore(t)
id, iam := identFor(t, store)
enrolled(t, store, "acme", iamSub, "Ada")
// Every registrable lookalike that a fold would admit.
for _, lookalike := range []string{
"hanzo-team ", " hanzo-team", " hanzo-team ", "hanzo-team\t", "\nhanzo-team",
} {
if id.forThisDeployment([]string{lookalike}) {
t.Errorf("SECURITY: audience %q folded onto the real app's identifier", lookalike)
}
c := homeIn("acme", iamSub)
c.Aud = lookalike
if _, err := id.iam(context.Background(), iam.Sign(t, c)); err == nil {
t.Errorf("SECURITY: a token minted for the lookalike app %q was accepted as a team session", lookalike)
}
}
// The real audience is still accepted, so the gate discriminates rather than
// refusing everything.
if !id.forThisDeployment([]string{iamtest.Audience}) {
t.Fatal("the deployment's own audience was refused; the test is not discriminating")
}
if _, err := id.iam(context.Background(), iam.Sign(t, homeIn("acme", iamSub))); err != nil {
t.Fatalf("the deployment's own audience was refused: %v", err)
}
// And the tidying still happens where the SET is built: an operator's padded
// config entry is theirs to normalise, and it admits the UNPADDED app — never
// the other way round.
t.Setenv("TEAM_IAM_AUDIENCES", " hanzo-front ")
built := sessionAudience(config{iamClientID: " hanzo-team "})
if !built["hanzo-team"] || !built["hanzo-front"] {
t.Fatalf("sessionAudience did not trim its own config entries: %v", built)
}
if built[" hanzo-team "] || built["hanzo-team "] {
t.Fatalf("sessionAudience kept a padded key, which a padded claim would then match: %v", built)
}
}
+8 -11
View File
@@ -69,7 +69,7 @@ const collabPrefix = "/collaborator"
type collabService struct {
vfs types.VFSClient
accounts *accountStore
secret string
ident *identity
hub *collabHub
degraded bool
}
@@ -211,11 +211,11 @@ func (s *collabService) rpc(ctx context.Context, in *collabRequest) (*collabResu
if s.degraded {
return nil, unavailable()
}
t, err := tokenOf(ctx, s.secret)
cl, err := callerOf(ctx, s.ident)
if err != nil {
return nil, zip.ErrUnauthorized("invalid session token")
}
org := t.Org()
org := cl.org
if org == "" {
return nil, zip.ErrUnauthorized("invalid session token")
}
@@ -223,19 +223,16 @@ func (s *collabService) rpc(ctx context.Context, in *collabRequest) (*collabResu
if err != nil {
return nil, zip.ErrBadRequest("malformed documentId")
}
// The workspace token names its workspace — the documentId must agree. A
// session token (no workspace claim) falls through to the membership check.
if t.Workspace != "" && t.Workspace != doc.workspace {
// An HS256 workspace token names its workspace — the documentId must agree. A
// credential that names none (a session token, and every IAM caller) falls
// through to the membership check, which is the whole authorization there.
if cl.workspace != "" && cl.workspace != doc.workspace {
return nil, zip.ErrNotFound("document not found")
}
if s.accounts == nil {
return nil, zip.Errorf(http.StatusServiceUnavailable, "team: collaborator unavailable")
}
w, err := s.accounts.WorkspaceByUUID(ctx, org, doc.workspace)
if err != nil {
return nil, zip.ErrNotFound("document not found")
}
if _, ok := s.accounts.Membership(ctx, w.ID, t.Account); !ok {
if _, err := s.ident.admit(ctx, cl, doc.workspace); err != nil {
return nil, zip.ErrNotFound("document not found")
}
+10 -13
View File
@@ -47,7 +47,6 @@ import (
"github.com/zap-proto/zip/wsx"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/team/token"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/types"
)
@@ -563,8 +562,11 @@ func (cc *collabConn) frame(ctx context.Context, data []byte) {
}
}
// auth verifies the in-band token exactly like the RPC lane (same token, same
// workspace pin, same membership gate) and on success joins the room.
// auth verifies the in-band credential exactly like the RPC lane (same seam, same
// workspace pin, same membership gate) and on success joins the room. The frame
// carries the credential itself, so it resolves through identity.verified rather
// than off the request's carriers — an IAM access token or team's HS256 token, the
// same two lanes in the same order.
func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
if _, ok := cc.sessions[docName]; ok {
return // duplicate Auth for a live session — idempotent
@@ -583,12 +585,12 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("malformed auth")
return
}
t, err := token.Decode(raw, cc.svc.secret, true)
if err != nil || t.Account == "" {
cl, err := cc.svc.ident.verified(ctx, raw)
if err != nil {
deny("invalid session token")
return
}
org := t.Org()
org := cl.org
if org == "" {
deny("invalid session token")
return
@@ -598,7 +600,7 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("malformed documentId")
return
}
if t.Workspace != "" && t.Workspace != doc.workspace {
if cl.workspace != "" && cl.workspace != doc.workspace {
deny("document not found")
return
}
@@ -606,12 +608,7 @@ func (cc *collabConn) auth(ctx context.Context, docName string, r *lreader) {
deny("collaborator unavailable")
return
}
w, err := cc.svc.accounts.WorkspaceByUUID(ctx, org, doc.workspace)
if err != nil {
deny("document not found")
return
}
if _, ok := cc.svc.accounts.Membership(ctx, w.ID, t.Account); !ok {
if _, err := cc.svc.ident.admit(ctx, cl, doc.workspace); err != nil {
deny("document not found")
return
}
+1 -1
View File
@@ -74,7 +74,7 @@ func collabHarness(t *testing.T) (svc *collabService, docName, memberTok string)
if err != nil {
t.Fatal(err)
}
svc = &collabService{vfs: vfs, accounts: mounted.State.accounts, secret: testSecret, hub: newCollabHub(vfs)}
svc = &collabService{vfs: vfs, accounts: mounted.State.accounts, ident: testIdent(mounted.State.accounts), hub: newCollabHub(vfs)}
docName = ws.UUID + "|document:class:Document|doc-1|content"
return svc, docName, tok
}
+1
View File
@@ -58,6 +58,7 @@ func gateApp(t *testing.T, commerce types.CommerceClient, planEnt func(context.C
t.Cleanup(func() { _ = store.Close() })
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret},
log: luxlog.New("test"),
commerce: commerce,
+20 -33
View File
@@ -91,8 +91,7 @@ const maxBlobSize = 100 << 20
// op cannot be wrapped by Mount's guard, so it asks for itself — see typed.go.
type filesService struct {
vfs types.VFSClient
accounts *accountStore
secret string
ident *identity
degraded bool
}
@@ -116,32 +115,20 @@ func (s *filesService) register(app cloud.Router, guard guardFn) {
zip.Delete(g, "/files/:workspace/:filename", s.deleteBlob, zip.WithStatus(http.StatusNoContent))
}
// principal resolves (account, org) from the request's VERIFIED session or
// workspace token (bearer or the HttpOnly account cookie) — the shared
// orgPrincipal resolution (billing.go).
func (s *filesService) principal(c *zip.Ctx) (account, org string, err error) {
return orgPrincipal(c, s.secret)
}
// authorize asserts :workspace belongs to org AND the caller is a MEMBER of it
// (Red F-C: bind files to workspace membership, not just same-org). Any failure is
// a 404 — no oracle distinguishing "no such workspace", "not your org", or "not a
// member". It takes the CONTEXT rather than the request because it needs nothing
// else off the wire, which is what lets the typed delete and the untyped
// upload/download share the one gate.
func (s *filesService) authorize(ctx context.Context, account, org, wsUUID string) error {
wsUUID = strings.TrimSpace(wsUUID)
if wsUUID == "" {
// authorize asserts :workspace belongs to the caller's org AND the caller is a
// MEMBER of it (Red F-C: bind files to workspace membership, not just same-org) —
// identity.admit, the one membership gate. Any failure is a 404: no oracle
// distinguishing "no such workspace", "not your org", or "not a member". It takes
// the CONTEXT rather than the request because it needs nothing else off the wire,
// which is what lets the typed delete and the untyped upload/download share it.
func (s *filesService) authorize(ctx context.Context, cl caller, wsUUID string) error {
if strings.TrimSpace(wsUUID) == "" {
return zip.ErrBadRequest("workspace required")
}
if s.accounts == nil {
if s.ident == nil || s.ident.accounts == nil {
return zip.Errorf(http.StatusServiceUnavailable, "team: file storage unavailable")
}
w, err := s.accounts.WorkspaceByUUID(ctx, org, wsUUID)
if err != nil {
return zip.ErrNotFound("workspace not found")
}
if _, ok := s.accounts.Membership(ctx, w.ID, account); !ok {
if _, err := s.ident.admit(ctx, cl, wsUUID); err != nil {
return zip.ErrNotFound("workspace not found")
}
return nil
@@ -152,12 +139,12 @@ func (s *filesService) authorize(ctx context.Context, account, org, wsUUID strin
// (front.ts: formData.append('file', file, uuid)). Response body is irrelevant
// (uploadFile discards it); we echo the id for curl/debug.
func (s *filesService) upload(c *zip.Ctx) error {
account, org, err := s.principal(c)
cl, err := s.ident.who(c)
if err != nil {
return zip.ErrUnauthorized("invalid session token")
}
ws := c.Param("workspace")
if err := s.authorize(c.Context(), account, org, ws); err != nil {
if err := s.authorize(c.Context(), cl, ws); err != nil {
return err
}
fh, err := c.Fiber().FormFile("file")
@@ -189,7 +176,7 @@ func (s *filesService) upload(c *zip.Ctx) error {
if len(data) == 0 {
return zip.ErrBadRequest("empty upload")
}
if err := s.vfs.Put(c.Context(), blobKey(org, ws, blobID), data); err != nil {
if err := s.vfs.Put(c.Context(), blobKey(cl.org, ws, blobID), data); err != nil {
// deps.VFS is DisabledVFS (fail-closed) unless the operator wires a real VFS
// backend — an honest 502, never a silent success.
return zip.Errorf(http.StatusBadGateway, "file storage unavailable")
@@ -203,19 +190,19 @@ func (s *filesService) upload(c *zip.Ctx) error {
// image/svg+xml → active XSS). Anything not a recognized raster image is served
// inert: application/octet-stream + attachment + nosniff.
func (s *filesService) download(c *zip.Ctx) error {
account, org, err := s.principal(c)
cl, err := s.ident.who(c)
if err != nil {
return zip.ErrUnauthorized("invalid session token")
}
ws := c.Param("workspace")
if err := s.authorize(c.Context(), account, org, ws); err != nil {
if err := s.authorize(c.Context(), cl, ws); err != nil {
return err
}
blobID := strings.TrimSpace(c.Query("file"))
if blobID == "" {
return zip.ErrBadRequest("file (blob id) required")
}
data, err := s.vfs.Get(c.Context(), blobKey(org, ws, blobID))
data, err := s.vfs.Get(c.Context(), blobKey(cl.org, ws, blobID))
switch {
case errors.Is(err, types.ErrBlobNotFound), err == nil && data == nil:
// Genuine miss (working backend). A cross-org/-workspace blobId is a DIFFERENT
@@ -270,12 +257,12 @@ func (s *filesService) deleteBlob(ctx context.Context, in *blobRef) (*none, erro
if s.degraded {
return nil, unavailable()
}
account, org, err := sessionOf(ctx, s.secret)
cl, err := callerOf(ctx, s.ident)
if err != nil {
return nil, zip.ErrUnauthorized("invalid session token")
}
ws := in.Workspace
if err := s.authorize(ctx, account, org, ws); err != nil {
if err := s.authorize(ctx, cl, ws); err != nil {
return nil, err
}
// deleteFile calls getFileUrl(ws, file) with no filename → path segment == the
@@ -289,7 +276,7 @@ func (s *filesService) deleteBlob(ctx context.Context, in *blobRef) (*none, erro
// deleting never confirms existence and a foreign blobId is a harmless no-op.
// But a backend that is unavailable/disabled (any OTHER error) fails CLOSED with
// 502 — never a silent success lie, never a nil-deref 500.
if err := s.vfs.Delete(ctx, blobKey(org, ws, blobID)); err != nil && !errors.Is(err, types.ErrBlobNotFound) {
if err := s.vfs.Delete(ctx, blobKey(cl.org, ws, blobID)); err != nil && !errors.Is(err, types.ErrBlobNotFound) {
return nil, zip.Errorf(http.StatusBadGateway, "file storage unavailable")
}
return nil, nil
+1 -1
View File
@@ -534,7 +534,7 @@ func TestCallbackVerifiesOwner(t *testing.T) {
accounts: store,
cfg: config{serverSecret: testSecret, iamEndpoint: iam.URL, iamClientID: "hanzo-team", provider: "openid"},
log: luxlog.New("test"),
verify: verify,
ident: &identity{verify: verify, secret: testSecret, accounts: store},
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
g.register(app, func(h zip.Handler) zip.Handler { return h })
+10 -11
View File
@@ -260,22 +260,21 @@ func (g *api) sendInvite(c *zip.Ctx, params map[string]any) error {
}
// getMemberships is the account RPC "getMemberships" — the mid-session membership
// refresh. It reads the caller's LIVE org set from IAM (via the extra.user id the
// session token carries) so a user invited into a new org mid-session sees it
// without re-logging-in. On any IAM error, or a legacy token without extra.user,
// it falls back to the session's own signed orgs set — the refresh is best-effort
// and never strands the user.
// refresh. It reads the caller's LIVE org set from IAM (via the caller's
// `<owner>/<name>` id) so a user invited into a new org mid-session sees it without
// re-logging-in. On any IAM error, or a credential that names no username, it falls
// back to the caller's own verified org set — the refresh is best-effort and never
// strands the user.
func (g *api) getMemberships(c *zip.Ctx) error {
t, _, err := sessionToken(c, g.cfg.serverSecret)
cl, err := g.ident.who(c)
if err != nil {
return g.fail(c, statusUnauthorized(err.Error()))
}
session := orgsFromExtra(t.Extra)
user, _ := t.Extra["user"].(string)
if user == "" {
return g.ok(c, session) // legacy token: no IAM id to refresh against
session := cl.orgs
if cl.user == "" {
return g.ok(c, session) // no IAM id to refresh against
}
live, err := g.iamGetMemberships(c.Context(), user)
live, err := g.iamGetMemberships(c.Context(), cl.user)
if err != nil || len(live) == 0 {
if err != nil {
g.log.Warn("team: getMemberships — IAM refresh failed, serving session set", "err", err)
+2
View File
@@ -89,6 +89,7 @@ func TestSendInviteGuestOverCapObserved(t *testing.T) {
}
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret", provider: "openid"},
log: luxlog.New("test"),
commerce: commerce,
@@ -149,6 +150,7 @@ func TestSendInviteGuestInfraErrorAdmits(t *testing.T) {
commerce := &fakeCommerce{err: fmt.Errorf("commerce not co-resident")}
g := &api{
accounts: store,
ident: testIdent(store),
cfg: config{serverSecret: testSecret, iamEndpoint: iamSrv.URL, iamClientID: "hanzo-team", iamClientSecret: "team-secret", provider: "openid"},
log: luxlog.New("test"),
commerce: commerce,
+87
View File
@@ -0,0 +1,87 @@
package team
// The workspace membership read, published on the internal plane.
//
// The workspaces/members tables have one writer and it is this process. A peer
// that must decide something about a workspace — meet, deciding whether a caller
// may join a room — used to read that decision off a signed workspace claim,
// which is the second bearer authority the estate is retiring. Once the caller
// arrives with an IAM identity and no workspace claim, the rows are the only
// place the answer exists, and they live here.
//
// The projection is deliberately narrow: whether there is a row, and the role on
// it. A peer deciding a join needs exactly that; handing over the member record
// would put a workspace's roster on the wire for one boolean.
import (
"context"
"github.com/zap-proto/zip"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// exposeMember publishes the membership read. Mount calls it.
func exposeMember(accounts *accountStore) {
zip.Post[plane.MemberIn, plane.Member](cloud.Plane(), "/team/member",
func(ctx context.Context, in *plane.MemberIn) (*plane.Member, error) {
return memberOf(ctx, accounts, in)
},
zip.WithOperationID(plane.TeamMember),
zip.WithSummary("This person's role in that workspace"))
}
// memberOf answers whether the named account holds a member row in the named
// workspace of the CALLER'S OWN org, and with what role.
//
// The org is taken from the CALL rather than from the argument. That is not a
// guarantee about the peer — a peer states its own caller (cloud.For / cloud.As),
// so a compromised or buggy one can name any org, and this op is only ever as
// tenant-safe as the process asking. It is the internal plane: peers are trusted,
// the socket is a UDS on the same node, and there is no authority here a peer could
// not also get by asking for the org it wanted. What taking it off the call DOES
// buy is that the org travels with the identity the asking process authenticated,
// so a peer cannot answer one caller's question with another caller's tenant by
// mistake — the failure mode that a workspace-plus-org argument invites.
//
// A call carrying no org is refused, not answered with "not a member": a refusal is
// a fault the operator can see, while a false negative is a join that silently
// stops working.
//
// It fails closed on a store that is not open: this process owns the store, so a
// nil handle is a boot-order fault, and "not a member" would read as a real
// answer about a workspace nobody could check.
func memberOf(ctx context.Context, accounts *accountStore, in *plane.MemberIn) (*plane.Member, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrUnauthorized("team: no org on the call")
}
if accounts == nil {
return nil, zip.Errorf(503, "team: account store not open in the process that owns it")
}
if in.Workspace == "" || in.Subject == "" {
return nil, zip.ErrBadRequest("team: workspace and subject are required")
}
// The subject → account resolution is THIS package's, and it is the STORE's:
// AccountForSubject is the same function the request lane uses, so a peer and a
// browser resolve one identity to one account or the peer is told there is none.
// A peer that derived its own would be a second derivation of one address — and
// it would have to reproduce the subject-only rule that keeps a token with no
// `sub` from resolving to whoever its username names.
account, ok := accounts.AccountForSubject(ctx, org, in.Subject)
if !ok {
return &plane.Member{}, nil
}
w, err := accounts.WorkspaceByUUID(ctx, org, in.Workspace)
if err != nil {
// Not this tenant's workspace, or none at all — the same answer either way,
// so a probe learns nothing about what exists in another org.
return &plane.Member{}, nil
}
role, ok := accounts.Membership(ctx, w.ID, account)
if !ok {
return &plane.Member{}, nil
}
return &plane.Member{Member: true, Role: role, Account: account}, nil
}

Some files were not shown because too many files have changed in this diff Show More