Compare commits

...
Author SHA1 Message Date
hanzo-dev 7a37a8fd92 analytics: identify carries the attributes that make the id legible
identify() sent a user id and nothing else, so the warehouse held a population
of opaque subjects. Every funnel could count users and none could say which
user, and answering "who hit this" meant an IAM lookup per row — which is why
no user attribute is visible anywhere downstream.

Email and name are first-party facts about our own users, and they arrive in
the same IAM claims this file already decodes to get the id: accountFromClaims
projects them onto the Account and the bridge dropped them on the floor. The
SDK has taken traits since it took a person id; nothing new is collected, a
value already in the token is simply not discarded on the way past.

The scrubber does not touch this: capturePII gates error TEXT, and identify
traits ride build()'s `...extra` verbatim. Secret redaction is unconditional
and stays that way.

A key is omitted rather than sent undefined, so an absent claim cannot blank a
trait an earlier identify established. The org is still not sent — the tenant
is stamped server-side from the validated bearer, and a tenant the client can
name is a tenant the client can get wrong.
2026-08-01 14:54:48 -07:00
hanzo-devandhanzo-dev 29c98ddf5b analytics: attribute the stream with the IAM bearer, identify by sub
Two faults, one outcome: every console row landed in the $public tenant, a
partition no org can read, with no user attached. 498 rows, zero identified users.

CREDENTIAL. The client posted same-origin and trusted the first-party cookie to
carry the tenant. That cookie is the casibase session; cloud resolves a tenant from
a validated IAM bearer. A cookie-only POST carries no principal, and the door does
not refuse it — it takes the anonymous lane, which files rows under $public and
drops identify with a 200 receipt. Both ends look healthy. Pass the IAM access
token the console already holds; read it through a function, since the module is
built at import time when nobody is signed in yet.

No publishable key is passed instead, and the note in the file says why: a pk- maps
to one org while this image serves three brands resolved at runtime, and the SDK
resolves `ingestKey ?? token`, so a key would silently override each signed-in
user's own identity rather than supplement it.

IDENTIFIER. identify() sent `${owner}/${name}`, an org-relative reference in a
different id space from the IAM sub that hanzo.ai and hanzo.chat send — so one user
counted twice across surfaces, and the ref moves when an org or handle is renamed,
rewriting history. The sub was already decoded here and used only to back-derive
owner/name, then dropped; carry it through as Account.userId and identify by it.
Absent stays absent rather than falling back to the actor ref.
2026-08-01 12:33:07 -07:00
hanzo-dev f64de84a4e deps: @hanzo/event 0.3.8 — the release that redacts credentials from event URLs
0.3.8 stops the client shipping a raw location on every event. The location is
stamped on all of them now, so an invite/reset/magic link — a JWT in the query,
an address in `?email=` — reached the warehouse in cleartext on the first click
and again on every later one. url, path and referrer now get the same redaction
the error plane has always applied to error text.

Console is the surface where that matters most and the one furthest behind: the
running bundle serves libraryVersion 0.3.0, six releases back, so it has no
error plane at all (sentry.ts did not exist yet) and never stamped a page onto
an event. The lockfile has been ahead of the image for a while — this bumps the
lockfile; the image has to be rebuilt and its pin moved for any of it to be
true in production.

Lock edited only where it names @hanzo/event, so an unrelated tree is not
re-resolved into a dependency bump. Integrity is the one npmjs serves for
0.3.8; `pnpm install --frozen-lockfile` accepts it on pnpm 11.17.0, the version
package.json pins and the Dockerfile installs via corepack.
2026-08-01 12:25:22 -07:00
hanzo-dev ecb9be3a8b deps: @hanzo/event ^0.3.5 for page-stamped autocapture
Autocapture ($click/$input/$change) reaches the wire through capture(),
which supplied no location, so every $click landed with an empty url and
path and was unattributable to a page -- the one thing a heatmap needs.
0.3.5 stamps the page in build(), the single point every event is built,
placed ahead of the caller's fields so pageview()'s explicit path still
wins on the route changes that fire before window.location catches up.

The lockfile moves with the range here. What ships is the lockfile, not
the range, which is why production was serving 0.3.3 while the declared
range already read ^0.3.4. Resolves 0.3.6 (the 0.3.5 runtime plus a
test), deduped with @hanzo/observe and @hanzogui/telemetry.
2026-08-01 10:53:23 -07:00
zooqueen 400a206d80 api: follow cloud onto /v1/o11y/reviews
cloud renamed /v1/o11y/annotation-queues[…] to /v1/o11y/reviews[…] — o11y's own
comment already called them human-review queues, so "queue" was the
implementation and "review" the resource.

lib/api/o11y.ts makes the three reads (list, detail, items) and must move or
the Annotation Queues board renders empty. The rest here is prose that named
the cloud address, including o11y.ts's "mirrors" note and next.config.mjs's
dev-proxy comment.

The console's own `annotation-queues` page — route id, label and docs link —
stays, for the same reason the score-configs page did: renaming a page id
without its heading and its documentation leaves a page that matches neither.
2026-07-31 17:35:47 -07:00
zooqueen 628e6fb469 api: follow cloud onto datasets/:name/items and rubrics
cloud nested dataset items inside the set that contains them
(/v1/evals/dataset-items -> /v1/evals/datasets/:name/items) and renamed score
configs to rubrics (/v1/evals/score-configs -> /v1/evals/rubrics).

The item change is a real contract change, not a path swap: the dataset was
always required — as a body field on POST and a query param on GET — and is now
a path segment. So EvalsApi.createDatasetItem takes it as its FIRST ARGUMENT
and CreateDatasetItemBody no longer carries datasetName; listDatasetItems
builds the nested URL. DatasetsModule passes the set it already had in hand.

Two comments here were wrong before this commit and are corrected rather than
merely renamed: registry.tsx, ScoreConfigsModule.tsx and o11y.ts all claimed
this surface was `/v1/o11y/score-configs`. o11y has never served it — o11y.ts's
own note says scores and their definitions STAY on /v1/evals — so the address
they named did not exist under either spelling.

DELIBERATELY NOT RENAMED: the console's own `score-configs` PAGE (its route id,
its "Score Configs" label and its ${DOCS}/score-configs link). That is product
copy plus an external docs URL, and moving the id without the label and the
docs page would leave a page whose name matches neither its own heading nor the
documentation it links to. It is a coordinated rename with hanzo-docs, not part
of an api sweep.
2026-07-31 17:33:52 -07:00
zooqueen c1a7d48113 api: follow cloud off the spend-caps and block-storage compounds
cloud renamed /v1/admin/spend-caps[/:id] to /v1/admin/caps[/:id] and
/v1/admin/block-storage to /v1/admin/volumes — under /v1/admin there is one
kind of cap, and what the storage board returns is a list of volumes.

TWO literal allowlists carry these names and both are load-bearing, so this
cannot land after cloud without breaking the admin boards:
  - ADMIN_AGGREGATE_HEADS (src/lib/server/admin-aggregate.ts) — allowAdminSurface
    admits `v1/admin/<head>[/...]`, so the head is what lets the :id sub-path
    through at all.
  - ADMIN_V1_HEADS (next.config.mjs) — the dev rewrite onto a real backend.

lib/api/admin-spend-caps.ts -> admin-caps.ts, with AdminSpendCapsApi ->
AdminCapsApi and the AdminSpendCap type -> AdminCap, so the module, the API
object, the type and the route all say one thing; its test moves with it.
storage-fleet.ts calls the volumes read. UsageCapsPromoModule.tsx, client.ts,
registry.tsx and the aggregate route's doc comments name these addresses in
prose and would otherwise document routes nobody serves.

e2e/storage-fleet.spec.ts intercepts the cloud call by URL, so its matcher moves
too or the fixture never binds and the board renders empty.
2026-07-31 17:28:26 -07:00
zooqueen e5f510b994 api: follow cloud off the load-balancers compound
cloud renamed /v1/load-balancers[/:id] to /v1/balancers[/:id] — nesting under
/v1/networks was unavailable (apps/zt owns that prefix), so the flat single
noun is the one-way answer, matching its sibling /v1/vpcs.

proxy-allow.ts is the load-bearing edit: it is a literal FIRST-SEGMENT
allowlist, and `load-balancers` was a real entry, so without this every call
403s at the proxy before it ever reaches cloud. LoadBalancerModule.tsx calls
list/create/delete directly.

The admin Infra tab slug moves too — same concept, one name — while its visible
label stays "Load balancers", which is both what a person reads and what
e2e/admin-infra.spec.ts clicks. The response body is unchanged: cloud still
returns {loadBalancers:[…]}, DigitalOcean's own name for its own resource.
2026-07-31 17:24:43 -07:00
zooqueen 0a883aefaf api: follow cloud off the share-classes and equity-plans compounds
cloud renamed /v1/captable/share-classes[/:id] to /v1/captable/classes[/:id]
and /v1/captable/equity-plans to /v1/captable/plans: the captable prefix
already supplies "share" and "equity", so each member was repeating its group.

lib/api/captable.ts builds both URLs and must move or the Classes and Plans
panels 404. CapTableModule.tsx prints the address in a BackendStateCard hint,
which is only useful if it names the route that actually failed. proxy-allow.ts
admits by first segment, so `captable` is unchanged — only its comment, which
lists the sub-paths that head covers.

The ShareClass and EquityPlan TYPES keep their names: those are the domain
objects, and a share class is a share class wherever it is addressed.
2026-07-31 17:22:03 -07:00
zooqueen 0c097129d8 api: follow cloud off the rotate-secret compound
cloud renamed POST /v1/webhooks/:id/rotate-secret to POST /v1/webhooks/:id/secret
— the endpoint has one signing secret and POST is what mints a new one, so the
verb belonged to the method, not the noun.

WebhooksModule.tsx calls the address directly, so it moves or the rotate button
404s. proxy-allow.ts needs no allowlist change (it admits by first segment, and
`webhooks` is unchanged); only its comment, which enumerates the sub-paths that
head covers, would otherwise name an address nobody serves.
2026-07-31 17:19:38 -07:00
zooqueen 3749f2669f api: /v1/billing/alerts — follow the rename off the compound 2026-07-31 16:21:05 -07:00
zeekayandClaude Opus 5 06d0681081 deps: unfreeze console's first-party pins; quarantine finance-ui 0.2.x
@hanzo/canvas was declared "^0.1.0". A caret on a 0.x version pins the
MINOR, so console was locked to canvas 0.1.0 while 0.2.1 was current —
the same trap that froze @hanzogui/shell on 7.5.1. @hanzo/ui@8.0.38
peer-depends on canvas ">=0.1.0", which 0.1.0 satisfies, so nothing
warned. Silent freeze.

Six exact pins (dash, gui, and the four @hanzogui/* entry points) could
never take a patch either. Floated them to carets; 8.x is a stable major
so a caret is the correct expression.

Moved on install: brand 1.4.4->1.4.5, canvas 0.1.0->0.2.1,
data 1.2.1->1.2.2, logo 1.0.13->1.0.14, ui 8.0.20->8.0.38.

@hanzo/finance-ui stays on 0.1.1, now written "~0.1.1" so the range says
so. 0.2.1 declares peer "@hanzo/ui": ">=8.0.0" — satisfiable, so the peer
check passes — but its source imports DataTable, LineChart, Column,
ChartPoint and Sparkline from @hanzo/ui, and no published @hanzo/ui
exports them (verified against 8.0.20, 8.0.38 and ui-shadcn 5.9.1).
It publishes raw src/, so those errors land in the consumer's tsc.
A satisfiable-but-false peer range is worse than an unsatisfiable one:
it fails at build time in the consumer instead of at install.

tsc --noEmit clean; next build succeeds; 3152 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:51 -07:00
zeekay cdb6f9d091 deps: console onto gui 8.x — the shell fixes can finally reach production
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / cicd (push) Failing after 1m7s
The console sat on @hanzo/gui 7.3.0 / @hanzogui/shell 7.6.3 while the gui line
shipped 8.0.0, so every shell fix landed on main and stopped there: cloud.hanzo.ai
and console.hanzo.ai kept serving a header from a package that could not move.

What actually pinned it was a phantom peer. Every published @hanzogui/shell
declared `@hanzo/iam@^0.13.1` — a package it never imported — and for a 0.x range
the caret pins the minor, so once IAM reached 0.21.2 the range was unsatisfiable
and the 8.x line was uninstallable. Fixed at the source and released as
@hanzogui/shell@8.0.1 with zero dependencies.

typescript goes ^7.0.2 -> ^5.8.2 in the same commit because it has to: Next 15
does not support TS 7, and on 7.0.2 `next dev` fails to a bare
"Cannot read properties of undefined (reading 'endsWith')" after silently
installing a 5.x behind your back. Same fix hanzo.ai just made.

Verified on the real 8.x tree at 1440x900 and 390x844: Products opens as FIVE
columns with all ten categories visible and nothing clipped (it was four columns
with WEB3 + APPS below the fold), one "Sign in" pointing at /signin, one h1,
the hero primary a white 999px pill, and Terms inside the viewport with no
underline and no horizontal overflow.
2026-07-31 14:35:28 -07:00
hanzo-dev 744164c4c5 merge: one host normalizer for the brand/admin-gate boundary
rescue/stash-0: brandFromHost routes through normHost, which now trims
before stripping the port and drops the FQDN root dot. A padded, ported
or dotted host can no longer soften the suffix match and swap adminDomain
onto the default brand. Tests cover the padded/ported/dotted/lookalike cases.
2026-07-31 14:30:36 -07:00
hanzo-dev 454636c4e2 merge: subscribe the console to the DocType engine's change feed
feat/framework-realtime, additive: framework client gains a change-feed
subscription plus its types and tests. No existing surface changes.
2026-07-31 14:30:36 -07:00
hanzo-dev 8bdbc9116a telemetry: say why the console bakes no ingest key
The env var is read but is undefined in every shipped artifact, which
reads like an oversight and invites a build arg. It is not one.

A pk- resolves to exactly ONE org -- cloud stamps the tenant from the key
-- and this image is brand-agnostic: one build serves cloud.hanzo.ai,
cloud.lux.cloud and cloud.zoo.cloud with the brand resolved at runtime
from the request hostname. Baking a key would file every brand's traffic
into whichever org owns the key: wrong data, and a cross-tenant leak. It
is the same reason the Dockerfile bakes no NEXT_PUBLIC_*.

Signed-in traffic does not need one. host:'' posts same-origin, so the
first-party session rides along and cloud resolves the tenant from it at
full capability; identify and track already land correctly.

What is genuinely unattributed is the logged-out lane, which reaches
cloud with no credential and takes the anonymous lane -- pageview and
error stored, track/identify/group dropped, 200 either way. Closing it
needs a per-host key delivered at runtime over a channel both artifacts
share, which is recorded here so the next reader does not reach for the
build arg instead.
2026-07-31 10:46:36 -07:00
hanzo-dev 599f6f3a31 billing: Stripe is back in the connector list — Square stays our rail
A merchant connecting THEIR Stripe account is a capability we offer; only
Hanzo's own charging is Square-only. Square leads the list because it is
the rail this platform bills on. The doc-comment cleanups stay: describing
a payload SHAPE by a vendor's name was always vague, whoever we charge on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:52:02 -07:00
hanzo-dev f26c8fbcca billing: the UI stops naming a rail we do not use
Square is the rail (via commerce), so the console says so: the payments
integration list drops Stripe, and every doc comment that reached for the
brand to describe a SHAPE — a nested subscription record, a seconds-vs-
millis period stamp, snake_case card fields, the developer-workbench
pattern — now describes the shape instead. No behavior change; the
normalizers still accept the same payloads, which is what the 25 billing
tests prove.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:22:43 -07:00
hanzo-dev 447825ef6d api: read the named total first; data2 is only the legacy fallback
The count of a list envelope now reads the named field before Casdoor's
untyped second slot: total, then data2, then the rows themselves. ONE
helper (envelopeTotal in lib/api/client.ts) owns the order; getList,
iamList, makeIamClient and AuditApi.list all go through it, so the data2
fallback lives in exactly one place and dies with the legacy emitters.

e2e fixtures still emit data2 on purpose — they pin today's live wire
and flip only when the fallback is deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 12:32:55 -07:00
hanzo-dev 9915705d10 referrals: signup, one vocabulary
Hanzo CI/CD / cicd (push) Successful in 6m2s
CI/CD / cicd (push) Successful in 6m2s
The status and the counts field say signup (was signed_up / signedUp),
matching the cloud rename that migrates the store rows — the whole
platform now spells the concept one way.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 10:02:01 -07:00
hanzo-dev b10c5ce18f identity: a refusal, a network error and an absence are three answers
IAM v1.33.31 made org scoping honour-or-refuse (internal/authz/authz.go Scope):
a non-SuperAdmin asking about a foreign owner is REFUSED, not silently re-pointed
at its own org. The confidential `hanzo-console` client is exactly the principal
that starts earning 403s, and this module read every failure as `null`.

`iamGetData` returned null when unconfigured, null on a thrown fetch, and null on
`!res.ok || status !== 'ok' || data == null` — so a 403 refusal, an unreachable
IAM, and a malformed envelope were indistinguishable from "no such row". IAM's
own getHandler (internal/compat/aliases.go) answers three DIFFERENT things from
one endpoint:

  hit      200 {status:"ok", data}
  absence  200 {status:"error", msg:"the entity does not exist"}   (httpx.Err)
  refusal  403 {status:"error", msg:"forbidden: …"}                (authz.Deny)

Collapsing them cost real behaviour, not just tidiness:

  - getMember is the invite flow's identity read. A refusal became "this
    invitation is no longer valid — the member was removed" (410), telling an
    invitee their membership was revoked when IAM merely would not answer, and it
    is the same null the single-use activation guard reads.
  - getUserKey is the API-key state read. A refusal became "no key", which is the
    exact regression its own docstring records: the page falls back to Create, the
    live key is hidden, and the user mints a duplicate over a key they can no
    longer revoke.

So: ONE transport (`iam`) that returns data or throws, carrying `absent` for the
one benign kind — the same contract and the same wire constants as the app's
client (ai-sdk lib/org/onboard.ts) and cloud's Go client (apps/account/iam.go
`do`), which is why `method` is explicit here: IAM has param-only POSTs. Over it
sit exactly two named policies: `iamGetOrAbsent` (absence is a legitimate answer
to null, everything else propagates) and `iamGetUser` (fail-SOFT, and safe
precisely because the admin gate admits only on positive evidence, so a lost
answer can only ever DENY). Soften where a lost answer closes a door, never where
it opens one.

That replaces FOUR transports — iamGetUser, iamCall, iamGetData, iamPostBody —
which had three different error conventions between them.

Also drops the org-onboarding half (getOrganization, createOrganization,
createUser, moveUserToOrg, IamOrganization): dead since onboarding moved server
side to cloud's POST /v1/iam/onboard (apps/account/account.go), which
OrgOnboarding.tsx calls via v1Url('iam/onboard'). Zero consumers — tsc --noEmit
over the whole app is the proof, not a grep. Deleted outright, no shim.

The one behaviour change beyond the above: the transport now refuses when the
confidential client is unconfigured instead of sending Basic Og== and letting IAM
401. Both throw; this one says why and skips the round-trip. Every route already
checks mintConfigured() first, so the three identity.test.ts cases that relied on
the old unguarded path now configure the client, which is the production
precondition they were always standing in for.

Tests: identity-refusal.test.ts pins all three answers plus the unreachable and
malformed cases for both live readers, and pins that a REFUSED claims read still
fails the admin gate CLOSED. 6 of its 11 fail against the code this replaces.
Suite 3141 passed / 8 skipped (was 3130 / 8); tsc --noEmit clean; next build
compiled successfully.
2026-07-28 23:30:58 -07:00
zooqueen d93e9148b2 docs: the identity server these comments describe is IAM, not a vendor
Every hit was a comment naming the wrong system. hanzoai/iam is Hanzo IAM —
original, clean-room work; the vendor-derived server was hanzoai/iam-v1, which
is retired and ships in nothing. Replaced the name with IAM and kept each
comment's fact intact: tokens really are ~3.6 KB full-user JWTs (hence the two
cookies and the chunking), IAM really does pack the full user object, and it
really does skip the client-secret check when the secret is empty.

Comments and markdown only — no identifier, cookie name, or wire field moved.
2026-07-28 17:12:30 -07:00
zooqueen 6b881b2a8e docs: /zap is served — the reason recorded for preferring REST is stale
This file says "the cloud /zap WS face is NOT served (the edge returns SPA
HTML, 200 not a WS upgrade)". That was true when written and is not true
now. Measured 2026-07-28 on api.hanzo.ai, platform.hanzo.ai and
cloud.hanzo.ai: a WebSocket upgrade handshake returns 401 on all three —
not SPA HTML, not 200. A route that refuses an unauthenticated upgrade is
a route that exists.

Read as current, the paragraph says ZAP is unavailable when it is merely
gated, which is the kind of stale rationale that gets acted on. The REST
path it describes is still correct and still shipping, so nothing about
the code changes — only the reason.

Marked stale in place rather than deleted: the history explains why
Providers moved to REST, and removing it would lose that. The correction
names what was measured and where, so the next reader can re-check rather
than trust either version.

Found while sweeping for docs that actively mislead. The sibling case,
universe crs/console.yaml justifying replicas:2 with an admin.hanzo.ai
route that no longer exists, needs no fix — that CR has since been
retired with the standalone console.
2026-07-28 13:30:59 -07:00
hanzo-dev f85b08e44d console: name the fleet board by its one name
Cloud folded /v1/paas into /v1/platform — paas was a second name for the same
product, and one product gets one name. The operator fleet/drift board now lives
at /v1/platform/fleet.

No functional change here, and that is the point: the `platform` allowlist head
already admits every /v1/platform sub-path, so the new route was reachable the
moment cloud shipped it. What was stale was the PROSE — three comments still
pointed readers at `/paas/apps`, a route that no longer exists, and the
allowlist's own sub-path list did not mention the board it admits. A comment
that names a dead route is worse than no comment: it sends the next reader to
look for a surface that was deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 11:45:18 -07:00
hanzo-dev daef1ea32c console(framework): subscribe to the DocType engine's change feed
The generic renderer had no realtime at all — zero EventSource, zero subscribe.
This adds the client half of the ONE mechanism the engine now serves:

  changes.list(q)        one page of the feed  -> GET /v1/framework/changes
  changes.subscribe(q,h) the same query held open as SSE -> /v1/framework/stream
  presence.list(dt,name) the roster            -> GET /v1/framework/presence

changeQuery is ONE function for the poll and the stream, because server-side
they are ONE query — the stream is that query in a loop. A view renders current
state from records.list, keeps the cursor it gets back, and applies changes from
there.

No credential and no org leaves the browser. subscribe opens a same-origin
EventSource through the console's existing /v1 bearer proxy, which already
streams res.body straight through; the proxy mints the short-lived user-bound
token and the engine resolves the org from its owner claim. There is nothing a
page could send to name another tenant, which is the reason to prefer SSE here
over a WebSocket that would need its own auth path.

Resume is the browser's job: each frame's id IS the change's seq, so a dropped
connection reconnects with Last-Event-ID and misses nothing. onReset fires when
the cursor fell behind the server's retention window — refetch, THEN resume,
never patch on top of a state you never had.

Passing watching: '<DocType>/<name>' additionally declares presence for as long
as the connection lives, which is why presence needs no client->server channel.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:58:12 -07:00
hanzo-dev 7a722f0a3c feat(console): the shared components report through the console's ONE client
@hanzo/ui 8.0.20 instruments itself — DataTable, PrimaryButton, SlideOver,
ConfirmDelete, the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView,
OrgSwitcher, ThemeToggle, Toast and EmptyState now report what a user did. The
console renders all of them, so it gets the whole interaction vocabulary without
a line of app code — that is the point of instrumenting the component instead of
the app.

But those components emit through module-scope `track()`, which resolves an
AMBIENT client, and left alone that would have been a SECOND client: default host
api.hanzo.ai, no session cookie, its own anon id and its own batch. Cloud's
anonymous capability lane admits pageview and error only, so every component
event would have been DROPPED on arrival — a silent, plausible-looking nothing.

So `src/lib/event.ts` — the file that already declares itself the ONE console
client — registers `eventClient` as the ambient one. Now the provider's pageviews,
the error boundaries, and every shared component ride a single same-origin batch
that carries the session cookie, which is what makes their events CREDENTIALED and
attributable to the signed-in org. One client, one anon id, one stream, exactly as
that file's header always promised.

Also pins typescript back to 5.9.3. Under the 7.0.2 that main had taken, Next
15.5.19 stops honoring the tsconfig `paths` map and the build dies on every
alias — `Can't resolve '~/config'`, `'~/lib/event'`, `'~/components/ProductRoute'`
— i.e. main could not build at all, before and independent of this change. Adding
`baseUrl` does not help; only the TS pin does.

Build: 8.0.20 installs clean under pnpm 11 (8.0.19 could not — it shipped
`workspace:*`, fixed upstream) and `next build` completes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:22:39 -07:00
hanzo-dev 58ff94c159 ci: delete the GitHub puller — one direction, decided elsewhere
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.

It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 08:57:09 -07:00
hanzo-dev b489ab0fdb ci: pin .hanzo/workflows/build.yml@v1
v1 is the .hanzo/workflows era. There is no v2 — one tag, forward only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:31:05 -07:00
zandhanzo-dev 13c5b30cd6 build: regenerate the lockfile for @hanzo/ui ^8.0.17
Hanzo CI/CD / cicd (push) Failing after 47s
CI/CD / cicd (push) Failing after 47s
CI fails at install, before anything is built:

    [ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile"
      specifiers in the lockfile don't match specifiers in package.json:
      - @hanzo/ui (lockfile: ^8.0.11, manifest: ^8.0.17)

package.json was bumped without regenerating the lockfile, and `--frozen-lockfile`
refuses — correctly; that is what frozen means and the strictness is worth
keeping.

Resolves to 8.0.18, the newest release satisfying ^8.0.17.

Regenerated with pnpm 11.17.0, the version this repo declares in
`packageManager`, so the file matches what the builder's corepack will use. The
diff is large but it is not a format rewrite: lockfileVersion stays 9.0, the
change begins exactly at the @hanzo/ui specifier, and only 30 package entries
are added (2 removed) — lucide-react, sonner, tailwind-merge, react-remove-scroll
and friends, i.e. what @hanzo/ui pulls in between 8.0.11 and 8.0.18. The rest of
the churn is peer-hash suffixes on existing entries. packages and snapshots both
count 1060, so the file is self-consistent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:21:32 -07:00
zeekayandhanzo-dev 108977e0ca fix(deps): take @hanzo/iam 0.21.2 and delete the patch it supersedes
`patches/@hanzo+iam+0.13.6.patch` added a fallback that reads the token's
`exp` claim when the server omits `expires_in`, so session expiry is still
learned. It was written against 0.13.6; the dep has since moved to 0.21.1,
so patch-package refused it — and, because a version mismatch is a
warning-class failure there, `postinstall` still exited 0. The patch had
silently stopped applying with nothing failing: verified absent from the
installed 0.21.1, so the console was running without the fallback.

Fixed upstream instead (hanzo-js/iam 08476b4, released as 0.21.2) where it
belongs — one implementation, every consumer — so the patch is now
genuinely unnecessary, which is what patch-package itself reported.

Verified: 0.21.2 installed, the fallback present in node_modules, and
postinstall clean ("No patch files found"). typecheck 0 errors; 3136 tests
pass.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:04:04 -07:00
hanzo-dev 137432ad42 refactor(gui): import the ONE scale, stop declaring it
gui.config.ts declared the console's type/radius/space ladder. It is not the
console's ladder — it is the ladder every @hanzo/ui/product component is drawn at,
and the dedicated Hanzo Social app renders those same components. A private copy
would have forked the moment either side tuned a size.

It now ships with the components (@hanzo/ui/gui-config, 8.0.17); this file is the
console's import path onto it, so `~/gui.config` call sites are unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:30:51 -07:00
hanzo-dev 9bd12d7c28 refactor(social): render the shared surface, not the console's private copy
The whole social product lived HERE — a 601-line SocialModule plus its own copy of
the /v1/social contract in src/lib/api/social.ts. That is fine while the console is
the only host, and it stopped being true: social.hanzo.ai gets a dedicated app, and
the only way for a second app to render Publish was to copy 800 lines and let them
drift.

So the product moved to @hanzo/ui/product/social (SocialResource + createSocialApi)
and the console keeps only what is genuinely the console's:

- src/lib/api/social.ts is now the TRANSPORT binding — the four verbs on
  originV1Url('social/…'), through our own app/v1 user-bearer BFF — and re-exports
  the contract so call sites keep one import path. It binds
  @hanzo/ui/product/social/api, the React-free entry, so the console's data layer
  (and its node tests) never load a component tree.
- SocialModule.tsx is the mount: hand SocialResource the bound client. 23 lines.
- The tests split the same way the code did: the contract's normalizers and paths
  are tested in @hanzo/ui; what is tested here is what is ours — that a contract
  path resolves to this origin's /v1/social/… .

817 lines of module + client become 77. Nothing about the surface, the routes, or
the tenant isolation changes: the org is still resolved SERVER-SIDE from the bearer
owner claim, and social.hanzo.ai still boots straight into this product.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:20:42 -07:00
zeekayandhanzo-dev bc93b371f6 build: typescript ^7.0.2 (native compiler)
TypeScript 7 is the native Go compiler; the npm package is a shim resolving
a platform-specific native binary. The build is `next build`, so SWC does the
emit and tsc is typecheck-only — no built artifact changes.

Measured on this same config before and after: 0 errors on both tsc 5.9 and
tsc 7, so the compiler swap introduces nothing new.

Note: `pnpm install` exits non-zero here on an unrelated, pre-existing
postinstall failure — patches/@hanzo+iam+0.13.6.patch no longer applies
because the installed @hanzo/iam has moved past 0.13.6. That failure
reproduces with the previous package.json and is not caused by this change.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:07:47 -07:00
hanzo-dev b55eb201d6 WIP on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev e7b36af216 index on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev 9d6190a783 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:04:25 -07:00
80 changed files with 2702 additions and 6198 deletions
+1 -1
View File
@@ -21,5 +21,5 @@ on:
pull_request:
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
-53
View File
@@ -1,53 +0,0 @@
name: Sync from GitHub
# git.hanzo.ai is canonical; development also lands on
# github.com/hanzoai/console. ONE deterministic direction: an in-cluster PULL.
# The runner reaches both ends (GitHub outbound, this forge via the instance URL
# actions/checkout already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
# either side.
#
# Inert until hanzoai/console stops being a pull mirror — while it is one,
# the forge overwrites main from GitHub on its own timer and rejects the push
# below. That conversion is also what turns Actions on here (measured today:
# mirror: true, has_actions: false ⇒ zero native runs).
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/console
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" main
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does not trigger workflows, so
# synced commits would never build. Dispatch CI explicitly.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
-d '{"ref":"main"}' \
&& echo "CI dispatched" || echo "CI dispatch failed (non-fatal — next direct push builds)"
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
else
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
exit 1
fi
+17 -9
View File
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
@@ -290,11 +291,18 @@ Findings + fixes (all in console2; honest states everywhere, no fakes):
separately) → honest "not available on this deployment" (was a scary error).
HUSD balance/top-up already honest "coming" (token unconfigured).
- **Providers was broken**`ProviderListView`/`ProviderEditView` imported the
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
the module showed "Failed to load providers". Switched both back to the working
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
until `/zap` is bound. Providers now shows real/empty over REST like every module.
ZAP twin (`~/lib/zap`), and at the time the cloud `/zap` WS face was not served
(the edge returned SPA HTML, 200 rather than a WS upgrade), so the module showed
"Failed to load providers". Switched both back to the working REST `~/lib/api`
(identical surface). Providers now shows real/empty over REST like every module.
**STALE AS OF 2026-07-28 — `/zap` IS served.** Measured on all three hosts
(api.hanzo.ai, platform.hanzo.ai, cloud.hanzo.ai): a WebSocket upgrade handshake
returns **401**, not SPA HTML and not 200. A route that refuses an
unauthenticated upgrade is a route that exists. The reason this section gives
for preferring REST no longer holds, and read as current it says ZAP is
unavailable when it is merely gated. The REST path is still correct and still
shipping — this is a stale rationale, not a bug. Re-measure before acting on it.
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
@@ -1400,7 +1408,7 @@ of session-only reads (get-account, get-cloud-usages) — so it is KEPT, not rep
The fix is **additive, one session manager, zero regression** (worst case === v8.4.28):
- **`src/lib/server/session.ts`** — THE token manager (server-only by construction:
`node:crypto` + `next/server`). Sealed AES-256-GCM (key = HKDF(`IAM_MINT_CLIENT_SECRET`);
no-secret → per-process random key, never a constant). Casdoor tokens are ~3.6 KB
no-secret → per-process random key, never a constant). IAM tokens are ~3.6 KB
full-user JWTs (86 claims incl. password hash / TOTP secret) — the ACCESS token and
the REFRESH token are BOTH that big — so a single cookie is impossible (browser ~4 KB
per-cookie cap; a real browser silently REJECTS an oversized cookie — a bug curl never
@@ -1463,7 +1471,7 @@ for wrong application (client_id)":
`/v1/iam/signin`, which the ingress routes to the CLOUD backend (casibase); casibase
redeems with ITS confidential `hanzo-cloud` client → mismatch. Now the console redeems
the code ITSELF: on an admin host `iam-login.ts` authorizes with **PKCE** (S256
`codeChallenge` in the login body — casdoor stores it with the code), and
`codeChallenge` in the login body — IAM stores it with the code), and
`completeSignIn` posts `{code, codeVerifier}` to the new BFF **`app/auth/signin`**, which
runs `pkceCodeGrant(client_id=admin-console, code, code_verifier)` with **NO client secret**.
Verified in IAM source (`object/token_oauth.go` GetAuthorizationCodeToken 880-896): an
@@ -1475,7 +1483,7 @@ for wrong application (client_id)":
- **`durableSessionClientId(host)`** (session.ts) is the ONE host→client decision:
admin host → `admin-console` (public — pkceCodeGrant + secretless refreshGrant), else null
→ the confidential `hanzo-console` path. `/auth/refresh` uses it so an admin session
refreshes with admin-console (casdoor skips the secret check when it's empty, token.go 469).
refreshes with admin-console (IAM skips the secret check when it's empty, token.go 469).
- The admin session rests on `hz_session` (the code grant returns access + refresh, minted
at authorize time), which `resolveUser`/`getAdminGate` read FIRST — so the admin console
works without the casibase cookie. `accountOf` + `applyCookies` extracted to session.ts
+4 -4
View File
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
}
/**
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
*/
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
* mutating verbs; only an allow-listed head/sub-path is ever reached.
*/
+2 -2
View File
@@ -2,7 +2,7 @@
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
*
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
* custom hanzo.id login worker doesn't establish an IAM account session, so a
* user who signed in through it lands on an account page that can't manage MFA
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
@@ -20,7 +20,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const TOTP = 'app' // Casdoor TotpType
const TOTP = 'app' // IAM TotpType
/**
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
+1 -1
View File
@@ -11,7 +11,7 @@
* the FIRST path segment, so the tab slugs fall through to the SPA.
*
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
+3 -2
View File
@@ -2,8 +2,9 @@
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
is the total row count on list endpoints).
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
is the row count on list endpoints; the legacy `data2` count is still accepted
as a fallback until every emitter finishes the rename).
Client modules live in `src/lib/api/`.
+1 -1
View File
@@ -132,7 +132,7 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
'/v1/billing/invoices',
'/v1/billing/usage',
'/v1/billing/payment-methods',
'/v1/billing/spend-alerts',
'/v1/billing/alerts',
'/v1/usage/summary',
'/v1/get-cloud-usages',
'/v1/o11y/observations',
+4 -4
View File
@@ -3,7 +3,7 @@
*
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
* envelope.
*
@@ -39,7 +39,7 @@ const ACCOUNT = {
signupApplication: 'hanzo-cloud',
}
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
const BUDGETS = [
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
@@ -61,8 +61,8 @@ async function mock(route: Route) {
if (path.startsWith('/auth/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
}
// The page under test — the real spend-alerts contract.
if (path === '/v1/billing/spend-alerts') {
// The page under test — the real alerts contract.
if (path === '/v1/billing/alerts') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
}
+1 -1
View File
@@ -43,7 +43,7 @@ async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
+10 -153
View File
@@ -1,154 +1,11 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
/**
* The console's GUI config IS the shared one. The type/radius/space scale used to be
* declared here; it now ships with the components it scales (`@hanzo/ui/gui-config`),
* because the dedicated Hanzo Social app renders the same @hanzo/ui/product set and a
* second copy of the ladder would fork silently — same components, different sizes.
*
* Kept as a file so `~/gui.config` stays the console's one import path.
*/
export { config, default } from '@hanzo/ui/gui-config'
// ─────────────────────────────────────────────────────────────────────────────
// THE ONE SCALE.
//
// Three scales used to disagree. `app/design/typography.css` declared the
// intended compact register (11/13/14/15/17/21/26 — the linear.app density);
// @hanzo/gui's Tamagui `$N` ladder is what components actually TYPE (thousands
// of `fontSize="$N"` call sites); and the rendered result was TEN distinct
// sizes, including 217 nodes at the retired 16px base and 30 at 10px, while the
// intended 11px label size rendered NOWHERE.
//
// The ladder is exactly why we do not edit thousands of call sites: REMAP IT
// ONCE and every surface lands on the design scale. So this file is the single
// place the console's type, radius and spacing scales are defined —
// `app/design/*.css` declares them for CSS consumers, this maps the `$N` tokens
// onto the same numbers for the component layer. Change a value here, the whole
// product moves. Adding a fourth spelling of a size is the thing to refuse.
//
// Canonical face: Geist Sans for UI, Geist Mono for anything numeric/code/id
// (`.hz-mono`, set in app/globals.css). Both self-hosted in app/fonts.css.
// ─────────────────────────────────────────────────────────────────────────────
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
/** Type — SIX sizes in the app, matching `--text-*` in app/design/typography.css.
* $1 label · $2 nav + dense body · $3 base · $4/$5 emphasis · $6 section head ·
* $7 page title · $8+ display. `$5` collapses onto 15 to retire the 16px base
* (217 stray nodes); `$9` collapses onto 26 so a page title has ONE size. */
const FONT_SIZE = {
1: 11,
2: 13,
3: 14,
4: 15,
5: 15,
6: 17,
7: 21,
8: 26,
9: 26,
10: 32,
11: 40,
12: 48,
13: 56,
14: 64,
15: 80,
16: 96,
true: 14,
} as const
/** Leading, paired 1:1 with the sizes above. A size token carries a line-height
* tuned for ONE line, so these track the type scale rather than the inherited
* ladder, which left an 11px label sitting in an 18px box. */
const LINE_HEIGHT = {
1: 16,
2: 18,
3: 20,
4: 22,
5: 22,
6: 24,
7: 28,
8: 32,
9: 32,
10: 38,
11: 46,
12: 54,
13: 62,
14: 70,
15: 86,
16: 102,
true: 20,
} as const
/** Radius — FOUR values, no more. 6 control · 8 input/row · 12 panel · pill.
* The inherited ladder had thirteen spellings rendering ten values, including
* three different spellings of "pill" (`{999}`, `{99}`, `$10`). `$10`+ IS the
* pill, so the 115 `rounded="$10"` call sites finally mean one thing. */
const RADIUS = {
0: 0,
1: 6,
2: 6,
3: 8,
4: 8,
5: 12,
6: 12,
7: 12,
8: 12,
9: 12,
10: 9999,
11: 9999,
12: 9999,
true: 8,
} as const
/** Spacing — the 4px ramp, and only the 4px ramp. The inherited ladder landed on
* odd pixels belonging to no scale: `$2`=7, `$3`=13, `$4`=18 were the three
* most-rendered paddings in the whole app. Mirrored into negative steps because
* Tamagui resolves `-$3` from this same map. */
const STEP: Record<string, number> = {
'0': 0,
'0.25': 1,
'0.5': 2,
'0.75': 3,
'1': 4,
'1.5': 6,
'2': 8,
'2.5': 10,
'3': 12,
'3.5': 14,
'4': 16,
'4.5': 20,
'5': 24,
'6': 32,
'7': 40,
'8': 48,
'9': 56,
'10': 64,
'11': 80,
'12': 96,
'13': 112,
'14': 128,
'15': 144,
'16': 160,
'17': 160,
'18': 176,
'19': 192,
'20': 208,
}
const space: Record<string, number> = {}
for (const [k, v] of Object.entries(STEP)) {
space[`$${k}`] = v
space[`-${k}`] = -v
}
space.$true = STEP['4']
space['-true'] = -STEP['4']
export const config = createGui({
...defaultConfig,
tokens: {
...defaultConfig.tokens,
radius: RADIUS,
space,
},
fonts: {
...defaultConfig.fonts,
body: { ...defaultConfig.fonts.body, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
heading: { ...defaultConfig.fonts.heading, family: GEIST, size: FONT_SIZE, lineHeight: LINE_HEIGHT },
},
})
export default config
export type Conf = typeof config
export type Conf = typeof import('@hanzo/ui/gui-config').config
+2 -2
View File
@@ -97,10 +97,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
// which is method-agnostic (Next matches on the URL), so POST is covered without a
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
/**
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
* reviews/users) to a real cloud backend so `npm run dev` renders the
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
* the built image), so production is unchanged — there the console host's edge routes
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
+20 -20
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.5.32",
"version": "8.5.35",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "BSD-3-Clause",
@@ -18,21 +18,22 @@
"postinstall": "patch-package"
},
"dependencies": {
"@hanzo/brand": "^1.4.0",
"@hanzo/canvas": "^0.1.0",
"@hanzo/dash": "0.3.0",
"@hanzo/data": "^1.2.0",
"@hanzo/event": "^0.3.4",
"@hanzo/finance-ui": "0.1.1",
"@hanzo/gui": "7.3.0",
"@hanzo/iam": "^0.21.1",
"@hanzo/logo": "^1.0.13",
"@hanzo/ui": "^8.0.11",
"@hanzo/brand": "^1.4.5",
"@hanzo/canvas": "^0.2.1",
"@hanzo/dash": "^0.3.0",
"@hanzo/data": "^1.2.2",
"@hanzo/event": "^0.3.8",
"@hanzo/finance-ui": "~0.1.1",
"@hanzo/gui": "^8.0.0",
"@hanzo/iam": "^0.21.2",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.38",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/shell": "^8.0.1",
"@lexical/html": "0.46.0",
"@lexical/link": "0.46.0",
"@lexical/list": "0.46.0",
@@ -53,17 +54,16 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-web": "0.21.2",
"superjson": "2.2.2",
"@hanzogui/shell": "^7.6.3"
"superjson": "2.2.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "22.20.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"patch-package": "^8.0.0",
"react-native": "0.83.9",
"typescript": "5.9.3",
"vitest": "3.2.4",
"patch-package": "^8.0.0"
"typescript": "^5.9.3",
"vitest": "3.2.4"
}
}
-132
View File
@@ -1,132 +0,0 @@
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
index fe3a04e..41c367e 100644
--- a/node_modules/@hanzo/iam/dist/browser.cjs
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
@@ -785,6 +785,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
index 4228603..1b9db27 100644
--- a/node_modules/@hanzo/iam/dist/browser.js
+++ b/node_modules/@hanzo/iam/dist/browser.js
@@ -783,6 +783,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
index d49c5d4..81cfb85 100644
--- a/node_modules/@hanzo/iam/dist/index.cjs
+++ b/node_modules/@hanzo/iam/dist/index.cjs
@@ -1172,6 +1172,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
index 48c5699..7a85d23 100644
--- a/node_modules/@hanzo/iam/dist/index.js
+++ b/node_modules/@hanzo/iam/dist/index.js
@@ -1170,6 +1170,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
index 8642b04..66da7d3 100644
--- a/node_modules/@hanzo/iam/dist/react.cjs
+++ b/node_modules/@hanzo/iam/dist/react.cjs
@@ -719,6 +719,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
index 8f4927a..81bef42 100644
--- a/node_modules/@hanzo/iam/dist/react.js
+++ b/node_modules/@hanzo/iam/dist/react.js
@@ -717,6 +717,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
+1511 -4483
View File
File diff suppressed because it is too large Load Diff
+42 -9
View File
@@ -7,16 +7,47 @@
*
* - `usePageview` emits a pageview on every path change (the provider fires the
* FIRST pageview itself, so this only covers subsequent client navigations).
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
* the API client already uses (`setCurrentActor`), never the email — once the
* session resolves. The org tenant is stamped server-side from the session, so
* we send the user id only. Anonymous placeholder sessions are skipped.
* - `identify` binds the user to their Hanzo IAM user id (`account.userId`, the
* OIDC `sub`) once the session resolves, AND carries the attributes that make
* that id legible. Anonymous placeholder sessions are skipped.
*
* WHY NOT `owner/name`. This used to identify by the `${owner}/${name}` actor ref.
* That is an org-relative REFERENCE, not a user id: it is a different id space
* from the one hanzo.ai and hanzo.chat identify by (the IAM `sub`), so the same
* user counted twice the moment they used two Hanzo surfaces — and every
* cross-property funnel, retention curve and path silently measured nothing. It
* also moves when an org or a login handle is renamed, which rewrites history.
*
* WHY TRAITS. A bare `identify(id)` writes a user id and nothing else, so the
* warehouse held a population of opaque subjects: every funnel could count users
* but no one could say WHICH user, and answering "who hit this error" meant a
* manual IAM lookup per row. Email and name are FIRST-PARTY facts about our own
* users — they arrive in the same IAM claims this file already decodes to get the
* id, and were simply dropped on the floor. Sent as traits they are exactly as
* sensitive as they were in the token, and the id stays the join key.
*
* WHAT IS STILL NOT SENT: the org. The tenant is stamped SERVER-SIDE from the
* validated bearer, so org-level cohorts are already queryable — and a tenant the
* client can name is a tenant the client can get wrong. Traits describe the user,
* never the scope they are trusted with.
*/
import { useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
import { useAnalytics, usePageview } from '@hanzo/event/react'
import { useSession } from '~/lib/auth/session'
import { type Account } from '~/lib/api/types'
/** The user attributes worth carrying alongside the id, from the IAM claims the
* session already decoded. A key is OMITTED rather than sent undefined, so an
* absent claim never overwrites a trait a prior identify established. */
export function identityTraits(account: Account): Record<string, unknown> {
const traits: Record<string, unknown> = {}
if (account.email) traits.email = account.email
const name = account.displayName ?? account.name
if (name) traits.name = name
return traits
}
export function AnalyticsBridge() {
const analytics = useAnalytics()
@@ -25,11 +56,13 @@ export function AnalyticsBridge() {
const identified = useRef('')
useEffect(() => {
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
analytics.identify(personId)
if (!account || account.type === 'anonymous-user') return
// No IAM subject means no IAM user — leave the visitor anonymous rather than
// inventing an id for them. Identity flows from the token's own claims.
const userId = account.userId
if (!userId || identified.current === userId) return
identified.current = userId
analytics.identify(userId, identityTraits(account))
}, [account, analytics])
return null
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { identityTraits } from './Analytics'
import { type Account } from '~/lib/api/types'
const account = (over: Partial<Account> = {}): Account => ({
owner: 'hanzo',
name: 'z',
userId: 'sub-1',
...over,
})
describe('identityTraits', () => {
it('carries the email and the human name off the IAM claims', () => {
expect(
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
})
it('falls back to the login handle when no display name was claimed', () => {
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
email: 'z@hanzo.ai',
name: 'z',
})
})
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
// is a trait written, and it would blank a value an earlier identify had set.
it('omits a key it has no claim for rather than sending undefined', () => {
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
expect(traits).toEqual({ name: 'Z Hanzo' })
expect('email' in traits).toBe(false)
})
// The tenant is stamped server-side from the validated bearer. A tenant the
// client can name is a tenant the client can get wrong.
it('never sends the org', () => {
const traits = identityTraits(
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
)
expect(traits).not.toHaveProperty('org')
expect(traits).not.toHaveProperty('organization')
expect(traits).not.toHaveProperty('owner')
})
})
+1 -1
View File
@@ -107,7 +107,7 @@ type Tone = { tone: 'ok' | 'err'; text: string }
/**
* Users with full CRUD — create, promote/demote admin, and delete — over the
* ready IamAdminApi mutations (add/update/delete-user) through the server-gated
* /admin/iam proxy, scoped to `owner`. This is the casdoor user surface, in
* /admin/iam proxy, scoped to `owner`. This is the IAM user surface, in
* console: no link-out for the common lifecycle. Honest states throughout.
*/
function UsersAdminView({ owner }: { owner: string }) {
@@ -4,7 +4,7 @@
* Annotation Queues — list of review queues (HIP-0106), native on @hanzo/gui.
*
* An annotation queue is a named work queue of traces/observations to review and
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
* score against a set of rubrics. Reads the REAL `/v1/o11y/reviews`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
* honest RuntimeNotice — never fabricated queues. Read-only list here; items are
* worked in the annotation flow.
+1 -1
View File
@@ -13,7 +13,7 @@
* Reports — cost breakdown by service (model/provider), filterable + charts.
* Accounts — which account pays and in what order: attach an account to the
* org or to one project, and reorder the chain commerce resolves.
* Budgets — create/list spend budgets (real `/v1/billing/spend-alerts`).
* Budgets — create/list spend budgets (real `/v1/billing/alerts`).
* Invoices — invoice history + download (`/v1/billing/invoices`).
* Subscriptions — the org's plans/status/renewal (reuses `SubscriptionsModule`).
* Payment — saved, masked payment methods (reuses `PaymentMethodsModule`).
+1 -1
View File
@@ -528,7 +528,7 @@ export function CapTableModule({ params }: { params: Record<string, string> }) {
) : null}
{active === 'classes' ? (
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/share-classes" />
classes.phase === 'error' ? <BackendStateCard state={classes.error} onRetry={refreshClasses} hint="endpoint · GET /v1/captable/classes" />
: classes.phase === 'ready' && classes.data.length === 0 ? (
<EmptyState icon={Layers} title="No share classes yet" description="Create a share class (Common, Preferred) — certificates and rounds reference it." primary={{ label: 'New share class', onPress: () => setDialog({ kind: 'class' }) }} />
) : <DataTable<ShareClass> columns={classCols} rows={classList} loading={classes.phase === 'loading'} rowKey={(c) => c.id} empty="No share classes yet." />
+4 -5
View File
@@ -5,7 +5,7 @@
* feature). Wired to the REAL cloud `/v1/evals/*` facade, which proxies the
* console's public dataset API:
* - POST /v1/evals/datasets create a dataset
* - POST /v1/evals/dataset-items add an item (input + expected output)
* - POST /v1/evals/datasets/:name/items add an item (input + expected output)
*
* The gateway does not mount a dataset LIST route yet, so the list area attempts
* the forward-compatible GET and renders an honest "not available here yet" card
@@ -122,8 +122,7 @@ export function DatasetsModule(_props: { params: Record<string, string> }) {
setAdding(true)
setAddMsg(null)
try {
await EvalsApi.createDatasetItem({
datasetName: itemDataset.trim(),
await EvalsApi.createDatasetItem(itemDataset.trim(), {
input: parseMaybeJson(input),
expectedOutput: parseMaybeJson(expected),
})
@@ -265,7 +264,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
const load = useCallback(() => {
setList({ phase: 'loading' })
// Native dataset-items require a datasetName; fetch the org's datasets, then
// A dataset item only exists inside a set; fetch the org's datasets, then
// their items in parallel, and flatten — real rows, never fabricated.
EvalsApi.listDatasets()
.then((datasets) =>
@@ -307,7 +306,7 @@ export function DatasetItemsModule(_props: { params: Record<string, string> }) {
}
/>
{list.phase === 'error' ? (
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/dataset-items" />
<BackendStateCard state={list.error} onRetry={load} hint="endpoint · GET /v1/evals/datasets/:name/items" />
) : (
<DataTable
columns={itemColumns}
@@ -6,9 +6,9 @@
*
* FULL CRUD over the unified cloud binary via the same-origin user-bearer `/v1`
* proxy, org resolved from the Bearer owner:
* - GET /v1/load-balancers list
* - POST /v1/load-balancers create (name + type + region)
* - DELETE /v1/load-balancers/:id delete
* - GET /v1/balancers list
* - POST /v1/balancers create (name + type + region)
* - DELETE /v1/balancers/:id delete
*
* When the backend doesn't serve the surface the list load fails and the honest
* not-configured / unavailable card renders instead of an empty grid; create/delete
@@ -66,7 +66,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<unknown>(cloudProxyV1Url('load-balancers'))
const r = await restGet<unknown>(cloudProxyV1Url('balancers'))
setRows(lbsOf(r))
setLoadError(null)
} catch (e) {
@@ -89,7 +89,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
setCreating(true)
setActionMsg(null)
try {
await restPost(cloudProxyV1Url('load-balancers'), { name: name.trim(), type, region })
await restPost(cloudProxyV1Url('balancers'), { name: name.trim(), type, region })
setActionMsg({ tone: 'ok', text: `Created load balancer "${name.trim()}".` })
setName('')
await load()
@@ -104,7 +104,7 @@ export function LoadBalancerModule(_props: { params: Record<string, string> }) {
if (typeof window !== 'undefined' && !window.confirm(`Delete load balancer "${lb.name || lb.id}"? This cannot be undone.`)) return
setActionMsg(null)
try {
await restDelete(cloudProxyV1Url(`load-balancers/${enc(lb.id)}`))
await restDelete(cloudProxyV1Url(`balancers/${enc(lb.id)}`))
setActionMsg({ tone: 'ok', text: `Deleted load balancer "${lb.name || lb.id}".` })
await load()
} catch (e) {
@@ -118,7 +118,7 @@ function ReferralsAdminReady({ data }: { data: AdminReferralsView }) {
return (
<YStack gap="$4">
<XStack gap="$3" flexWrap="wrap">
<MetricCard icon={<Users size={16} color={toneColor('muted')} />} label="Referrals" value={String(s.total)} caption={`${s.signedUp} signed up`} />
<MetricCard icon={<Users size={16} color={toneColor('muted')} />} label="Referrals" value={String(s.total)} caption={`${s.signup} signed up`} />
<MetricCard icon={<Gift size={16} color="$color11" />} label="Qualified" value={String(s.qualified + s.credited)} caption={`${s.credited} credited`} />
<MetricCard icon={<Coins size={16} color={toneColor('warning')} />} label="Credit granted" value={usd(s.grantedCents)} caption="both sides, promo credit" />
</XStack>
@@ -4,7 +4,7 @@
* Score Configs — list of score definitions (HIP-0106), native on @hanzo/gui.
*
* A score config defines a score's data type and its valid range (numeric) or
* categories (categorical/boolean). Reads the REAL `/v1/o11y/score-configs`
* categories (categorical/boolean). Reads the REAL `/v1/evals/rubrics`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
* an honest RuntimeNotice — never fabricated configs. Read-only here; configs are
* authored where scores are recorded.
+13 -602
View File
@@ -1,612 +1,23 @@
'use client'
/**
* Social — the ONE native social surface, rendered IN-CONSOLE over cloud
* `/v1/social/*` (`hanzoai/cloud` clients/social: a native-Go per-org accounts + posts
* store on Base/SQLite, the in-process fold of the live social stack
* github.com/hanzoai/social, twin of clients/crm) through the `/v1` user-bearer proxy.
* NO link-out, one surface.
* Social — the console's mount of the ONE social surface. The whole product (compose,
* schedule, list + calendar, publish, connect, honest states) is `SocialResource` in
* `@hanzo/ui/product/social`; this is the console half of the seam, and all it does is hand
* that component the console's bound `/v1/social` client.
*
* This is the console half of the `/v1/social` domain seam — the host→mode twin of
* Billing: on social.hanzo.ai (config.socialOnly) the console boots straight into THIS
* product. Parity with the live social-frontend: compose + schedule, a list AND a
* calendar (agenda) view, a real Publish action (POST /v1/social/posts/:id/publish),
* and a connect flow that reflects each network's LIVE publish-readiness
* (GET /v1/social/providers) — honest about which OAuth-app credentials a deployment
* still needs, never a fabricated "connected".
* It used to be a 600-line copy of the product. Nothing but the transport differs
* between hosts, so the copy was pure drift risk: the dedicated social.hanzo.ai app
* renders the SAME component with its own binding.
*
* Every read/write is org-scoped by the Bearer owner claim SERVER-SIDE. States are
* honest: loading, a BackendStateCard on a `/v1` failure, and real empty states.
* This is the host→mode twin of Billing: on social.hanzo.ai (config.socialOnly) the
* console boots straight into this product. Every read/write is org-scoped by the
* Bearer owner claim SERVER-SIDE.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Share2, Send, Link2, Plus, RefreshCw, Calendar, List, CheckCircle2, AlertTriangle } from '@hanzogui/lucide-icons-2'
import { SocialResource } from '@hanzo/ui/product/social'
import {
PROVIDERS,
SocialApi,
type Account,
type Post,
type ProviderCapability,
type Summary,
} from '~/lib/api/social'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { FieldRow, FieldText, FieldTextArea, FieldSelect } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { StatusTag } from '~/components/ui/StatusTag'
import { SlideOver } from '~/components/ui/SlideOver'
/** Compose intents → the (status, scheduleAt) the backend stores. */
const COMPOSE_MODES = ['draft', 'schedule', 'now'] as const
type ComposeMode = (typeof COMPOSE_MODES)[number]
const COMPOSE_LABEL: Record<ComposeMode, string> = { draft: 'Save draft', schedule: 'Schedule', now: 'Publish now' }
/** Unix seconds → a short local timestamp, or '—' when unset (0). */
function when(unix: number): string {
if (!unix) return '—'
return new Date(unix * 1000).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
/** Unix seconds → a day bucket key + label for the agenda (calendar) view. */
function dayOf(unix: number): { key: string; label: string } {
const d = new Date(unix * 1000)
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
const label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
return { key, label }
}
/** Truncate a post body for a table cell / agenda card. */
function preview(s: string): string {
const t = s.trim()
return t.length > 72 ? `${t.slice(0, 72)}` : t || '—'
}
/** Parse a user-typed datetime (ISO or local) into unix seconds; invalid → 0. */
function toUnix(dt: string): number {
const ms = Date.parse(dt.trim())
return Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
}
type Async<T> =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; data: T }
type Data = { summary: Summary; posts: Post[]; accounts: Account[]; providers: ProviderCapability[] }
type View = 'list' | 'calendar'
const POST_COLUMNS: Column<Post>[] = [
{ key: 'content', header: 'Post', render: (p) => preview(p.content) },
{ key: 'channel', header: 'Channel', render: (p) => p.channel || '—' },
{ key: 'status', header: 'Status', render: (p) => <StatusTag status={p.status} /> },
{ key: 'scheduleAt', header: 'Scheduled', render: (p) => when(p.scheduleAt) },
]
const ACCOUNT_COLUMNS: Column<Account>[] = [
{ key: 'handle', header: 'Account', render: (a) => a.handle || '—' },
{ key: 'provider', header: 'Network', render: (a) => a.provider || '—' },
{ key: 'status', header: 'Status', render: (a) => <StatusTag status={a.status} /> },
]
/** The per-org summary bar (real `/v1/social/summary` counts). */
function SummaryBar({ summary }: { summary: Summary }) {
const cells: { label: string; value: string | number }[] = [
{ label: 'Posts', value: summary.posts },
{ label: 'Scheduled', value: summary.scheduled },
{ label: 'Published', value: summary.published },
{ label: 'Accounts', value: summary.accounts },
]
return (
<XStack gap="$3" flexWrap="wrap">
{cells.map((c) => (
<YStack key={c.label} gap="$1" borderWidth={1} borderColor="$borderColor" rounded="$4" px="$4" py="$3" minW={140}>
<Text fontSize="$1" color="$color10">
{c.label}
</Text>
<Text fontSize="$6" fontWeight="500" className="hz-tnum">
{c.value}
</Text>
</YStack>
))}
</XStack>
)
}
/** List / Calendar view toggle (a small inline segmented control). */
function ViewToggle({ view, onChange }: { view: View; onChange: (v: View) => void }) {
const opts: { id: View; label: string; icon: typeof List }[] = [
{ id: 'list', label: 'List', icon: List },
{ id: 'calendar', label: 'Calendar', icon: Calendar },
]
return (
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
{opts.map((o) => {
const Icon = o.icon
const active = view === o.id
return (
<XStack
key={o.id}
items="center"
gap="$2"
px="$3"
py="$2"
cursor="pointer"
bg={active ? '$color4' : 'transparent'}
hoverStyle={{ bg: active ? '$color4' : '$color2' }}
onPress={() => onChange(o.id)}
>
<Icon size={14} />
<Text fontSize="$2" fontWeight={active ? '500' : '400'}>
{o.label}
</Text>
</XStack>
)
})}
</XStack>
)
}
/** Calendar (agenda) view: scheduled/published posts with a time, grouped by day. */
function PostAgenda({ posts, onOpen }: { posts: Post[]; onOpen: (p: Post) => void }) {
const days = useMemo(() => {
const timed = posts.filter((p) => p.scheduleAt > 0).sort((a, b) => a.scheduleAt - b.scheduleAt)
const groups: { label: string; items: Post[] }[] = []
const index = new Map<string, number>()
for (const p of timed) {
const { key, label } = dayOf(p.scheduleAt)
let i = index.get(key)
if (i === undefined) {
i = groups.length
index.set(key, i)
groups.push({ label, items: [] })
}
groups[i].items.push(p)
}
return groups
}, [posts])
if (days.length === 0) {
return (
<EmptyState
icon={Calendar}
title="Nothing on the calendar"
description="Scheduled and timed posts appear here, grouped by day. Compose a post and pick Schedule to plan ahead."
/>
)
}
return (
<YStack gap="$4">
{days.map((d) => (
<YStack key={d.label} gap="$2">
<Text fontSize="$3" fontWeight="500" color="$color11">
{d.label}
</Text>
{d.items.map((p) => (
<XStack
key={p.id}
items="center"
justify="space-between"
gap="$3"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
px="$4"
py="$3"
cursor="pointer"
hoverStyle={{ bg: '$color2' }}
onPress={() => onOpen(p)}
>
<YStack gap="$1" flex={1}>
<Text fontSize="$3">{preview(p.content)}</Text>
<XStack gap="$2" items="center">
<Text fontSize="$1" color="$color10">
{p.channel}
</Text>
<Text fontSize="$1" color="$color10">
· {when(p.scheduleAt)}
</Text>
</XStack>
</YStack>
<StatusTag status={p.status} />
</XStack>
))}
</YStack>
))}
</YStack>
)
}
/** The post detail drawer — full content + publish results + a real Publish action. */
function PostDetail({ post, onChanged }: { post: Post; onChanged: () => void }) {
const [publishing, setPublishing] = useState(false)
const [error, setError] = useState<string | null>(null)
const canPublish = post.status === 'draft' || post.status === 'scheduled' || post.status === 'failed'
const publish = async () => {
setPublishing(true)
setError(null)
try {
await SocialApi.posts.publish(post.id)
onChanged()
} catch (e) {
// 503 (not configured) carries the exact missing credentials — show it verbatim.
setError(classifyBackend(e).message || 'Publish failed.')
} finally {
setPublishing(false)
}
}
return (
<YStack gap="$3" p="$4">
<FieldRow label="Content">
<Text fontSize="$3">{post.content || '—'}</Text>
</FieldRow>
<FieldRow label="Channel">
<Text fontSize="$3">{post.channel}</Text>
</FieldRow>
<FieldRow label="Status">
<StatusTag status={post.status} />
</FieldRow>
{post.scheduleAt > 0 ? (
<FieldRow label="Scheduled">
<Text fontSize="$3">{when(post.scheduleAt)}</Text>
</FieldRow>
) : null}
{post.media.length > 0 ? (
<FieldRow label="Media">
<YStack gap="$1">
{post.media.map((m) => (
<Text key={m} fontSize="$2" color="$color11">
{m}
</Text>
))}
</YStack>
</FieldRow>
) : null}
{post.externalId ? (
<FieldRow label="External id">
<Text fontSize="$3" className="hz-tnum">
{post.externalId}
</Text>
</FieldRow>
) : null}
{post.error ? (
<FieldRow label="Last error">
<Text fontSize="$2" color="$red10">
{post.error}
</Text>
</FieldRow>
) : null}
{error ? (
<Text fontSize="$2" color="$red10">
{error}
</Text>
) : null}
{canPublish ? (
<PrimaryButton onPress={publish} disabled={publishing} icon={<Send size={16} />}>
{publishing ? 'Publishing…' : 'Publish now'}
</PrimaryButton>
) : null}
</YStack>
)
}
/** The compose panel — real POST /v1/social/posts, with draft/schedule/publish-now. */
function CreatePostPanel({
providers,
onCreated,
}: {
providers: ProviderCapability[]
onCreated: () => void
}) {
const [content, setContent] = useState('')
const [channel, setChannel] = useState<string>('x')
const [mode, setMode] = useState<ComposeMode>('draft')
const [scheduleAt, setScheduleAt] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const cap = providers.find((p) => p.provider === channel)
const unconfigured = (mode === 'now' || mode === 'schedule') && cap && !cap.credentialsConfigured
const submit = async () => {
if (!content.trim()) {
setError('Content is required.')
return
}
let status = 'draft'
let at = 0
if (mode === 'schedule') {
at = toUnix(scheduleAt)
if (at <= Math.floor(Date.now() / 1000)) {
setError('Pick a future date and time to schedule.')
return
}
status = 'scheduled'
} else if (mode === 'now') {
status = 'scheduled' // scheduled + scheduleAt 0 ⇒ the backend publishes on create
}
setSaving(true)
setError(null)
try {
const created = await SocialApi.posts.create({ content: content.trim(), channel, status, scheduleAt: at })
// Honest publish-now feedback: if the fan-out failed (e.g. not configured), keep the
// panel open and show why — the post exists and is marked failed.
if (mode === 'now' && created.status === 'failed') {
setError(created.error || 'Publish failed.')
setSaving(false)
return
}
onCreated()
} catch (e) {
setError(classifyBackend(e).message || 'Failed to create post.')
setSaving(false)
}
}
return (
<YStack gap="$3" p="$4">
<FieldRow label="Content">
<FieldTextArea value={content} onChange={setContent} disabled={saving} />
</FieldRow>
<FieldRow label="Channel">
<FieldSelect value={channel} options={[...PROVIDERS]} onChange={setChannel} disabled={saving} />
</FieldRow>
<FieldRow label="When">
<FieldSelect
value={mode}
options={[...COMPOSE_MODES]}
onChange={(v) => setMode(v as ComposeMode)}
disabled={saving}
/>
</FieldRow>
{mode === 'schedule' ? (
<FieldRow label="Schedule at">
<FieldText value={scheduleAt} onChange={setScheduleAt} placeholder="2026-07-15 09:00" disabled={saving} />
</FieldRow>
) : null}
{unconfigured ? (
<XStack items="flex-start" gap="$2">
<AlertTriangle size={14} color="var(--yellow10)" />
<Text fontSize="$1" color="$color10">
{channel} isnt configured to publish yet needs {cap?.missingCredentials.join(', ')}. The post is saved and
marked failed on publish until credentials are supplied.
</Text>
</XStack>
) : null}
{error ? (
<Text fontSize="$2" color="$red10">
{error}
</Text>
) : null}
<PrimaryButton onPress={submit} disabled={saving}>
{saving ? 'Working…' : COMPOSE_LABEL[mode]}
</PrimaryButton>
</YStack>
)
}
/** The connect panel — LIVE per-network readiness + a real account add. */
function ConnectAccountPanel({
providers,
onCreated,
}: {
providers: ProviderCapability[]
onCreated: () => void
}) {
const [provider, setProvider] = useState<string>('x')
const [handle, setHandle] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async () => {
setSaving(true)
setError(null)
try {
await SocialApi.accounts.create({ provider, handle: handle.trim(), status: 'connected' })
onCreated()
} catch (e) {
setError(classifyBackend(e).message || 'Failed to connect account.')
} finally {
setSaving(false)
}
}
return (
<YStack gap="$4" p="$4">
<YStack gap="$2">
<Text fontSize="$2" color="$color10">
Network publish-readiness
</Text>
{providers.map((p) => (
<XStack key={p.provider} items="center" justify="space-between" gap="$3" py="$1">
<XStack items="center" gap="$2">
{p.credentialsConfigured ? (
<CheckCircle2 size={14} color="var(--green10)" />
) : (
<AlertTriangle size={14} color="var(--yellow10)" />
)}
<Text fontSize="$2">{p.provider}</Text>
</XStack>
<Text fontSize="$1" color="$color10">
{p.credentialsConfigured ? 'Ready' : `needs ${p.missingCredentials.join(', ')}`}
</Text>
</XStack>
))}
</YStack>
<YStack gap="$3">
<FieldRow label="Network">
<FieldSelect value={provider} options={[...PROVIDERS]} onChange={setProvider} disabled={saving} />
</FieldRow>
<FieldRow label="Handle">
<FieldText value={handle} onChange={setHandle} placeholder="@hanzo" disabled={saving} />
</FieldRow>
{error ? (
<Text fontSize="$2" color="$red10">
{error}
</Text>
) : null}
<PrimaryButton onPress={submit} disabled={saving}>
{saving ? 'Connecting…' : 'Connect account'}
</PrimaryButton>
</YStack>
</YStack>
)
}
import { SocialApi } from '~/lib/api/social'
export function SocialModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<Async<Data>>({ phase: 'loading' })
const [view, setView] = useState<View>('list')
const [creatingPost, setCreatingPost] = useState(false)
const [creatingAccount, setCreatingAccount] = useState(false)
const [openPost, setOpenPost] = useState<Post | null>(null)
const load = useCallback(async () => {
setState({ phase: 'loading' })
try {
const [summary, posts, accounts, providers] = await Promise.all([
SocialApi.summary(),
SocialApi.posts.list(),
SocialApi.accounts.list(),
SocialApi.providers(),
])
setState({ phase: 'ready', data: { summary, posts, accounts, providers } })
} catch (e) {
setState({ phase: 'error', error: classifyBackend(e) })
}
}, [])
useEffect(() => {
void load()
}, [load])
const providers = state.phase === 'ready' ? state.data.providers : []
const posts = state.phase === 'ready' ? state.data.posts : []
const onCreatedPost = () => {
setCreatingPost(false)
void load()
}
const onCreatedAccount = () => {
setCreatingAccount(false)
void load()
}
const onPostChanged = () => {
setOpenPost(null)
void load()
}
const empty = state.phase === 'ready' && state.data.posts.length === 0 && state.data.accounts.length === 0
return (
<YStack gap="$4" p="$4">
<PageHeader
title="Publish"
subtitle="Compose, schedule and publish your content across networks — per org, over the native /v1/social engine."
actions={
<>
<PrimaryButton onPress={() => setCreatingPost(true)} icon={<Plus size={16} />}>
New post
</PrimaryButton>
<PrimaryButton onPress={() => setCreatingAccount(true)} icon={<Link2 size={16} />}>
Connect account
</PrimaryButton>
<PrimaryButton onPress={() => void load()} icon={<RefreshCw size={16} />}>
Refresh
</PrimaryButton>
</>
}
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={() => void load()} />
) : empty ? (
<YStack gap="$4">
{state.phase === 'ready' ? <SummaryBar summary={state.data.summary} /> : null}
<EmptyState
icon={Share2}
title="No posts or accounts yet"
description="Connect a social account, then compose, schedule and publish across X, Instagram, LinkedIn, TikTok and more."
primary={{ label: 'New post', onPress: () => setCreatingPost(true) }}
/>
</YStack>
) : (
<YStack gap="$5">
{state.phase === 'ready' ? <SummaryBar summary={state.data.summary} /> : null}
<YStack gap="$3">
<XStack items="center" justify="space-between" gap="$2" flexWrap="wrap">
<XStack items="center" gap="$2">
<Send size={16} />
<Text fontSize="$5" fontWeight="500">
Posts
</Text>
</XStack>
<ViewToggle view={view} onChange={setView} />
</XStack>
{view === 'list' ? (
<DataTable<Post>
columns={POST_COLUMNS}
rows={posts}
loading={state.phase === 'loading'}
empty="No posts yet."
rowKey={(p) => p.id}
onRowPress={(p) => setOpenPost(p)}
/>
) : (
<PostAgenda posts={posts} onOpen={(p) => setOpenPost(p)} />
)}
</YStack>
<YStack gap="$2">
<XStack items="center" gap="$2">
<Link2 size={16} />
<Text fontSize="$5" fontWeight="500">
Accounts
</Text>
</XStack>
<DataTable<Account>
columns={ACCOUNT_COLUMNS}
rows={state.phase === 'ready' ? state.data.accounts : []}
loading={state.phase === 'loading'}
empty="No accounts connected yet."
rowKey={(a) => a.id}
/>
</YStack>
</YStack>
)}
<SlideOver open={creatingPost} onClose={() => setCreatingPost(false)} title="New post" icon={Send} ariaLabel="New post">
<CreatePostPanel providers={providers} onCreated={onCreatedPost} />
</SlideOver>
<SlideOver
open={creatingAccount}
onClose={() => setCreatingAccount(false)}
title="Connect account"
icon={Link2}
ariaLabel="Connect account"
>
<ConnectAccountPanel providers={providers} onCreated={onCreatedAccount} />
</SlideOver>
<SlideOver
open={openPost !== null}
onClose={() => setOpenPost(null)}
title="Post"
icon={Send}
ariaLabel="Post detail"
>
{openPost ? <PostDetail post={openPost} onChanged={onPostChanged} /> : null}
</SlideOver>
</YStack>
)
return <SocialResource api={SocialApi} />
}
+1 -1
View File
@@ -9,7 +9,7 @@
* `up == 0` is a real "down". Nothing is fabricated — there are no fake green dots,
* only what the TSDB actually reports.
*
* Why not `/paas/apps` (the old source): the platform apps inventory reports ZERO
* Why not `/v1/platform/fleet` (the old source): the fleet inventory reports ZERO
* apps on this deployment, so that board was empty for admins and "managed by Hanzo"
* for customers — it showed no health at all. VictoriaMetrics is where the live
* signal is, and platform status is a status-page concern appropriate for any
+2 -2
View File
@@ -8,7 +8,7 @@
* - POST /v1/webhooks create → the row incl. `secret` (reveal-once)
* - PATCH /v1/webhooks/:id enable/disable + edit
* - DELETE /v1/webhooks/:id remove
* - POST /v1/webhooks/:id/rotate-secret → { secret } (reveal-once)
* - POST /v1/webhooks/:id/secret → { secret } (reveal-once)
* - POST /v1/webhooks/:id/test → { delivered, httpStatus, durationMs, error? }
* - GET /v1/webhooks/:id/deliveries → recent attempts (newest-first)
*
@@ -436,7 +436,7 @@ export function WebhooksModule({ params }: { params: Record<string, string> }) {
setBusyId(w.id)
setActionMsg(null)
try {
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/rotate-secret`))
const res = await restPost<{ secret?: string }>(cloudProxyV1Url(`webhooks/${enc(w.id)}/secret`))
const secret = str(res?.secret)
if (secret) setRevealed({ title: 'New signing secret', url: w.url, secret })
setActionMsg({ tone: 'ok', text: 'Signing secret rotated.' })
@@ -10,7 +10,7 @@
* 2. CAPS — oversight + override of ANY org's usage caps: pick a target org, list its
* spend caps (real threshold / period-spend meter / hard-cap vs alert / rate limit /
* when it resets), and create / edit / delete a cap on that org's behalf. Backed by
* `GET/POST/PATCH/DELETE /v1/admin/spend-caps?org=<slug>`.
* `GET/POST/PATCH/DELETE /v1/admin/caps?org=<slug>`.
*
* All reads/writes terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy
* (`getAdminGate`, fail-closed 403, then a minted user bearer + same-origin CSRF), and
@@ -41,7 +41,7 @@ import {
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { ApiError } from '~/lib/api'
import { AdminPromosApi, type PlatformPromo } from '~/lib/api/admin-promos'
import { AdminSpendCapsApi, type AdminSpendCap } from '~/lib/api/admin-spend-caps'
import { AdminCapsApi, type AdminCap } from '~/lib/api/admin-caps'
import { fmtInt, fmtUsd } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
@@ -341,7 +341,7 @@ function CapFields({ form, setForm, disabled }: { form: BudgetForm; setForm: (f:
)
}
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onChanged: () => void }) {
function CapCard({ org, cap, onChanged }: { org: string; cap: AdminCap; onChanged: () => void }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<BudgetForm>(() => formForAlert(cap))
const [saving, setSaving] = useState(false)
@@ -367,7 +367,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
setSaving(true)
setError(null)
try {
await AdminSpendCapsApi.update(org, cap.id, {
await AdminCapsApi.update(org, cap.id, {
title: v.title,
thresholdCents: v.thresholdCents,
project: v.project,
@@ -391,7 +391,7 @@ function CapCard({ org, cap, onChanged }: { org: string; cap: AdminSpendCap; onC
setRemoving(true)
setError(null)
try {
await AdminSpendCapsApi.remove(org, cap.id)
await AdminCapsApi.remove(org, cap.id)
onChanged()
} catch (e) {
const a = asApiError(e)
@@ -499,7 +499,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
setSaving(true)
setError(null)
try {
await AdminSpendCapsApi.create(org, {
await AdminCapsApi.create(org, {
title: v.title,
thresholdCents: v.thresholdCents,
project: v.project,
@@ -542,7 +542,7 @@ function AddCapForm({ org, onDone, onCancel }: { org: string; onDone: () => void
function CapsTab() {
const [orgInput, setOrgInput] = useState('')
const [org, setOrg] = useState('')
const [state, setState] = useState<Async<AdminSpendCap[]> | null>(null)
const [state, setState] = useState<Async<AdminCap[]> | null>(null)
const [adding, setAdding] = useState(false)
const load = useCallback((slug: string) => {
@@ -551,7 +551,7 @@ function CapsTab() {
setOrg(s)
setAdding(false)
setState({ phase: 'loading' })
AdminSpendCapsApi.list(s)
AdminCapsApi.list(s)
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [])
@@ -154,7 +154,7 @@ export function InfraModule({ params }: { params: Record<string, string> }) {
if (tab === 'clusters') return <ClustersTab data={data} loading={loading} />
if (tab === 'nodes') return <NodesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
if (tab === 'volumes') return <VolumesTab data={data} loading={loading} reload={() => void load(true)} toast={toast} />
if (tab === 'load-balancers') return <LoadBalancersTab data={data} loading={loading} />
if (tab === 'balancers') return <LoadBalancersTab data={data} loading={loading} />
if (tab === 'audit') return <AuditTab data={data} loading={loading} />
return <OverviewTab data={data} loading={loading} />
})()
@@ -448,7 +448,7 @@ const TONE_COLOR = { green: '$green11', yellow: '$yellow11', red: '$red11', neut
// fillOf joins the block-storage read onto an inventory volume. The two backends
// answer different questions about the SAME object — /v1/admin/infra knows whether a
// volume is referenced (and therefore safe to delete), /v1/admin/block-storage knows
// volume is referenced (and therefore safe to delete), /v1/admin/volumes knows
// how full it is — so the board reads both and shows one row. Two boards for one
// noun is what this replaces; two READS for one row is fine, and each degrades on
// its own (no fill data → an honest em-dash, never a fabricated 0%).
@@ -2,7 +2,7 @@
/**
* Budgets & limits — view and set per-scope SPEND CAPS and RATE LIMITS over the REAL
* commerce spend-alerts API (`GET/POST/PATCH/DELETE /v1/billing/spend-alerts`, the
* commerce alerts API (`GET/POST/PATCH/DELETE /v1/billing/alerts`, the
* user-group endpoints billing.hanzo.ai itself uses), scoped to the caller's OWN
* subject by the `/billing` proxy (server-pinned — a caller only ever sees/edits their
* own budgets).
@@ -586,7 +586,7 @@ export function BillingBudgets(_props: { params: Record<string, string> }) {
/>
{state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/spend-alerts" />
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/billing/alerts" />
) : state.phase === 'loading' ? (
<LoadingCards />
) : (
@@ -148,7 +148,7 @@ export type OverviewContext = {
allOrgs?: boolean
/**
* True when the viewer is a global (cross-tenant) admin. Loaders use this to
* SKIP admin-only aggregates (e.g. `/v1/admin/overview`, `/paas/apps`) that a
* SKIP admin-only aggregates (e.g. `/v1/admin/overview`, `/v1/platform/fleet`) that a
* tenant user's browser would only ever get a 403 from, going straight to the
* org-scoped source instead — the board renders the same, minus the console noise.
*/
@@ -51,7 +51,7 @@ const fnRange = (r: OverviewRange): '24H' | '7D' | '30D' => (r === '24h' ? '24H'
* renders. NEVER throws into the caller.
*/
async function withHealth(data: OverviewData, probeApps = true): Promise<OverviewData> {
// The apps inventory (`/paas/apps`) is admin-gated on this deployment, so a
// The fleet inventory (`/v1/platform/fleet`) is admin-gated on this deployment, so a
// tenant user's probe only 403s — skip it and leave health in its honest empty
// state instead of firing a request we know will be rejected (keeps the browser
// console clean). Super admins (probeApps=true, the default) still enrich health.
@@ -13,22 +13,22 @@ describe('referrals logic — money/label/tone formatting', () => {
})
it('labels each status', () => {
expect(statusLabel('signed_up')).toBe('Signed up')
expect(statusLabel('signup')).toBe('Signed up')
expect(statusLabel('qualified')).toBe('Qualified')
expect(statusLabel('credited')).toBe('Credited')
expect(statusLabel('weird' as never)).toBe('weird')
})
it('tones by status (credited positive, qualified warning, signed_up muted)', () => {
it('tones by status (credited positive, qualified warning, signup muted)', () => {
expect(statusTone('credited')).toBe('positive')
expect(statusTone('qualified')).toBe('warning')
expect(statusTone('signed_up')).toBe('muted')
expect(statusTone('signup')).toBe('muted')
})
it('colors a status from the one greyscale map', () => {
for (const s of ['credited', 'qualified', 'signed_up'] as const)
for (const s of ['credited', 'qualified', 'signup'] as const)
expect(statusColor(s)).toMatch(/^\$color(9|10|11|12)$/)
expect(statusColor('signed_up')).toBe('$color9')
expect(statusColor('signup')).toBe('$color9')
})
it('short-dates a unix second, em-dash for unset', () => {
@@ -43,7 +43,7 @@ describe('referrals logic — money/label/tone formatting', () => {
expect(progressCaption({ id: 'a', referee: 'x', status: 'qualified', creditsCents: 0, createdAt: 1, qualifiedAt: 1, creditedAt: 0 })).toBe(
'Qualified — bonus landing',
)
expect(progressCaption({ id: 'a', referee: 'x', status: 'signed_up', creditsCents: 0, createdAt: 1, qualifiedAt: 0, creditedAt: 0 })).toBe(
expect(progressCaption({ id: 'a', referee: 'x', status: 'signup', creditsCents: 0, createdAt: 1, qualifiedAt: 0, creditedAt: 0 })).toBe(
'Signed up — earns when they use Hanzo',
)
})
+1 -1
View File
@@ -15,7 +15,7 @@ export function usd(cents: number | null | undefined): string {
/** Human label for a referral status. */
export function statusLabel(status: ReferralStatus): string {
switch (status) {
case 'signed_up':
case 'signup':
return 'Signed up'
case 'qualified':
return 'Qualified'
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* Workbench — the persistent Developers dock at the FOOT of the dashboard (the
* Stripe-Workbench pattern): a slim always-there bar ("Developers" + a `$` prompt
* developer-workbench pattern): a slim always-there bar ("Developers" + a `$` prompt
* + quick icons) that expands into a bottom drawer of developer tabs, available on
* EVERY page without leaving it. Desktop-only (lg+ — a pointer-density concern);
* open state persists per-user via the ONE preferences store.
+14
View File
@@ -66,6 +66,20 @@ describe('white-label cloud tenants (7stars / yotoda)', () => {
expect(brandFromHost('cloud.yotoda.tech')).toBe('yotoda')
})
it('resolves the brand under a padded / ported / trailing-dot host (admin-gate boundary)', () => {
// A mis-resolve here would swap the admin-gate adminDomain onto the default
// brand. Normalization must strip a trailing port even when whitespace-padded
// (trim-before-port) and a trailing FQDN root dot.
expect(brandFromHost(' lux.network:8443 ')).toBe('lux')
expect(brandFromHost('\tcloud.7stars.dev:443\n')).toBe('7stars')
expect(brandFromHost('lux.network.')).toBe('lux')
expect(brandFromHost('cloud.7stars.dev.')).toBe('7stars')
expect(brandFromHost('LUX.NETWORK.:443')).toBe('lux')
// A lookalike must still fall to the default even with a trailing dot / port.
expect(brandFromHost('evil7stars.dev.')).toBe('hanzo')
expect(brandFromHost('lux.network.evil.com:443')).toBe('hanzo')
})
it('authenticates against the hanzo.id issuer (no own .id) with the per-brand org + app', () => {
const s = resolveConfig('cloud.7stars.dev')
expect(s.brand).toBe('7stars')
+12 -4
View File
@@ -217,9 +217,12 @@ const HOST_BRANDS: ReadonlyArray<{ suffix: string; brand: BrandId }> = [
{ suffix: 'yotoda.tech', brand: 'yotoda' },
]
/** Resolve the brand id from a hostname (port/case-insensitive). Defaults to hanzo. */
/** Resolve the brand id from a hostname (port/case/trailing-dot-insensitive).
* Defaults to hanzo. Normalization goes through the one `normHost` helper so
* the suffix match can never be softened by a padded/ported/FQDN-dot host
* (a mis-resolve here would swap the admin-gate `adminDomain`). */
export function brandFromHost(host?: string | null): BrandId {
const hostname = (host ?? '').toLowerCase().replace(/:\d+$/, '').trim()
const hostname = normHost(host)
if (hostname) {
for (const e of HOST_BRANDS) {
if (hostname === e.suffix || hostname.endsWith('.' + e.suffix)) return e.brand
@@ -234,9 +237,14 @@ function currentHost(): string {
return process.env.NEXT_PUBLIC_DEFAULT_HOST ?? 'cloud.hanzo.ai'
}
/** Normalize a host for keying/matching (lowercase, strip port). */
/** Normalize a host for keying/matching: trim, lowercase, strip trailing port,
* strip the FQDN root dot(s). Order is load-bearing — trim BEFORE the port
* strip so a padded `"host:port "` still loses its port (the `:\d+$` anchor
* would otherwise miss past trailing space); strip the port before the trailing
* dot so an FQDN-with-port collapses to the bare host. Mirrors
* `@hanzo/brand`'s `normalizeHost` (the fleet's one host normalizer). */
function normHost(host?: string | null): string {
return (host ?? '').toLowerCase().replace(/:\d+$/, '').trim()
return (host ?? '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.+$/, '')
}
/**
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// The IAM seam. `session()` reads a valid access token and projects its claims onto
// the console `Account`; mocking the token lets the projection be asserted directly.
const token = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('~/lib/auth/iam', () => ({
iamValidAccessToken: async () => token.value,
iamUserInfo: async () => null,
iamExpiresInSeconds: () => 3600,
iamSignOut: () => {},
}))
const { AccountApi } = await import('~/lib/api/account')
/** Build an unsigned JWT carrying `claims` — only the payload is ever decoded. */
function jwt(claims: Record<string, unknown>): string {
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.`
}
/** The claim set hanzo.id actually issues (trimmed to what this projection reads). */
const IAM_CLAIMS = {
iss: 'https://hanzo.id',
sub: '2d4d67ab-30f1-474e-b81f-f60461852259',
owner: 'hanzo',
organization: 'hanzo',
email: 'z@hanzo.ai',
preferred_username: 'z',
name: 'Zach Kelling',
}
describe('account identity from IAM claims', () => {
beforeEach(() => {
token.value = null
})
it('carries the IAM user id (the `sub` claim) onto the account', async () => {
token.value = jwt(IAM_CLAIMS)
const account = await AccountApi.current()
// This is the value every Hanzo property identifies this user by. It was
// previously decoded, used only to back-derive owner/name, and dropped — so
// nothing downstream could join this user to their hanzo.ai or hanzo.chat
// activity.
expect(account?.userId).toBe('2d4d67ab-30f1-474e-b81f-f60461852259')
})
it('keeps the user id distinct from the org-relative owner/name reference', async () => {
token.value = jwt(IAM_CLAIMS)
const account = await AccountApi.current()
// owner/name still resolve (display + org-scoped API paths depend on them) —
// but they are a DIFFERENT id space, and identifying by them is what counted
// one user twice across surfaces.
expect(account?.owner).toBe('hanzo')
expect(account?.name).toBe('Zach Kelling')
expect(account?.userId).not.toBe(`${account?.owner}/${account?.name}`)
})
it('leaves the user id absent rather than substituting owner/name', async () => {
// A token with no `sub` has no IAM user id. Filling it with the actor ref would
// silently reintroduce the cross-surface split this field exists to fix.
const { sub: _dropped, ...noSub } = IAM_CLAIMS
token.value = jwt(noSub)
const account = await AccountApi.current()
expect(account).not.toBeNull()
expect(account?.userId).toBeUndefined()
})
it('has no account at all when signed out', async () => {
token.value = null
expect(await AccountApi.current()).toBeNull()
})
})
+7
View File
@@ -46,6 +46,13 @@ function accountFromClaims(claims: Record<string, unknown>): Account | null {
return {
owner,
name,
// The IAM user id, carried through rather than discarded. It was already
// decoded here and used only to BACK-DERIVE owner/name; the stable id itself
// was dropped, so nothing downstream could identify this user as the same
// one hanzo.ai and hanzo.chat see. Empty on a token that carries no `sub`,
// and absent is left absent — never substituted with owner/name, which is a
// different id space.
userId: sub || undefined,
type: str('type') ?? 'normal-user',
displayName: str('displayName') ?? str('display_name'),
email: str('email'),
@@ -8,7 +8,7 @@ vi.mock('./client', () => ({
}))
import { originGet, originPost, originPatch, originDelete } from './client'
import { AdminSpendCapsApi, normalizeAdminCap } from './admin-spend-caps'
import { AdminCapsApi, normalizeAdminCap } from './admin-caps'
const mGet = originGet as unknown as ReturnType<typeof vi.fn>
const mPost = originPost as unknown as ReturnType<typeof vi.fn>
@@ -76,7 +76,7 @@ describe('normalizeAdminCap', () => {
})
})
describe('AdminSpendCapsApi', () => {
describe('AdminCapsApi', () => {
beforeEach(() => {
mGet.mockReset()
mPost.mockReset()
@@ -89,20 +89,20 @@ describe('AdminSpendCapsApi', () => {
{ id: 'a1', threshold: 100 },
{ id: 'a2', threshold: 200 },
])
const rows = await AdminSpendCapsApi.list('acme')
expect(mGet).toHaveBeenCalledWith('admin/spend-caps', { org: 'acme' })
const rows = await AdminCapsApi.list('acme')
expect(mGet).toHaveBeenCalledWith('admin/caps', { org: 'acme' })
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2'])
expect(rows[0].thresholdCents).toBe(100)
})
it('list tolerates a non-array payload → honest empty', async () => {
mGet.mockResolvedValueOnce(null)
expect(await AdminSpendCapsApi.list('acme')).toEqual([])
expect(await AdminCapsApi.list('acme')).toEqual([])
})
it('create POSTs the wire body (threshold cents), org-scoped, omitting absent currency', async () => {
mPost.mockResolvedValueOnce({ id: 'new', threshold: 50000 })
await AdminSpendCapsApi.create('acme', {
await AdminCapsApi.create('acme', {
title: 'Cap',
thresholdCents: 50000,
project: '',
@@ -112,7 +112,7 @@ describe('AdminSpendCapsApi', () => {
rateLimitRpm: 0,
})
expect(mPost).toHaveBeenCalledWith(
'admin/spend-caps',
'admin/caps',
{ title: 'Cap', threshold: 50000, project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0 },
{ org: 'acme' },
)
@@ -120,13 +120,13 @@ describe('AdminSpendCapsApi', () => {
it('update PATCHes only the provided fields (partial), org-scoped', async () => {
mPatch.mockResolvedValueOnce({ id: 'a1', threshold: 100 })
await AdminSpendCapsApi.update('acme', 'a1', { thresholdCents: 100 })
expect(mPatch).toHaveBeenCalledWith('admin/spend-caps/a1', { threshold: 100 }, { org: 'acme' })
await AdminCapsApi.update('acme', 'a1', { thresholdCents: 100 })
expect(mPatch).toHaveBeenCalledWith('admin/caps/a1', { threshold: 100 }, { org: 'acme' })
})
it('remove DELETEs the :id sub-path, org-scoped, and url-encodes the id', async () => {
mDelete.mockResolvedValueOnce(undefined)
await AdminSpendCapsApi.remove('acme', 'a/b')
expect(mDelete).toHaveBeenCalledWith('admin/spend-caps/a%2Fb', { org: 'acme' })
await AdminCapsApi.remove('acme', 'a/b')
expect(mDelete).toHaveBeenCalledWith('admin/caps/a%2Fb', { org: 'acme' })
})
})
@@ -3,7 +3,7 @@
* (the same spend-alert / budget primitive a tenant manages under Billing Budgets,
* but cross-tenant). GLOBAL-ADMIN only.
*
* Reads/writes the cloud `/v1/admin/spend-caps` surface (casibase `{status,msg,data}`
* Reads/writes the cloud `/v1/admin/caps` surface (casibase `{status,msg,data}`
* envelope) through `originGet`/`originPost`/`originPatch`/`originDelete` same-origin,
* so they terminate at the GLOBAL-ADMIN-GATED `app/admin/aggregate` proxy (`getAdminGate`,
* fail-closed 403, then a minted user bearer + same-origin CSRF). The target org travels
@@ -21,7 +21,7 @@ import { originGet, originPost, originPatch, originDelete } from './client'
import type { SpendAlert } from './billing'
/** A tenant spend cap as seen by the operator — the `SpendAlert` + oversight fields. */
export type AdminSpendCap = SpendAlert & {
export type AdminCap = SpendAlert & {
/** The IAM user (`<owner>/<name>`) the cap belongs to, if the backend reports it. */
userId: string
/** The billing period the meter covers, `YYYY-MM`. */
@@ -51,8 +51,8 @@ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const bool = (v: unknown): boolean => v === true || v === 'true' || v === 1
/** Roll one backend spend-cap row into the display `AdminSpendCap`; snake_case tolerant. */
export function normalizeAdminCap(raw: unknown): AdminSpendCap {
/** Roll one backend spend-cap row into the display `AdminCap`; snake_case tolerant. */
export function normalizeAdminCap(raw: unknown): AdminCap {
const r = asRecord(raw)
return {
id: str(r.id) || str(r.alertId),
@@ -90,20 +90,20 @@ function capBody(input: Partial<CapInput>): Record<string, unknown> {
return b
}
export const AdminSpendCapsApi = {
/** Every spend cap for `org` (`GET /v1/admin/spend-caps?org=<slug>`); honest empty on none. */
list: async (org: string): Promise<AdminSpendCap[]> =>
arr(await originGet<unknown>('admin/spend-caps', { org })).map(normalizeAdminCap),
export const AdminCapsApi = {
/** Every spend cap for `org` (`GET /v1/admin/caps?org=<slug>`); honest empty on none. */
list: async (org: string): Promise<AdminCap[]> =>
arr(await originGet<unknown>('admin/caps', { org })).map(normalizeAdminCap),
/** Create a cap for `org` (`POST /v1/admin/spend-caps?org=<slug>`). */
create: async (org: string, input: CapInput): Promise<AdminSpendCap> =>
normalizeAdminCap(await originPost<unknown>('admin/spend-caps', capBody(input), { org })),
/** Create a cap for `org` (`POST /v1/admin/caps?org=<slug>`). */
create: async (org: string, input: CapInput): Promise<AdminCap> =>
normalizeAdminCap(await originPost<unknown>('admin/caps', capBody(input), { org })),
/** Edit a cap (`PATCH /v1/admin/spend-caps/:id?org=<slug>`); a partial of the fields. */
update: async (org: string, id: string, patch: Partial<CapInput>): Promise<AdminSpendCap> =>
normalizeAdminCap(await originPatch<unknown>(`admin/spend-caps/${encodeURIComponent(id)}`, capBody(patch), { org })),
/** Edit a cap (`PATCH /v1/admin/caps/:id?org=<slug>`); a partial of the fields. */
update: async (org: string, id: string, patch: Partial<CapInput>): Promise<AdminCap> =>
normalizeAdminCap(await originPatch<unknown>(`admin/caps/${encodeURIComponent(id)}`, capBody(patch), { org })),
/** Remove a cap (`DELETE /v1/admin/spend-caps/:id?org=<slug>`). */
/** Remove a cap (`DELETE /v1/admin/caps/:id?org=<slug>`). */
remove: (org: string, id: string): Promise<void> =>
originDelete(`admin/spend-caps/${encodeURIComponent(id)}`, { org }),
originDelete(`admin/caps/${encodeURIComponent(id)}`, { org }),
}
+3 -3
View File
@@ -49,7 +49,7 @@ export type AdminReferral = {
export type AdminReferralSummary = {
total: number
signedUp: number
signup: number
qualified: number
credited: number
grantedCents: number
@@ -69,7 +69,7 @@ function normalizeAdminReferral(v: unknown): AdminReferral {
referrerOrg: str(r.referrerOrg),
refereeOrg: str(r.refereeOrg),
code: str(r.code),
status: str(r.status) || 'signed_up',
status: str(r.status) || 'signup',
referrerGrantCents: int(r.referrerGrantCents),
refereeGrantCents: int(r.refereeGrantCents),
referrerTxn: str(r.referrerTxn),
@@ -84,7 +84,7 @@ function normalizeSummary(v: unknown): AdminReferralSummary {
const r = asRecord(v)
return {
total: int(r.total),
signedUp: int(r.signedUp),
signup: int(r.signup),
qualified: int(r.qualified),
credited: int(r.credited),
grantedCents: int(r.grantedCents),
+3 -2
View File
@@ -6,7 +6,8 @@
* `/admin/iam/*` or `/admin/kms/*`, sending only the first-party session cookie;
* the server route (`app/admin/{iam,kms}/[...path]/route.ts`) enforces the GLOBAL
* admin gate and forwards to IAM / KMS as the user. IAM speaks the
* `{status,msg,data,data2}` envelope; KMS speaks plain JSON.
* `{status,msg,data,total}` envelope (legacy `data2` count accepted); KMS speaks
* plain JSON.
*
* Cross-tenant (any org) is global-admin only — a customer managing their OWN org
* uses `TeamApi` (the `/org/iam` proxy) instead. Both share the ONE envelope
@@ -20,7 +21,7 @@ import { makeIamClient, DEFAULT_PAGE_SIZE, qs, type Query, type Paged } from './
export type { Paged }
/** An IAM organization's theme (`themeData`) — the accent + surface style the org
* brands with. Mirrors the Casdoor/IAM `ThemeData` shape. */
* brands with. Mirrors the IAM `ThemeData` shape. */
export type ThemeData = {
themeType?: string
/** Primary accent color (hex), e.g. `#D4D4D4`. */
+7
View File
@@ -71,6 +71,13 @@ describe('AuditApi.list — org-scoped, filtered, paginated', () => {
expect(page.total).toBe(7)
})
it('reads the named total first; legacy data2 is only a fallback', async () => {
stubJson({ status: 'ok', data: [{ seq: 1, action: 'a' }], total: 9, data2: 7 })
expect((await AuditApi.list()).total).toBe(9)
stubJson({ status: 'ok', data: [{ seq: 1, action: 'a' }], total: 9 })
expect((await AuditApi.list()).total).toBe(9)
})
it('page 1 and unfiltered omit the p/ filter params (clean URL)', async () => {
const cap = stubJson({ status: 'ok', data: [], data2: 0 })
await AuditApi.list({ page: 1 })
+8 -7
View File
@@ -8,12 +8,13 @@
*
* This is the customer-facing twin of the admin god-view (`/v1/admin/audit`): the
* SAME tamper-evident, hash-chained store, scoped to the caller. The response is the
* `{status,data,data2}` envelope (data = rows, data2 = total for pagination), so we
* read it defensively via `restGet` and never throw on a shape drift.
* `{status,data,total}` envelope (data = rows, total for pagination — legacy
* emitters send it as `data2`), so we read it defensively via `restGet` and never
* throw on a shape drift.
*
* Field names mirror the Go `audit.Wire` struct exactly.
*/
import { restGet, originV1Url } from './client'
import { restGet, originV1Url, envelopeTotal } from './client'
// ── Defensive coercion ───────────────────────────────────────────────────────
const num = (v: unknown): number => {
@@ -111,15 +112,15 @@ const buildUrl = (query: AuditQuery): string => {
/**
* AuditApi.list — the caller's OWN org audit trail, newest first. Reads the
* `{status,data,data2}` envelope: data = the rows, data2 = the total matching the
* filter (ignoring pagination) so the UI can page. Org is server-pinned.
* `{status,data,total}` envelope (legacy `data2` accepted): data = the rows,
* total = the count matching the filter (ignoring pagination) so the UI can
* page. Org is server-pinned.
*/
export const AuditApi = {
list: (query: AuditQuery = {}): Promise<AuditPage> =>
restGet<unknown>(buildUrl(query)).then((raw) => {
const env = asRecord(raw)
const rows = arrayOf(env.data).map(normalizeEvent)
const total = typeof env.data2 === 'number' ? env.data2 : rows.length
return { rows, total }
return { rows, total: envelopeTotal(env, rows) }
}),
}
+6 -6
View File
@@ -125,14 +125,14 @@ describe('BillingApi.usage — real ledger mapping (Cost page)', () => {
/**
* Subscriptions — read-only over the same per-tenant `/billing/*` proxy. Commerce
* is Stripe-shaped, so the normalizer must reach the plan name + price from a flat
* is a flat nested vendor shape, so the normalizer must reach the plan name + price from a flat
* `plan`, a nested `plan.nickname`, or the first `items.data[].price`, and must
* treat `current_period_end` as either a Unix epoch (seconds) or an ISO string.
*/
describe('BillingApi.subscriptions — Stripe-shaped commerce mapping', () => {
describe('BillingApi.subscriptions — nested commerce mapping', () => {
afterEach(teardown)
it('reads plan/status/quantity/price from a nested Stripe subscription', async () => {
it('reads plan/status/quantity/price from a nested subscription', async () => {
stubJson({
subscriptions: [
{
@@ -190,13 +190,13 @@ describe('BillingApi.subscriptions — Stripe-shaped commerce mapping', () => {
/**
* Payment methods — read-only + MASKED. The normalizer must surface ONLY the
* non-sensitive descriptor (brand + last4 + expiry) commerce returns, from either
* camelCase or Stripe snake_case, and must never produce a PAN/CVV/token (none is
* camelCase or snake_case, and must never produce a PAN/CVV/token (none is
* present in the payload).
*/
describe('BillingApi.paymentMethods — masked descriptor mapping', () => {
afterEach(teardown)
it('reads brand/last4/expiry/default from a nested Stripe card (snake_case)', async () => {
it('reads brand/last4/expiry/default from a nested card (snake_case)', async () => {
stubJson({
payment_methods: [
{ id: 'pm_1', type: 'card', is_default: true, card: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030 } },
@@ -381,7 +381,7 @@ describe('BillingApi.reactivateSubscription — clears the scheduled cancel', ()
describe('normalizeSubscriptions — cancel state (cancelAtPeriodEnd / canceledAt)', () => {
afterEach(teardown)
it('reads Stripe snake_case cancel_at_period_end + canceled_at', async () => {
it('reads snake_case cancel_at_period_end + canceled_at', async () => {
stubJson({
subscriptions: [
{ id: 's1', status: 'active', cancel_at_period_end: true, current_period_end: 1893456000 },
+16 -16
View File
@@ -12,7 +12,7 @@
* Read-only here: WRITES (paying, adding/removing a payment method, changing a
* subscription) stay in the brand billing portal (`config.billingUrl`), which the
* pages link to — the console reads and displays, it does not mutate money state.
* Shapes are intentionally permissive: commerce's Stripe-shaped payloads are
* Shapes are intentionally permissive: commerce's payloads are
* normalized defensively so a field rename upstream degrades a cell to "—" rather
* than throwing, and card data is masked to brand + last4 (never a PAN/CVV/token).
* On a 404/501/401 the caller renders the shared `BackendStateCard` — never
@@ -71,7 +71,7 @@ export type Subscription = {
id: string
/** Plan / product name (from the plan nickname or the first subscription item). */
plan: string
/** Commerce/Stripe status — active, trialing, past_due, canceled, etc. */
/** Commerce status — active, trialing, past_due, canceled, etc. */
status?: string
/** Seats / units, if the subscription reports a quantity. */
quantity?: number
@@ -113,7 +113,7 @@ export type PaymentMethod = {
/**
* One spend alert / budget — a threshold (USD cents) that trips when the org's
* spend crosses it (commerce `spendalert`). This is the ONE real budgets surface:
* commerce serves `GET/POST /v1/billing/spend-alerts` under the user group (the
* commerce serves `GET/POST /v1/billing/alerts` under the user group (the
* same one billing.hanzo.ai calls), so the console reads + creates real budgets —
* never a fake form. `triggeredAt` is set by commerce when the threshold trips.
*/
@@ -129,7 +129,7 @@ export type SpendAlert = {
triggeredAt?: string
/** ISO creation time. */
createdAt?: string
// ── Scope + enforcement (the spend-alerts extended fields) ──────────────────
// ── Scope + enforcement (the alerts extended fields) ──────────────────
// Forward-compatible: a legacy soft-alert row that predates these lights up with
// sensible defaults (org-wide, alert-only, softPct 80, no rate limit, zero spent)
// and the meter/enforce/rate-limit surface activates the moment the backend emits
@@ -236,7 +236,7 @@ const objAt = (r: Record<string, unknown>, key: string): Record<string, unknown>
/**
* An ISO date from a field that may be an ISO string OR a Unix epoch in SECONDS
* (Stripe's `current_period_end`) or MILLISECONDS. Returns undefined when absent
* (a `current_period_end`-style seconds stamp) or MILLISECONDS. Returns undefined when absent
* or unparseable — the caller renders "—", never a fabricated date.
*/
const isoDate = (v: unknown): string | undefined => {
@@ -244,14 +244,14 @@ const isoDate = (v: unknown): string | undefined => {
if (s) return s
const n = num(v)
if (n === undefined || n <= 0) return undefined
// < 1e12 ⇒ seconds (Stripe); otherwise already milliseconds.
// < 1e12 ⇒ seconds; otherwise already milliseconds.
const ms = n < 1e12 ? n * 1000 : n
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
}
/**
* Roll a commerce/Stripe subscription record into the display shape. The plan name
* Roll a commerce subscription record into the display shape. The plan name
* and price live in different places across shapes: a flat `plan`, a nested
* `plan.nickname`/`plan.amount`, or the first `items.data[].price` — read every
* known location, degrade to "—"/undefined, never invent.
@@ -306,7 +306,7 @@ function oneRecord(payload: unknown): Record<string, unknown> {
}
/**
* Roll a commerce/Stripe payment-method record into the masked display shape. Reads
* Roll a commerce payment-method record into the masked display shape. Reads
* ONLY the non-sensitive descriptor (brand + last4 + expiry) from `card`/`data` —
* there is no PAN/CVV/token in this shape and none is ever produced here.
*/
@@ -473,15 +473,15 @@ export const BillingApi = {
restDelete(billingProxyV1Url(`payment-methods/${encodeURIComponent(id)}`)),
/**
* The org's spend alerts / budgets (`GET /v1/billing/spend-alerts`). The proxy
* The org's spend alerts / budgets (`GET /v1/billing/alerts`). The proxy
* scopes the read to the caller's OWN subject (pins `?user=`), so this returns
* only the caller's budgets.
*/
spendAlerts: (): Promise<SpendAlert[]> =>
restGet<unknown>(billingProxyV1Url('spend-alerts')).then(normalizeSpendAlerts),
restGet<unknown>(billingProxyV1Url('alerts')).then(normalizeSpendAlerts),
/**
* Create a spend alert / budget (`POST /v1/billing/spend-alerts`). The subject is
* Create a spend alert / budget (`POST /v1/billing/alerts`). The subject is
* pinned server-side by the proxy (`scopedBillingBody`) — the browser sends only
* the title + threshold (USD cents) + currency, and cannot create a budget for
* another tenant. Returns the created alert.
@@ -496,7 +496,7 @@ export const BillingApi = {
softPct?: number
rateLimitRpm?: number
}): Promise<SpendAlert> =>
restPost<unknown>(billingProxyV1Url('spend-alerts'), {
restPost<unknown>(billingProxyV1Url('alerts'), {
title: input.title,
threshold: Math.round(input.thresholdCents),
currency: (input.currency ?? 'usd').toLowerCase(),
@@ -508,7 +508,7 @@ export const BillingApi = {
}).then((r) => normalizeSpendAlerts([r])[0]),
/**
* Update a budget's cap / scope / enforcement (`PATCH /v1/billing/spend-alerts/:id`).
* Update a budget's cap / scope / enforcement (`PATCH /v1/billing/alerts/:id`).
* Only the provided fields are sent; the subject is pinned server-side by the proxy
* (a caller can only edit their OWN budgets). Returns the updated alert.
*/
@@ -532,14 +532,14 @@ export const BillingApi = {
if (patch.enforce !== undefined) body.enforce = patch.enforce
if (patch.softPct !== undefined) body.softPct = patch.softPct
if (patch.rateLimitRpm !== undefined) body.rateLimitRpm = patch.rateLimitRpm
return restPatch<unknown>(billingProxyV1Url(`spend-alerts/${encodeURIComponent(id)}`), body).then(
return restPatch<unknown>(billingProxyV1Url(`alerts/${encodeURIComponent(id)}`), body).then(
(r) => normalizeSpendAlerts([r])[0],
)
},
/** Remove a budget (`DELETE /v1/billing/spend-alerts/:id`); subject pinned server-side. */
/** Remove a budget (`DELETE /v1/billing/alerts/:id`); subject pinned server-side. */
deleteSpendAlert: (id: string): Promise<void> =>
restDelete(billingProxyV1Url(`spend-alerts/${encodeURIComponent(id)}`)),
restDelete(billingProxyV1Url(`alerts/${encodeURIComponent(id)}`)),
/**
* PUBLIC Square Web Payments config for THIS org (`GET /v1/billing/payment-config`):
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from './captable'
describe('rows — tolerates the inconsistent list shapes', () => {
it('reads a BARE array (stakeholders, share-classes)', () => {
it('reads a BARE array (stakeholders, classes)', () => {
expect(rows([{ id: '1' }, { id: '2' }])).toHaveLength(2)
})
it('reads a {data} envelope (shares, safes, rounds, …)', () => {
+5 -5
View File
@@ -15,7 +15,7 @@
* in `proxy-allow.ts` CLOUD_HEADS.
*
* Transport is PLAIN REST (raw JSON, real HTTP status) — the goja bundle's contract.
* The list shapes are INCONSISTENT by design (stakeholders + share-classes return a
* The list shapes are INCONSISTENT by design (stakeholders + classes return a
* BARE array; shares/options/safes/convertibles/rounds/investments wrap in `{ data }`),
* so the ONE `rows()` unwrapper tolerates both. The cap-table MATH lives in the backend
* bundle (`captable.ts`); this client mirrors the wire shapes + derives ONLY the
@@ -520,12 +520,12 @@ export const CapTableApi = {
remove: (id: string): Promise<void> => restDelete(url(`stakeholders/${enc(id)}`)),
},
shareClasses: {
list: (): Promise<ShareClass[]> => restGet<unknown>(url('share-classes')).then((p) => rows(p).map(normShareClass)),
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('share-classes'), body).then(() => undefined),
list: (): Promise<ShareClass[]> => restGet<unknown>(url('classes')).then((p) => rows(p).map(normShareClass)),
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('classes'), body).then(() => undefined),
},
equityPlans: {
list: (): Promise<EquityPlan[]> => restGet<unknown>(url('equity-plans')).then((p) => rows(p).map(normEquityPlan)),
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('equity-plans'), body).then(() => undefined),
list: (): Promise<EquityPlan[]> => restGet<unknown>(url('plans')).then((p) => rows(p).map(normEquityPlan)),
create: (body: Record<string, unknown>): Promise<void> => restPost<unknown>(url('plans'), body).then(() => undefined),
},
shares: {
list: (): Promise<Share[]> => restGet<unknown>(url('shares')).then((p) => rows(p).map(normShare)),
+25 -12
View File
@@ -5,9 +5,10 @@
* always included (the backend sets a session cookie at `/v1/iam/signin`), and the
* Accept-Language header is forwarded so server-side messages localize.
*
* The backend wraps every response as `{ status, msg, data, data2 }`. We unwrap
* it here and throw `ApiError` on `status !== "ok"`, so callers get the payload
* directly or a typed failure — never a half-checked envelope.
* The backend wraps every response as `{ status, msg, data, total }` (legacy
* emitters still send the count as `data2`; we read the named field first). We
* unwrap it here and throw `ApiError` on `status !== "ok"`, so callers get the
* payload directly or a typed failure — never a half-checked envelope.
*/
import { activeApiBase } from '~/lib/network'
import { IS_EMBED } from '~/lib/embed'
@@ -22,10 +23,23 @@ export type ApiResponse<T> = {
status: 'ok' | 'error'
msg: string
data: T
/** Secondary payload — total row count for list endpoints. */
/** Total row count for list endpoints (the named field; absent means absent). */
total?: number
/** Legacy Casdoor second slot — superseded by `total`; read only as fallback. */
data2?: unknown
}
/**
* Count from an envelope: the named `total` first, legacy `data2` second, else
* the rows themselves. The data2 fallback lives ONLY here and dies with the
* legacy emitters.
*/
export const envelopeTotal = (env: { total?: unknown; data2?: unknown }, rows: unknown): number => {
if (typeof env.total === 'number') return env.total
if (typeof env.data2 === 'number') return env.data2
return Array.isArray(rows) ? rows.length : 0
}
export class ApiError extends Error {
readonly status: number
constructor(message: string, status = 0) {
@@ -326,7 +340,7 @@ export async function originPost<T>(path: string, body?: unknown, query?: Query,
* Envelope PUT / PATCH / DELETE pinned to the console's OWN origin (`<origin>/v1/<path>`)
* — the idempotent-upsert / partial-edit / remove twins of `originPost`. Used by the
* admin AGGREGATE mutations that need a verb beyond POST (`PUT /v1/admin/promos` upserts
* the single platform promo; `PATCH`/`DELETE /v1/admin/spend-caps/:id` edit/remove a cap).
* the single platform promo; `PATCH`/`DELETE /v1/admin/caps/:id` edit/remove a cap).
* They terminate at the global-admin-gated `app/admin/aggregate` proxy — which applies the
* same-origin CSRF check on every mutating method (PUT/PATCH/DELETE included) — so pinning
* the ORIGIN (not `config.cloudUrl`) keeps a split-origin `NEXT_PUBLIC_CLOUD_URL` from
@@ -376,12 +390,11 @@ export async function cloudPost<T = string>(path: string, body?: unknown, query?
return r
}
/** GET that returns the full envelope (for list endpoints needing `data2` total). */
/** GET that returns the full envelope (for list endpoints needing the total). */
export async function getList<T>(path: string, query?: Query): Promise<{ rows: T; total: number }> {
const r = await request<T>('GET', path, { query })
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
const total = typeof r.data2 === 'number' ? r.data2 : Array.isArray(r.data) ? r.data.length : 0
return { rows: r.data, total }
return { rows: r.data, total: envelopeTotal(r, r.data) }
}
/** POST a JSON body; returns the `msg` (most mutations return ok/affected). */
@@ -393,17 +406,17 @@ export async function post<T = string>(path: string, body?: unknown, query?: Que
/**
* IAM over the SAME `/v1` path + resilient fetch as every cloud call — IAM's
* `{status,msg,data,data2}` envelope IS our `ApiResponse`, so there is no second
* `{status,msg,data,total}` envelope IS our `ApiResponse`, so there is no second
* client. These target `/v1/iam/<segment>`, which the cloud IAM edge (org-scoped)
* serves in the one-binary console and the `/v1` bearer proxy forwards in the split
* one — ONE path, ONE gate, in both topologies. `iamList` surfaces `data2` (the
* total) that plain `get` drops.
* one — ONE path, ONE gate, in both topologies. `iamList` surfaces the total
* (`total`, legacy `data2`) that plain `get` drops.
*/
export async function iamList<T>(segment: string, query?: Query): Promise<{ rows: T[]; total: number }> {
const r = await request<T[]>('GET', `iam/${segment}`, { query })
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
const rows = Array.isArray(r.data) ? r.data : []
return { rows, total: typeof r.data2 === 'number' ? r.data2 : rows.length }
return { rows, total: envelopeTotal(r, rows) }
}
export async function iamOne<T>(segment: string, query?: Query): Promise<T> {
+1 -1
View File
@@ -23,7 +23,7 @@ describe('csrf (embed ambient-cookie money path)', () => {
it('csrfRequired is SCOPED to the money-write surfaces cloud gates (no spurious pre-auth mint)', async () => {
const { csrfRequired } = await import('./csrf')
// The cloud requireCSRF surfaces (clients/account/account.go) — must require a token.
for (const u of ['/v1/iam/keys', '/v1/iam/onboard', '/v1/commerce/topup/wallet', '/v1/billing/spend-alerts', '/v1/commerce/product'])
for (const u of ['/v1/iam/keys', '/v1/iam/onboard', '/v1/commerce/topup/wallet', '/v1/billing/alerts', '/v1/commerce/product'])
expect(csrfRequired('POST', u)).toBe(true)
// NON-money mutating writes (login/session/control-plane) must NOT trigger the
// /v1/csrf mint — that pre-auth fetch was the SPA's only console error.
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { envelopeTotal } from './client'
/**
* The ONE count read for the `{status,msg,data,total}` envelope: named `total`
* first, legacy Casdoor `data2` second, the rows themselves last. Every list
* reader (getList, iamList, makeIamClient, AuditApi) goes through this.
*/
describe('envelopeTotal — total, then data2, then rows', () => {
it('prefers the named total over data2 and the rows', () => {
expect(envelopeTotal({ total: 9, data2: 7 }, [1])).toBe(9)
expect(envelopeTotal({ total: 0, data2: 7 }, [1])).toBe(0)
})
it('falls back to a numeric data2 while legacy emitters remain', () => {
expect(envelopeTotal({ data2: 7 }, [1])).toBe(7)
expect(envelopeTotal({ data2: 0 }, [1, 2])).toBe(0)
})
it('falls back to the rows when neither field is a number', () => {
expect(envelopeTotal({}, [1, 2])).toBe(2)
expect(envelopeTotal({ total: '9', data2: null }, [1, 2])).toBe(2)
expect(envelopeTotal({}, undefined)).toBe(0)
})
})
+10 -9
View File
@@ -4,7 +4,7 @@
* warehouse owned by hanzoai/ai — `hanzo.observations` / `hanzo.cloud_usage`).
*
* This is the ONE client for the console's Observe surface: datasets, dataset
* items, evaluators, score-configs, scores, traces (+ the trace-detail
* items, evaluators, rubrics, scores, traces (+ the trace-detail
* span/observation tree), observations, sessions, dataset runs / experiments,
* and the dashboard metrics. It is the faithful port target of the upstream
* observability screens, reskinned to @hanzo/gui.
@@ -115,7 +115,6 @@ export type EvalRunRequest = {
export type CreateDatasetBody = { name: string; description?: string; metadata?: Record<string, unknown> }
export type CreateDatasetItemBody = {
datasetName: string
input?: unknown
expectedOutput?: unknown
metadata?: Record<string, unknown>
@@ -348,7 +347,7 @@ export type EvalSessionDetail = { session: EvalSession; traces: Trace[] }
// ── the eval-orchestration client ─────────────────────────────────────────────
// SCOPE: this is the /v1/evals eval-ARTIFACT SDK — datasets, evaluators, runs,
// score-configs, scores, AND the eval-domain trace/observation/session reads (which
// rubrics, scores, AND the eval-domain trace/observation/session reads (which
// carry the eval cost/token/score joins raw OTel spans lack). It is NOT the Observe
// console read plane: as of the gen_ai observation-of-record flip,
// O11yApi.traces/observations/sessions read the o11y SPAN plane (/v1/o11y), NOT these.
@@ -407,7 +406,7 @@ export const EvalsApi = {
return { session: base, traces: (res.traces ?? []).map(toTrace) }
},
// ---- scores + score-configs (Observe · Scores) ----------------------------
// ---- scores + rubrics (Observe · Scores) ----------------------------------
/** List scores as canonical Score[] (for the scores table + metrics folds). */
listScoresTyped: async (
q: { name?: string; runName?: string; traceId?: string; limit?: number } = {},
@@ -421,11 +420,11 @@ export const EvalsApi = {
return { data: rows<EvalScore>(res) }
},
listScoreConfigs: async (): Promise<EvalScoreConfig[]> => {
const res = await restGet<{ data?: EvalScoreConfig[] }>(url('score-configs'))
const res = await restGet<{ data?: EvalScoreConfig[] }>(url('rubrics'))
return rows<EvalScoreConfig>(res)
},
createScoreConfig: (body: CreateScoreConfigBody): Promise<EvalScoreConfig> =>
restPost<EvalScoreConfig>(url('score-configs'), body),
restPost<EvalScoreConfig>(url('rubrics'), body),
// ---- datasets + items (Observe · Datasets) --------------------------------
listDatasets: async (): Promise<EvalDataset[]> => {
@@ -436,11 +435,13 @@ export const EvalsApi = {
createDataset: (body: CreateDatasetBody): Promise<EvalDataset> => restPost<EvalDataset>(url('datasets'), body),
deleteDataset: (name: string): Promise<void> => restDelete(url(`datasets/${encodeURIComponent(name)}`)),
listDatasetItems: async (datasetName: string, limit?: number): Promise<EvalDatasetItem[]> => {
const res = await restGet<{ data?: EvalDatasetItem[] }>(url('dataset-items', { datasetName, limit }))
const res = await restGet<{ data?: EvalDatasetItem[] }>(
url(`datasets/${encodeURIComponent(datasetName)}/items`, { limit }),
)
return rows<EvalDatasetItem>(res)
},
createDatasetItem: (body: CreateDatasetItemBody): Promise<EvalDatasetItem> =>
restPost<EvalDatasetItem>(url('dataset-items'), body),
createDatasetItem: (datasetName: string, body: CreateDatasetItemBody): Promise<EvalDatasetItem> =>
restPost<EvalDatasetItem>(url(`datasets/${encodeURIComponent(datasetName)}/items`), body),
// ---- evaluators -----------------------------------------------------------
listEvaluators: async (): Promise<EvalEvaluator[]> => {
+6 -6
View File
@@ -1,6 +1,7 @@
/**
* IAM envelope client factory — the ONE typed client for the IAM
* `{status,msg,data,data2}` envelope, bound to a same-origin gated proxy base.
* `{status,msg,data,total}` envelope (legacy `data2` count accepted), bound to a
* same-origin gated proxy base.
*
* The browser never holds an IAM credential: it calls a SAME-ORIGIN server route
* (`/admin/iam/*` for cross-tenant global-admin ops, `/org/iam/*` for a customer
@@ -8,11 +9,11 @@
* the gate + tenant scoping and forwards to IAM as the user. Both speak the same
* envelope, so this ONE factory serves both (DRY) — only the base path differs.
*/
import { ApiError } from './client'
import { ApiError, envelopeTotal } from './client'
type Envelope<T> = { status?: string; msg?: string; data?: T; data2?: unknown }
type Envelope<T> = { status?: string; msg?: string; data?: T; total?: number; data2?: unknown }
/** Paged result — rows plus the backend's total (data2). */
/** Paged result — rows plus the backend's total (`total`, legacy `data2`). */
export type Paged<T> = { rows: T[]; total: number }
export type Query = Record<string, string | number | boolean | undefined | null>
@@ -62,8 +63,7 @@ export function makeIamClient(base: string) {
async function iamList<T>(segment: string, query: Query): Promise<Paged<T>> {
const r = await iamReq<T[]>('GET', segment, { query })
const rows = Array.isArray(r.data) ? r.data : []
const total = typeof r.data2 === 'number' ? r.data2 : rows.length
return { rows, total }
return { rows, total: envelopeTotal(r, rows) }
}
async function iamOne<T>(segment: string, query: Query): Promise<T> {
+2 -2
View File
@@ -6,7 +6,7 @@
* dash), never a fabricated value, and the `{ status, data }` envelope unwraps.
* (2) the REAL O11yApi methods, with the transport + evals mocked, asserting the
* FLIP: traces / observations / sessions read the o11y SPAN plane (`/v1/o11y`),
* while SCORES + score-configs STAY on `/v1/evals` (the eval artifacts) — the
* while SCORES + rubrics STAY on `/v1/evals` (the eval artifacts) — the
* mission's one-way contract.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -172,7 +172,7 @@ describe('O11yApi reads the span plane natively from /v1/o11y', () => {
})
})
describe('SCORES + score-configs STAY on /v1/evals (never the o11y span plane)', () => {
describe('SCORES + rubrics STAY on /v1/evals (never the o11y span plane)', () => {
beforeEach(() => {
restGet.mockReset()
listScoresTyped.mockReset()
+8 -8
View File
@@ -120,7 +120,7 @@ export type ScoreConfigCategory = { label: string; value: number }
/**
* A score config — the definition of a score: its data type and valid range or
* categories. Numeric configs carry min/max; categorical/boolean carry the
* allowed categories. Mirrors `/v1/o11y/score-configs`.
* allowed categories. Mirrors `/v1/evals/rubrics`.
*/
export type ScoreConfig = {
id: string
@@ -141,7 +141,7 @@ export type ScoreConfig = {
/**
* An annotation queue — a named work queue of traces/observations to review and
* score against a set of score configs. Mirrors `/v1/o11y/annotation-queues`.
* score against a set of rubrics. Mirrors `/v1/o11y/reviews`.
*/
export type AnnotationQueue = {
id: string
@@ -408,10 +408,10 @@ const scoreConfigOf = (c: EvalScoreConfig): ScoreConfig => ({
* DETAIL) read NATIVELY from the o11y gen_ai span plane (`/v1/o11y`, the declared
* observation-of-record). trace + session DETAIL are composed from the list views
* filtered by traceId/sessionId (o11y exposes no detail endpoint — the real
* waterfall without a backend change). SCORES + score-configs + every eval artifact
* waterfall without a backend change). SCORES + rubrics + every eval artifact
* (datasets/evaluators/runs) STAY the eval-orchestration read on `/v1/evals`; the
* trace detail's inline scores are the eval scores FOR that trace (listScoresTyped by
* traceId), so nothing is lost. users / annotation-queues / health read the same
* traceId), so nothing is lost. users / reviews / health read the same
* `/v1/o11y` surface. Every read surfaces honest states (ApiError → RuntimeNotice /
* empty) until spans flow and the reverse-proxy resolves — never fabricated rows.
*/
@@ -461,7 +461,7 @@ export const O11yApi = {
observations: async (q: O11yListQuery = {}): Promise<O11yList<Observation>> =>
asList((await o11yList<O11yObservationWire>('observations', { limit: q.limit, offset: offsetOf(q) })).map(observationOf), q),
// SCORES + score-configs STAY on /v1/evals (the eval artifacts) — only the span
// SCORES + rubrics STAY on /v1/evals (the eval artifacts) — only the span
// plane (traces/observations/sessions) moved to o11y.
scores: async (q: O11yListQuery = {}): Promise<O11yList<Score>> =>
asList(await EvalsApi.listScoresTyped({ limit: q.limit }), q),
@@ -475,13 +475,13 @@ export const O11yApi = {
// ── annotation QUEUES have no o11y llmobs equivalent (flat annotations only) —
// honest states over the same /v1/o11y surface until a queue runtime exists ──
annotationQueues: (q: O11yListQuery = {}) =>
restGet<O11yList<AnnotationQueue>>(o11yUrl('annotation-queues', { limit: q.limit, page: q.page })),
restGet<O11yList<AnnotationQueue>>(o11yUrl('reviews', { limit: q.limit, page: q.page })),
annotationQueue: (id: string) =>
restGet<AnnotationQueueDetail>(o11yUrl(`annotation-queues/${encodeURIComponent(id)}`)),
restGet<AnnotationQueueDetail>(o11yUrl(`reviews/${encodeURIComponent(id)}`)),
annotationQueueItems: (id: string, q: O11yListQuery = {}) =>
restGet<O11yList<AnnotationQueueItem>>(o11yUrl(`annotation-queues/${encodeURIComponent(id)}/items`, { limit: q.limit, page: q.page })),
restGet<O11yList<AnnotationQueueItem>>(o11yUrl(`reviews/${encodeURIComponent(id)}/items`, { limit: q.limit, page: q.page })),
/**
* Liveness of the embedded o11y runtime — `GET /v1/o11y/health` via the `/v1`
+9 -9
View File
@@ -20,10 +20,10 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
referrerBonusCents: 1000,
refereeBonusCents: 500,
creditsEarnedCents: 2000,
counts: { total: 3, signedUp: 1, qualified: 0, credited: 2 },
counts: { total: 3, signup: 1, qualified: 0, credited: 2 },
referrals: [
{ id: 'ref_1', referee: 'orgB', status: 'credited', creditsCents: 1000, createdAt: 5, qualifiedAt: 6, creditedAt: 6 },
{ id: 'ref_2', referee: 'orgC', status: 'signed_up', creditsCents: 0, createdAt: 7, qualifiedAt: 0, creditedAt: 0 },
{ id: 'ref_2', referee: 'orgC', status: 'signup', creditsCents: 0, createdAt: 7, qualifiedAt: 0, creditedAt: 0 },
],
})
expect(o).toMatchObject({
@@ -32,7 +32,7 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
referrerBonusCents: 1000,
refereeBonusCents: 500,
creditsEarnedCents: 2000,
counts: { total: 3, signedUp: 1, qualified: 0, credited: 2 },
counts: { total: 3, signup: 1, qualified: 0, credited: 2 },
})
expect(o.referrals.map((r) => r.id)).toEqual(['ref_1', 'ref_2'])
expect(o.referrals[0].status).toBe('credited')
@@ -41,23 +41,23 @@ describe('Referrals normalizers — real cloud JSON shape, defensive', () => {
it('coerces missing/garbage fields to safe defaults (never throws)', () => {
const o = normalizeOverview(null)
expect(o).toMatchObject({ code: '', link: '', creditsEarnedCents: 0 })
expect(o.counts).toEqual({ total: 0, signedUp: 0, qualified: 0, credited: 0 })
expect(o.counts).toEqual({ total: 0, signup: 0, qualified: 0, credited: 0 })
expect(o.referrals).toEqual([])
// A row with no id is filtered out of the list.
expect(normalizeOverview({ referrals: [null, 'x', { id: 'ref_9', referee: 'z' }] }).referrals.map((r) => r.id)).toEqual([
'ref_9',
])
// A row defaults status to signed_up.
expect(normalizeMyReferral({ id: 'ref_x' }).status).toBe('signed_up')
// A row defaults status to signup.
expect(normalizeMyReferral({ id: 'ref_x' }).status).toBe('signup')
// Numeric coercion from strings.
expect(normalizeMyReferral({ id: 'r', creditsCents: '1500' }).creditsCents).toBe(1500)
})
it('normalizes a claim result', () => {
expect(normalizeClaim({ id: 'ref_1', code: 'ABC', status: 'signed_up', created: true, createdAt: 9 })).toEqual({
expect(normalizeClaim({ id: 'ref_1', code: 'ABC', status: 'signup', created: true, createdAt: 9 })).toEqual({
id: 'ref_1',
code: 'ABC',
status: 'signed_up',
status: 'signup',
created: true,
createdAt: 9,
})
@@ -77,7 +77,7 @@ describe('ReferralsApi — hits the /v1/referrals bearer-proxy path', () => {
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
fetched.push({ url, method: init?.method ?? 'GET' })
const body = url.endsWith('/claim')
? { id: 'ref_1', code: 'ABC', status: 'signed_up', created: true, createdAt: 1 }
? { id: 'ref_1', code: 'ABC', status: 'signup', created: true, createdAt: 1 }
: { code: 'ABC', link: 'https://hanzo.ai/?ref=ABC', counts: {}, referrals: [] }
return Promise.resolve(
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }),
+6 -6
View File
@@ -37,8 +37,8 @@ const arrayUnder = (payload: unknown, keys: string[]): Record<string, unknown>[]
// ── Domain types (mirror cloud clients/referrals JSON tags) ─────────────────
/** A referral advances signed_up → qualified → credited (credited is terminal). */
export type ReferralStatus = 'signed_up' | 'qualified' | 'credited' | (string & {})
/** A referral advances signup → qualified → credited (credited is terminal). */
export type ReferralStatus = 'signup' | 'qualified' | 'credited' | (string & {})
/** One row in the referrer's own list (the people THEY referred). */
export type MyReferral = {
@@ -53,7 +53,7 @@ export type MyReferral = {
export type ReferralCounts = {
total: number
signedUp: number
signup: number
qualified: number
credited: number
}
@@ -85,7 +85,7 @@ export function normalizeMyReferral(v: unknown): MyReferral {
return {
id: str(r.id),
referee: str(r.referee),
status: (str(r.status) || 'signed_up') as ReferralStatus,
status: (str(r.status) || 'signup') as ReferralStatus,
creditsCents: int(r.creditsCents),
createdAt: int(r.createdAt),
qualifiedAt: int(r.qualifiedAt),
@@ -97,7 +97,7 @@ function normalizeCounts(v: unknown): ReferralCounts {
const r = asRecord(v)
return {
total: int(r.total),
signedUp: int(r.signedUp),
signup: int(r.signup),
qualified: int(r.qualified),
credited: int(r.credited),
}
@@ -123,7 +123,7 @@ export function normalizeClaim(v: unknown): ClaimResult {
return {
id: str(r.id),
code: str(r.code),
status: (str(r.status) || 'signed_up') as ReferralStatus,
status: (str(r.status) || 'signup') as ReferralStatus,
created: r.created === true,
createdAt: int(r.createdAt),
}
+1 -1
View File
@@ -3,7 +3,7 @@
* customer snapshot behind the admin.hanzo.ai "SaaS Metrics" board. It is computed
* IN commerce (the system of record for subscriptions + the usage ledger) from ONE
* cross-org walk and served at `GET /v1/commerce/metrics/saas`; the console only
* renders it — no client-side Stripe, no re-aggregation.
* renders it — no client-side billing SDK, no re-aggregation.
*
* Transport: `originGet('admin/saas', …)` pins the request to the console's OWN
* origin, terminating at the global-admin-gated `app/admin/saas` proxy, which runs
+7 -75
View File
@@ -1,85 +1,17 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
SocialApi,
PROVIDERS,
normalizePost,
normalizeSummary,
normalizeProviderCapability,
normalizeProviders,
normalizeAccounts,
} from './social'
import { SocialApi } from './social'
/**
* Social API + pure normalizers. The module calls the DOCUMENTED cloud `/v1/social`
* contract same-origin, keyless and prefix-free (`originV1Url` → `<origin>/v1/social`),
* the canonical CRM/Agents form. These tests pin (1) the EXACT same-origin paths for the
* new publish surface (providers + posts/:id/publish), (2) that the real store.go JSON
* shape — including the server-managed publish results — normalizes, and (3) that a
* garbage/absent field degrades to a safe default, never throws.
* The console's `/v1/social` BINDING. The contract (types, normalizers, paths) is
* tested in `@hanzo/ui/product/social` — the ONE place it lives. What is console-only,
* and therefore tested here, is the TRANSPORT: that a contract path resolves to this
* origin's `/v1/social/...` (keyless, prefix-free, through the user-bearer BFF), which
* is the canonical CRM/Agents form.
*/
const ORIGIN = 'https://social.hanzo.ai'
describe('Social normalizers — real store.go JSON shape, defensive', () => {
it('normalizes a post including the server-managed publish results', () => {
const p = normalizePost({
id: 'post_1', content: 'hi', channel: 'linkedin', status: 'published',
scheduleAt: 1000, accountId: 'acct_1', externalId: 'ext_9', error: '',
createdAt: 1, updatedAt: 2,
})
expect(p).toMatchObject({
id: 'post_1', content: 'hi', channel: 'linkedin', status: 'published',
scheduleAt: 1000, accountId: 'acct_1', externalId: 'ext_9',
})
// Empty error normalizes to undefined (omitted), never the string "".
expect(p.error).toBeUndefined()
})
it('coerces missing/garbage post fields to safe defaults (never throws)', () => {
const p = normalizePost({ id: 'post_2' })
expect(p).toMatchObject({ id: 'post_2', content: '', channel: 'x', status: 'draft', scheduleAt: 0 })
expect(p.externalId).toBeUndefined()
expect(normalizePost(null).id).toBe('')
})
it('carries a posts media through — clouds PUT rebuilds the row, so dropping it would wipe it', () => {
expect(normalizePost({ id: 'post_3', media: ['https://s3/a.png', 'https://s3/b.png'] }).media).toEqual([
'https://s3/a.png',
'https://s3/b.png',
])
// Always an array, and non-string entries are dropped rather than rendered.
expect(normalizePost({ id: 'post_4' }).media).toEqual([])
expect(normalizePost({ id: 'post_5', media: 'nope' }).media).toEqual([])
expect(normalizePost({ id: 'post_6', media: ['ok', 7, null] }).media).toEqual(['ok'])
})
it('normalizes a provider capability with the missing-credentials list', () => {
const c = normalizeProviderCapability({
provider: 'x', credentialsConfigured: false, missingCredentials: ['X_API_KEY', 'X_API_SECRET'],
})
expect(c).toEqual({ provider: 'x', credentialsConfigured: false, missingCredentials: ['X_API_KEY', 'X_API_SECRET'] })
// A configured provider with a non-array field degrades to an empty list.
expect(normalizeProviderCapability({ provider: 'linkedin', credentialsConfigured: true })).toEqual({
provider: 'linkedin', credentialsConfigured: true, missingCredentials: [],
})
})
it('reads lists from any envelope key or a bare array', () => {
expect(normalizeProviders({ data: [{ provider: 'x' }, { provider: 'threads' }] }).map((c) => c.provider)).toEqual([
'x', 'threads',
])
expect(normalizeAccounts([{ id: 'a' }, { id: 'b' }]).length).toBe(2)
expect(normalizeSummary({ posts: 3, scheduled: 1, published: 2, accounts: 4 })).toEqual({
posts: 3, scheduled: 1, published: 2, accounts: 4,
})
})
it('exposes the network vocabulary', () => {
expect(PROVIDERS).toEqual(['x', 'facebook', 'instagram', 'linkedin', 'tiktok', 'youtube', 'threads'])
})
})
describe('SocialApi — hits the same-origin /v1/social contract', () => {
describe('SocialApi — binds the contract to the console origin', () => {
const fetched: { url: string; method: string }[] = []
beforeEach(() => {
+45 -196
View File
@@ -1,205 +1,54 @@
/**
* Social API — Hanzo Social (per-org accounts + posts), over the REAL cloud
* `/v1/social` surface (cloud `clients/social`, a native-Go per-org accounts+posts
* store on Base/SQLite — the in-process fold of the live social stack
* github.com/hanzoai/social, twin of clients/crm). NOT a proxy to the standalone
* social pods.
* Social — the console's BINDING to the one `/v1/social` contract, which lives in
* `@hanzo/ui/product/social/api` (`createSocialApi` + the types + the normalizers). This file used
* to carry its own copy of that contract; the copy is gone, because the dedicated
* social.hanzo.ai app renders the same component and there can only be one.
*
* Every call is same-origin, keyless and prefix-free (`originV1Url('social/...')`
* → `<origin>/v1/social/...`). The console's OWN `app/v1` user-bearer BFF serves the
* `social` head — it mints a short-lived user-bound IAM token server-side and
* forwards it; the cloud backend resolves the org from the token's `owner` claim, so
* every read/write is org-scoped SERVER-SIDE and no credential reaches the browser.
* This is the EXACT per-tenant path CRM/Agents/Prompts use (the `social` head is
* allow-listed in `proxy-allow.ts` CLOUD_HEADS). A cookie-only call would 403
* ("X-Org-Id required"), so the bearer BFF is mandatory — never a direct call.
* All this owns is TRANSPORT. Every call is same-origin, keyless and prefix-free
* (`originV1Url('social/...')` → `<origin>/v1/social/...`), the exact per-tenant path
* CRM/Agents/Prompts use: the console's OWN `app/v1` user-bearer BFF serves the `social`
* head (allow-listed in `proxy-allow.ts` CLOUD_HEADS), minting a short-lived user-bound
* IAM token server-side, and the cloud backend resolves the org from the token's `owner`
* claim — so every read/write is org-scoped SERVER-SIDE and no credential reaches the
* browser. A cookie-only call would 403 ("X-Org-Id required"), so the bearer BFF is
* mandatory — never a direct call.
*
* Routes (from cloud `clients/social/social.go`):
* GET /v1/social/summary per-org roll-up
* GET/POST /v1/social/accounts list (?provider=) / connect
* GET/PUT/DELETE /v1/social/accounts/:id detail / update / disconnect
* GET/POST /v1/social/posts list (?status=) / create-or-schedule
* GET/PUT/DELETE /v1/social/posts/:id detail / update / delete
*
* Plain REST (raw JSON, real HTTP status). Payloads are normalized DEFENSIVELY — a
* field rename upstream degrades a cell rather than throwing, and the list is read
* from whichever envelope key the backend uses (`data`/`items`/`rows`, or a bare
* array). Mirrors the crm.ts / marketing.ts shape exactly.
* Backend: cloud `clients/social` — a native-Go per-org accounts+posts store on
* Base/SQLite, the in-process fold of the standalone social stack, twin of clients/crm.
*/
import { createSocialApi, type SocialRest } from '@hanzo/ui/product/social/api'
import { restGet, restPost, restPut, restDelete, originV1Url } from './client'
const BASE = 'social'
const enc = encodeURIComponent
// ── Coercion helpers (defensive; crm.ts style) ──────────────────────────────
const str = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
const num = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
return 0
}
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
const asRecord = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
const arrayUnder = (payload: unknown, keys: string[]): Record<string, unknown>[] => {
if (Array.isArray(payload)) return payload.filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
if (payload && typeof payload === 'object') {
for (const k of keys) {
const v = (payload as Record<string, unknown>)[k]
if (Array.isArray(v)) return v.filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
}
}
return []
}
const rows = (payload: unknown) => arrayUnder(payload, ['data', 'items', 'rows'])
// ── Domain types (mirror cloud clients/social/store.go JSON tags) ────────────
/** Networks (cloud rejects an unknown provider/channel; '' → x). */
export const PROVIDERS = ['x', 'facebook', 'instagram', 'linkedin', 'tiktok', 'youtube', 'threads'] as const
export type Provider = (typeof PROVIDERS)[number]
/** Account connection lifecycle (cloud rejects an unknown status; '' → connected). */
export const ACCOUNT_STATUSES = ['connected', 'disconnected', 'error'] as const
/** Post lifecycle (cloud rejects an unknown status; '' → draft). */
export const POST_STATUSES = ['draft', 'scheduled', 'published', 'failed'] as const
export type Account = {
id: string
provider: string
handle: string
status: string
createdAt: number
updatedAt: number
/** `path` is relative to `/v1/social` — the contract's form, resolved on our origin. */
const rest: SocialRest = {
get: (path) => restGet<unknown>(originV1Url(`social/${path}`)),
post: (path, body) => restPost<unknown>(originV1Url(`social/${path}`), body),
put: (path, body) => restPut<unknown>(originV1Url(`social/${path}`), body),
del: (path) => restDelete(originV1Url(`social/${path}`)),
}
export type Post = {
id: string
content: string
channel: string
status: string
/** Unix seconds; 0 = not scheduled / publish now. */
scheduleAt: number
/**
* Attached media URLs. Cloud stores these and ALWAYS serializes an array (never
* null), and its PUT rebuilds the row from the body — so this has to round-trip:
* an update that omitted it would wipe the post's media.
*/
media: string[]
/** Server-managed publish results (empty until a publish attempt lands). */
accountId?: string
externalId?: string
error?: string
createdAt: number
updatedAt: number
}
export const SocialApi = createSocialApi(rest)
export type Summary = { posts: number; scheduled: number; published: number; accounts: number }
/**
* A network's publish-readiness (from GET /v1/social/providers): whether this
* deployment has the OAuth-app credentials to publish, and — if not — exactly which
* env vars are missing. The honest connect affordance; never fabricated.
*/
export type ProviderCapability = {
provider: string
credentialsConfigured: boolean
missingCredentials: string[]
}
/** Create/update bodies — only the writable fields (server owns id/org/timestamps). */
export type NewAccount = Partial<Omit<Account, 'id' | 'createdAt' | 'updatedAt'>> & { provider: string }
export type NewPost = Partial<Omit<Post, 'id' | 'createdAt' | 'updatedAt'>> & { content: string }
// ── Normalizers (pure) ──────────────────────────────────────────────────────
export function normalizeAccount(raw: unknown): Account {
const r = asRecord(raw)
return {
id: str(r.id),
provider: str(r.provider) || 'x',
handle: str(r.handle),
status: str(r.status) || 'connected',
createdAt: num(r.createdAt),
updatedAt: num(r.updatedAt),
}
}
export function normalizePost(raw: unknown): Post {
const r = asRecord(raw)
return {
id: str(r.id),
content: str(r.content),
channel: str(r.channel) || 'x',
status: str(r.status) || 'draft',
scheduleAt: num(r.scheduleAt),
media: strs(r.media),
accountId: str(r.accountId) || undefined,
externalId: str(r.externalId) || undefined,
error: str(r.error) || undefined,
createdAt: num(r.createdAt),
updatedAt: num(r.updatedAt),
}
}
export function normalizeSummary(raw: unknown): Summary {
const r = asRecord(raw)
return { posts: num(r.posts), scheduled: num(r.scheduled), published: num(r.published), accounts: num(r.accounts) }
}
export function normalizeProviderCapability(raw: unknown): ProviderCapability {
const r = asRecord(raw)
return {
provider: str(r.provider),
credentialsConfigured: Boolean(r.credentialsConfigured),
missingCredentials: strs(r.missingCredentials),
}
}
export const normalizeAccounts = (p: unknown): Account[] => rows(p).map(normalizeAccount).filter((a) => a.id)
export const normalizePosts = (p: unknown): Post[] => rows(p).map(normalizePost).filter((p) => p.id)
export const normalizeProviders = (p: unknown): ProviderCapability[] =>
rows(p).map(normalizeProviderCapability).filter((c) => c.provider)
// ── Network methods (thin — one per documented route) ───────────────────────
export const SocialApi = {
summary: (): Promise<Summary> => restGet<unknown>(originV1Url(`${BASE}/summary`)).then(normalizeSummary),
/** Publish-readiness per network (+ the exact missing OAuth-app credentials). */
providers: (): Promise<ProviderCapability[]> =>
restGet<unknown>(originV1Url(`${BASE}/providers`)).then(normalizeProviders),
accounts: {
list: (provider?: string): Promise<Account[]> =>
restGet<unknown>(
originV1Url(`${BASE}/accounts${provider ? `?provider=${enc(provider)}` : ''}`),
).then(normalizeAccounts),
get: (id: string): Promise<Account> =>
restGet<unknown>(originV1Url(`${BASE}/accounts/${enc(id)}`)).then(normalizeAccount),
create: (body: NewAccount): Promise<Account> =>
restPost<unknown>(originV1Url(`${BASE}/accounts`), body).then(normalizeAccount),
update: (id: string, body: NewAccount): Promise<Account> =>
restPut<unknown>(originV1Url(`${BASE}/accounts/${enc(id)}`), body).then(normalizeAccount),
remove: (id: string): Promise<void> => restDelete(originV1Url(`${BASE}/accounts/${enc(id)}`)),
},
posts: {
list: (status?: string): Promise<Post[]> =>
restGet<unknown>(
originV1Url(`${BASE}/posts${status ? `?status=${enc(status)}` : ''}`),
).then(normalizePosts),
get: (id: string): Promise<Post> =>
restGet<unknown>(originV1Url(`${BASE}/posts/${enc(id)}`)).then(normalizePost),
create: (body: NewPost): Promise<Post> =>
restPost<unknown>(originV1Url(`${BASE}/posts`), body).then(normalizePost),
update: (id: string, body: NewPost): Promise<Post> =>
restPut<unknown>(originV1Url(`${BASE}/posts/${enc(id)}`), body).then(normalizePost),
remove: (id: string): Promise<void> => restDelete(originV1Url(`${BASE}/posts/${enc(id)}`)),
/** Publish a post NOW to its channel's connected accounts. */
publish: (id: string): Promise<Post> =>
restPost<unknown>(originV1Url(`${BASE}/posts/${enc(id)}/publish`)).then(normalizePost),
},
}
// The contract itself — re-exported so console call sites keep ONE import path.
export {
PROVIDERS,
ACCOUNT_STATUSES,
POST_STATUSES,
normalizeAccount,
normalizeAccounts,
normalizePost,
normalizePosts,
normalizeProviderCapability,
normalizeProviders,
normalizeSummary,
} from '@hanzo/ui/product/social/api'
export type {
Account,
NewAccount,
NewPost,
Post,
Provider,
ProviderCapability,
SocialSummary,
} from '@hanzo/ui/product/social/api'
+3 -3
View File
@@ -3,11 +3,11 @@
* watch the analytics datastore (and every volume) fill and scale DO storage before
* it runs out. admin.hanzo.ai only.
*
* ONE read on the aggregate surface: `GET /v1/admin/block-storage` — the `block-storage` head is
* ONE read on the aggregate surface: `GET /v1/admin/volumes` — the `volumes` head is
* allow-listed in `admin-aggregate.ts` + `next.config.mjs`, so the request rides the
* SAME global-admin-gated `app/admin/aggregate` BFF (getAdminGate → fail-closed 403,
* then a minted user bearer) as every other admin read; in the go:embed console it
* hits cloud's `/v1/admin/block-storage` directly under the first-party session cookie.
* hits cloud's `/v1/admin/volumes` directly under the first-party session cookie.
*
* Backend contract (cloud `clients/admin/block_storage.go`, DO API inventory + o11y/df fill):
* {
@@ -127,7 +127,7 @@ export function normalizeSnapshot(raw: unknown): StorageSnapshot {
export const StorageFleetApi = {
/** The block-storage fleet snapshot — global-admin only. */
async snapshot(): Promise<StorageSnapshot> {
const raw = await restGet<unknown>(originV1Url('admin/block-storage'))
const raw = await restGet<unknown>(originV1Url('admin/volumes'))
return normalizeSnapshot(raw)
},
}
+12
View File
@@ -132,6 +132,18 @@ export type Message = Owned & {
export type Account = {
owner: string
name: string
/**
* The Hanzo IAM user id — the OIDC `sub` claim, a UUID. This is the ONE
* identifier for this user across every Hanzo property: hanzo.ai and
* hanzo.chat know them by the same value.
*
* `owner`/`name` are the org and the login handle, and their pair (`hanzo/z`)
* is an org-relative REFERENCE, not a user id — it changes if either part is
* renamed, and it says nothing about the same user on another surface. Use
* this for anything that must join across properties; use owner/name for
* display and for org-scoped API paths.
*/
userId?: string
/** casibase user type; "anonymous-user" means no real sign-in. */
type?: string
displayName?: string
+80 -13
View File
@@ -17,14 +17,11 @@
* versions <= 0.3.1 had no envelope code at all.
*
* Wiring for the console SPA:
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`, so the
* first-party session cookie rides along (the go:embed cloud binary serves it
* natively; the standalone BFF forwards it as the signed-in user). The client
* NEVER sends an org/tenant — Cloud stamps it from the validated session.
* - `ingestKey` (optional, `NEXT_PUBLIC_EVENT_INGEST_KEY`) — a publishable pk_ key
* for LOGGED-OUT / public views (sign-in, marketing faces) so their pageviews +
* errors ingest without a session. Unset → logged-in flows via the cookie and
* logged-out is best-effort anonymous. Mint one per org via POST /v1/ingest/keys.
* - `host: ''` — SAME-ORIGIN. Events POST to the console's own `/v1/event`. The
* client NEVER sends an org/tenant — Cloud stamps it from the validated bearer.
* - `getToken` — the signed-in visitor's own Hanzo IAM access token. THIS is what
* attributes the stream, and it is the only mechanism that is correct here; see
* the note below for why no publishable key is passed.
* - `dsn` (`NEXT_PUBLIC_HANZO_EVENT_DSN`) — the error plane's own credential,
* shaped `https://<key>@api.hanzo.ai/v1/sentry/<project>`. Publishable by design
* (it ships in the client bundle). Unset → errors are captured and dropped.
@@ -34,12 +31,15 @@
* via `reportError`.
*
* Consent + PII: the stream is PII-free by construction — a random anon id + the
* stable `owner/name` actor id (never an email), org never sent. On top of that we
* honor an explicit browser opt-out (Global Privacy Control / Do-Not-Track): a
* IAM user id (never an email), org never sent. On top of that we honor an
* explicit browser opt-out (Global Privacy Control / Do-Not-Track): a
* visitor who signals it is not tracked at all. This is the consent layer for the
* logged-out marketing/public views.
*/
import { createAnalytics, type Analytics } from '@hanzo/event'
import { setTelemetry } from '@hanzo/ui/telemetry'
import { iamAccessToken } from '~/lib/auth/iam'
/** Honor an explicit browser opt-out signal (GPC, then legacy DNT). SSR (no
* navigator) defaults to enabled; the browser instance reads the real signal. */
@@ -51,8 +51,38 @@ function consented(): boolean {
return dnt !== '1' && dnt !== 'yes'
}
/** Publishable ingest key for logged-out ingestion; empty → same-origin cookie/anon. */
const ingestKey = process.env.NEXT_PUBLIC_EVENT_INGEST_KEY?.trim() || undefined
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
//
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
// here.
//
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
// this image is brand-agnostic: one build serves cloud.hanzo.ai, cloud.lux.cloud and
// cloud.zoo.cloud, with the brand resolved at RUNTIME from the request hostname
// (src/config). Baking a key would file every brand's — and every customer's —
// traffic into whichever org the key belongs to, which is both wrong data and a
// cross-tenant leak. It is the same reason Dockerfile bakes no NEXT_PUBLIC_*.
//
// Worse, it would be SILENT: @hanzo/event resolves the outgoing credential as
// `ingestKey ?? token`, so a key takes PRECEDENCE over the bearer — setting one
// would OVERRIDE each signed-in user's own identity rather than supplement it.
//
// THE COOKIE IS NOT A CREDENTIAL HERE. This file used to claim that posting
// same-origin let the first-party session ride along, so signed-in traffic landed
// correctly. It does not. That cookie is the casibase session, while cloud resolves
// a tenant from a VALIDATED IAM bearer (SanitizeIdentity) — so a cookie-only POST
// carries no principal. It is not refused: it silently takes the ANONYMOUS lane,
// which files every row under the `$public` tenant (a partition no org can read) and
// drops `identify` with a 200 receipt. Production proved it — 498 console rows, all
// `$public`, zero identified users.
//
// `getToken` below is the fix, and it has neither problem: cloud resolves the tenant
// from the token's OWN owner claim, so each visitor's events land in THEIR org, on
// every brand host, with no per-brand configuration. Logged-out views carry no token
// and stay anonymous — the honest outcome for a visitor who has not identified
// themselves, and still the open question for the public/marketing faces (closing
// that needs a PER-HOST key resolved at RUNTIME, e.g. the `GET /v1/brand?host=`
// shape src/config already anticipates; a module-scope const cannot receive it).
/** Error-plane credential. Unset → captureError is inert (fail-safe). */
const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
@@ -65,11 +95,48 @@ const dsn = process.env.NEXT_PUBLIC_HANZO_EVENT_DSN?.trim() || undefined
export const eventClient: Analytics = createAnalytics({
product: 'console',
host: '',
ingestKey,
// Read through a function, not captured once: this module is a singleton built at
// import time, when the visitor is not signed in yet and the token does not exist.
// The client calls this at flush time, so a sign-in — and every silent refresh
// after it — is picked up with no rebuild. Returns undefined on the server and
// when signed out, which is the anonymous path.
getToken: () => iamAccessToken() ?? undefined,
dsn,
enabled: consented(),
})
// ── The shared components emit through THIS client, not a second one ─────────
//
// @hanzo/ui instruments itself: DataTable, PrimaryButton, SlideOver, ConfirmDelete,
// the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView, OrgSwitcher,
// ThemeToggle, Toast and EmptyState all report what a user did. They emit through
// module-scope `track()`, which resolves an AMBIENT client — and left alone that
// client would be a SECOND one: default host api.hanzo.ai, no cookie, no session.
// Cloud's anonymous lane admits only pageview + error, so every one of those
// component events would have been DROPPED on arrival.
//
// Registering `eventClient` as the ambient client is the whole fix, and it is what
// keeps the promise this file's header makes: ONE client, one batch, one anon id,
// one stream, same-origin with the session cookie — so component events arrive
// CREDENTIALED and are attributed to the signed-in org. `AnalyticsProvider` in
// Provider.tsx already hands this same instance to `useAnalytics()`.
//
// The wrapper is the `Telemetry` shape @hanzogui/telemetry hands out; every method
// delegates, so there is nothing here to keep in sync but the delegation itself.
setTelemetry({
enabled: true,
product: 'console',
client: eventClient,
track: (event, properties, commerce) => eventClient.capture(event, properties, commerce),
pageview: (path, properties) => eventClient.pageview(path, properties),
identify: (personId, traits) => eventClient.identify(personId, traits),
group: (groupId, traits) => eventClient.group(groupId, traits),
captureError: (err, context) => eventClient.captureError(err, context),
captureException: (err, context) => eventClient.captureError(err, context),
setCohort: (patch) => eventClient.setCohort(patch),
flush: () => eventClient.flush(),
})
/**
* Report a caught / React-boundary error to the ONE error stream. Marks it handled
* (the app caught it) and never throws back — telemetry must not break recovery.
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { listQuery } from './client'
import { changeQuery, listQuery, watching } from './client'
describe('listQuery — the generic document list querystring', () => {
it('is empty for no query', () => {
@@ -26,3 +26,40 @@ describe('listQuery — the generic document list querystring', () => {
expect(listQuery({ filters: {} })).toBe('')
})
})
describe('changeQuery — the ONE change-feed querystring (poll AND stream)', () => {
it('is empty for no query', () => {
expect(changeQuery()).toBe('')
expect(changeQuery({})).toBe('')
})
it('joins doctypes and modules, and passes since + limit', () => {
const p = new URLSearchParams(
changeQuery({ doctypes: ['Ticket', 'HD Team'], modules: ['help'], since: 42, limit: 10 }).replace(/^\?/, ''),
)
expect(p.get('doctypes')).toBe('Ticket,HD Team')
expect(p.get('modules')).toBe('help')
expect(p.get('since')).toBe('42')
expect(p.get('limit')).toBe('10')
})
it('carries no org — the tenant is server-derived from the bearer, never sent', () => {
const qs = changeQuery({ doctypes: ['Ticket'], since: 1, watching: 'Ticket/TKT-1' })
expect(qs).not.toMatch(/org/i)
expect(qs).not.toMatch(/tenant/i)
})
it('omits since=0 so a fresh subscriber streams from NOW, not from history', () => {
expect(changeQuery({ since: 0 })).toBe('')
})
})
describe('watching — how a document is named to the live surface', () => {
it('is <DocType>/<name>', () => {
expect(watching('Ticket', 'TKT-00001')).toBe('Ticket/TKT-00001')
})
it('is the DocType alone for a Single, which has exactly one document', () => {
expect(watching('Support Settings')).toBe('Support Settings')
})
})
+104
View File
@@ -21,11 +21,15 @@
*/
import { restGet, restPost, restPut, restDelete, cloudProxyV1Url } from '~/lib/api/client'
import type {
Change,
ChangeFeed,
ChangeQuery,
DocType,
FrameworkDoc,
ListQuery,
ModuleInfo,
InstallResult,
Viewer,
} from './types'
const BASE = 'framework'
@@ -65,6 +69,40 @@ export function listQuery(q?: ListQuery): string {
const url = (path: string): string => cloudProxyV1Url(`${BASE}/${path}`)
/**
* Build the change-feed querystring. ONE function for the poll and the stream,
* because they are ONE server-side query — the stream is that query in a loop.
* Exported for testing.
*/
export function changeQuery(q?: ChangeQuery & { watching?: string }): string {
const p = new URLSearchParams()
if (q?.doctypes?.length) p.set('doctypes', q.doctypes.join(','))
if (q?.modules?.length) p.set('modules', q.modules.join(','))
if (q?.since) p.set('since', String(q.since))
if (q?.limit) p.set('limit', String(q.limit))
if (q?.watching) p.set('watching', q.watching)
const s = p.toString()
return s ? `?${s}` : ''
}
/** `<DocType>/<name>` — the ONE way a document is named to the live surface.
* A Single needs no name. Split server-side at the FIRST slash. */
export const watching = (doctype: string, name?: string): string =>
name ? `${doctype}/${name}` : doctype
const asChange = (v: unknown): Change => {
const r = asRecord(v)
return {
seq: Number(r.seq ?? 0),
doctype: String(r.doctype ?? ''),
module: r.module ? String(r.module) : undefined,
name: String(r.name ?? ''),
action: String(r.action ?? '') as Change['action'],
docstatus: Number(r.docstatus ?? 0),
at: Number(r.at ?? 0),
}
}
export const FrameworkApi = {
/** The DocType registry (schemas) — the collection definitions of every app lane. */
doctypes: {
@@ -120,6 +158,72 @@ export const FrameworkApi = {
}),
},
/**
* The change feed — the ONE way anything here learns a document changed, for
* EVERY doctype. `list` is one page; `subscribe` is the same query held open
* as Server-Sent Events. A view renders current state from `records.list`,
* takes the `cursor` it gets back, and applies changes from there.
*/
changes: {
list: (q?: ChangeQuery): Promise<ChangeFeed> =>
restGet<unknown>(url(`changes${changeQuery(q)}`)).then((v) => {
const r = asRecord(v)
return {
changes: rows(r.changes ?? v).map(asChange),
cursor: Number(r.cursor ?? 0),
reset: r.reset === true,
}
}),
/**
* Hold the feed open. Returns the unsubscribe.
*
* EventSource, not WebSocket, and it needs no credential: it is a same-origin
* GET through the console's `/v1` bearer proxy, so it carries the session
* cookie and the ORG is resolved server-side from the minted bearer's owner
* claim. A browser can never name its own tenant.
*
* The browser also resumes for us: each frame's `id` is the change's `seq`,
* so a dropped connection reconnects with `Last-Event-ID` and misses nothing.
* Passing `watching` additionally declares presence for as long as the
* connection lives — that is why no client→server channel is needed.
*
* `onReset` fires when the cursor fell behind the server's retention window:
* refetch current state before applying anything further.
*/
subscribe: (
q: (ChangeQuery & { watching?: string }) | undefined,
handlers: {
onChange: (c: Change) => void
onSync?: (cursor: number) => void
onReset?: (cursor: number) => void
onError?: (e: Event) => void
},
): (() => void) => {
if (typeof EventSource === 'undefined') return () => {} // SSR: nothing to open
const es = new EventSource(url(`stream${changeQuery(q)}`))
const cursorOf = (e: MessageEvent): number => Number(asRecord(JSON.parse(e.data)).cursor ?? 0)
es.addEventListener('change', (e) => handlers.onChange(asChange(JSON.parse((e as MessageEvent).data))))
es.addEventListener('sync', (e) => handlers.onSync?.(cursorOf(e as MessageEvent)))
es.addEventListener('reset', (e) => handlers.onReset?.(cursorOf(e as MessageEvent)))
if (handlers.onError) es.onerror = handlers.onError
return () => es.close()
},
},
/**
* Who is viewing a document right now. Joins and leaves arrive on the change
* feed above as `present`/`away` on the named document; this reads the roster
* they point at — the same rule documents follow (the feed says WHAT changed,
* the client re-reads the value).
*/
presence: {
list: (doctype: string, name?: string): Promise<Viewer[]> =>
restGet<unknown>(url(`presence${changeQuery({ watching: watching(doctype, name) })}`)).then((r) =>
rows(r).map((v) => ({ user: String(v.user ?? ''), since: Number(v.since ?? 0) })),
),
},
/** Per-org role assignments (grant editors; the owner is seeded System Manager). */
roles: {
list: (): Promise<{ user: string; role: string }[]> =>
+63
View File
@@ -106,3 +106,66 @@ export interface ListQuery {
/** The redacted-Password marker the engine returns for a set secret. */
export const REDACTED_MARKER = '__set__'
/* ── realtime ─────────────────────────────────────────────────────────────── */
/**
* One committed state change to one document — the unit of the engine's ONE
* realtime mechanism (`/v1/framework/{changes,stream}`).
*
* It is a FACT and carries NO payload: a subscriber that cares re-reads the
* document through the ordinary permission-checked `records.get`, so the feed
* never becomes a second, weaker read path. `seq` is a total order in commit
* order and is what a client resumes from.
*/
export interface Change {
seq: number
doctype: string
module?: string
name: string
action: ChangeAction
docstatus: number
at: number
}
/**
* What happened. `present`/`away` are ROSTER changes on the named document —
* presence rides the same feed, so a subscriber needs no second subscription;
* re-read the roster with `presence.list` rather than expecting it in the frame.
*/
export type ChangeAction =
| 'created'
| 'updated'
| 'submitted'
| 'cancelled'
| 'deleted'
| 'present'
| 'away'
/** One page of the feed. */
export interface ChangeFeed {
changes: Change[]
/** Pass as the next `since`. It advances past changes this caller cannot see. */
cursor: number
/**
* The requested cursor fell behind the server's retention window, so changes
* between them are gone. Refetch current state, THEN resume from `cursor` —
* never patch on top of a state you never had.
*/
reset?: boolean
}
/** Which changes a subscriber wants. Both narrow; neither can widen what the
* caller's roles already allow, and neither can name another org. */
export interface ChangeQuery {
doctypes?: string[]
modules?: string[]
since?: number
limit?: number
}
/** One live viewer of a document. */
export interface Viewer {
user: string
since: number
}
+1 -1
View File
@@ -90,7 +90,7 @@ export function resolveRouting(
export type ComingSoonGroup = { group: string; items: string[] }
export const COMING_SOON: ComingSoonGroup[] = [
{ group: 'GPUs & cloud', items: ['RunPod', 'Lambda', 'AWS', 'GCP', 'Azure'] },
{ group: 'Payments', items: ['Stripe', 'Square', 'PayPal'] },
{ group: 'Payments', items: ['Square', 'Stripe', 'PayPal'] },
{ group: 'Ecommerce', items: ['Shopify', 'WooCommerce'] },
{ group: 'Marketing', items: ['Mailchimp', 'HubSpot'] },
{ group: 'Analytics', items: ['Google Analytics', 'Mixpanel', 'PostHog'] },
+4 -4
View File
@@ -756,7 +756,7 @@ export const catalog: CatalogEntry[] = [
{ slug: 'clusters', label: 'Clusters' },
{ slug: 'nodes', label: 'Nodes' },
{ slug: 'volumes', label: 'Volumes' },
{ slug: 'load-balancers', label: 'Load balancers' },
{ slug: 'balancers', label: 'Load balancers' },
{ slug: 'audit', label: 'Audit' },
],
},
@@ -784,7 +784,7 @@ export const catalog: CatalogEntry[] = [
// admin.hanzo.ai USAGE CAPS & PROMO — the platform config surface for two levers:
// the single plan PROMO (percent-off applied to paid plans, over `/v1/admin/promos`)
// and cross-tenant CAP oversight/override (list/create/edit/delete any org's usage
// caps over `/v1/admin/spend-caps?org=<slug>`). GLOBAL-ADMIN ONLY (`admin: true` hides
// caps over `/v1/admin/caps?org=<slug>`). GLOBAL-ADMIN ONLY (`admin: true` hides
// it from every customer's nav/palette; both surfaces are server-gated by
// getAdminGate behind `/admin/aggregate`). A config surface (not money-moving) — the
// caps model reuses the tenant SpendAlert primitive, so `budgets-logic` is shared, no fork.
@@ -3571,7 +3571,7 @@ export const catalog: CatalogEntry[] = [
},
{
// Native — score DEFINITIONS (data type + valid range/categories) on the REAL
// /v1/o11y/score-configs surface. Read-only list; honest RuntimeNotice on 503.
// /v1/evals/rubrics surface. Read-only list; honest RuntimeNotice on 503.
id: 'score-configs',
label: 'Score Configs',
icon: Ruler,
@@ -3584,7 +3584,7 @@ export const catalog: CatalogEntry[] = [
routes: [{ path: '', component: ScoreConfigsModule }],
},
{
// Native — human-review queues on the REAL /v1/o11y/annotation-queues surface.
// Native — human-review queues on the REAL /v1/o11y/reviews surface.
// Read-only list; honest RuntimeNotice when the runtime is not initialized.
id: 'annotation-queues',
label: 'Annotation Queues',
+5 -5
View File
@@ -54,11 +54,11 @@ describe('allowAdminSurface — least-privilege admin read surface (v1/admin/<he
expect(ADMIN_AGGREGATE_HEADS).toContain('promos')
})
it('admits the spend-caps oversight surface — the list AND the :id edit/delete sub-path', () => {
expect(allowAdminSurface('v1/admin/spend-caps')).toBe(true)
// PATCH/DELETE /v1/admin/spend-caps/:id ride the same head via the sub-path rule.
expect(allowAdminSurface('v1/admin/spend-caps/alert-123')).toBe(true)
expect(ADMIN_AGGREGATE_HEADS).toContain('spend-caps')
it('admits the caps oversight surface — the list AND the :id edit/delete sub-path', () => {
expect(allowAdminSurface('v1/admin/caps')).toBe(true)
// PATCH/DELETE /v1/admin/caps/:id ride the same head via the sub-path rule.
expect(allowAdminSurface('v1/admin/caps/alert-123')).toBe(true)
expect(ADMIN_AGGREGATE_HEADS).toContain('caps')
})
it('admits the providers control board — the list AND the two mutation sub-paths', () => {
+4 -4
View File
@@ -24,15 +24,15 @@
* is a HEAD like the others; `allowAdminSurface` admits `v1/admin/providers[/...]`,
* so both the read and the two mutation sub-paths pass, and NOTHING else does.
* - `promos` the single platform plan promo (GET the current promo; PUT upserts it).
* - `spend-caps` cross-tenant usage-cap oversight/override (GET the list for an org;
* POST creates; PATCH/DELETE `spend-caps/:id` edit/remove all `?org=<slug>`-scoped).
* `allowAdminSurface` admits `v1/admin/spend-caps[/...]`, so the `:id` sub-path passes.
* - `caps` cross-tenant usage-cap oversight/override (GET the list for an org;
* POST creates; PATCH/DELETE `caps/:id` edit/remove all `?org=<slug>`-scoped).
* `allowAdminSurface` admits `v1/admin/caps[/...]`, so the `:id` sub-path passes.
* - `infra` the DigitalOcean fleet read (GET the snapshot) plus its three mutations:
* POST `infra/volumes/:id/snapshot`, DELETE `infra/volumes/:id`, POST
* `infra/nodes/:id/cordon`. `allowAdminSurface` admits `v1/admin/infra[/...]`, so
* every sub-path passes and nothing outside `v1/admin/<head>` ever does.
*/
export const ADMIN_AGGREGATE_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage', 'infra'] as const
export const ADMIN_AGGREGATE_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes', 'infra'] as const
const ALLOWED = new Set<string>(ADMIN_AGGREGATE_HEADS)
+175
View File
@@ -0,0 +1,175 @@
/**
* A REFUSAL, a network error and an ABSENCE are three different answers.
*
* IAM v1.33.31 made org scoping honour-or-refuse (`internal/authz/authz.go`
* `Scope`): a non-SuperAdmin principal that asks about a foreign owner is REFUSED
* rather than silently re-pointed at its own org. Read from that source, the wire
* shapes are:
*
* hit HTTP 200 {status:"ok", data:{}} (httpx.Ok)
* absence HTTP 200 {status:"error", msg:"the entity does not exist"}
* (httpx.Err 200 BY
* CONTRACT: "branch on
* status, not HTTP code")
* refusal HTTP 403 {status:"error", msg:"forbidden: …"} (authz.Deny 403)
*
* Absence is the ONLY one that may become `null`. This module's confidential
* client is exactly the kind of principal the v1.33.31 rollout starts refusing, so
* this is a deploy-ordering hazard rather than a theoretical one, and the damage
* is concrete:
*
* - `getMember` is the invite flow's identity read. A `null` tells the invitee
* "your membership was removed" (410) when IAM merely would not answer, and it
* is the same `null` the single-use activation guard reads.
* - `getUserKey` is the API-key state read. A `null` reports "no key", which is
* the exact regression its docstring records: the page falls back to "Create",
* the live key is hidden, and the user mints a duplicate over a key they can no
* longer revoke.
*
* The admin gate's own read is the deliberate EXCEPTION and is pinned here too: it
* is fail-SOFT because the gate needs positive evidence, so softening can only ever
* DENY. That asymmetry is the point soften where a lost answer closes a door,
* never where it opens one.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
// The confidential client is read at module scope, and every route checks
// `mintConfigured()` before calling — so configure it before the import, which is
// what production guarantees.
vi.hoisted(() => {
process.env.IAM_MINT_CLIENT_ID = 'hanzo-console'
process.env.IAM_MINT_CLIENT_SECRET = 'test-secret'
})
vi.mock('./session', () => ({ consoleClaims: vi.fn() }))
import { getMember, getUserKey, getAdminGate, type SessionUser } from './identity'
/** IAM's genuine-absence envelope, verbatim (compat/aliases.go getHandler). */
const ABSENT = { status: 'error', msg: 'the entity does not exist' }
/** IAM's refusal, verbatim (authz.errForeignOrg, rendered by authz.Deny at 403). */
const REFUSAL = {
status: 'error',
msg: 'forbidden: this credential is scoped to organization hanzo',
}
const json = (body: unknown, status = 200) =>
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status })))
const user: SessionUser = {
owner: 'maxpower',
name: 'dave',
id: 'maxpower/dave',
accessKey: '',
email: 'dave@maxpower.io',
emailVerified: true,
isAdmin: true,
isSuperAdmin: false,
}
afterEach(() => vi.unstubAllGlobals())
describe('getMember — absence is the ONLY null', () => {
it('a genuine absence is null (the invite really does name nobody)', async () => {
json(ABSENT)
await expect(getMember('acme/ghost')).resolves.toBeNull()
})
it('a hit returns the member', async () => {
json({ status: 'ok', data: { owner: 'acme', name: 'dave', password: '' } })
await expect(getMember('acme/dave')).resolves.toMatchObject({ name: 'dave' })
})
it('a REFUSAL is not an absence — it throws, and says why', async () => {
json(REFUSAL, 403)
await expect(getMember('acme/dave')).rejects.toThrow(/scoped to organization hanzo/)
})
it('a 5xx is not an absence', async () => {
json({ status: 'error', msg: 'boom' }, 502)
await expect(getMember('acme/dave')).rejects.toThrow()
})
it('an unreachable IAM is not an absence', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed')
}),
)
await expect(getMember('acme/dave')).rejects.toThrow(/unreachable/)
})
it('an unreadable envelope is not an absence', async () => {
json({ status: 'error', msg: 'id (owner/name) or name is required' })
await expect(getMember('acme/dave')).rejects.toThrow(/is required/)
})
})
describe('getUserKey — a lost answer never reports "no key"', () => {
it('a genuine absence is an honest empty', async () => {
json(ABSENT)
await expect(getUserKey(user)).resolves.toEqual({ accessKey: '', updatedAt: '' })
})
it('a hit returns the real key state', async () => {
json({ status: 'ok', data: { accessKey: 'sk-live-abc', updatedTime: '2026-01-01T00:00:00Z' } })
await expect(getUserKey(user)).resolves.toEqual({
accessKey: 'sk-live-abc',
updatedAt: '2026-01-01T00:00:00Z',
})
})
it('a REFUSAL throws — it must NOT hide a live key behind "Create"', async () => {
json(REFUSAL, 403)
await expect(getUserKey(user)).rejects.toThrow(/scoped to organization hanzo/)
})
it('an unreachable IAM throws rather than reporting no key', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed')
}),
)
await expect(getUserKey(user)).rejects.toThrow(/unreachable/)
})
})
/**
* The admin gate reads IAM too, and there the softening is CORRECT: the gate
* admits only on positive evidence (a verified brand-domain email AND an IAM admin
* flag), so a lost answer can only deny. Pinned so the strictness above is never
* "fixed" into throwing here and, more importantly, so a refusal can never be
* mistaken for an admission.
*/
describe('getAdminGate — a refused IAM read still fails CLOSED', () => {
const req = (host: string) =>
({
headers: {
get: (h: string) => (h === 'host' ? host : h === 'cookie' ? 'cloud_session_id=x' : null),
},
}) as unknown as import('next/server').NextRequest
it('refuses when IAM refuses the claims read (no gate opened on a 403)', async () => {
// get-account answers with a brand-domain user whose verification is unknown;
// the authoritative IAM re-read is REFUSED. The gate must not admit.
let call = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
call += 1
return call === 1
? new Response(
JSON.stringify({
status: 'ok',
data: { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', type: 'normal-user' },
}),
{ status: 200 },
)
: new Response(JSON.stringify(REFUSAL), { status: 403 })
}),
)
await expect(getAdminGate(req('admin.hanzo.ai'))).resolves.toBeNull()
})
})
+9
View File
@@ -1,5 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The confidential client is read at module scope, and the ONE IAM transport
// refuses to call without it (every route checks `mintConfigured()` first, so a
// configured client is the production precondition for the mint/issue primitives
// exercised below). Set before the import — `vi.hoisted` runs ahead of it.
vi.hoisted(() => {
process.env.IAM_MINT_CLIENT_ID = 'hanzo-console'
process.env.IAM_MINT_CLIENT_SECRET = 'test-secret'
})
// Control the console-session claims the resolver sees. (The casibase fallback is
// exercised by stubbing the get-account fetch below.)
vi.mock('./session', () => ({ consoleClaims: vi.fn() }))
+130 -229
View File
@@ -18,7 +18,6 @@
* Every value here is server-only env (sourced from KMS via the deployment's
* secret refs) NEVER `NEXT_PUBLIC_`, never in the browser bundle.
*/
import { randomUUID } from 'node:crypto'
import { type NextRequest } from 'next/server'
import { brandFromHost } from '~/config'
@@ -223,64 +222,126 @@ async function resolveSessionUser(
return { owner, name, id, accessKey, email, emailVerified, isAdmin, isSuperAdmin }
}
/**
* Read a user's authoritative identity claims (email + admin flags) from IAM as
* the confidential client. GET, Basic auth same credential the mint/issue
* primitives use. Fail-soft (returns null) so a transient IAM error never opens
* the admin gate.
*/
async function iamGetUser(id: string): Promise<UserClaims | null> {
if (!mintConfigured()) return null
let res: Response
try {
res = await fetchWithTimeout(`${IAM_URL}/v1/iam/get-user?id=${encodeURIComponent(id)}`, {
headers: { Authorization: basicAuth(), Accept: 'application/json' },
cache: 'no-store',
})
} catch {
return null
}
const json = (await res.json().catch(() => null)) as { status?: string; data?: UserClaims } | null
if (!res.ok || !json || json.status !== 'ok' || !json.data) return null
return json.data
}
function basicAuth(): string {
return 'Basic ' + Buffer.from(`${MINT_CLIENT_ID}:${MINT_CLIENT_SECRET}`).toString('base64')
}
/**
* POST a privileged IAM primitive as the confidential client, on behalf of a
* resolved user. Throws on any non-ok envelope so callers map it to a 5xx.
* IAM's genuine-absence answer, verbatim (`internal/compat/aliases.go` getHandler).
* It arrives as HTTP **200**: `httpx.Err` is 200 by contract the SDK is told to
* "branch on status, not HTTP code" so absence is an ENVELOPE fact, never a 404.
*/
async function iamCall<T = Record<string, unknown>>(
const ABSENT = 'the entity does not exist'
/** An IAM answer we could not act on. `absent` marks the ONE benign kind. */
class IamError extends Error {
constructor(
message: string,
readonly absent = false,
) {
super(message)
}
}
/**
* THE IAM call one transport, one error convention: it returns data or throws.
*
* IAM answers three DIFFERENT things and they must stay three (v1.33.31 made org
* scoping honour-or-refuse in `internal/authz/authz.go` `Scope`):
*
* hit 200 `{status:"ok", data}` the data
* absence 200 `{status:"error", msg:"<ABSENT>"}` throw, `absent`
* refusal 403 `{status:"error", msg:"forbidden: …"}` throw (authz.Deny)
*
* plus an unreachable IAM and a malformed envelope, which are also throws. Only a
* caller for whom "no such row" is a legitimate answer may soften `absent`. A
* refusal, a network error and an absence collapsed into one `null` is how a
* caller decides a taken slug is free and writes over it, or tells an invitee
* their membership was revoked when IAM merely would not answer so this
* function never collapses them.
*
* `method` is explicit because IAM has param-only POSTs (mint/revoke/issue take
* their id in the query and no body). Same shape as cloud's Go client
* (`apps/account/iam.go` `do`), so the fleet has ONE IAM call convention.
*/
async function iam<T>(
method: 'GET' | 'POST',
path: string,
query: Record<string, string>,
body?: unknown,
): Promise<T> {
const qs = new URLSearchParams(query).toString()
const res = await fetchWithTimeout(`${IAM_URL}${path}?${qs}`, {
method: 'POST',
headers: { Authorization: basicAuth(), Accept: 'application/json' },
cache: 'no-store',
})
const json = (await res.json().catch(() => null)) as { status?: string; msg?: string; data?: T } | null
if (!res.ok || !json || json.status !== 'ok') {
throw new Error(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
if (!mintConfigured()) {
throw new IamError(`IAM ${path} refused: no confidential client is configured`)
}
return (json.data ?? ({} as T))
const qs = new URLSearchParams(query).toString()
let res: Response
try {
res = await fetchWithTimeout(`${IAM_URL}${path}${qs ? `?${qs}` : ''}`, {
method,
headers: {
Authorization: basicAuth(),
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
cache: 'no-store',
})
} catch (e) {
throw new IamError(`IAM ${path} unreachable: ${e instanceof Error ? e.message : String(e)}`)
}
const json = (await res.json().catch(() => null)) as
| { status?: string; msg?: string; data?: T }
| null
// A non-2xx is an authorization or transport verdict, never a statement about
// whether the row exists.
if (!res.ok) throw new IamError(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
if (!json || json.status !== 'ok') {
const msg = json?.msg || `IAM ${path} returned an unreadable response`
throw new IamError(msg, msg === ABSENT)
}
return (json.data ?? null) as T
}
/**
* Read a row whose ABSENCE is a legitimate answer: the data, or `null` when IAM
* says there is no such row. Every other failure a refusal, an unreachable IAM,
* a malformed envelope propagates, so `null` here means "IAM says it does not
* exist", never "we could not find out".
*/
async function iamGetOrAbsent<T>(path: string, query: Record<string, string>): Promise<T | null> {
try {
return await iam<T>('GET', path, query)
} catch (e) {
if (e instanceof IamError && e.absent) return null
throw e
}
}
/**
* A user's authoritative identity claims (email + admin flags), read as the
* confidential client. Fail-SOFT by design and safe: the admin gate needs
* POSITIVE evidence (a verified brand-domain email AND an IAM admin flag), so
* empty claims deny. Softening every failure here can only ever close the gate
* the opposite of the existence probes above, where a softened failure would
* authorize a write.
*/
async function iamGetUser(id: string): Promise<UserClaims | null> {
return iam<UserClaims>('GET', '/v1/iam/get-user', { id }).catch(() => null)
}
/** (Re)generate the user's `hk-` Cloud API key; returns the new key (shown once). */
export async function mintUserKey(user: SessionUser): Promise<string> {
const data = await iamCall<{ accessKey?: string }>('/v1/iam/mint-user-keys', { id: user.id })
const key = data.accessKey ?? ''
const data = await iam<{ accessKey?: string } | null>('POST', '/v1/iam/mint-user-keys', {
id: user.id,
})
const key = data?.accessKey ?? ''
if (!key) throw new Error('IAM did not return an access key')
return key
}
/** Clear the user's `hk-` Cloud API key (immediate revoke; gateway cache ~5m). */
export async function revokeUserKey(user: SessionUser): Promise<void> {
await iamCall('/v1/iam/revoke-user-keys', { id: user.id })
await iam('POST', '/v1/iam/revoke-user-keys', { id: user.id })
}
/**
@@ -294,11 +355,19 @@ export async function revokeUserKey(user: SessionUser): Promise<void> {
* active key (uncopyable, unrevocable, and re-minted as a duplicate). IAM
* `get-user?id=<owner>/<name>` returns the real `accessKey` (+ the user's
* `updatedTime`, the last time the key row changed). Basic-auth confidential
* client, fail-soft: an unreadable IAM leaves the key absent (honest empty),
* never fabricates one.
* client.
*
* An unreadable IAM must NOT report "no key": that is the very bug above, and a
* refusal would recreate it the page falls back to "Create", the live key is
* hidden, and the user mints a duplicate over a key they can no longer revoke. So
* only a genuine absence is an honest empty; anything else throws and `GET /keys`
* answers 502 (it already maps a throw that way).
*/
export async function getUserKey(user: SessionUser): Promise<{ accessKey: string; updatedAt: string }> {
const u = await iamGetData<{ accessKey?: string; updatedTime?: string }>('/v1/iam/get-user', { id: user.id })
const u = await iamGetOrAbsent<{ accessKey?: string; updatedTime?: string }>(
'/v1/iam/get-user',
{ id: user.id },
)
return { accessKey: u?.accessKey ?? '', updatedAt: u?.updatedTime ?? '' }
}
@@ -320,196 +389,20 @@ export async function issueUserToken(
): Promise<{ accessToken: string; expiresIn: number }> {
const query: Record<string, string> = { id: user.id }
if (audience) query.aud = audience
const data = await iamCall<{ accessToken?: string; expiresIn?: number }>('/v1/iam/issue-user-token', query)
if (!data.accessToken) throw new Error('IAM did not return a token')
const data = await iam<{ accessToken?: string; expiresIn?: number } | null>(
'POST',
'/v1/iam/issue-user-token',
query,
)
if (!data?.accessToken) throw new Error('IAM did not return a token')
return { accessToken: data.accessToken, expiresIn: data.expiresIn ?? 0 }
}
// ── Org onboarding (create org + move the caller into it) ─────────────────────
// The confidential `hanzo-console` client is allowlisted for BOTH the org-admin
// (IAM_ORG_ADMIN_APPS) and user-admin (IAM_USER_ADMIN_APPS) capabilities, so it
// may create an organization and make the signed-in user that org's admin. The
// cloud backend scopes all data by the user's IAM `owner` (GetEffectiveOrg →
// session user.Owner), and an IAM user belongs to exactly ONE org, so giving
// a zero-org user their own org means MOVING them into it (owner=slug,
// isAdmin=true). The user's password travels with the user row (verification
// uses user.PasswordType first — object/check.go), so the move never locks them
// out; we still clone the source org's password/locale settings so the new org
// is well-formed (and covers a user whose PasswordType is empty).
/** An IAM organization as IAM returns it (only the fields we read/clone). */
type IamOrganization = {
owner?: string
name?: string
displayName?: string
passwordType?: string
passwordSalt?: string
passwordObfuscatorType?: string
passwordObfuscatorKey?: string
passwordOptions?: string[]
countryCodes?: string[]
languages?: string[]
defaultAvatar?: string
accountItems?: unknown[]
[k: string]: unknown
}
/** GET an IAM resource as the confidential client; null when absent/unreadable. */
async function iamGetData<T>(path: string, query: Record<string, string>): Promise<T | null> {
if (!mintConfigured()) return null
const qs = new URLSearchParams(query).toString()
let res: Response
try {
res = await fetchWithTimeout(`${IAM_URL}${path}?${qs}`, {
headers: { Authorization: basicAuth(), Accept: 'application/json' },
cache: 'no-store',
})
} catch {
return null
}
const json = (await res.json().catch(() => null)) as { status?: string; data?: T } | null
if (!res.ok || !json || json.status !== 'ok' || json.data == null) return null
return json.data
}
/** POST a JSON body to an IAM primitive as the confidential client. */
async function iamPostBody<T = unknown>(
path: string,
query: Record<string, string>,
body: unknown,
): Promise<T> {
const qs = new URLSearchParams(query).toString()
const res = await fetchWithTimeout(`${IAM_URL}${path}${qs ? `?${qs}` : ''}`, {
method: 'POST',
headers: { Authorization: basicAuth(), Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
})
const json = (await res.json().catch(() => null)) as { status?: string; msg?: string; data?: T } | null
if (!res.ok || !json || json.status !== 'ok') {
throw new Error(json?.msg || `IAM ${path} failed (HTTP ${res.status})`)
}
return (json.data ?? ({} as T))
}
/** Read an organization (owned by the `admin` org) by name; null when absent. */
export async function getOrganization(name: string): Promise<IamOrganization | null> {
return iamGetData<IamOrganization>('/v1/iam/get-organization', { id: `admin/${name}` })
}
/**
* Create a customer organization. Clones password + locale settings from the
* caller's current org (so the org is well-formed and the moved user's login is
* unaffected) and clears all instance-specific material (apps, logos, master
* secrets, MFA). The org is owned by the `admin` org; `isPersonal` marks a
* personal org (the "skip" path).
*/
export async function createOrganization(opts: {
name: string
displayName: string
personal: boolean
/** The caller's current org, cloned for password/locale compatibility. */
sourceOwner: string
}): Promise<void> {
const src = await getOrganization(opts.sourceOwner)
const org: IamOrganization = {
owner: 'admin',
name: opts.name,
displayName: opts.displayName,
createdTime: new Date().toISOString(),
isPersonal: opts.personal,
// Cloned for compatibility (best-effort; sane IAM defaults otherwise).
passwordType: src?.passwordType || 'bcrypt',
passwordSalt: src?.passwordSalt || '',
passwordObfuscatorType: src?.passwordObfuscatorType || 'Plain',
passwordObfuscatorKey: src?.passwordObfuscatorKey || '',
passwordOptions: src?.passwordOptions ?? ['AtLeast6'],
countryCodes: src?.countryCodes ?? ['US'],
languages: src?.languages ?? ['en'],
defaultAvatar: src?.defaultAvatar || 'https://cdn.hanzo.ai/img/hanzo-cloud-user.png',
accountItems: src?.accountItems ?? [],
// Never inherited — each org gets its own (or none) of these.
defaultApplication: '',
logo: '',
logoDark: '',
favicon: '',
masterPassword: '',
defaultPassword: '',
masterVerificationCode: '',
mfaItems: [],
tags: [],
websiteUrl: '',
enableSoftDeletion: false,
isProfilePublic: false,
}
await iamPostBody('/v1/iam/add-organization', {}, org)
}
/**
* Create a brand-new account as the ADMIN of an org (self-serve signup). The org
* (`opts.org`) must already exist the caller mints it via `createOrganization`
* first, so the new user's own personal org carries proper password/locale policy.
*
* Password hashing is IAM-side and non-negotiable: we send the plaintext `password`
* with NO `passwordType`, so casibase's `AddUser` runs `UpdateUserPassword`, which
* hashes with the org's policy (argon2id, cloned from the brand org) and explicitly
* REFUSES to store plaintext. We pass an explicit `id` (UUID) so AddUser skips the
* signup-application lookup a fresh personal org has no default app for.
*/
export async function createUser(opts: {
org: string
username: string
email: string
password: string
displayName: string
/** The brand app the account signs up through (hygiene; login is by email). */
signupApplication: string
}): Promise<void> {
const now = new Date().toISOString()
await iamPostBody('/v1/iam/add-user', {}, {
owner: opts.org,
name: opts.username,
id: randomUUID(),
type: 'normal-user',
// Plaintext in — IAM hashes it (argon2id) and never persists it as-is. Do NOT
// set passwordType, or AddUser skips hashing and stores the value verbatim.
password: opts.password,
displayName: opts.displayName,
email: opts.email,
emailVerified: false,
phone: '',
countryCode: '',
signupApplication: opts.signupApplication,
createdTime: now,
updatedTime: now,
isAdmin: true,
isForbidden: false,
isDeleted: false,
avatar: '',
score: 0,
ranking: 0,
})
}
/**
* Move a user into `org` as that org's admin. Sends the FULL current user object
* (IAM's update-user overwrites the default column set from the body, so a
* partial object would blank fields) with owner + isAdmin changed. The caller is
* always the signed-in user (the route binds the id to the session), so this can
* only ever move oneself.
*/
export async function moveUserToOrg(user: SessionUser, org: string): Promise<void> {
const current = await iamGetData<Record<string, unknown>>('/v1/iam/get-user', { id: user.id })
if (!current) throw new Error('Could not read the current user from IAM')
const moved = { ...current, owner: org, isAdmin: true }
await iamPostBody('/v1/iam/update-user', { id: user.id }, moved)
}
// ── Team-invite acceptance (activate a pending member) ───────────────────────
// An org admin's invite creates a real member row (owner=org, isAdmin=role) with
// NO password — it can't sign in yet ("pending"). The accept flow lets the invitee
// set that password themselves via a sealed invite token (see server/invite.ts).
// Both ops use the SAME confidential client as createUser/moveUserToOrg (already
// Both ops use the SAME confidential client as the key/token primitives (already
// allowlisted for user-admin), so no new IAM capability is required.
/** An IAM member row (only the fields the accept flow reads/writes). */
@@ -524,9 +417,17 @@ export type IamMember = {
[k: string]: unknown
}
/** Read a member (`<owner>/<name>`) as the confidential client; null when absent. */
/**
* Read a member (`<owner>/<name>`) as the confidential client.
*
* `null` means IAM says there is no such member the ONE thing the callers may
* treat as "this invite no longer names anyone". A refusal or an unreachable IAM
* throws instead, because reporting those as absence tells an invitee their
* membership was revoked when IAM simply would not answer, and it is the same
* `null` the activation guard reads.
*/
export async function getMember(id: string): Promise<IamMember | null> {
return iamGetData<IamMember>('/v1/iam/get-user', { id })
return iamGetOrAbsent<IamMember>('/v1/iam/get-user', { id })
}
/**
@@ -554,7 +455,7 @@ export async function activateMember(
id: string,
opts: { password: string; displayName?: string; signupApplication?: string },
): Promise<void> {
const current = await iamGetData<IamMember>('/v1/iam/get-user', { id })
const current = await iam<IamMember | null>('GET', '/v1/iam/get-user', { id })
if (!current) throw new Error('Could not read the member from IAM')
const updated: IamMember = {
...current,
@@ -577,7 +478,7 @@ export async function activateMember(
updated.signupApplication = opts.signupApplication
columns.push('signup_application')
}
await iamPostBody('/v1/iam/update-user', { id, columns: columns.join(',') }, updated)
await iam('POST', '/v1/iam/update-user', { id, columns: columns.join(',') }, updated)
}
// ── Admin gate ───────────────────────────────────────────────────────────────
+1 -1
View File
@@ -83,7 +83,7 @@ export function validateOrgName(input: string): OrgNameResult {
// The `/auth/signup` BFF creates a brand-new IAM user PLUS their own personal org
// (as admin) in one shot, then the client signs them in. IAM users always belong
// to an org (AddUser rejects owner==""), so a "create account, then onboard" split
// isn't possible against Casdoor — the org is minted here. These helpers are the
// isn't possible against IAM — the org is minted here. These helpers are the
// pure naming/validation policy; the route owns the IAM calls + the crypto digest.
/** Minimum password length. Above the cloned org policy (AtLeast6), a safe floor. */
+2 -2
View File
@@ -80,9 +80,9 @@ describe('allowCloudSurface', () => {
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
})
it('admits the evals facade (scores/datasets/dataset-items/evaluators/runs)', () => {
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
expect(CLOUD_HEADS).toContain('evals')
for (const sub of ['scores', 'datasets', 'dataset-items', 'evaluators', 'runs']) {
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
expect(allowCloudSurface(`v1/evals/${sub}`)).toBe(true)
}
// the bare head + a query-less path both admit
+14 -12
View File
@@ -46,7 +46,7 @@ export const CLOUD_HEADS: readonly string[] = [
// prompts/agents — the single `automations` head admits every sub-path (pieces,
// flows CRUD + enable/disable/run, runs, mcp). Replaces the retired /v1/auto proxy.
'automations',
// Webhooks (cloud clients/webhooks): /v1/webhooks[/:id[/{deliveries,test,rotate-secret}]].
// Webhooks (cloud clients/webhooks): /v1/webhooks[/:id[/{deliveries,test,secret}]].
// The org's outbound event destinations — the handler resolves the org from the Bearer
// owner (principal.Tenant) and 403s a cookie-only or forged-header call, so it routes
// through /v1 exactly like automations/agents. The single `webhooks` head admits every
@@ -101,8 +101,8 @@ export const CLOUD_HEADS: readonly string[] = [
// through /v1 exactly like crm — the single `company` head admits the formation
// read + every stage-action + transition sub-path.
'company',
// Cap table (cloud clients/captable): /v1/captable/{company,stakeholders,share-classes,
// equity-plans,shares,options,safes,convertibles,rounds,investments,summary}[/:id].
// Cap table (cloud clients/captable): /v1/captable/{company,stakeholders,classes,
// plans,shares,options,safes,convertibles,rounds,investments,summary}[/:id].
// The per-org capitalization ledger on Base/SQLite (HIP-0106); every route resolves
// the org from the Bearer owner (principal.Org) and 403s a cookie-only call, so it
// routes through /v1 exactly like crm — the single `captable` head admits every
@@ -184,8 +184,8 @@ export const CLOUD_HEADS: readonly string[] = [
// a cookie-only call 401s, so it routes through /v1 like the rest. Distinct from the
// admin god-view, which stays on the global-admin aggregate proxy.
'audit',
// Evals facade (cloud clients/eval): /v1/evals/{scores,datasets,dataset-items,
// evaluators,runs}. Single-segment sub-paths under the one `evals` head; the
// Evals facade (cloud clients/eval): /v1/evals/{scores,datasets[/:name/items],
// rubrics,evaluators,runs}. Single-segment sub-paths under the one `evals` head; the
// facade resolves the console project key pair from the request tenant (the
// Bearer owner), so routing it through /v1 gives correct per-org scoping —
// the same reason it must NOT be a cookie-only same-origin call (that 403s).
@@ -206,11 +206,13 @@ export const CLOUD_HEADS: readonly string[] = [
// handler resolves the org from the Bearer owner (X-Org-Id) and 403s a cookie-only
// call, so it routes through /v1 like the rest of the surface.
'projects',
// PaaS control plane (cloud clients/platform): /v1/platform/{projects,projects/:p/
// apps,apps/:a/deploy,.../deployments,.../deployments/:id/logs,health}. Per-org
// container-app platform on Base/SQLite; SanitizeIdentity resolves the org from the
// Bearer owner and 403s a cookie-only call, so it routes through /v1 like the
// rest — the single `platform` head admits every project/app/deployment sub-path.
// The platform control plane (cloud clients/platform): /v1/platform/{projects,
// projects/:p/apps,.../deploy,.../deployments,.../deployments/:id/logs,fleet,health}.
// Per-org container-app platform on Base/SQLite; SanitizeIdentity resolves the org
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1 like
// the rest — the single `platform` head admits every sub-path, including the
// operator fleet board at /v1/platform/fleet (folded in from the retired /v1/paas:
// paas was a second name for platform, and one product gets one name).
'platform',
// SBOM datastore (cloud clients/sbom): /v1/sbom/{ref} — the software bill of
// materials CI recorded for an image ref/digest (components + licenses). The
@@ -238,9 +240,9 @@ export const CLOUD_HEADS: readonly string[] = [
'fleet',
'clusters',
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
// (/v1/vpcs[/:id], /v1/load-balancers[/:id]).
// (/v1/vpcs[/:id], /v1/balancers[/:id]).
'vpcs',
'load-balancers',
'balancers',
// (`dns` — the managed-DNS head — is declared ONCE above, with the data resources.)
// Platform aggregates (read-only, derived): deploy targets, CI pipelines, image/
// binary builds, and versioned releases (/v1/{environments,pipelines,builds,releases}).
+1 -1
View File
@@ -87,7 +87,7 @@ describe('accessClaims', () => {
describe('sealSession (identity + refresh, browser-safe)', () => {
it('seals a SMALL identity cookie (projected claims, NOT the ~KB access JWT)', () => {
const bigAccess = fakeJwt({ ...Z_CLAIMS, blob: 'x'.repeat(6000) }) // simulate a fat Casdoor token
const bigAccess = fakeJwt({ ...Z_CLAIMS, blob: 'x'.repeat(6000) }) // simulate a fat IAM token
const r = sealSession({ accessToken: bigAccess, refreshToken: 'rt', expiresIn: 3600 })
expect(r?.expiresInMs).toBe(3600_000)
expect(r?.claims.name).toBe('z')
+5 -5
View File
@@ -15,7 +15,7 @@
* (proactively before expiry + reactively on a 401) via `grant_type=refresh_token`.
* The casibase cookie stays exactly as-is underneath as the graceful FALLBACK.
*
* TWO COOKIES, by necessity (Casdoor tokens are ~3.6 KB full-user JWTs):
* TWO COOKIES, by necessity (IAM tokens are ~3.6 KB full-user JWTs):
* - `hz_session` (Path=/, ~1 KB): the SEALED IDENTITY {access-token expiry +
* the small projected claims the console reads}. resolveUser + every BFF proxy +
* /auth/session GET read this; it is small, so it rides every request with no
@@ -30,7 +30,7 @@
* - The refresh token NEVER reaches the browser's JS (httpOnly) and the refresh call
* is server-side (BFF). Sealed at rest in the cookie; rotation-aware (IAM rotates
* the refresh token one-time-use we always persist the NEW one; a replay 400s).
* - The IAM access token (a Casdoor JWT that packs secret material password hash,
* - The IAM access token (a JWT that packs secret material password hash,
* TOTP secret) is NEVER stored in a cookie, NEVER logged, NEVER returned to the
* client: `sealSession` projects it to the small display/authz claim set and
* discards the raw token.
@@ -99,7 +99,7 @@ const cookie = (name: string, value: string, path: string, maxAge: number): Cook
maxAge,
})
/** Split a sealed string into ≤CHUNK_BYTES pieces (Casdoor tokens exceed one cookie). */
/** Split a sealed string into ≤CHUNK_BYTES pieces (IAM tokens exceed one cookie). */
function chunk(s: string): string[] {
const out: string[] = []
for (let i = 0; i < s.length; i += CHUNK_BYTES) out.push(s.slice(i, i + CHUNK_BYTES))
@@ -182,7 +182,7 @@ export function open<T>(sealed: string | undefined | null): T | null {
// ── Identity + refresh (what the cookies carry) ──────────────────────────────────
/** The console-JWT claims the console reads. Casdoor packs the full user object; we
/** The console-JWT claims the console reads. IAM packs the full user object; we
* extract ONLY display/authz fields never the secret material it also carries. */
export type ConsoleClaims = {
owner?: string
@@ -491,7 +491,7 @@ export function durableSessionClientId(host?: string | null): string | null {
}
/** Project session claims to the client-facing Account (display + admin fields only
* never the secret material Casdoor also packs). `owner === 'admin'` implies
* never the secret material IAM also packs). `owner === 'admin'` implies
* SuperAdmin. Shared by /auth/session (GET) and /auth/signin. */
export function accountOf(c: ConsoleClaims): Account {
return {