A Base its host opened for one org refused every token acting in another, so a reader signed in with Hanzo ID could not comment on a site whose Base is org hanzo's. Opening that org to any account would hand the CRM to anyone, since its rules admit any signed-in caller. A base collection now admits visitors when it says so, with "visitors": true. A visitor reaches only its records and files; every other route, and every collection that has not opted in, still answers 403. Its record carries role "visitor", admin false and its own org, so `author = @request.auth.id` holds a row to its writer. Any other collection's rules read a visitor as a guest, so a relation, expand or filter from a visitors' collection carries it no further. Its writes are held to ten a minute per Base, keyed by subject, whatever the deployment limits. A superuser is never a visitor.
19 KiB
LLM.md: hanzoai/base
A map of this repository for coding agents. How to use Base is in README.md
and docs/. This file says where things are, how to build and test, and what
must stay true. CLAUDE.md is a symlink to it.
What Base is
One Go module, github.com/hanzoai/base, whose binary is examples/base:
collections and records over a /v1 REST API, realtime, files, collection
rules and JavaScript hooks, stored in SQLite through github.com/hanzoai/sqlite.
Identity is Hanzo IAM and nothing else. With IAM_ENDPOINT set, every org gets
its own Base at <data dir>/orgs/<org>/data.db.
Where things are
| Path | What it holds |
|---|---|
base.go |
base.New, the root flags (--dir, --dev, --encryptionEnv, --queryTimeout, --zap, --mdns, --no-mdns), ZAP registration |
examples/base/main.go |
the binary: jsvm, migratecmd, waitlist (always on), ghupdate, org (when IAM_ENDPOINT or IAM_URL is set), bootnode, calendar, cloudsql |
cmd/ |
serve.go, and the cli HTTP client in cli.go and cmd/cli/ |
apis/ |
routes and middleware. base.go mounts the groups under BASE_API_PREFIX; serve.go opens the listeners and serves the admin UI; middlewares.go turns a credential into an identity and an org; credential.go reads credentials; record_crud.go applies rules; also rest.go, realtime*.go, functions.go, private.go, public.go |
core/ |
the app, collections, records, the rule resolver (record_field_resolver*.go), migrations, settings, Tasks wiring (base.go), the replication hook (base_network.go) |
migrations/ |
system migrations, in Go |
plugins/jsvm |
.base.js hooks and JS migrations on goja; internal/types holds the generated types.d.ts |
plugins/org |
IAM client and /v1/iam proxy, per-org Bases, KMS client, /v1/bases, /v1/idv proxy, publishable keys |
plugins/zap |
the ZAP listener, which serves the HTTP handler |
plugins/extruntime, plugins/gojavm |
the runtime interface and the goja runtime behind /v1/functions |
plugins/{migratecmd,ghupdate,waitlist,bootnode,calendar,cloudsql,commerce} |
what their names say |
graph/ |
a Base's records as typed GraphQL: the schema rendered from its collections, every field answered by a request on its own router as the caller (see "The graph") |
modules/ |
named collection sets an org installs into its Base (POST /v1/modules/{name}, core.GrowCollections); modules/crm is the CRM (crm.json), blank-imported by the binary |
plugins/{ha,replicate,tasks,scheduler,vault,pyvm,v8vm,wasmvm,starkvm,extbench} |
plugins the binary does not link |
network/ |
quasar replication (BASE_NETWORK=quasar) and its s3:// archive |
store/ |
at-rest encryption for per-base SQLite: keyrings and encrypted replicas |
crdt/ |
CRDT documents, privacy backends, chain anchors |
iam/ |
IAM client types, aliased for the plugins |
tools/ |
router, hook chains, the filter language (search), filesystem, cron over the Tasks client (tasks), identity headers (claims), security |
ui-react/ |
the admin SPA; dist/ is committed and embedded |
tests/ |
the test app and API scenarios; tests/engine runs against PostgreSQL |
sdk/go, sdk/private |
the Go client interface and the private-store TypeScript client. The JS and Dart clients live in hanzo-js/base and hanzo-dart/base |
hack/ |
pitr-restore.go and a compose smoke script |
docs/ |
user docs (versions, rules, hooks, config, listeners, threat-model) and design notes |
Build and test
cd examples/base && CGO_ENABLED=0 go build && ./base serve --http 127.0.0.1:8090
GOWORK=off CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go vet ./...
GOWORK=off CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go test -count=1 ./...
golangci-lint run -c ./golangci.yml ./...
pnpm --dir ui-react install && pnpm --dir ui-react build && pnpm --dir ui-react smoke
The fixture is migrated. Every test app is a copy of tests/data, and a copy
that lacks a migration runs it before its test begins. Twelve missing migrations
cost each test app about four seconds, which is what pushed core and apis past
the runner's ten minutes (26 s and 23 s once the fixture held them). A new
migration is added to the fixture with go run ./tests/fixture;
tests.TestTheFixtureIsMigrated fails, naming it, until it is.
CI (hanzo.yml) runs the Go gates with those three variables, runs
tests/engine against PostgreSQL 18, and keeps the skips in
network/attack_vectors_test.go apart from the passes. Do not use make build
or make ui: they run pnpm in ui/, which does not exist.
macOS. The whole suite passes with the test command above, including
TestTasksEmbed, TestOrgBaseIsEncryptedAtRest, TestOAuth2ConfigValidate and
TestOAuth2ProviderConfigValidate. The first two failed on a Mac before
hanzoai/sqlite v0.5.10, whose pure-Go codec would decrypt only into RAM. It now
prefers RAM (HANZO_SQLITE_RAMFS_DIR when it names a tmpfs, then /dev/shm)
and falls back to the OS temp directory.
What must stay true
One auth, IAM's. Base has no passwords, sign-in routes, OTP or OAuth2 of its
own. resolveJWKSToken (apis/middlewares.go) verifies a token against IAM's
JWKS and mirrors it into an unsaved record: _superusers when
authz.Claims.Sudo() holds (a person whose own org — the first entry of
orgs — is IAM's admin org; a membership of admin is not it), otherwise
users. Never read authority from the owner claim, which IAM sets to the org
of the application a token was minted through. With plugins/org registered,
only IAM tokens count. Without it, a users token Base signed earlier still
verifies, but nothing issues a first one. Superuser tokens Base signed stopped
verifying at 1789100100_rotate_superuser_tokens.go, and auth-refresh renews
no superuser token IAM did not issue.
An org Base under a master key is never plaintext. plugins/org reads the
key from KMS beneath IAMOrg in Register, refuses to start when it cannot
tell whether one exists or SQLCipher is not linked, and its keyed connector
refuses a plaintext file.
The org is the token's. orgOf takes it from the token's memberships.
X-Org-Id only selects among them; anything else is 403, never 404, since a
404 answers a question about data. tools/claims strips X-Org-Id,
X-User-Id and X-User-Email on the way in. loadAuthToken points
RequestEvent.App at the org's Base before any handler runs, so handlers know
nothing about tenancy.
A visitor reaches only what admits one. A Base whose host opened it for
one org (StoreKeyTenant) refuses a token acting in another org, except on the
routes in visitorRoutes (records and files) of a base collection with
visitors: true (Collection.AdmitsVisitors). The visitor's mirrored record
has role visitor (core.IsVisitor) and admin false, and every other
collection's rules read it as a guest (processRequestAuthField), so a
relation, expand or filter cannot carry it further. Its writes go through
holdTo keyed by sub. A superuser is never a visitor.
Rules are enforced in Go, on every engine. nil is superuser only and ""
is public. A list rule is ANDed into the query before paging and counting. An
update is checked against the record as it would be after the write
(shadowRow). Code that reads records outside the record API checks with
CanAccessRecord.
A publishable key reaches only public routes. pk- keys ship in web pages.
plugins/org/publishable.go refuses them on any route not wrapped in
apis.Public(...), and each product marks its own public routes where it
registers them.
One credential reader. apis.Credential is the only code that reads a token
or key off a request.
A hook follows its event. jsvm states record and model hooks on every Base
through core.AppBindings; routerAdd and cronAdd register once. Each
handler is compiled from its own source text and cannot see file-level
variables.
Realtime opens with a grant. POST /v1/realtime/token mints a grant that
GET /v1/realtime?token= spends once within 30 seconds
(apis/realtime_grant.go). An IAM token never travels in a query string.
Functions are records. _functions rows run in-process on goja
(apis/functions.go). The host binds list, one and start, capped by
functionMaxRows (500) and functionMaxRuns (8). Nothing installs
StoreKeySandboxes, so start refuses.
One SQLite driver. Import github.com/hanzoai/sqlite only. A direct
modernc.org/sqlite import registers the sqlite driver twice under cgo and
panics at init.
Engine differences go through app.Dialect() (hanzoai/orm/dialect),
never through a branch on the engine's name. Config.DataDSN opens
PostgreSQL; empty means embedded SQLite.
Listeners bind where they are told. ZAP takes the HTTP host unless given an
address, mDNS is opt-in and never on loopback, BASE_LISTEN_P2P binds its host
exactly, and the Tasks and direct KMS clients dial without listening.
docs/listeners.md is the list; change it with the code.
An org admin changes its own Base's schema, and nothing wider.
RequireSchemaAuth admits a superuser or an org admin (IsOrgAdmin: the
credential administers the org the request acts in); rotate stays a
superuser's. An org admin reaches ordinary base collections only
(SchemaManageable: not auth, not view, not system, no _ name), judged as each
is and as the request would leave it, never deleteMissing, and every change is
logged. Any member reads the org's own schema (RequireSchemaReadAuth; Base's
own tables are hidden from one), because a client draws records by it. The CRM
module installs through the same door (POST /v1/modules/crm).
Rules read the caller's org authority. @request.auth.admin and
@request.auth.role (owner, admin or member) come off the verified token onto
the unsaved record IAM is mirrored into, and an unsaved auth record's own fields
are read off the record rather than joined against a row that does not exist
(processRequestAuthField); before that @request.auth.org_id matched nothing.
A Base opened for one org answers that org. A host that pools Bases sets
StoreKeyTenant; a token acting in any other org is refused there.
Composite values are one JSON column. currency (amountMicros,
currencyCode), emails, phones, links, address, fullName and actor are
CompositeFields: NULL when empty, parts reached by name in filters, sorts and
aggregates (emails.primaryEmail), a path naming no part refused. Emails,
phones and links normalize what is typed, and their primary part can be unique
(a validation query: SQLite admits one writer, PostgreSQL could race two). Every
field embeds FieldMeta (label, description, icon, position); a select carries
options (label, colour from TagColors, position) for values of values only;
a base collection carries label, plural, icon, description, history.
Editor HTML is sanitized before it is written (bluemonday UGC).
The writer is stated, never sent. An actor field is stamped from
core.ActorFrom(ctx): record CRUD states the person (apis.WithRequestActor), a
host states a program (core.WithActor), and nothing in a body sets one.
History is written in the record's transaction. A base collection with
history writes a _history row per create, update and delete (who, when,
changed fields from and to), merging one writer's edits within ten minutes;
GET /v1/collections/{c}/records/{id}/history answers under the record's view
rule, without the changes of a field hidden or removed since (a superuser reads
the row whole).
A where filter is the filter language, written as data. core.CompileWhere
turns {"and"|"or"|"not"|field, op, value} into Base's filter with bound
parameters: per-type operators, relative dates in a time zone, @me,
back-relations (list_members_via_person.list is "in list") and
inSegment/notInSegment over the segments collection. ?where= on the
records list and GET /v1/collections/{c}/aggregate?ops=&group= (count, sums,
averages, empties, uniques, grouped lanes) both run under the list rule. An
aggregate reads columns directly, so for a caller who is not a superuser it
refuses a hidden field and an auth collection's email (core.EmailGated, the
rule the filter resolver applies by ANDing emailVisibility).
Grow never drops. core.GrowCollections creates what is missing and adds
fields, select values and indexes by name, keeping every field, rule, option and
index an org changed; it is how modules and a host's imports reach a Base. A
document may say which earlier rules its own replace (replaces): a collection
still holding one of those exactly takes the new rule, and a rule an org wrote is
kept. The _views* system collections are retired (1790000200); saved views
are the CRM's ordinary views collection, the org's (visibility org or empty)
and a person's own (private, which only its maker lists, reads, changes or
deletes, an org admin included). IAM preferences hold only which view a person
opens first.
Paths are /v1. No /api/ segment anywhere. BASE_API_PREFIX moves the
data plane and nothing else: /v1/iam and /healthz stay put. The admin SPA
writes /v1 into ui-react/src/lib/api.ts, so it does not work under another
prefix. Outbound calls follow the same rule: the Tasks client posts to
/v1/tasks/namespaces/default. route_prefix_test.go fails on a /api/ string
literal anywhere in the module or a quoted /api path in ui-react or sdk.
The graph
graph.New(app, router) is the Base's records as GraphQL, executed by zip's
engine: a host mounts g.Schema and g.Resolve with zip.MountGraphFor (cloud
serves it at /v1/base/graphql and composes it into /v1/graphql as base).
The package has no transport of its own and no second authorization.
- The schema is the Base's own, rendered from its collections and rebuilt
when one changes (keyed by each collection's id and
updated). Base and view collections are types; a collection Base keeps for itself (_*, system) and an auth collection are not, and a relation to one reads as the id it holds. Naming:people→BasePerson, rootsperson(id),people(...),peopleGroupBy(field, filter),createPerson,updatePerson,upsertPerson(data, on),deletePerson; a name that would collide is left out (model.skipped). Field names are Base's, verbatim. Every type starts withBase, so a host composes it without renaming. - Every field is a request on the Base's router, made with the caller's
headers (
zip.Headers): the records list (?where=,sort,expand), view, create, update, delete,/aggregateand/history. Rules, tenancy, rate limits and expand's view checks are therefore the REST path's;graph_testholds the graph's refusals to REST's own messages. - A filter is a where clause.
BasePersonFilterisand/or/not,inSegment/notInSegment, each field's own filter and each back-relation (list_members_via_person), and every operator becomes one leaf ofcore.CompileWhere;@me, relative dates,tzandweekStartmean what they mean there.orderByis a list of one-field objects (a composite part, or one relation deep), with the id appended so the order is total. - A cursor is a position. Base pages by page and perPage, so a window of n
from an offset is read in pages of n+1, one page or the two it spans; inserts
before a cursor shift what follows it.
totalCountis counted only when selected, andaggregate(on any connection) andGroupByare the aggregate route, over the filter and never the page. - Bounds. A page holds at most 100; a selection reaches at most four relations, back-relations or histories deep, refused before any request; one root field makes at most 50 requests; zip refuses a request past 10,000 resolutions whole.
- No subscriptions. Realtime is the SSE stream, under the same rules.
Admin UI
ui-react: React 19, TanStack Router, @hanzo/ui components on @hanzo/gui,
no Tailwind. Served at / only when BASE_ENABLE_ADMIN_UI=1; who may frame it
is BASE_FRAME_ANCESTORS (default 'self'). It signs in with IAM PKCE
(VITE_IAM_URL, default https://hanzo.id; client hanzo-base).
- Rebuild
dist/in the same commit as anysrc/change, and on Linux: a macOS build emits a larger bundle from the same source. - A green build proves little.
@hanzo/guidrops props it does not know without an error;pnpm smokechecks what renders. - Only one tab may refresh the session at a time (
navigator.locks). IAM rotates refresh tokens and revokes the family when one is reused. - Reads blank secrets and
PATCHmerges, so a form omits a secret field left empty rather than sending it.
Known problems
-
A composite's unique primary part (an email, a phone, a URL) is held by a SELECT before the write (
checkUniquePart), not by an index: the part is inside a JSON column and an expression index is spelled per engine. SQLite admits one writer, so it holds there; on a Base placed on PostgreSQL two writers of one address at once can both pass. Decided to keep until a placed Base takes concurrent writes of one key; the fix is a unique expression index from the dialect. -
migrations/1780500000_connect_messaging_calendar.gogivesconnectedAccountencrypted token fields, so an OAuth token can sit in a record. Secrets belong in KMS; the collection is retired by another change, and nothing reads it. -
plugins/jsvm/internal/types/generated/types.d.tshas not been regenerated for the composite field types: the generator names its aliases at random, so a run rewrites some 36,000 lines. -
apis/installer.gotells a Base without IAM to runsuperuser upsert, a command that no longer exists. -
--publicDirand--indexFallbackdo nothing:apis.ServeregistersGET /{path...}before the binary's static route checks for one. -
An encrypted org Base needs SQLCipher linked (
CGO_ENABLED=1,-tags libsqlite3and the flags in hanzoai/sqlite's README). The image buildsCGO_ENABLED=0, whose pure-Go codec gives each handle a private copy of a file, so a Base with a master key refuses to start there. -
TASKS_EMBEDfails andNewBaseAppdrops the error when the default socket path is relative (zap.Networkreadsbase_/data/tasks.sockas a TCP address) and on a data directory's first start (the engine binds before the directory exists). -
The start banner prints
/v1/and a Dashboard address whateverBASE_API_PREFIXandBASE_ENABLE_ADMIN_UIsay. -
No GitHub release after
v0.36.7-hanzo.1carries binaries, sobase updatefails, andghcr.io/hanzoai/basehas no image forv1.5.92tov1.5.96. -
go install github.com/hanzoai/base/examples/base@<version>fails on thereplaceingo.mod. -
In
cmd/cli,K8sContext()returns literals such asgke_mainnetthat name no cluster, andEnvURLsbuildsexample.comhosts and has no caller.
Writing in this repo
Paths are /v1. Hanzo IAM is the only auth. Zen models are Hanzo's own, and no
upstream model or product is named. Docs are plain and short, and every command
in them has been run.