Commit Graph
12 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 10f67d734c Org Bases open under the KMS master key or not at all
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]>
2026-09-11 13:58:52 -07:00
zeekayandHanzo Dev 0fd5c6b353 an off-switch that does nothing is worse than one that is not there
Six env flags, two conventions, and both failed silently against the other.
Five read `== "true"`, so BOOTNODE_ENABLED=1 left bootnode off and
CALENDAR_ENABLED=1 left the calendar off. One read `!= "1"`, so
BASE_ENABLE_ADMIN_UI=true served no admin. And ZAP_DISABLED read `!= "true"`, so
an operator who wrote ZAP_DISABLED=1 to stop the listener watched it come up
anyway. In every case the flag was accepted, ignored, and never mentioned again.

This tree already answers "read a boolean from a string" four times, with
strconv.ParseBool (tools/filesystem, tools/search, tools/router,
core/settings_query). The hand-rolled comparisons were the deviation, so
osutils.Bool is that answer given a name and a default, and all six sites read
it. Strictly more permissive: every spelling that worked before still works, and
the ones that silently did nothing now do what they say.

An unparseable value yields the default rather than its opposite — someone who
typed something meaningless has not asked for the other thing.

Coverage: plugins/zap 5.9% -> 23.5%, and DefaultConfig is pinned including the
case that started this (ZAP_DISABLED=1 must disable) and a garbage ZAP_PORT,
which must keep 9999 rather than quietly binding somewhere else.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 10:34:54 -07:00
zeekayandHanzo Dev 55aa586615 a product states which of its own routes a publishable key reaches
What such a key may reach was a list of addresses kept in the rule that
reads it. The list held Base's own routes and nothing else, so bootnode's
twelve reads — which it registers precisely to be reachable that way, and
guards with a read-only refusal beside each of its eleven writes —
answered 403 wherever the plugin was on.

A list of one package's addresses kept in another goes stale the moment
that package mounts a route, and it goes stale silently. So the answer
moves to the route: apis.Public declares one where it is registered, and
the rule holds no addresses at all. Bootnode declares its twelve reads;
Base declares its records, rest, files and health reads. The rule keeps
one address of its own, the public submit path, because whether it is
public is the collection's statement and not the route's.

A route needs to know its whole address for that, so the mux records it
as it mounts: Route.Pattern is the method, the group prefixes and the
path, the same string a request reports having matched.

Second layer, restored: bootnode read ReadOnly off the identity headers,
which the org plugin writes identically for every key it resolves — so a
publishable key arrived as a person and no read-only refusal could fire.
The headers say who; the credential says what kind.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 06:55:07 -07:00
zeekayandHanzo Dev 89adc2a014 one reader answers what credential a request carries
A rule that decides what a credential may reach and a door that resolves
it agree only if they read the same bytes. Three readers each accepted a
different subset of the spellings: the rule read `Bearer ` case-sensitively
from one header, the auth door matched the scheme without regard to case
across three headers and accepted a bare token, bootnode read X-API-Key
first. Past the /v1/iam and /v1/idv mounts — which forward headers and
query verbatim with the key doors unbound — the rule is the only thing
standing, so a spelling it did not read was a key the issuer authenticated.

apis.Credential is that one reader: the Bearer scheme matched without
regard to case and separated by spaces or tabs, a bare token, the four
headers in order, then the `key` query parameter. A value naming another
scheme presents no token, so a Basic blob is not compared against key
prefixes. The query is split on `;` as well as `&`, because this answer
decides what gets forwarded and the far side may split either way.

bootnode classifies by the credential rather than by which header carried
it — the same token was a JWT under one header and unsupported under
another — and a shape it does not recognise is IAM's to judge.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 06:47:32 -07:00
zeekayandHanzo Dev 60055ec45d address IAM by its nouns, and present a credential when doing it
The five user verbs collapsed onto the user collection and the key half went to
the key endpoint, so this client asks the same questions at the addresses that
answer them:

  get-user?accessKey=  -> GET  /v1/iam/keys/principal   (envelope)
  get-users?field=     -> GET  /v1/iam/users            (the page itself)
  get-user?owner&email -> GET  /v1/iam/users            (the page itself)
  add-user             -> POST /v1/iam/users            (the record itself)

Three things follow from the move rather than accompany it.

The credential travels in the Authorization header. A query clientId is not a
credential IAM reads, so every one of these reads would have arrived
unauthenticated; the plugin installs the application credentials it already
holds in Config where the client is built, and each read presents them.

The collection narrows on the address and on nothing else. LookupByAttribute
answers "email" and refuses any other attribute instead of sending a filter that
would be ignored — an ignored filter answers with the org's first page, and
every row on it reads as a match.

EnsureUser reads before it writes. The create refuses a duplicate username and
admits a duplicate address, so creating first would file a second row under one
address and leave both unresolvable by it. A username lost to a racing provision
still resolves by the address the two agree on.

Where an address names two accounts the answer is a refusal, not the first row:
that choice is how somebody acts under a colleague's identity. The team invite
asks for two for the same reason and stays pending when it sees them.

A resolved key carries the account the key endpoint names — owner/name, unique
across tenants where a bare username is unique only inside one — and an
unresolved one yields no user at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-24 01:47:45 -07:00
zeekay d9f7c4da8e the storage tier can address the backend it selects
BASE_DB_TIER=sql opened PostgreSQL and then spoke to it in SQLite: strftime,
sqlite_master, json_extract, json_each, an INTEGER column holding an int64, an
outer join with no ON, backticked identifiers, and a rowid that only one engine
has. The tier picked a backend the queries could not reach.

Every one of those now comes from hanzoai/orm/dialect, resolved once from the
driver the data DB was opened with and reachable as app.Dialect(). Base holds no
switch on the engine — it asks for the JSON accessors, the schema catalog, the
generated instant and identifier, and the column types, and it asks whether a
feature exists at all where one engine has nothing to offer: an insertion-order
column, a write-ahead log to fold, a function that renders an instant. Empty
means the caller falls back or refuses rather than emitting something the engine
will reject.

Some differences turned out not to need a dialect at all. An instant is stored
as text in a fixed layout, so bucketing the log rollup to the hour is a
substring rather than a calendar function, which every engine spells the same
way and an index can be built on. Null-safe equality is IS NOT DISTINCT FROM.
An int64 column is BIGINT. A join with nothing of its own to match still states
a condition. Case-insensitive comparison stays COLLATE NOCASE, because the
schema now asks the engine for the collation that name refers to.

The proof is a Postgres 18 running the data plane: migrations apply, a
collection created over /v1 lands as a table with text, numeric and jsonb
columns and its declared index, and records insert, page, sort, filter across
scalars, multi-value elements and JSON paths, update and delete. A unique index
declared COLLATE NOCASE rejects the second spelling of a handle.

Comparing a JSON sub-path to a number is refused there: the extraction yields
text, the literal binds as a number, and the filter language does not say which
the comparison meant. Compared as text it works.
2026-08-11 10:39:19 -07:00
zeekay 054d65663a they are Bases, one per org — the word tenant is gone
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.
2026-08-10 18:23:40 -07:00
zeekayandHanzo Dev e0521b1568 test: align the IAM mock with the path its client actually sends
The fake IAM answered /api/get-users while LookupByAttribute has sent
/v1/iam/get-users for some time, so the case was unreachable. The test still
passed — the caller treats a lookup error as 'no match' — which is why the
drift went unnoticed. Instrumenting the branch confirms it is still not
exercised by any test here; that gap is pre-existing and worth its own test.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 11:47:57 -07:00
zeekayandHanzo Dev 133906dc11 fix(iam): use the canonical HIP-0111 JWKS path, derive the origin from it
JWKS was pointed at the bare /.well-known/jwks; IAM serves it at
/v1/iam/.well-known/jwks (HIP-0111). Because IAM answers a 200 text/html SPA
catch-all for any unregistered path, the wrong path is silent breakage rather
than a 404 — the fetch "succeeds" and returns markup where keys should be.

iamOrigin() recovers the IAM origin by trimming that one canonical suffix, so
the retired-endpoint pointers and the auth-methods authorize URL derive from a
single configured value instead of a second hand-maintained setting.

Build + apis/plugins tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 18:17:57 -07:00
069fcecb5e feat(admin): Twenty-grade editable record grid on @hanzo/ui (true-black) (#30)
* test+fix: clear pre-existing base test failures (iam /api→/v1, kube ns, pool timing)

These failed on main already — surfaced once the zip-dep failure (#24) stopped
masking the test run. None relate to zip; all are real drift/bugs:

- iam userinfo path drift (5 failures): the client correctly requests the
  canonical /v1/iam/oauth/userinfo (HIP-0111), but the test fake-servers still
  registered the legacy /api/userinfo → 404. Updated iam_test.go (×4 + comment),
  platform_test.go, bootnode_test.go to the /v1 path. /api/get-user(s)/add-user
  are left as-is (client + tests agree there; a separate migration).

- kube namespace bug (real): Client.New(defaultNamespace) let the in-cluster
  serviceaccount namespace file OVERRIDE an explicit namespace — so an ARC
  runner (arc-system) silently clobbered the caller's 'bootnode'. Now the pod
  namespace is adopted ONLY when the caller passed "". Explicit config wins.

- db pool idle-eviction flake: IdleTimeout 50ms was shorter than the 3-pool
  setup loop on a loaded runner, so the sweeper evicted pool 0 before the
  Len()==3 assertion. Bumped to 300ms (+ wait 150→400ms).

* build: goreleaser before-hook go mod download, not tidy (fixes release SECURITY ERROR)

The release workflow runs GoReleaser on every PR; its before-hook ran
`go mod tidy`, which RE-RESOLVES the module graph and trips the
authenticated-git-only private module set (SECURITY ERROR / go.sum mismatch on
the luxfi deps) — the Test and build steps pass because they verify against the
committed go.sum via the runner's git auth instead of re-resolving.

Release must never re-tidy (it mutates committed deps at release time). Swap to
`go mod download`, which just populates the cache against the existing go.sum —
same resolution path as the passing `go test ./...` step.

* ci(release): GOPRIVATE + git-auth so goreleaser resolves luxfi from git

The goreleaser go-mod hook fetched luxfi/keys from the public proxy (re-pushed
bits) instead of authenticated git, so go.sum verification failed (SECURITY
ERROR). Add GOPRIVATE + the same git insteadOf auth the Docker gate uses, at the
job level, so test + the download hook + the builds all fetch hanzoai/* +
luxfi/* from git — matching the committed go.sum.

* feat(admin): @hanzo/ui true-black design system + buildable Vite baseline

The ui-react admin was un-buildable WIP: package.json listed wouter/react-query
while src used @tanstack/react-router + @luxfi/ui (Tamagui) with no Tailwind
pipeline, a broken "/base" SDK import in all 19 routes, and a duplicate
getRecord export. Nothing compiled. This lands a buildable baseline on the
true-black @hanzo/ui shadcn design system.

Verdict on @hanzo/ui consumability (settled empirically): the published
@hanzo/ui@8 (gui/Tamagui, raw-TS source) and the @hanzo/ui barrel are NOT
Vite-consumable — the barrel's index.mjs hard-imports next/image, next/link,
cmdk, sonner, vaul, react-resizable-panels (unresolvable in Vite). @hanzo/ui@5
granular subpaths resolve but several components are coupled to bespoke theme
classes. So we adopt @hanzo/ui's token table + true-black override and vendor
the shadcn primitives (the pattern @hanzo/ui's own templates/vite-app uses).
Verified in a browser: bodyBg #000, card #0a0a0a, hairline #171717,
near-white primary, Basel + Geist Mono, .dark active.

- package.json: real deps (@hanzo/[email protected], react-router, tailwind v3, radix,
  react-hook-form); drop wouter/@luxfi/ui
- tailwind.config.cjs + postcss.config.cjs + src/index.css: shadcn HSL token
  scheme, true-black .dark (#000 canvas, gui surface/border ladder), Basel
  (self-hosted) + Geist Mono fonts
- src/components/ui/{button,input,label}.tsx: vendored shadcn primitives on the
  shared tokens; src/lib/cn.ts
- src/lib/api.ts: fix duplicate getRecord export (-> getRecordById), add
  getFullRecords paging + real /v1/realtime SSE client, export request
- src/lib/base-client.ts: BaseClient object API over the /v1 fetch layer —
  unbreaks all 19 routes with zero route rewrites; base.ts drops broken "/base"
- main.tsx: QueryClientProvider, import css, pin .dark, router basepath bound to
  BASE_ADMIN_UI_PATH (fixes /_/ mount routing); vite.config proxies /v1
- login.tsx converged onto @hanzo/ui primitives (first true-black route)
- delete dead src/pages/* (old wouter generation, imported nowhere)
- embed.go: correct the stale build comment (source is ./src, pnpm build)
- rebuild committed dist/ (embedded by //go:embed)

* feat(admin): Twenty-grade editable record grid on @hanzo/ui

Replace the read-only records table with an inline-editable data grid — the
heart of "edit everything". Cells own a display/edit split with a single
commit/cancel protocol; the grid owns a two-tier keyboard cursor.

- components/grid/RecordGrid: sortable columns (per-field sort toggle), sticky
  header, row selection + select-all, per-row actions (open/duplicate/delete),
  two-tier cursor — arrows move the active cell, Enter/F2/type-to-edit enters
  edit mode, Tab advances. True-black, hairline rows.
- components/grid/EditableCell: display mode → floating Popover editor per type
  (text/number/date/textarea/json/select/relation); bool toggles in place;
  file/password/autodate render read-only. json validates on commit.
- lib/fields: field-type → editor-kind mapping + display formatting + API value
  coercion, orthogonal to the grid.
- routes/collections.$id.records: optimistic single-field persist (react-query
  onMutate cache patch + rollback on error), create (→ detail panel), duplicate,
  single + bulk delete behind a confirm Dialog, filter, pagination.
- vendored primitives: checkbox, select, popover, dropdown-menu, dialog,
  textarea, badge (shadcn on the shared tokens); add the radix deps.
- rebuild committed dist/

* feat(admin): record detail/edit panel on @hanzo/ui with per-type editors

Rewrite the record editor on @hanzo/ui primitives + lib/fields. Each field
renders the right editor by type — Input (text/number/date/url/email), Textarea
(editor/json, json validated on submit), Select (single select), Checkbox
(bool), file input (upload), relation as id csv. Auth collections get a
password field; id + autodate render read-only. Controlled form state, optimistic
invalidate, create/save/delete. True-black card, Basel type. Drops react-hook-form
in favor of a small controlled form so @hanzo/ui Select/Checkbox bind cleanly.

* feat(admin): converge root sidebar + collections list to true-black @hanzo/ui

Phase 1 of the view convergence. The always-visible chrome and the entry point
to the grid now match the true-black system.

- __root: sidebar on bg-background/border-border with lucide icons + accent
  active state (was bg-neutral-950 + indigo)
- collections: clickable collection rows -> /collections/$id/records, type/system
  badges, @hanzo/ui Input filter + Button delete (was raw table + indigo button)

* fix(admin): flat route escapes so record grid/editor are reachable + safe default sort

Two correctness fixes surfaced by end-to-end browser verification against a real
Base server:

1. Routing: collections.$id[.records[.$recordId]] nested as TanStack layouts, but
   the parent components render content (not <Outlet/>), so /collections/$id/records
   rendered the collections list instead of the grid. Rename to flat escaped routes
   (collections_.$id_.records...) so each is a standalone route under root — URLs and
   all Link targets unchanged.
2. Default sort '-created' 400s on this fork's base collections (no `created` field).
   Default to '' (API insertion order, valid for any schema); sorting stays opt-in
   via column headers.

Verified: grid renders true-black against /v1, inline text edit (floating editor)
and bool toggle both persist to the real Base API (confirmed server-side).

* docs(llm): admin @hanzo/ui rebuild — consumability verdict, IAM-native auth finding, phased plan

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-03 17:26:28 -07:00
zeekayandGitHub d7d7bd33c0 feat(bootnode): resolve web3.* and .cloud apex brands in OAuth redirect (#16)
NetworkFromRedirectURI only handled cloud.<net>.<tld> and a substring
match on bootno.de. The four live brand surfaces — web3.hanzo.ai,
web3.lux.network, web3.zoo.ngo, web3.pars.id, plus the apex hosts
lux.cloud and zoo.cloud — fell through to "" and would have exchanged
the OAuth code against the fallback IAM client id instead of the shared
lux-web3 app.

Add an apexNetworks table for bare brand TLDs (bootno.de, lux.cloud,
zoo.cloud) and extend the subdomain rule to accept the web3 entrypoint
alongside cloud. Both prefixes map to the same per-network IAM redirect;
they are alternate brand surfaces over one IAM app.

Tests assert every live hostname resolves to lux-web3 and that unknown
hosts still fall back. No IAM logic duplicated — this only derives the
client id from the redirect host.

Co-authored-by: zeekay <[email protected]>
2026-06-18 21:07:13 -07:00
zeekayandGitHub 7dcecb01c9 feat(bootnode): Go port foundation as a Base plugin (5 modules end-to-end) (#15)
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]>
2026-06-18 17:16:15 -07:00