a pk- key authenticates nobody, so stop telling readers it reads

The README offered the two key shapes as a permission split — pk- to read,
sk- to write. cloud's own identity boundary says otherwise: middleware_identity
returns nil for any pk- before it consults the key store, because a key meant
for a browser bundle must not read. A pk- resolves to an org so a beacon can be
attributed to a tenant, and that is all it does.

Following the old sentence, a reader exports a pk- and every one of the five
flows answers 403 — /v1/tools spells out why, "a validated principal is
required" — and that refusal reads like a revoked key rather than the wrong
shape of key. examples/client.py now refuses a pk- before the request goes out,
the same argument that already rejects an unsendable access_token.

Also: three status codes in example docstrings had drifted from what the API
answers now (/v1/kv/keys/* 404s on every verb, /v1/automations/mcp 404s), and
two dead files went with them — examples/.keep, whose directory has held real
flows for a while, and scripts/mock, which reads a .stats.yml this repo does
not have and which scripts/test already says is unnecessary.
This commit is contained in:
2026-08-14 14:45:57 -07:00
parent 241ca6dc59
commit 7a5968f9dc
9 changed files with 46 additions and 63 deletions
+17
View File
@@ -134,6 +134,23 @@ short-term shape, but the real fix belongs upstream: when cloud's emission
declares a bearer scheme, `access_token` starts working, and the header should
move back into `Configuration` in one edit. Until then, one place sets it.
## A `pk-` is not a read key
`cloud.APIKeyPrefixes` is `{"pk-", "sk-"}`, and it is tempting to read that pair
as a permission split — publishable for reads, secret for writes. It is not one.
`middleware_identity.go` returns nil for **any** `pk-` before it ever consults the
key store: "a key meant for a browser bundle must not read … resolvable, not
authenticating". A `pk-` resolves to an org so an analytics beacon can be
attributed to a tenant, and that is the whole of it.
So every route these examples use refuses a valid `pk-` exactly as it refuses no
key at all — `/v1/tools` says why in as many words, `"a validated principal is
required"` — and the 403 that comes back reads like a revoked key rather than the
wrong shape of key. `examples/client.py` rejects a `pk-` up front for that reason,
which is the same argument as the `access_token` note above: an unsendable
credential and an unauthenticating one produce the same misleading refusal, so
both are caught before the request goes out.
**Two spec defects were found and fixed upstream while regenerating at 3.1.5.** Neither was
patched here; both are in `hanzoai/openapi` main:
- `fc0c17a` — 35 `/v1/platform` operations carried no `responses`. OAS 3.x requires it and
+5 -2
View File
@@ -50,8 +50,11 @@ with client as api:
Every route follows that shape: one `*Api` class per tag, one method per
operation, typed models in and out. Keys come from
[cloud.hanzo.ai](https://cloud.hanzo.ai) or `hanzo login`, in two shapes —
`pk-` to read, `sk-` to write.
[cloud.hanzo.ai](https://cloud.hanzo.ai) or `hanzo login` in two shapes, and only
one of them works here. Use an `sk-`: it carries a principal, which every call
above needs. A `pk-` is publishable — it is safe in a browser bundle precisely
because it names an org and authenticates nobody, so cloud refuses it at the
identity boundary and it reads nothing.
## Auth
-4
View File
@@ -1,4 +0,0 @@
File generated from our OpenAPI spec by Stainless.
This directory can be used to store example files demonstrating usage of this SDK.
It is ignored by Stainless code generation and its content (other than this keep file) won't be touched.
+11 -2
View File
@@ -24,15 +24,24 @@ MODEL = os.environ.get("HANZO_MODEL", "zen4")
def api_key() -> str:
"""Fail loudly and early when the key is absent.
"""Fail loudly and early when the key is absent or of the wrong shape.
Without this the SDK sends an unauthenticated request and the flow dies on a
401 several frames deep, which reads like an API bug rather than an unset
shell variable.
A ``pk-`` is rejected here for the same reason. It is the PUBLISHABLE shape:
cloud resolves it to an org so a browser beacon can be attributed, but the
identity boundary refuses it outright, so it never becomes a principal. Every
route below wants one — ``/v1/tools`` says so in as many words, "a validated
principal is required" — so a ``pk-`` collects the same 403 as no key at all,
and that 403 reads like a revoked key rather than the wrong shape of key.
"""
key = os.environ.get("HANZO_API_KEY")
if not key:
raise SystemExit("HANZO_API_KEY is not set — export an IAM JWT or a pk-/sk- cloud key")
raise SystemExit("HANZO_API_KEY is not set — export an sk- cloud key or an IAM JWT")
if key.startswith("pk-"):
raise SystemExit("HANZO_API_KEY is a pk- (publishable) key, which authenticates nobody — use an sk-")
return key
+3 -3
View File
@@ -5,10 +5,10 @@
DELETE /v1/kv/{name} delete_kv_by_name tear down
This is the PROVISIONING plane. The per-key data plane the spec also describes
(``/v1/kv/keys/{key}``) is not mounted anywhere — GET 404s and PUT/DELETE 405 at
(``/v1/kv/keys/{key}``) is not mounted anywhere — every verb 404s at
api.hanzo.ai, and kv.hanzo.ai 404s the whole prefix — so a set/get/delete
round-trip on keys would be an example that cannot run. These three all answer
403 unauthenticated, i.e. routed and gated.
round-trip on keys would be an example that cannot run. These three answer 403
"a validated principal is required" unauthenticated, i.e. routed and gated.
The delete runs in ``finally``, so a failed read still tears the store down
rather than leaving it billable for the next run to collide with.
+6 -7
View File
@@ -4,13 +4,12 @@
MCP surface: each entry is a tool name, its description and its input schema.
A note on the MCP door, because it is easy to pick the wrong one. There is a
live JSON-RPC endpoint at ``POST /v1/mcp`` that answers ``tools/list`` with the
same catalog (730 tools at the time of writing) — but it is NOT in hanzo.yaml,
so the generator emits no method for it and an example would have to bypass the
SDK to call it, which defeats the point of an SDK example. Of the MCP routes
that ARE declared, ``/v1/automations/mcp`` returns 405 at api.hanzo.ai. So this
catalog read is the one that is both generated and served. When ``/v1/mcp`` is
added to the spec, this flow should move to it.
JSON-RPC endpoint at ``POST /v1/mcp`` that answers ``tools/list`` with the same
catalog — but it is NOT in the document, so the generator emits no method for it
and an example would have to bypass the SDK to call it, which defeats the point
of an SDK example. Of the MCP routes that ARE declared, ``/v1/automations/mcp``
404s at api.hanzo.ai. So this catalog read is the one that is both generated and
served. When ``/v1/mcp`` is added to the spec, this flow should move to it.
python -m examples.tools
"""
@@ -27,7 +27,7 @@ from __future__ import annotations
import re
from typing import Any, Dict, List, Final, Tuple, Union
from functools import lru_cache # noqa: TID251 - not using Stainless SDK
from functools import lru_cache # noqa: TID251 - the typed replacement is hanzoai-only
# Type aliases with full annotations
ASTDict = Dict[str, Any]
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
if [[ -n "$1" && "$1" != '--'* ]]; then
URL="$1"
shift
else
URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)"
fi
# Check if the URL is empty
if [ -z "$URL" ]; then
echo "Error: No OpenAPI spec path/url provided or found in .stats.yml"
exit 1
fi
echo "==> Starting mock server with URL ${URL}"
# Run prism mock on the given spec
if [ "$1" == "--daemon" ]; then
npm exec --package=@stainless-api/[email protected] -- prism mock "$URL" &> .prism.log &
# Wait for server to come online
echo -n "Waiting for server"
while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
echo -n "."
sleep 0.1
done
if grep -q "✖ fatal" ".prism.log"; then
cat .prism.log
exit 1
fi
echo
else
npm exec --package=@stainless-api/[email protected] -- prism mock "$URL"
fi
+3 -3
View File
@@ -8,9 +8,9 @@ Covers two guarantees:
`hanzo_zap.CloudClient.call(...)` — asserting the derived method, forwarded auth,
and body — using a mock CloudClient, so no network and no `hanzo-zap` install.
This file used to lock the STAINLESS surface — `Hanzo`, `AsyncHanzo`, `Client`,
`Stream`, `HanzoError`, … — and import `Hanzo` at module scope. Every one of those
18 names was deleted by 648e7354 ("retire Stainless"), which replaced
This file used to lock the hand-written surface that preceded generation —
`Hanzo`, `AsyncHanzo`, `Client`, `Stream`, `HanzoError`, … — and import `Hanzo` at
module scope. All 18 of those names went away at 648e7354, which replaced
`pkg/hanzoai` with openapi-generator output whose entry point is `ApiClient` +
`Configuration`. The import therefore raised at COLLECTION, so pytest reported an
error and ran none of the transport tests below — which is how the whole suite