35 Commits
Author SHA1 Message Date
zeekay 4dd1d9686c The admin UI knows the new field types 2026-10-18 20:58:38 -07:00
zeekayandHanzo Dev db45138cb7 Offer the one provider Base serves
Twenty-three buttons, twenty-two of which the validator now refuses with a
400 and, before that check existed, saved clean while authenticating nobody.
Base has one provider by design.

smoke.mjs has imported playwright since it was written and package.json never
said so, so the check could not run from a clean install.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 21:55:06 -07:00
zeekayandHanzo Dev 99a3d82c8e Merge api-prefix
Two conflicts, both version pins. go.mod takes the branch's
hanzo.ai/tygoja v0.0.3 — plugins/jsvm imports the vanity path, and the
old github.com/hanzoai/tygoja line no longer resolved it — over main's
newer github.com/hanzos3/go v1.0.2. ui-react/package.json takes the
branch's exact pins at main's newer numbers, and the pnpm override moves
with the dependency it overrides rather than holding @hanzo/ui two
releases back.

The branch tip did not compile on its own: it removed tools/auth but left
core/collection_model_auth_options_test.go importing it to exercise the
InitProvider it also removed. That test goes with the code it tested.

@tanstack/react-router pins 1.170.33 because router-plugin 1.168.36 peers
on ^1.170.33; exact pins do not float up to meet a peer the way the
ranges they replace did.

go vet ./... clean, ui-react builds and typechecks, dist rebuilt.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 15:16:21 -07:00
zeekay b7eff88cb8 the face is Zen, and it ships from @hanzo/font
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.

Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.

Comments naming a face we no longer use are deleted, not reworded.
2026-09-01 14:23:58 -07:00
zeekay 6e81a4c3ed fix(font): the admin serves the faces it declares
The pin moved to @hanzo/design ^0.5.23 without the lockfile, which still
resolved 0.4.5, so the committed dist was built against the version that
shipped Geist: 141 KB of Vercel's face and an @font-face declaring family
Geist. The dist was then edited by hand to say --font-sans:"Zen","Zen" — a
family no @font-face defined — so the admin rendered in the OS sans and
downloaded Geist that nothing used.

The lockfile agrees with the manifest and dist is what the build writes. The
floor is 0.5.25 because 0.5.23 and 0.5.24 carry Zen's name over Geist's bytes
— vendor VRCL, 975 glyphs, no hanzoai/font copyright; identity.py refuses
them. 0.5.25 is the first release whose faces are ours.
2026-08-28 17:14:28 -07:00
zeekay 6c2eb7b4f3 fix(type): the binding is named for the face it holds
A docs-site template repeated across these repos named three different faces in
one place: a binding called `inter` or `geist`, constructed from next/font/google
so it actually held Inter, publishing a custom property called
`--font-geist-sans` that @hanzo/font does not set. Whichever of the three a
reader trusted, two of them were wrong.

They take Zen and ZenMono from @hanzo/font — bindings named `sans` and `mono`
for the role they fill, setting --font-zen-sans and --font-zen-mono, which is
what the package publishes.
2026-08-28 08:51:31 -07:00
zeekay 268edfb66c the mark takes its colour from the page, and sign-in says Hanzo ID
The brand marks are drawn in currentColor so one file serves a light page and a
dark one. They were rendered with <img>, which is a separate document and does
not inherit it, so the mark resolved to black and disappeared against the black
the admin renders on. As a CSS mask the colour comes from the side that knows —
the page — and a brand that changes its mark needs nothing here.

Sign-in named the software rather than the product a person signs in to. It is
Hanzo ID. The package is still @hanzo/iam and the code still says so; this is the
sentence a visitor reads.

The brand asset's cache-eviction parameter moves to v2. The edge is still
answering HTML at 200 for /brands/hanzo/logo.svg, which is the failure the
parameter exists to step around and the reason the mark is missing in production
today rather than merely dark.
2026-08-21 13:14:19 -07:00
zeekay b5949b17b6 a count nobody could take leads the list and marks the total a floor
DescribeDatabase already reported an uncountable collection as unknown rather
than as empty, and the page already showed it as "?", but it showed it at the
BOTTOM of a scrolling list — sorted below the empty ones, because null read as
-1 — so the one row worth going and looking at was the one row nobody would see.
Unknown now leads.

The headline total carries a "+" when some collection could not be counted, and
now says what the "+" means. Without that it was a mark with no reading.

TestDatabaseUncountable holds both halves of the report at once: a dropped table
makes three collections uncountable, and the case asserts those read null while
the rest still carry their counts. Watched fail twice — once with the count
error returned instead of logged, which answers 500 for the whole page, and once
with the count written unconditionally, which reports the broken ones as empty.
2026-08-21 11:58:30 -07:00
zeekay fe65917d82 a Base can say where it keeps its records, and reclaim what they no longer use
The admin had no storage surface at all: nothing in ui-react named an engine, a
file or a size, so the one question a growing Base raises — how much is in here
and where is it — could only be answered by finding the process's data
directory.

`/v1/database` answers it, and Settings -> Data renders it. core.DescribeDatabase
measures the engine, the files while the app holds them, and a count per
collection through CountRecords, so both engines answer the same questions and
the answer has one shape. A collection the engine refuses to count reads as
unknown rather than as empty, and is logged, so one broken view leaves the rest
of the report standing rather than taking the page down.

core.ReclaimDatabase is the one thing the page can DO, and it does something
real: it rewrites both databases without the pages deleted records left behind,
folds the log back into them, refreshes the statistics, and returns the size
either side so the page reports what was freed rather than claiming something
was. Driven in a browser it took a database from 9.0 MB to 1.1 MB with the
record counts unchanged.

The rewrite goes BEFORE the fold. A VACUUM lands through the log like any other
write, so folding first leaves the whole rewrite sitting in the log and the app
holding more than it started with — 2,068,480 bytes to 2,665,912 that way, and
to 593,920 this way. Both orders shrink something, which is why
TestDatabaseReclaim measures each database separately: an earlier version was
satisfied by 8 KB off the auxiliary log and went on passing with the rewrite
removed entirely.

There is no switch, because whether an instance belongs on a server is the
host's question and Base stopped having an opinion about it. The page states
which of the two it is on and what the other would mean, and it does not name a
backend Base cannot open — a disabled control for one would be the datastore
tier constant again, in a place a customer can see.

core.LocalDatabase is now the single place that asks whether the app holds the
database; backup asked it inline and now asks it there. tests/engine gains the
cross-engine cases, which CI already runs against PostgreSQL 18 — the LLM.md
line saying it does not was two releases stale. The third copy of a byte
formatter went into ui-react/src/lib/format.ts and the two existing pages now
read from it.
2026-08-21 11:53:27 -07:00
zeekay 8ef5f14055 functions reach the admin, and the binary can run them
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.
2026-08-21 11:28:19 -07:00
zeekayandHanzo Dev b93afcace1 a fortnight's cache belongs to content-addressed files only
This route falls back to index.html for any path it cannot find, which is what
makes deep links work — but the fallback is served UNDER THE REQUESTED URL, at
200, and every non-root path was being marked cacheable for two weeks. So a path
that does not exist YET becomes HTML for two weeks after the release that adds
it.

Measured rather than reasoned: /brands/hanzo/logo.svg was requested before the
release carrying it, the edge stored the fallback, and afterwards it still
answered text/html at 200 with age=2888 while its five siblings — never
requested early — served correctly. That is the failure ui-react/src/icon.ts
already describes: HTML where an image was asked for, at 200, rendering broken
with nothing in the console and nothing in the network log.

Only /assets carries a content hash in the name, so only /assets can be
immutable. Everything else now gets five minutes: long enough to spare the
origin, short enough that a stale answer heals itself.

The header is fixed here, but a stored answer cannot be fixed from the origin —
only stepped around — so the in-app mark asks for ?v=1 to leave the poisoned
entry behind.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 08:22:56 -07:00
zeekayandHanzo Dev 45fbd3d9cd base wears the mark of the host that served it, not a generic glyph
base.hanzo.ai showed a rounded-square "B" — a placeholder, not Hanzo's logo —
in the tab and in the app, which renders it from icon.ts in __root and login.

The real marks already exist and are already served per brand at
/brands/<brand>/logo.svg. They are carried here rather than linked cross-origin,
and they draw in currentColor, so they take the surface's own colour instead of
shipping a second palette.

ONE map from host to brand, in index.html, resolved before the bundle loads and
left on window for the app to read. The alternative was that map plus a copy in
the bundle, and two answers to "which brand is this host" is exactly how a page
ends up titled Hanzo over somebody else's logo. Title, favicon and in-app mark
now come from the same answer and cannot disagree.

Checked from the built output across every host the fleet serves:

  base.hanzo.ai / superbase / recipes.base  Hanzo Base + hanzo mark
  lux.id, base.lux.network                  Lux Base   + lux mark
  zoolabs.id, base.zoo.ngo                  Zoo Base   + zoo mark
  localhost                                 Base       + generic glyph

An unknown host keeps the unbranded glyph, and the patterns anchor to the
registrable domain: evil-hanzo.ai.attacker.com and zoolabs.id.attacker.com are
handed no mark of ours at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 07:32:40 -07:00
zeekayandHanzo Dev 826bfbfc3f build the branded page, and let Lux and Zoo keep their .id hosts
Two things the first pass missed.

ui-react/dist is COMMITTED and is what the server actually serves — .gitignore
un-ignores it deliberately — so editing only the source changed nothing a
visitor could see. The built page carries the change now; no asset hash moved,
so this is the one file.

And the brand map knew lux.network and zoo.ngo but not lux.id or zoolabs.id,
which is where those two brands actually answer — the id fleet serves hanzo.id,
lux.id, zoolabs.id, osage.id and pars.id off one ingress. A map that omits half
a brand's hosts white-labels half the time.

Checked against every host the fleet serves and both lookalikes:
base.hanzo.ai and superbase → Hanzo Base, lux.id → Lux Base, zoolabs.id → Zoo
Base, and evil-hanzo.ai.attacker.com / zoolabs.id.attacker.com stay unbranded
because the patterns anchor to the registrable domain.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 07:23:10 -07:00
zeekayandHanzo Dev 9525b093b8 the mark in the corner is the one the brand actually uses
The admin drew a rounded tile with a letter B on it — a mark nobody designed,
on a ground that fought whatever chrome it sat in.

It is the Hanzo mark now, taken from the package that owns it rather than
redrawn here, so it cannot drift from every other surface. No ground: it takes
its tone from the chrome, dark on light and light on dark, and a renderer
resolving no colour still gets a white core inside a black rim rather than
nothing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 10:22:37 -07:00
zooqueen 052ea6cc63 the links scroll, so the account stays on the phone
Giving the strip the overflow instead of the links left every box the size
it asked for and put two of them off the side of a 390px phone: the links
ran to 532 and carried the account out to 610. Reachable by dragging the
strip sideways, which nobody does, so signing out was gone.

Nothing already measured moves — the document does not grow, the pane does
not overflow, the nav still measures a correct full-width 390 — which is
why the gate that passed it measured all three. It now asks whether the
account's right edge is inside the viewport: 634 against 390 before, 378
after.

`min-width: 0` is the half a flex child does not do on its own, and is what
lets the links become narrower than their content and scroll inside the
space left over.
2026-08-13 18:35:26 -07:00
zeekayandHanzo Dev 53678cd362 an empty box means leave it, and a session renews before it is spent
A secret is never rendered, so the browser cannot compare the box against
what is stored — the field simply is not in the document it received. The
guard that meant to drop an unchanged secret therefore never fired, and an
empty box was merged over the stored value: the mail password on every save
of those settings, and the object-store secret on any save of backup options
while that store was switched off, from a field not on the screen. An empty
box means leave it, stated once for all three.

Whether there is a session is one question and one answer. Routes asked two —
whether a string was present, or whether it had expired — so the weaker one
admitted a dead session into most of the admin. Renewal happens before a
request is sent rather than after one is refused, so the refusal stays the
backstop it was built as.

Renewal is serialized across the origin, because a refresh token rotates on
use and a replay revokes the whole family with no grace: two tabs renewing at
once do not race for a token, they sign each other out. The one that loses
the lock finds the work already done.

Forms offered what the server has no field for: five mail templates and a
collection for a test message that takes an address; a password on a
collection with no such field type; an address setting spelled differently
enough that it read as off and then wrote that off over the real one; and a
log level coloured by a name where the server writes a number.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 18:22:33 -07:00
zooqueen a15250bdd4 a frame cannot host the IdP's login page, so it does not pretend to
Embedded and signed out, the redirect to IAM is the whole point: a visitor
already signed in to the page Base is sitting in has a live IAM session, so
authorize hands back a code without rendering anything and the panel fills
in. Asking them to sign in to Base is the second login this is meant not to
have, so the frame takes the trip itself.

It gets exactly one attempt, marked in sessionStorage. Landing back here
signed out is indistinguishable from arriving, so without the mark a visitor
with no IAM session bounces frame to IAM and back forever.

That visitor cannot finish in a frame — the IdP's own login refuses to be
framed, and is right to — so the answer is Base's real address in a new tab.
Not a form of ours: Base has no password to collect, and a panel that draws
one is claiming an authority it does not have.
2026-08-13 18:14:24 -07:00
zooqueen d3b35335c3 the admin is one UI with three places to be, and it fits all of them
Base renders standalone at its own host and as a section inside a Hanzo
surface. One bundle serves both: the same bytes the binary already embeds,
so a host is current the moment the pin moves, rather than a component
every host recompiles with its own copy of a transport that only works
against the Base that served it.

Who may frame the admin is a CSP source list, BASE_FRAME_ANCESTORS,
default 'self' — which is what the deployment did before. It is stated in
one place, on the admin, the only surface a frame can be aimed at to any
effect. X-Frame-Options goes rather than sitting beside it: it can say
"nobody" or "same origin" and cannot name a host, so an allow-list is the
one thing it cannot express, and a second weaker copy of a rule drifts
from the first. The ingress already says it this way in front of hanzo.id.

Auth is untouched. The framed admin runs its own PKCE, a live IAM session
returns a code without rendering anything, and no token crosses an origin.

The frame drops the brand and the account because the host answers both
already, read from window.self !== window.top — where a document is, is a
fact the browser holds, and a flag is a second answer that can be wrong.

The nav was a 14rem column at every width, which on a 390px viewport left
166px and put the table past the right edge, inside a pane that scrolls —
so the page never grew and no check written against the document could
see it. The strip is now the base layout and the column is what a wide
page of our own opts into, each declared once. smoke gates all three.
2026-08-13 18:09:43 -07:00
zeekayandHanzo Dev 9b98332a0c the admin offers what the server has, and keeps your work while you sign in
Three pages wrote fields no Go struct has, and each reported success: a
password change against a collection with no password field, a superuser
create that wrote a row unable to sign in, and a token page offering two
tokens the product mints nowhere and one that is not a field at all. The
pages that remain offer what Base issues, and replacing a secret is a button
that sends nothing.

An expired session lost the form, not at the refusal — the values survive
that — but on the way to sign in again, because that is a whole-page
navigation. Signing in happens in another tab and the session arrives through
storage, so the next save carries it and the typed values are still there.
Whether a session is valid is now read from the token's own expiry rather
than from a string being present, which had been bouncing the admin off the
page it needed.

An imported document was trusted for its shape as well as its syntax, so a
field holding an object rather than a string unmounted the page and left it
blank until reload.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:09:48 -07:00
zeekay ad48cf4296 the Open button goes where the studio actually is
It is served at /studio on this same host, which the image is built to know —
NEXT_PUBLIC_BASE_PATH moves its routes and its assets together. The dashboard
only has to name the address.
2026-08-11 00:18:44 -07:00
zeekay b61fc76bfb the dashboard opens a Base instead of only naming it
base.hanzo.ai is the multi-tenant dashboard: you sign in through IAM, see the
Bases your token reaches, and go into one. The list was doing the first two.

The destination is a same-origin path, so the session already in the browser is
the session the studio gets — no token handed across an origin boundary and no
second sign-in. It is a link rather than a button because an org with no Base
yet has nothing to open, and a button would have to explain that.

The dashboard states where a Base is opened; it does not encode which service
answers there. That stays an ingress concern, so moving the studio does not
edit this file.
2026-08-10 23:28:08 -07:00
zeekay 66b9335f95 finish removing the Bases twin, and stop asserting on machine speed
Half of this removal is already on main: 25ae70fcb swept up the staged deletion
of core/bases.go and core/bases_test.go, but not the unstaged edits that took
out what referenced them — so main currently declares Bases() *Bases over a
deleted type and does not compile. This is the other half.

What went, and why it could go: core.Bases was a second, complete implementation
of the product's central noun — a per-org database registry with its own
encryption, lazy open, and concurrent/nonconcurrent pools, reachable through
three core.App methods and gated behind MULTI_BASE/MASTER_KEY. It lived in the
same directory as the live one and disagreed with it on everything else:
data.db + auxiliary.db against org.db, one shared master key against a per-org
DEK from KMS, validateOrgID against validateSlug. Two Bases for one org, neither
aware of the other, had both ever run.

Neither ever did. Its only callers were its own tests, no deployment in the
fleet sets either env var, and nothing outside this repo calls Bases(). The live
path is plugins/org, which provisions on the first request carrying an org and
derives that org's key from KMS.

Also fixes TestNotifyWatcher_CollectionsUpdate, which failed under parallel load
and had been widened once before ("1-2 reloads") without the cause being found.
The cause is that reload count tracks the gaps between writes, gaps track
machine load, and the ceiling was a constant — so a loaded runner pushes a
healthy watcher past any fixed number, and no burst size fixes it because the
burst's duration scales with load too. The ceiling now comes from the burst's
own measured duration divided by the watcher's own debounce window, which takes
machine speed out of the assertion entirely while still catching a watcher that
has stopped debouncing (it emits one reload per change at any speed). The 50ms
window was written twice, once as a literal and once in English in a comment; it
is named once now, and the test reads that name through export_test.go rather
than restating the number.

Verified: full core suite green three times under six competing builds, the
configuration that reproduced the failure.
2026-08-10 22:37:20 -07:00
zeekay e9634a77dd Base sends no auth mail, because it has no auth flow to send it for
The verification and email-change templates were the last of local auth. Their
endpoints went in the IAM-native rip — bindRecordAuthApi keeps auth-methods and
auth-refresh and nothing else — so both answered 404 for every deployment, not
only IAM ones, and their buttons addressed a route in an admin SPA that no
longer exists. Re-pathing them last commit made them honest; this removes them,
along with everything that existed only to serve them.

That turned out to be a lot, and each piece was already dead on its own terms:

  core.EmailTemplate and the two collection fields — no other user
  the mails package entire — its two functions were the only callers of its
    own template resolver and its own html layout
  the $mails JSVM namespace — an empty object once the two binds went, and its
    type declarations advertised FIVE helpers, three of which (password reset,
    OTP, auth alert) were removed in that same rip and never bound at all, so
    hook authors have been reading a contract for functions that do not exist
  OnMailerRecordVerificationSend / OnMailerRecordEmailChangeSend — nothing has
    been able to fire either since the mails package was their only caller
  core.MailerRecordEvent — the type those two hooks carried

Nothing loses a capability. $app.newMailClient().send() is the general sender,
and it is what an app binding its own confirm route would have used anyway; the
two removed binds were convenience wrappers over it.

POST /v1/settings/test/email stays and gets better. It rendered a template
against a throwaway record, so "can this deployment deliver mail" was answerable
only through a flow that no longer exists. It sends a plain message now — no
template, no collection, no record, no token — which is the question an operator
has after editing SMTP settings.

The collection fields are DEFAULTS applied at creation; a collection stores its
own copy, and an options key the struct no longer declares is ignored on read,
so nothing already deployed changes shape. types.d.ts is regenerated by its own
generator rather than hand-edited (the generator still listed the deleted
package, which is why it had to be fixed first).
2026-08-10 21:14:46 -07:00
zeekay 84fc18ac38 three links into an admin that no longer exists
Each pointed at `/_/#/...` — the old admin mount plus a hash route in the SPA
this fork replaced — and each had been dead since that rewrite. They survived
because nothing about them fails loudly: the server answers an unknown path with
the admin page at 200, so a link to nowhere looks like a link.

The first boot message handed an operator a browser URL at
`/_/#/baseinstal/{token}` and printed the CLI command underneath as a fallback.
There is no `baseinstal` route, so the fallback was the only thing that ever
worked; it is now the instruction. Nothing mints a throwaway superuser token to
put in that URL any more either.

The other two are the default verification and email-change mails, whose buttons
went to `confirm-verification` and `confirm-email-change`. Those endpoints were
deleted in the IAM-native rip — bindRecordAuthApi keeps auth-methods and
auth-refresh and nothing else, and both paths answer 404 for every deployment,
not only IAM ones. So the button in the one mail a person is most likely to
click had nowhere to go. The defaults now link to the app itself and say that
the account is where the address is confirmed. {TOKEN} still substitutes, since
an app can bind its own confirm route from a hook and write its own template —
what a default may not do is name a route on the reader's behalf and be wrong.

These are defaults, applied when a collection is created; a collection stores its
own copy, so nothing already deployed changes. The mails and jsvm suites go on
asserting the old shape against their fixtures, correctly: they test that a
template's placeholders get substituted, whatever the template says.

Also removes the last /_/ from the settings placeholder and from smoke.mjs, which
was serving the bundle at the old mount — a visual check against a layout nothing
runs would pass while the real page broke. LLM.md documented the deleted knob.
2026-08-10 20:28:53 -07:00
zeekay 6f3e1c93c7 the admin is the site: serve it at the root, not at /_/
base.hanzo.ai IS the admin, so it now lives at the address people type. The API
answers under /v1, a longer pattern, so it wins every request that belongs to it.

/_/ came from upstream and cost more than it looked. Three things had to agree —
the server's mount path, the SPA's build-time base, and the redirect URI
registered with IAM — and each was configured separately, so any asset written
as a root path (the logo, twice) missed the mount and landed on the single-page
fallback: a 200 carrying HTML where an image was asked for. There was also a
knob to move the admin and a second knob to redirect / to wherever the first one
pointed; both are deleted, along with the path they existed to reconcile.

Login needs no coordination: the SPA derives its callback from the same base, and
the issuer already accepts https://base.hanzo.ai/auth/callback — verified against
the live application record before making the change. The seed here still
described the old path and now describes the real one.
2026-08-10 19:56:12 -07:00
zeekay 581065c45f the admin logo resolves against the path the admin is mounted at
The admin mounts under a base path (/_/ by default) and the logo was addressed
from the root, so the browser asked for /icon.svg, reached the single-page
fallback, and was handed HTML where it wanted an image — at 200, with nothing in
the console and nothing in the network log. The logo simply rendered broken, on
the sign-in card, which is the first thing anyone sees.

Vite rewrites the references it can see: the favicon in index.html already
carried the prefix. A path written as a string in JSX is opaque to it and ships
verbatim, which is why the same asset was right in one place and wrong in two.
The base now lives in one value both usages read.

Bundle rebuilt — dist is committed and CI does not rebuild the SPA — and it
carries /_/icon.svg. The browser check asserts every image DECODES rather than
that its request returned a status, which is the only assertion that catches
this: the status was always 200.
2026-08-10 19:40:44 -07:00
zeekayandHanzo Dev 7ea0929ccd admin: a control's edge is --border-control, not --border-strong
@hanzo/design 0.4.2 moved control edges off the neutral ladder and back
onto alpha, so a resting field is a hairline again instead of a mid-grey
box. This sheet was still asking for --border-strong on three controls —
the input/select/textarea, the outline button and the dashed add button —
which is the decoration rung, one step louder than the control rung.

.preview is the exception and stays off the tokens entirely. It paints
its own #fff and this admin is dark-only, and a border composites over
its own element's background, so every white-alpha boundary in the dark
theme draws nothing at all there. It takes rgb(0 0 0 / .16) — the value
--border-strong itself carries in the light theme, because a light ground
needs a light ground's edge.

Pins ^0.4.5, not ^0.4.2: 0.4.2 shipped a stray */ in styles.css that
PostCSS rejects outright, so it is not a version anything can build
against. dist/ is rebuilt here because embed.go serves it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 11:10:46 -07:00
zeekayandHanzo Dev 53b538f2fb admin: vite 8, and drop the two deps that were holding it back
Every other package in this admin was already on its latest published version;
vite was two majors behind at 6.4.3. Both plugins support 8 at their latest
(@vitejs/plugin-react 6.0.5 peers vite ^8.0.0, router-plugin 1.168.23 peers
>=8.0.0), so the pin was the only thing keeping it there.

`esbuild` was a direct devDependency that nothing imported: no source file
references it and no plugin here peers on it — router-plugin's peer list is
rsbuild/vite/solid/webpack/react-router. It only surfaced as a problem because
vite 8 peers esbuild ^0.27||^0.28 and 0.25 tripped it, which is how a
vestigial dep announces itself. Removing it is what silenced the warning;
bumping it would have kept an unused package on the list.

`build.rollupOptions.output.manualChunks: undefined` set a default to its own
default. Removing it produces byte-identical output (same content hashes), and
under rolldown the option is spelled elsewhere anyway, so it was config that
could only mislead.

Rolldown does the same work in a fraction of the time and ships less of it:
7.9s -> 1.3s, 871 kB -> 836 kB (gzip 269 -> 254).

Verified the bundle, not just the build — the whole point of smoke.mjs is that
a green build says nothing about what renders, and a bundler swap is exactly
the change that could quietly break it. All 19 routes paint, every token
resolves, no utility-class residue, no unprobed control, overlays open, confirm
dialog measures 384.

pnpm build 0 (vite v8.2.0, built in 1.34s) · pnpm smoke SMOKE OK 0 ·
CGO_ENABLED=0 go build 0 · go test -count=1 ./ui-react/... ./apis/... ok 144.5s

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:05:08 -07:00
zeekayandHanzo Dev 4152cae838 admin: assert the dialog width the smoke test only printed
The 8.x rewrite this finishes traded ~220 utility classes for a token sheet
and moved the overlays onto @hanzo/ui + @hanzo/gui. gui drops a prop it does
not recognise in SILENCE, so the smoke test opened the confirm dialog and
measured it — but measured it mid enter-animation, read ~viewport width, and
then never compared the number to anything. It printed 1265 next to a
`maxW={384}` and still said SMOKE OK.

Settle a frame, read the panel, and gate on it. The expected width is parsed
out of the route that asks for it, so the check cannot drift from its subject.
Proved the gate fires by patching `maxW:384` to `maxW:void 0` in the built
bundle: 1239 vs 384, SMOKE FAILED, exit 1.

Same class of hole one layer up: a control probed as null was reported as
"absent from the output" rather than as a check that never ran. `.panel` was
absent on every route because the run signs in before its first navigation and
/login is the only page that renders one. Look at the signed-out login card
first, and fail on any control that was never on screen.

Also retires what the rewrite left behind:

- `authWithPassword` in api.ts and base-client.ts. The fork retired local
  password auth and the server 404s `auth-with-password` for EVERY collection
  (apis/middlewares_test.go); login is IAM PKCE. Dead code aimed at a dead
  endpoint.
- `scripts/sync-admin-ui.sh`, which copied a bundle built out-of-tree in
  gui/apps/admin-base. The SPA is built here now, so that was a second way to
  do one thing.
- `ui-react/.smoke/`, scratch probes superseded by smoke.mjs (they hardcode an
  absolute path to another checkout and carry a stale built bundle).

LLM.md and ui-react/README.md described the stack this replaced — pin
@hanzo/ui@^5.7.1, adopt vendored shadcn, wire Tailwind v3, sync from
gui/apps/admin-base — under the old package names. Rewritten to what is true:
@hanzo/ui on @hanzo/gui, zero Tailwind/Radix/shadcn, tokens as plain CSS
custom properties, and the reason a green build proves nothing here.

pnpm build 0 · pnpm smoke SMOKE OK 0 · go build 0 · go vet 0 · go test
./ui-react/... ./apis/... ok 70.5s (CGO_ENABLED=0, the way it ships —
core/sqlite_math_required.go is a deliberate compile error for cgo builds
without -tags sqlite_math_functions).

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 00:58:25 -07:00
zeekayandHanzo Dev 00d80514d3 wip: preserve agent work interrupted by session limit
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 00:30:46 -07:00
zeekayandHanzo Dev e51411a97c wip: preserve agent work interrupted by session limit
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 19:18:14 -07:00
zeekayandHanzo Dev b414a135c6 fix(base-admin): right-align + tabular figures for numeric grid columns
gitleaks / scan (push) Waiting to run
Pixel QA of the Twenty-grade record grid: numeric columns rendered as plain
left-aligned text, so digits didn't line up column-wise. Number columns now
right-align (header + cells) and number/date cells use tabular-nums, so amounts,
counts and timestamps read as precise, column-aligned data — matching the grid's
mono/tabular intent. Rebuilt the embedded dist (tsc -b && vite build, base=/_/).

The grid already scrolls horizontally INSIDE its container on narrow viewports
(verified 360/390/768/834/1440 — no body overflow), so mobile keeps readable
column widths rather than compressing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-05 05:47:21 -07:00
9faf979740 feat(admin): IAM PKCE login — replace retired _superusers password auth (#31)
Docker / test (push) Waiting to run
Docker / docker (push) Blocked by required conditions
gitleaks / scan (push) Waiting to run
release / build (push) Waiting to run
* feat(admin): IAM PKCE login via @hanzo/iam (retire dead _superusers password)

The admin login.tsx still POSTed to the retired
/v1/collections/_superusers/auth-with-password endpoint (404/410 Gone),
so nobody could reach the admin. Base is IAM-native (HIP-0111): rewrite
sign-in as OAuth2 PKCE (S256) against Hanzo IAM via the @hanzo/iam SPA
SDK.

- lib/iam.ts: IAM SDK instance. serverUrl=hanzo.id, client_id=hanzo-base
  (<org>-base), redirect_uri under the admin mount (BASE_URL) /auth/callback,
  scope 'openid profile email'. Direct to IAM (not Base's /v1/iam proxy,
  which strips the prefix into IAM's SPA catch-all); IAM sends CORS for the
  admin origin.
- routes/login.tsx: 'Sign in with Hanzo' -> iam.signinRedirect(). No local
  password. @hanzo/ui true-black.
- routes/auth.callback.tsx: iam.handleCallback() exchanges the code, then
  bridges the IAM access-token JWT into the Base auth store. Base /v1
  validates it against IAM JWKS and mints an ephemeral _superusers session
  for admins (isGlobalAdmin/isAdmin/built-in) — mirrored client-side for the
  superuser guard.

* build(admin): rebuild ui-react dist with IAM PKCE login

CI does not rebuild the SPA — the committed dist/ (go:embed all:dist in
ui-react/embed.go) is what ships. tsc -b && vite build green.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-03 18:06:01 -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
zeekay e7df8c856e base: complete pocketbase→base rename; Base = pure IAM client
Rename / namespace:
- Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep
  across all Go sources; restore the renamed root launcher (Base, base.go).
- Drop the upstream-only surfaces ours doesn't ship (admin ui/, the
  password/OTP/oauth2/impersonate auth files, extensions) re-added by the
  upstream merge.
- Decomplect the on-disk layout to one base_/ namespace: data/, migrations/,
  hooks/, types.d.ts are siblings under base_/ (default data dir
  base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's
  identical JS/Go branches to one path.
- Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and
  gitignore the runtime namespace dirs.

Deps / build:
- kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module
  (the root kms module zip exceeds Go's download limit); cloud/pkg/base
  Mount removed (lives in the fused daemon).
- replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto);
  network/metrics.go import fix; auth gitlab/github tidy.

Tests / behavior:
- Wire registerNotifyWatcherHooks; debounce notify files past the kqueue
  rescan window so cross-instance settings/collections reload fires.
- migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate
  Go+JS automigration goldens from canonical output.
- Convert the two stale archive red-probes to assert the landed defenses
  (backlog cap bounds memory; forged-segment signature is rejected).

IAM-native (Base = pure IAM client):
- Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the
  iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers
  rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused
  daemon) is required; Base validates IAM JWTs via JWKS only — superuser is
  the IAM admin claim, resolved to an ephemeral unpersisted record.
- Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was
  mis-colocated in the embedded onboarding file and is independent of IAM.
- Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a
  JWKS stub (no hardcoded record tokens, no persisted rows).
2026-06-10 21:29:14 -07:00