Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87012d765 | ||
|
|
11a6ce27a4 | ||
|
|
74b743065b | ||
|
|
5baf8ca104 | ||
|
|
a8b5bd5337 | ||
|
|
d0e8867117 | ||
|
|
8b89c877b7 | ||
|
|
ab7c3d0e6b | ||
|
|
a94b25870a | ||
|
|
266e578e83 | ||
|
|
49b3651596 | ||
|
|
a6d2d94665 | ||
|
|
c8b7c310da | ||
|
|
4c1686e9e1 | ||
|
|
12ae6f0dd9 | ||
|
|
a7b3ccacfa | ||
|
|
5a368bd261 | ||
|
|
8bdf63c6b9 | ||
|
|
29ac3d1a96 | ||
|
|
05c82f45c0 | ||
|
|
a284ccf282 | ||
|
|
b13196c728 | ||
|
|
b42e7bf0ec | ||
|
|
d79b53d396 | ||
|
|
b474e5d71d | ||
|
|
07ff50327a | ||
|
|
058e434ac6 | ||
|
|
2f322c4659 | ||
|
|
7d12120120 | ||
|
|
dc84b46df5 | ||
|
|
a8b952f473 | ||
|
|
b180a086ff | ||
|
|
633b692511 | ||
|
|
caae99ef8a | ||
|
|
a6b174e4e4 | ||
|
|
e8c34bfa31 |
+93
-17
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user