One mount-prefix knob for the data plane: BASE_API_PREFIX (default /v1). Multi-app deployments set /v1/<app> so a gateway can route by path. Hanzo IAM is always a fixed sibling at /v1/iam — apps do not mount their own IAM at /v1/<app>/iam. Ripped: - BASE_AUTH_MODE env knob — was a no-op after the IAM-native rip (the "builtin" branch had no routes left to bind). - apis/superuser_auth_oidc.go (~460 lines) — stale parallel OIDC superuser path at /_/auth/oidc/*. The canonical path is /v1/iam/oauth/* via the platform plugin (proxy or embedded). - apis/base.go EXTERNAL_AUTH_ONLY env-var fallback — the platform plugin is mandatory and sets the store keys directly. One way only. installer.go: skip the first-superuser installer when the platform plugin has flagged external-only mode (IAM owns first login). LLM.md updated to reflect the new contract.
12 KiB
LLM.md - Hanzo Base
Identity
Hanzo Base is the local-first application runtime for Lux.
Not a fork, not a wrapper. The Web5 client runtime.
Architecture
Base = local encrypted SQLite + CRDT sync + chain-anchored trust
Lux = trust/control plane (identity, keys, policy, anchors)
Per user/app/org:
- Local encrypted SQLite file as the primary database
- CRDT op log for sync/merge (encrypted ops — peers see ciphertext only)
- Portable identity from Lux I-Chain (IdentityVM)
- Key wrapping/recovery/sharing from Lux K-Chain (KeyVM) + T-Chain (ThresholdVM)
- Chain anchors for integrity, policy, receipts, portability
- Cloud sync as encrypted blob/oplog replication, NOT as source of truth
The server is a relay/index/cache layer, not the owner of truth.
Core Plugins
| Plugin | Path | Purpose |
|---|---|---|
| vault | plugins/vault/ | Per-user encrypted SQLite shards, DEK/KEK, CRDT sync, chain anchor |
| kms | plugins/kms/ | Client-side KMS integration (talks to K-Chain or cloud HSM) |
| zap | plugins/zap/ | ZAP transport (8.7us latency) |
| platform | plugins/platform/ | Hanzo platform integration |
| functions | plugins/functions/ | Event workers (on CRDT ops, chain receipts) |
| jsvm | plugins/jsvm/ | JavaScript plugin VM |
Vault SDK (plugins/vault/)
5 primitives, 18 tests:
- Identity —
OpenUser(userID)-> resolve DID, derive DEK, bind device - Key Access — DEK/KEK hierarchy: Master KEK -> Org KEK -> User DEK
- Local DB —
Put(key, value),Get(key),Delete(key)-> encrypted SQLite - Sync —
Sync(),Merge(ops)-> CRDT over ZAP (encrypted ops) - Anchor —
Anchor()-> merkle root to chain, audit receipt
Key hierarchy:
Cloud HSM / K-Chain ML-KEM
+-- Master KEK (never on disk)
+-- Org KEK = HMAC-SHA256(master, "vault:org:" + orgID)
+-- User DEK = HMAC-SHA256(orgKEK, "vault:user:" + userID)
+-- AES-256-GCM per entry (random nonce)
What Goes On-Chain (Lux)
- DID / identity roots
- Key handles and rotation events
- Capability / policy state
- Sync checkpoint Merkle roots
- Audit receipts
- Payment / metering records
- Provider registry
What Stays Local (Base)
- All mutable app data (SQLite)
- CRDT operation logs
- Decrypted user state
- Device key material
- Blob references
- App-specific indexes
The Firebase Replacement
| Firebase | Web5 (Base + Lux) |
|---|---|
| Auth | DID + capability/session gateway |
| Firestore | Encrypted SQLite shard per user |
| Offline sync | CRDT (already local-first) |
| Storage | Content-addressed encrypted blobs |
| Functions | Workers on CRDT ops / chain receipts |
| Security Rules | Signed capabilities + chain policy |
Roadmap
v1 (shipped): vault plugin, encrypted SQLite, DEK/KEK, CRDT sync, anchor v2: org sharing, multi-device enrollment, threshold recovery, per-collection sharing v3: provider marketplace, pay-for-sync/storage/recovery, portable exports v4: FHE/ZK policy modules for selected confidential compute workloads
Build & Run
go build ./...
go test ./...
go test ./plugins/vault/ # 18 tests
go test ./cmd/cli/ # 39 tests (network flags, cluster, operator, config, etc.)
go test ./cmd/ # integration tests (collection, record, login, superuser)
CLI Surface (2026-04-13)
Every Base-derived daemon (ats, bd, ta) uses cmd.AddCLISubcommands(root) to get:
| Command | Purpose | Lux CLI Equivalent |
|---|---|---|
cluster init/start/stop/status/leader/replicate/failover |
Manage base-ha HA groups | lux network start/stop/status |
operator apply/status/describe/upgrade/logs |
Manage Liquidity K8s operator CRDs | lux chain deploy |
config show/set-env/set-org/init |
CLI config (~/.config/base/config.json) | lux config |
status |
Daemon health + cluster state | lux status |
self version/doctor |
Binary management | lux self |
rpc get/post/patch/delete |
Direct API passthrough | lux rpc |
Network Flags
All commands accept --mainnet/-m, --testnet/-t, --devnet/-d, --dev. Exactly one may be set.
Fallback: $LIQUIDITY_ENV -> $BASE_ENV -> default local.
Config File
~/.config/base/config.json (respects $XDG_CONFIG_HOME). Contains default env, per-env URLs, default org.
Cluster (HA)
Local mode (--dev): spawns N base-ha processes with auto-filled BASE_PEERS.
K8s mode (--mainnet/--testnet/--devnet): kubectl scale against the correct GKE context.
Consensus: --consensus lux (default) or --consensus pubsub.
Operator (K8s CRDs)
Wraps kubectl against liquid.network/v1alpha1 CRDs. Context map:
- devnet:
gke_liquidity-devnet_us-central1_dev - testnet:
gke_liquidity-testnet_us-central1_test - mainnet:
gke_liquidity-mainnet_us-central1_main
FHE Position
FHE is NOT the default execution model. Use it for:
- Encrypted policy checks
- Encrypted scoring/matching
- Private collaborative computations
- Sensitive server-side transforms
Do NOT make "FHE SQLite" the baseline. Local SQLite is decrypted locally. Cloud sees ciphertext. Chain sees commitments. FHE is opt-in compute.
Key Principle
Web5 = local-first apps with blockchain as the trust layer. Put trust on-chain, keep state local, sync privately, make identity portable.
Ecosystem Alignment (2026-04-10)
See the full alignment guide below. Summary of conflicts:
| Area | Base Current | Ecosystem Standard | Status |
|---|---|---|---|
| Timestamp fields | created/updated |
createdAt/updatedAt |
CONFLICT |
| API prefix | configurable via BASE_API_PREFIX (default /v1) |
/v1 or /v1/<app> |
DONE |
| Soft delete | Hard delete only | Deleted bool flag |
MISSING |
| Multi-tenancy | None | Per-org SQLite + CEK | MISSING |
| Auth | Built-in auth collections | Hanzo IAM (OIDC/JWKS, mandatory) | DONE (platform plugin, one way) |
| SSE event name | CONNECT |
CONNECT |
OK (server + SDK aligned) |
| Error format | {status, message, data} |
{status, message, data} |
OK |
| Pagination | {items, page, perPage, totalItems, totalPages} |
Same | OK |
Migration path: 5 phases, backward-compatible aliases first. Full details: research brief produced by scientist agent on 2026-04-10.
IAM-native auth (one and only one way)
Hanzo IAM is the only auth source. There is no BASE_AUTH_MODE toggle,
no built-in password / OTP / MFA / OAuth2 / email-change / password-reset
surface, no legacy /_/auth/oidc/* parallel path. The platform plugin
(plugins/platform/) is mandatory and registers IAM unconditionally;
booting without IAM_ENDPOINT (or IAM_MODE=embedded) errors at startup.
Two ways to host IAM, identical OIDC contract from the client side:
- External (default): set
IAM_ENDPOINT=https://hanzo.id(or your tenant)./v1/iam/*is a transparent reverse proxy to that endpoint. Full Hanzo IAM features: federation, MFA, social, magic links, multi-tenant orgs. - Embedded: set
IAM_MODE=embedded./v1/iam/*is served in-process by the minimal OIDC provider inplugins/platform/iam_embedded.go(email+password only, no federation). For single-tenant solo deployments. See section below for the surface details.
Both modes expose the same six paths under /v1/iam/* (discovery, JWKS,
authorize, login, token, userinfo) and the same RS256 JWT shape. The
@hanzo/iam/browser SDK does PKCE redirect against either with no
client-side branching — only the feature ceiling differs.
The middleware mirrors IAM identity into _superusers by email, so an
admin row with the JWT's email automatically authorizes admin endpoints
(collections, settings, logs, backups). Identity from IAM; admin
privilege from a _superusers row keyed by email.
Store keys: StoreKeyExternalAuthOnly (always true once platform
registers), StoreKeyJWKSURL (external mode), StoreKeyAuthUsersCollection
(default "users").
Mount prefix (BASE_API_PREFIX)
One knob for where the app's data plane lives. Default /v1. For
multi-app deployments where a gateway routes by path, set
BASE_API_PREFIX=/v1/<app> (e.g. /v1/base, /v1/team).
The SPA client must match: VITE_API_PREFIX (in gui/apps/admin-*/vite.config.ts
define block) is the client-side counterpart. Both are configured at
deploy together.
IAM is always a fixed sibling at /v1/iam regardless of
BASE_API_PREFIX. In production a gateway typically routes /v1/iam/*
to the central IAM service; in solo/dev mode IAM_MODE=embedded serves
it in-process. Apps do NOT mount their own IAM at /v1/<app>/iam.
Root liveness probe stays at /healthz (outside the mount prefix) so
ops doesn't have to know the app name.
Embedded IAM Mode (IAM_MODE=embedded)
Set IAM_MODE=embedded to boot Base with an in-process OIDC provider
at /v1/iam/* instead of reverse-proxying to an external Hanzo IAM.
Same @hanzo/iam/browser PKCE contract from the client's perspective —
the path doesn't change, only the implementation. We use /v1/iam, not
/api/iam — this is NOT Casdoor.
Surface (minimal viable, NOT a full Hanzo IAM):
GET /v1/iam/.well-known/openid-configuration— OIDC discovery (issuer derived from request Host)GET /v1/iam/.well-known/jwks— public RSA JWKGET /v1/iam/oauth/authorize— plain HTML login formPOST /v1/iam/oauth/login— verifies email+password, redirects toredirect_uri?code=...&state=...POST /v1/iam/oauth/token— exchanges single-use code for RS256-signed JWT (1h TTL)GET /v1/iam/oauth/userinfo— bearer-validated user record
Signing key: ${DataDir}/iam.key (RSA-2048 PEM, 0600). Generated on
first boot; back it up alongside the SQLite database — losing it
invalidates every outstanding JWT.
Users: _iam_users system collection (email + bcrypt-cost-12 password
- name). Bootstrap via either:
- env:
[email protected] EMBEDDED_IAM_ROOT_PASSWORD=...on first boot (no-op if_iam_usersalready has rows) - CLI:
./base iam-user create [email protected](prompts for password via stdin, or honorIAM_USER_PASSWORD)
Token validation runs in-process via the platformEmbeddedAuth
middleware bound at DefaultLoadAuthTokenMiddlewarePriority - 1. The
JWT is verified against the local signer (NOT the JWKS-over-HTTP path
external mode uses) and re.Auth is set to the matching _iam_users
record, so the standard identity-header pipeline keeps working
unchanged.
Out of scope (boot against an external Casdoor at IAM_ENDPOINT if
you need any of these): multi-tenant orgs, social federation
(Google/GitHub/SAML), MFA/OTP, password reset, refresh tokens, fancy
login UI.
Network (Quasar replication) — singleton collapse (v0.48.1)
BASE_NETWORK=quasar only engages the Quasar cross-pod plane when at least
one peer is present. A pod started with BASE_PEERS="" (empty or unset)
collapses to the standalone noop: no ZAP listener, no self-dial, no
reconnect loop. Same binary scales 1 → N by adding peers to BASE_PEERS.
Env matrix:
| BASE_NETWORK | BASE_PEERS | Enabled | Behavior |
|---|---|---|---|
| unset | * | false | legacy single-node SQLite |
standalone |
* | false | explicit standalone |
quasar |
empty | false | sole writer, no replication |
quasar |
a,b,... | true | full Quasar quorum over ZAP |
BASE_PEERS entries may be the operator-emitted pod FQDN
(liquid-bd-0.liquid-bd-network.<ns>.svc.cluster.local:9999) while
BASE_NODE_ID is the bare hostname; isSelfPeer matches on the first DNS
label so the transport never dials itself.