A module is a named import document, and installing one is a grow:
core.GrowCollections creates what is missing and adds the fields, select
values and indexes an existing collection lacks, in one transaction,
changing nothing that exists. A relation names its collection by name.
Installing twice changes nothing.
modules/crm carries the thirteen collections: people, companies,
opportunities, candidates, applications, lists, list_members, segments,
activities (one row per source id), notes, tasks, attachments and the
org's shared views. POST /v1/modules/{name} installs, GET /v1/modules
lists, both for a superuser or the org's admin, who can never reach a
collection only a superuser manages this way.
The _view* system collections are retired. Nothing read them, and team
views are the ordinary views collection now.
zip's registration is a verb method on the group now, so an operation reads as
the group answering it rather than a function taking the group, and a route that
is not a typed operation says which method it answers with. The package-level
form is gone, along with the Router interface it existed to accept any target
through.
Co-authored-by: Hanzo Dev <[email protected]>
Base is not the only thing that runs plugins. Cloud grew a second goja runtime
— its own shim, its own bundler, its own registry — because the loader and the
runners lived here and there was nothing to import. hanzoai/plugin is that
something, and it implements zip's Module and Loader rather than the private
copies of them that sat beside these runners.
extruntime, gojavm, pyvm and wasmvm move out. jsvm stays, because it is base's
own app scripting rather than a plugin runtime — the two were only ever adjacent.
Their one tie to base was a single call for an unguessable string, and
crypto/rand says that without a dependency.
Co-authored-by: Hanzo Dev <[email protected]>
The base binary passes IAM_ORGANIZATION as IAMOrg, and plugins/org
reads base/MASTER_KEY_B64 beneath it at Register: 32 bytes, base64.
With KMS configured, Register fails when no org is set, when KMS does
not answer, or when the key is not 32 bytes. No key stored leaves org
Bases unencrypted and logs it; a 404 from the KMS HTTP door reads as a
missing secret.
An encrypted Base needs SQLite to encrypt pages as it writes them, which
only a linked SQLCipher codec does, so Register refuses a master key on a
build without one. Under a key, the connector refuses an org Base file
that is plaintext.
Co-authored-by: Hanzo Dev <[email protected]>
The README quickstart, docs/versions.md, hooks.md, rules.md, config.md
and listeners.md, and examples/hooks-hybrid-search, each command run
against the binary built from examples/base. LLM.md is cut to a current
map with test commands and known problems; threat-model, NETWORK,
SECURITY and CONTRIBUTING are corrected to the code.
Co-authored-by: Hanzo Dev <[email protected]>
There were two ways to hold one thing. PRINCIPAL_ENCRYPTION_KEY carried a
32-byte master key through the environment into a Secret beside the pod, and
KMSEndpoint named a KMS that was supposed to hold it. Only the first worked,
because the client spoke ZAP and refused an http endpoint at construction —
and no KMS in this estate serves ZAP. So the deployment set
KMS_URL=http://kms.hanzo.svc, Register returned that refusal, and the env key
was the only path left. A field named Deprecated sat beside it, read through a
two-branch accessor, which is a third spelling of the same value.
Now: one key, one coordinate, one credential.
- httpSecrets implements the existing secrets interface over /v1/kms. The
transport is a detail behind that interface, which is why adding one is not
adding a second way to read a secret.
- It authenticates as an IAM application using the client credentials this
plugin already holds to talk to IAM. One identity per Base, not two.
- The org leads the path on the ZAP side because that segment is the base
boundary. Over HTTP the org comes from the credential and another tenant's
secret is unnameable, so the prefix is dropped rather than composed twice.
- PrincipalEncryptionKey, its Deprecated twin and principalKey() are gone. The
master key is read at Register from base/MASTER_KEY_B64 under the org.
- Absent stays non-fatal: no KMS, or no key stored, runs with encryption off
exactly as an unset variable did.
Also dropped the default endpoint zap.kms.svc.cluster.local:9999. Nothing
answers there, so it turned "this deployment has no KMS" — which callers handle
— into a transport error on every read. Unset now means unset.
The HTTP-rejection test asserted the belief that caused this; it now asserts
what is true, that the door needs a credential. Added a coordinate test for the
org-segment drop.
Co-authored-by: Hanzo Dev <[email protected]>
Every address the plugin publishes hands out, or acts on, the connection
string that opens a database, and that string carries the PostgreSQL
password the plugin holds. Register now returns on a Config that is not
Enabled, so /v1/cloud-sql and /v1/meta are addresses an unconfigured
process does not answer at rather than addresses it refuses at.
Opt in with CLOUD_SQL_ENABLED=true, the way bootnode and calendar opt in:
an Enabled field, a ConfigFromEnv that reads it, and a Register that
returns early without it.
The admin served 22 routes and none was functions, so a Base kept and
served functions nobody could see. /functions is now a page: list,
author, edit, delete, and run one against a JSON payload. It reads
/v1/functions, which is the record path, so the collection's own rules
decide who may write one and who may call one.
The binary could not run them either. apis/functions.go looks a runtime
up by language at call time and examples/base/main.go linked none, so
every call answered "No runtime is linked for js functions" while
apis/functions_test.go passed — that file blank-imports plugins/gojavm
and the shipped binary did not. Measured on the built binary: before,
invoke 500s; after, handler returns its value and base.list reads rows
as its caller. TestBinaryRunsFunctions asks the same question where the
binary is assembled, so a dropped import fails in CI rather than in
production.
The page says what the server said. A function that throws reads as the
500 it is, and base.start answers that this deployment has nowhere to
run work — which is true: the Sandboxes seam is defined and nothing
installs one.
49b2d5633 committed apis/functions.go reading Store().Get(StoreKeySandboxes)
and the const it names never landed, so main did not build: the definition and
the plugins/functions retirement were sitting uncommitted beside it. examples
still imported the retired package for the same reason.
This is that half, unchanged from where it was written — the const, the example's
import, and the deletion that was already staged. Nothing about the sandbox
design is decided here; a commit that cannot compile is decided against by the
compiler.
Minting a token has to address the brand, because IAM stamps the issuer from
the host it was asked on and a relying party that discovered through one brand
refuses a token issued by another. Reading answers about the credential
presented and does not depend on which brand was addressed.
They were one value, so a deployment could only have the brand — every read
left the cluster, crossed the public edge and came back to a pod one hop away,
and naming the pod instead broke minting.
They are two now. The brand stays where it was; the service is named when it
answers somewhere else, and reads take it. A bare address is ZAP.
Co-authored-by: Hanzo Dev <[email protected]>
The org plugin is what scopes a Base per organization, and an org is resolved
from identity. So it registers when an IAM endpoint is configured — the same
value the plugin itself requires, rather than a switch beside it. Where no
identity provider is configured there is nothing for a Base to be scoped to,
and this binary serves ONE Base needing no upstream at all.
`make dev` is that path: go run, loopback, ./pb_data. One command to a running
Base for the studio and the /rest/v1 clients to point at.
Multi-tenant deployments are unchanged: they set IAM_ENDPOINT (or IAM_URL)
already, so the plugin registers exactly as before, and cloud links this
package rather than this binary.
Co-authored-by: Hanzo Dev <[email protected]>
Every read landed on the shared platform Base whatever org was named. The
per-org files existed and were written by nothing.
Three things were wrong, stacked. The org was taken from the `owner` claim,
which IAM stamps with the org of the APPLICATION a token was minted through, so
a token carrying alpha resolved to whichever org owned the sign-in app. It then
travelled over X-Org-Id, a header the client also sets and this overwrote. And
whichever org it landed on, the resolver opened a dbx pool, set it on the
request as "orgDB", and nothing anywhere read that key — every handler kept
using e.App. Provisioning ran, directories appeared, and the request went to
data.db regardless.
The org is now resolved once, in loadAuthToken, from the membership set
authz.Claims carries, and RequestEvent.App is pointed at that org's Base before
any handler runs. An org's Base is a Base: {DataDir}/orgs/{org}/data.db,
bootstrapped on first use, with its own collections and settings. apis.Bases is
the one lookup from an org to its Base; plugins/org registers it, and a
deployment that registers none has exactly one Base for everybody.
X-Org-Id and the rest of the identity headers are deleted on ingress, because
inbound they are a client asserting an identity. The header is read once first,
as the org a request says it means, and honored only where the token already
carries it — naming any other org answers 403 rather than quietly serving the
caller's own, which reads as an empty Base and is a different, false statement.
The reserved admin org's Base is the process's own, since _superusers, schema,
settings, backups and logs are process-wide. A token with no membership at all
is a machine and is refused rather than handed that Base: its org could only
come from `owner`, and reading that claim is the bug above.
PrincipalIsolation is gone. Registering the plugin is what gives an org a Base;
a deployment with orgs and one shared database is not a shape anyone wants. The
pool manager it gated went with it, having lost its only caller. The org's file
is data.db rather than org.db — the directory already says whose it is.
Two things wore the name "platform" here, and neither is the PaaS at
platform.hanzo.ai. That collision is why "where are my Bases?" had no answer a
person could act on: one prefix, two products, and the one you reached was
whichever you happened to mean.
A Base is what an org gets — its own collections, its own files, its own key —
so the resource a client addresses is /v1/bases. Not /v1/orgs: the org is IAM's
noun and Base does not get a second one. Everything scoped to an org now hangs
off the Base it belongs to (/v1/bases/{id}/config, /creds, /customers), and
compliance moves to /v1/compliance, a sibling of /v1/iam rather than a child of
Base's own resource. The package is `org`, which seven of its files already
were. Nothing addressed the old prefix — no client, no UI, no manifest — so this
renames a surface rather than breaking one.
The auth proxy is DELETED, not renamed. Its five routes reshaped bodies and
forwarded to /v1/iam/login, /signup, /userinfo and /send-verification-code —
which the opaque /v1/iam/{path...} passthrough beside it already reaches. So
Base carried a hand-written login, signup and one-time-code flow whose only job
was to be a second way to reach IAM. Auth is IAM's, and one way is the whole
point of that rule.
The prefix test that already banned /api/ bans this too, so the name cannot come
back: it reads route LITERALS through go/ast, so a comment explaining the ban is
prose and a route is a route. Verified to fail on the exact regression.
Base's own registry was already org-native in every method — OrgDB(orgID),
HasOrg(orgID), orgDir(orgID) — and only the type names said tenant. So this is
the vocabulary catching up with the code, not a change of behaviour.
TenantRegistry -> Bases app.Bases().OrgDB(org)
TenantConfig -> BasesConfig
MultiTenant -> PerOrg it enables per-org databases; say that
MULTI_TENANT -> MULTI_BASE
core/tenant.go -> core/bases.go
Tenant was the wrong noun twice over: it is the multi-tenancy word, and this
product's unit is the thing a customer creates per org — a Base. Identity here
is org, user, project, and nothing else.
The UI never said it; this was Go-side only. 64 packages green.
One test was collateral and is fixed rather than renamed: TestToHTTPHeader
asserts a pure snake_case-to-Header-Case transform, so rewriting its EXPECTED
value while its input stayed "tenant_id" made it assert something false. The case
is dropped — org_id already covers that transform.
Rename plugins/scheduling -> plugins/calendar and mount under /v1/calendar,
serving the Cal <Booker> atom's endpoint subset — public event, available
slots, advisory slot holds, booking create/read/reschedule/cancel and an
anonymous /me — so cal.hanzo.ai renders Cal's real Booker straight against
native Base.
The reviewed core is preserved verbatim: computeSlots, the isOpenSlot
availability gate, the transactional double-book guard + partial unique index,
and the per-IP/per-host rate limiter (slots_test + redverify_test still pass).
Security decisions live in cal.go: event types resolve by (owner, slug) so the
client-supplied numeric eventTypeId is never trusted for data access; the
public event and /me expose no host email and no raw meeting location (only
revealed to the booking uid holder post-booking); reserve/delete are advisory
holds that never gate the write path.
The public booking backend a booking page (cal.hanzo.ai) talks to, IAM-native:
GET /v1/schedule/{owner}/{slug} — the event type (public)
GET /v1/schedule/{owner}/{slug}/slots — open slots from availability minus
existing bookings + synced calendar
POST /v1/schedule/{owner}/{slug}/book — create a booking (conflict re-checked)
GET /v1/booking/{uid} — view a booking by its opaque uid
POST /v1/booking/{uid}/cancel — cancel by uid
Slot computation is pure + tested (weekly windows in the host's tz, buffers,
minimum-notice, conflict exclusion). Bookings are written by the endpoint after
validation. Opt-in via SCHEDULING_ENABLED=true; wired into examples/base.
Builds green; slot tests + boot-migration test pass.
Docker / docker (push) Blocked by required conditions
Docker / test (push) Waiting to run
gitleaks / scan (push) Waiting to run
release / build (push) Waiting to run
The shipped base binary read only IAM_URL for the platform IAMEndpoint, while
the canonical env (docs, healthy deployments) is IAM_ENDPOINT — a deployment
setting only one spelling would silently misconfigure Base. Resolve both
(IAM_ENDPOINT then IAM_URL; likewise KMS_ENDPOINT then KMS_URL) via a small
envAny helper, matching plugins/platform/kms_helpers.go. One way to configure,
two accepted spellings — no per-deploy config drift.
Also align modernc_versions_check.go's expected constants to the pairing
actually in go.mod (sqlite v1.50.0 / libc v1.72.0) so the benign consistency
WARN stops firing. libc is not semver-stable, so both move in lockstep with
the sqlite tag's go.mod requirement (issue #6136).
Co-authored-by: Hanzo Dev <[email protected]>
Docker / docker (push) Blocked by required conditions
release / build (push) Waiting to run
The waitlist plugin (POST /v1/waitlist/join etc.) existed in plugins/waitlist but
was never registered in examples/base/main.go — the binary that builds
ghcr.io/hanzoai/base. So /v1/waitlist/join 404'd, console2's WAITLIST_URL had
nothing to point at, and the UI showed 'the waitlist is not open on this
deployment yet'. Register it (Enabled:true; Turnstile/admin/rate-limit resolve
from env). It OnBootstrap-creates the waitlists + waitlist_entries collections.
Also fix go.sum: luxfi/[email protected] + luxfi/[email protected] still carried stale
direct-git hashes that mismatch the immutable public proxy the Dockerfile now
uses (finishing the retag-refresh the [email protected] commit started) → base CI was
red. Synced both to the canonical sum.golang.org hashes. Builds + vets clean via
proxy; waitlist route serves.
Co-authored-by: hanzo-dev <[email protected]>
Why: consolidate the Python bootnode backend (bootnode/api/, ~100 .py files)
onto Hanzo Base, leveraging Base's IAM client and per-org/per-user tenant
infrastructure instead of reimplementing auth, sessions, and multi-tenancy.
This is the structural foundation + the 5 most-important modules, not the whole
port.
What landed
- plugins/bootnode/: blockchain developer platform mounted under /v1.
Five modules ported end-to-end:
1. auth — multi-network OAuth2 callback (lux/pars/zoo/hanzo share the
lux-web3 IAM app; client id derived from redirect_uri) + bn_
project API keys (salted SHA-256, raw key shown once, verified
in constant time). Accepts IAM JWTs and pk-/sk-/hk- keys by
reusing github.com/hanzoai/base/iam — no IAM logic duplicated.
2. team — org/member CRUD scoped to the caller's project; invited emails
resolved against IAM (active) or held pending with an invite
token.
3. networks — applies bootno.de/v1 Network CRs (white-label brand, tier,
region, validator fleet). Replaces the Python's kubectl + raw
nginx-Ingress templating with a declarative CR for the
bootno.de operator to reconcile.
4. nodes — applies bootno.de/v1 NodeFleet CRs (CRD-driven cloud path;
the Python docker provider was a local-dev concern).
5. keys — applies bootno.de/v1 KMSSecret CRs by KMS path. NO plaintext
key material ever touches this service; the request and
response carry none, and a guard rejects any private-key field.
- plugins/bootnode/kube/: dependency-free Kubernetes REST client (net/http
server-side apply). No client-go, no CGO. In-cluster SA or KUBE_APISERVER.
- plugins/commerce/: typed Hanzo Commerce (Square billing) client behind a
Client interface. bootnode depends on the interface; commerce never depends
on bootnode/iam.
- 11 SQLAlchemy models -> Base collections (models/collections.go). No `users`
collection: IAM owns identity; bootnode references IAM user ids as text.
OrgCluster is the canonical org->k8s-cluster mapping.
- examples/base/main.go: platform plugin now runs PrincipalIsolation="sqlite"
(per-org + per-user encrypted SQLite); bootnode registered (BOOTNODE_ENABLED).
Modules pending (15, tracked in PR body): chat, zap, billing-http, bundler,
fleets, gas, infra, launch, lux, mpc, nfts, observability, rpc, tokens,
transfers, wallets, webhooks-http. (chains is also ported as a bonus 6th.)
Tests (20 functions, all green incl -race)
- auth: token classification, key gen/hash/verify (tamper + wrong-salt),
redirect->clientId derivation.
- kube: server-side-apply shape (PATCH + apply-patch+yaml + fieldManager),
error propagation, 404 get/delete idempotency.
- commerce: disabled no-op, get-or-create (create + existing), usage error
propagation, immediate cancel.
- workers: HMAC-signed delivery, non-2xx-is-failure, unreachable-is-failure.
- bootnode: full end-to-end against a fake IAM + fake apiserver — /me 401 then
200, project + bn_ key, team invite/list, Network + NodeFleet CR apply/get,
KMSSecret plaintext-rejection + apply + status, public chains. Plus
fail-fast on the insecure default salt against production IAM.
Verified: `go build ./...` exits 0; `go test -race ./plugins/bootnode/...
./plugins/commerce/...` passes; live binary serves /v1/chains (200) and gates
/v1/auth/me, /v1/networks (401); per-org SQLite isolation logged active; all
10 _bootnode_ collections created on boot.
Co-authored-by: zeekay <[email protected]>
Aligns base with the canonical IAM_* env set defined by
hanzoai/kms fe6aca7:
IAM_URL — where IAM lives
IAM_KEYS_URL — JWKS endpoint
The standalone example in examples/base/main.go now reads
IAM_URL / KMS_URL for endpoint config — drops the
IAM_ENDPOINT / KMS_ENDPOINT alias.
No back-compat fallbacks.
ZAP binary protocol duplex RPC standardizes on :9999 across every
service. Lux validator staking/RPC ports (9630/9631 mainnet, 9650/9651
devnet) are unchanged — those are luxd network protocol ports, not ZAP.
One ZAP port everywhere (was a mix of 9651/9652/9653/9633/9900).
Adds plugins/cloudsql with per-tenant database provisioning via Neon,
postgres-meta reverse proxy for schema introspection, and REST API
at /api/cloud-sql/databases. Registers platform + cloudsql plugins
in examples/base/main.go.
Replace github.com/pocketbase/pocketbase with github.com/hanzoai/base
in 106 Go source files. Keep pocketbase/dbx and pocketbase/tygoja as-is
(upstream dependencies). Add compiled binary to .gitignore. Include
realtime DX design document inspired by Convex patterns.