Compare commits

..
416 Commits
Author SHA1 Message Date
hanzo-dev ea14dc9a15 fix(console): finetuning/kubeflow 403 + API-keys CORS crack — mint user Bearer, same-origin keys route (v8.4.89)
The Finetuning + ML Pipelines pages showed 'Not enabled for your account' for a
signed-in customer, and Organization-Settings API keys couldn't be listed/minted.
Both are real money-path cracks — verified live as Dave (org maxpower).

ROOT CAUSE (proven live, NOT the iss theory):
- /training proxy forwarded the raw session COOKIE to cloud-api /v1/train/*, which
  authorizes on a validated JWT principal => 403 'no validated principal'. With a
  Bearer it is 200. (The token iss is already https://hanzo.id — IAM folds the
  in-cluster host to originFrontend[0]; cloud-api accepts it. functions/app-platform
  already work via the /cloud bearer proxy, so they were not the break.)
- keys.ts called cloud.hanzo.ai/v1/console/keys — a DIFFERENT origin than
  console.hanzo.ai => browser CORS 'Failed to fetch' (and cloud-api 501s that
  handler anyway).

FIX (surgical, isolation-safe, DRY):
- /training now mints a short-lived user-bound Bearer via adminBearer (the ONE
  per-user cache the /cloud proxy uses) and forwards Bearer + X-Org-Id (orgFor pin);
  the cookie is dropped upstream. Fails closed (502) if the token can't be minted.
  Org stays server-authoritative (token owner claim) — no tenant-isolation change.
- New same-origin app/keys/route.ts uses identity.ts mintUserKey/getUserKey/
  revokeUserKey (IAM confidential-client, the WORKING key path); keys.ts addresses
  <origin>/keys. CSRF-guarded, honest 501 when unconfigured, secret shown once.
- Bundle the edge-503 hygiene: interpretPlatformError maps 503 -> 'unavailable'
  (clean card), so a fail-closed zt backend never leaks ZT_CLIENT_* env to a customer.

tsc clean; vitest green; next build ok (/keys + /training routes registered).
2026-07-04 01:31:28 -07:00
zeekayandClaude Opus 4.8 ddde6818c6 chore(release): v8.4.88 — clean tag over main (docs-button cleanup + :tab routes + un-blank routes)
Deterministic release from main HEAD (includes the :tab route fix v8.4.86, the
6 dead-Docs-button removals v8.4.87, and #94 route un-blank). Tagged so the build
is immune to the main-push concurrency-cancel war on the contended v8.4.87.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:23:01 -07:00
0b5fa4a205 fix(nav): un-blank every product route — slug aliases + mocked-network blank-audit proof (#94)
* test(console): mocked-network blank audit for every product route + product-content hook

* fix(nav): alias human product slugs (traces/deploy/plans-pricing/wallets/model-catalog/fine-tuning/web-search) → canonical ids

The single biggest source of 'half the pages are blank': a directly-navigated
URL whose slug != the registry id resolved to notfound -> a Next 404 the operator
read as a blank page. The nav/launcher/palette always open the canonical id, so
these 7 aliases exist only to keep a human slug (docs, bookmarks, the CTO e2e
list, a hand-typed URL) from 404ing. One map (SLUG_ALIASES), already applied by
resolveProductView via canonicalSlug. Proven end-to-end by the blank audit
(every alias slug now renders content, 0 notfound).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 01:17:16 -07:00
zeekayandClaude Opus 4.8 a29e164323 fix(registry): drop dead Docs buttons for 6 products with no docs page
accessibility, crm, erp, templates, markets, trading set docs: ${DOCS}/<slug>
but those docs.hanzo.ai/docs/<slug> pages don't exist (verified HTTP 404 live) and
have no honest target — a redirect would mislead. Remove the docs: field on those
6 entries; consumers fall back to config.docsUrl (docs root), so no dead deep link.
The docs lane handles the other previously-missing slugs via docs-side redirects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:11:51 -07:00
a5fef9c37e fix(auth): admin host silent-SSOs the admin-console client — no second manual sign-in form (#93)
On admin.<brand> the admin guard has already authenticated the operator at the brand
IAM (a live SSO session), yet /signin rendered <SignInForm/> on EVERY host — forcing a
SECOND manual login that resolves the tenant identity (hanzo/z), not the operator
identity (admin/z), which breaks the operator panel.

Now the signin page auto-initiates admin-console OAuth on an admin host (isAdminHost):
startAdminSignin() mints a PKCE verifier, stashes it for the callback, and redirects to
IAM /v1/iam/oauth/authorize for client_id=admin-console in organization=admin — reusing
the guard SSO session and returning to /auth/callback with a code, no extra step. The
callback hands the stashed verifier to completeSignIn, which redeems it via the console
own BFF (PKCE, no secret). Tenant hosts are UNCHANGED — still <SignInForm/>, no verifier,
cloud-backend exchange.

Loop-guarded: fires once, never with a live session, never mid-callback.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 00:42:00 -07:00
zeekayandClaude Opus 4.8 17d3b048d9 fix(nav): declare :tab routes for Containers/Finetuning/Tasks + correct API-keys docs link
The Containers (Pods/Containers/Images/Namespaces/Events), Fine-tuning
(Datasets/Checkpoints/Models), and Tasks (Schedules/Queues/Workers) modules
render their tab bar as REAL sub-routes (go(t.id) -> /<id>/<tab>, reading
params.tab) but their registry entries declared only { path: '' } -- so every
tab 404'd (Tasks/Queues degraded to a stub). Declare the ':tab' route on each so
the tab bar resolves. Tasks keeps its 2-segment ':ns/:wid' detail route
(unambiguous by segment count).

Also fix the API Keys docs button: 'https://docs.hanzo.ai/api' -> the docs site
serves under /docs (a bare docs.hanzo.ai/<slug> 404s), so use
${config.docsUrl}/docs/api (white-labeled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:29:28 -07:00
d76975b3d7 feat(observe): wire per-product Status/Logs/Metrics to the LIVE o11y (SigNoz) runtime, one DRY mechanism (v8.4.85) (#87)
The shared per-product sub-page system (Status/Logs/Metrics/Settings — already
routed for all 136 products) is now backed by live o11y, scoped per product by
its OTel service.name via ONE parameterized mechanism (no bespoke per-product
wiring). Reuses the existing ApmApi o11y client + RuntimeNotice + LivingOverview.

- sources.ts: o11yServiceFor(entry) — product -> OTel service.name (repoBase
  convention + tiny override), new o11yService field on subpageSourcesFor.
- apm.ts: per-service o11y filtering — listQueryPayload gains optional filters
  (back-compat), serviceFilterItem, ApmApi.logs/traceSearch(service?), and
  ApmApi.serviceHealth (pickService + serviceHealthOf RED verdict).
- Status: LIVE o11y RED-metrics health band (org-scoped, works for customers) +
  deployment state; managed card only when neither reports; never a fake green.
- Logs: live o11y logs filtered to the product's service (replaces the dead
  /paas/logs path); RuntimeNotice + honest empty states.
- Metrics: real ledger + live o11y p99 merged into the latency KPI (was stuck
  at '—'); latencyP95 -> latencyP99 (honest to the RED metric we have).

Rebased onto origin/main (v8.4.84). tsc clean; vitest 1643/1643 (129 files);
next build 14/14 pages. o11y is LIVE (o11y.hanzo.ai/api/v2/readyz=200).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 00:20:12 -07:00
hanzo-dev 3fa78b8c82 fix(data): route provisioning through /cloud bearer-proxy (false 'Not enabled' fix)
The managed data products (Vector, SQL, KV, Datastore, DocDB, Search) all
drive ProvisioningApi, which addressed a bare /v1/<kind>. On the live console
ingress a bare /v1/* is routed straight to hanzoai/gateway (bypassing Next),
where the provisioning backends authorize on the Bearer owner claim and 403 a
cookie-only call ('X-Org-Id required'). classifyBackend maps that 403 to
'access' -> the FALSE title 'Not enabled for your account' on pages whose data
is actually live and per-org.

Class-fix (same as storage.ts /v1/s3 + framework, v8.4.70): ProvisioningApi now
builds cloudProxyV1Url(kind) -> <origin>/cloud/v1/<kind>. app/cloud/[...path]
mints a short-lived user-bound IAM token from the session and forwards it, so
the org is resolved server-side and the real resources load. All seven data
kinds are already allow-listed in proxy-allow.ts CLOUD_HEADS; the /cloud route
already serves GET/POST/PUT/PATCH/DELETE. One-line-of-intent transport swap.

Genuinely-empty orgs now show the honest 'Create your first ...' empty state,
never 'not enabled'. 'Not enabled' remains only for a true 403 enablement gate.

Tests: pin every ProvisioningApi call to <origin>/cloud/v1/<kind> (never a bare
/v1) in provisioning.test.ts, and move provisioning into the canonical-paths
'proxy exceptions' block (it was wrongly asserted prefix-free, encoding the bug).
tsc + vitest (1620) + next build green.

v8.4.84
2026-07-04 00:02:29 -07:00
9f84043b18 feat(ai-proxy): allow-list the async video poll/download sub-paths (#92)
Video generation is now async (Sora-style): create returns a job id immediately,
the client polls GET /v1/videos/{id} and downloads GET /v1/videos/{id}/content.
The /ai proxy's allow-list was exact-match and only permitted the CREATE path
(v1/videos/generations), so the Playground could reach create but neither poll
nor download — the two dynamic sub-paths 404'd at the proxy.

Add a narrow, anchored pattern (v1/videos/{id} and /{id}/content, conservative
id charset) alongside the exact set. It stays a tight allow-list — anchored to
v1/videos/, create still only the exact path — never a general tunnel; method is
enforced by the backend (GET-only there). Complements hanzoai/ai#68.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:01:26 -07:00
8d31438a84 feat(tracker): native @hanzo/gui Tracker module (v8.4.83) (#91)
* feat(tracker): native @hanzo/gui Tracker module — issues grouped by status

Add a native console Tracker over the real cloud /v1/tracker surface,
proving a native FE renders issue ROWS GROUPED BY STATUS (the thing the
old Huly/Svelte hanzo.team tracker could not do).

- src/lib/api/tracker.ts: TrackerApi client modeled on crm.ts — same
  keyless originV1Url + plain-REST helpers; bare-array lists; defensive
  pure normalizers; projects + issues CRUD (PATCH updates).
- src/lib/api/client.ts: add restPatch (backend uses PATCH; restRequest
  gains 'PATCH') — one-way, minimal.
- src/components/products/TrackerModule.tsx: project list (create +
  drill-in) → per-project grouped List (one DataTable section per
  status, the proof view) + Board toggle (5 columns, same data);
  create/edit issue in a SlideOver with a quick one-click status control;
  Linear-grade `c` keyboard shortcut opens create (⌘K stays owned by the
  global CommandPalette). Reuses only the local ui/ kit.
- registry.tsx: one Platform catalog entry (id 'tracker', ClipboardList).
- next.config.mjs + proxy-allow.ts: allow-list the `tracker` head in both
  (BFF→cloud user-bearer proxy), exactly like `crm`.

typecheck (tsc --noEmit) clean; 1611 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): v8.4.83 — ship native Tracker module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:52:17 -07:00
zeekayandClaude Opus 4.8 59f793f8ea feat(models+compute): distinct per-family model icons + clear CPU-credit vs GPU-prepay funding — v8.4.82
Fix 1 — per-family model icons (DRY, one map, every catalog surface):
Every model family rendered the same faint mark (the Zen ensō ring on every Zen
row — the biggest family — plus flat letter monograms Q/Me/DS/Mi/G/AI for the
third-party families), so nothing read as a recognizable brand. Added ONE curated
family→mark map (`src/components/ui/brand-marks.ts`, `BRAND_MARK` keyed by the
canonical BrandKey) of our own tasteful, monochrome inline-SVG marks, consumed
ONLY by `ProviderLogo` — so the Models catalog family headers + rows, the
playground ModelPicker rail + rows, Marketplace, Provider admin and Providers
explore all light up for free:
  - Zen (house brand) → the bold Hanzo block-H, knocked out of a filled tile —
    NEVER an upstream family glyph (brand policy); replaces the faint ensō circle.
  - Qwen → origami hexagon · DeepSeek → whale · Meta → infinity · Mistral → block-M
    · Google Gemma → cut gem · OpenAI GPT-OSS → six-point knot. (+ xAI/Moonshot/
    NVIDIA marks for the broader provider picker.) Each on its brand hue, each
    visually distinct; unknown providers keep the honest neutral-initials fallback.
No external logo hotlinks, no trademark files — avatars, `currentColor`,
theme-adaptive. brand.test locks every curated family to its OWN unique mark.

Fix 2 — CPU=credit vs GPU=prepay-card, made obvious:
The behavior was already enforced server-side; this makes the funding source clear
to the customer. New pure `fundingModel(kind,{creditCents,hasCard})` (machines/
logic.ts, unit-tested) is the one source of truth, rendered by a shared, visually
distinct `FundingNote`:
  - CPU / non-GPU → GREEN "Launches on your Hanzo credit · $X available · charged
    to credits · no card required" (real balance from /v1/billing/balance; empty →
    "Add credits"). Shown in the launch drawer AND the Machines overview.
  - GPU → YELLOW "Prepay only · charged to your card · 24-hour minimum" (never
    credits; no card on file → "Add a payment card & prepay"). Shown in the launch
    drawer AND the GPUs overview. Replaces the drawer's redundant no-card block.
Nothing fabricated — the credit figure is the real balance; copy matches what the
server charges.

typecheck clean · vitest 1611 pass (+ funding + brand-mark coverage) · next build ✓

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:14:53 -07:00
hanzo-dev dddab4a0b5 chore(release): v8.4.81 — audience-scope operator admin bearer (close the admin.hanzo.ai operator-panel gate) 2026-07-03 21:10:40 -07:00
422b54d145 fix(admin): audience-scope the operator /v1/admin/* bearer so cloud accepts it (#90)
The admin-aggregate forwards a user bearer minted by issue-user-token for the
reserved-admin operator (admin/z). Its owner=admin + isAdmin=true are correct, but
its aud defaults to the target user's own app (admin-console), which is NOT in
cloud's audience allowlist — so SanitizeIdentity rejects the token, the request
resolves anonymous, and every /v1/admin/* 403s 'global admin required'.

Fix: scope the minted bearer to the brand cloud audience (<brand>-cloud, always in
cloud's BrandAudiences), host-aware, admin path only.
- config.cloudAudience(host) = BRANDS[brand].iamApp (correct even on admin hosts).
- issueUserToken(user, aud?) / adminBearer(user, aud?) cached per (user, audience).
- BearerProxyOpts.audience; admin-aggregate passes cloudAudience(host).
Tenant proxies omit audience → default (target-app) aud, unchanged; confidential
mint client (hanzo-console) and owner semantics untouched. No cloud change.

tsc clean; vitest 1603/1603; next build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 20:47:28 -07:00
zeekayandClaude Opus 4.8 b94cafd099 fix(billing): Overview usage/spend — call /billing/v1/* directly (not /v1/billing)
The default Overview (and every product overview + o11y usage panel) showed the
"Access required" wall because GET /v1/billing/usage returned 403. Root cause
(proven live as davelorenzini/maxpower): on the console ingress /v1/* is routed to
the gateway-fronted cloud binary, which requires a JWT bearer (AUTH_PUBLIC_PATHS
excludes billing) — a cookie-only browser request has none, so cloud-api's
clients/console/billing.go resolveCaller finds no validated principal and 403s
("sign in to view billing"). Same class proven by /v1/functions -> 403
"X-Org-Id required". The bare /v1/billing/* NEVER reaches the console Next server
(so the next.config /v1/billing -> /billing/v1 rewrite never fires).

The console's OWN per-tenant proxy app/billing/v1/[...path]/route.ts (service token
+ server-pinned org subject) works perfectly: proven live /billing/v1/balance ->
{available:2046235,user:"maxpower"} and /billing/v1/usage -> 287 real ledger rows.
wallet.ts already addresses it directly; billing.ts + aimetrics.ts + SettlementModule
regressed to the bare /v1/billing/* form.

Fix (same class as v8.4.70 framework/s3 -> /cloud/v1): add ONE billingProxyV1Url
helper (client.ts, the billing twin of cloudProxyV1Url) and point every billing
client at /billing/v1/* directly. Kill wallet.ts's duplicate local appUrl (DRY).
canonical-paths.test pins billing into the proxy-exceptions block; aimetrics.test
asserts /billing/v1/usage. Tenant isolation unchanged (proxy still pins the subject
server-side). No cloud/backend change needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:56:49 -07:00
zeekayandClaude Opus 4.8 1b1b529806 chore(release): v8.4.79 — per-product Billing/Usage/Metrics quick links
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:32 -07:00
zeekayandClaude Opus 4.8 8ed465491a feat(overview): per-product Billing/Usage/Metrics quick links on every product overview
One reusable ProductQuickLinks band, wired once in the product catch-all for
every product's Overview (native, living, or bespoke) — never hand-copied per
module. Each card is scoped to THAT product and links to a real destination:

- Billing → Cost Reports pre-filtered to the product's meter
  (/billing/reports?product=<tag>; unfiltered for the whole-ledger inference
  surfaces). BillingReports reads the ?product deep-link and filters the ledger.
- Usage   → the product's own Metrics sub-page (/<id>/metrics), the REAL
  /v1/billing/usage ledger scoped by metadata.product.
- Metrics → the same per-product Metrics dashboard.

Figures are REAL (one product-scoped UsageApi.overview read) or honest-empty —
never fabricated, never an Access-required wall; the links always work. The
product→meter decision reuses the ONE metricsScopeFor source (DRY), so Usage for
Models shows model usage, Usage for GPUs shows GPU usage. Suppressed for the
money/account/admin/rollup surfaces. Pure quick-links.ts is unit-tested (11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:22 -07:00
a8d07bfaa6 fix(auth): admin.<brand> redeems its OAuth code as admin-console (PKCE, no secret) — v8.4.76 (#89)
Leg 2 of the admin.hanzo.ai login. The credential login mints the code for the
PUBLIC admin-console app, but the redeem POSTed the console origin /v1/iam/signin,
which the ingress routes to the cloud backend (casibase) — and casibase redeems
with its confidential hanzo-cloud client, so IAM rejected it ("the token is for
wrong application (client_id)") and bounced the operator back to /signin.

The console now redeems the code ITSELF, host-aware: on an admin host iam-login
authorizes with PKCE (S256 codeChallenge in the login body) and completeSignIn
posts {code, codeVerifier} to the new BFF app/auth/signin, which runs
pkceCodeGrant(client_id=admin-console, code_verifier) with NO client secret —
RFC 7636 public-client path (verified in IAM GetAuthorizationCodeToken: empty
secret + matching S256 verifier is admitted; admin-console has the
authorization_code + refresh_token grants). Tenant hosts are unchanged (no
challenge; the cloud backend keeps redeeming with hanzo-cloud).

durableSessionClientId(host) is the ONE host->client decision (admin-console on
admin hosts, else the confidential hanzo-console); /auth/refresh uses it so an
admin session refreshes secretlessly with admin-console. accountOf + applyCookies
extracted to session.ts (one writer for /auth/session|refresh|signin); createPkce
is the one PKCE source. No new secret to provision.

tsc clean; npm test 1578/1578 (+durableSessionClientId/pkceCodeGrant/refresh
host-aware + PKCE S256 correctness); next build green (/auth/signin registered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:47:50 -07:00
zeekayandClaude Opus 4.8 1b4cbbdebe fix(gpus): catalog reads visor via /vm proxy; GPU launches are card-funded prepay w/ 24h minimum (8.4.77)
Problem 1 — GPU catalog unreachable ("Accelerators 0 available to launch").
ROOT CAUSE: the console host ingress routes /v1/* straight to the gateway
(-> cloud-api), BYPASSING Next -- so the next.config /v1/gpu-sizes -> /vm/v1/gpus
rewrite never runs and cloud-api serves NO visor catalog route -> empty catalog.
Same class as the framework/s3 fix (v8.4.70). Proven in-cluster: visor
/v1/{gpus,regions,sizes} = 200 real DO catalog; cloud-api /v1/gpu-sizes 404s.
FIX: new vmProxyBase/vmProxyV1Url (client.ts); VisorApi reads the catalog
(regions/sizes/gpus) through the /vm visor proxy EXPLICITLY and machines/
launch/quote/terminate through the /cloud user-bearer proxy -- never a bare
/v1/*. Regression-pinned in visor.test.ts (fetch-capture) + canonical-paths.test.ts.

Problem 2 — GPU launches bill a REAL card (prepay), never credits.
GPU billing policy surfaced in LaunchDrawer + enforced server-side (cloud-api +
commerce, tracked separately): PREPAY ONLY (card-funded prepaid balance, never
granted credits), CARD REQUIRED (no card on file -> launch BLOCKED with an add-card
CTA -> /billing/credits), 24-HOUR MINIMUM (hourly x 24 charged upfront, shown as the
Quote headline + "charged now" on Launch). Copy changed from "metered to your Hanzo
balance" -> prepay/card/24h-minimum. Card-on-file gate fails CLOSED (a failed
payment-methods read blocks the launch, never a silent credit fallback). CPU
machines unchanged (still metered to the credit balance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:34:46 -07:00
zeekayandClaude Opus 4.8 ab6b13ae74 chore(release): v8.4.76 — console design polish (type + tables)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:29:53 -07:00
zeekayandClaude Opus 4.8 c732df5917 polish(tables): DataTable skeleton-row loading, refined header, mono numeric columns, robust cell centering
The shared list primitive every module renders:
- Loading paints SKELETON ROWS in the real column layout (honest 'loading',
  not a centered spinner void).
- Header: quiet Medium ($color10), hairline underline instead of a heavy fill.
- Columns gain `align` + `mono`: numeric/amount/ID columns right-align and
  typeset in Geist Mono tabular figures (dashboard-grade, column-aligned data).
- Cells vertically center their content (justify=center); empty state calmer.
- Rows ease their hover fill (.hz-row, 140ms).

StorageModule: timestamps + byte sizes -> mono tabular; actions column ->
align:right + centered (drops the self=stretch flex=1 that floated the trash
icon below the row baseline). No other module used that pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:29:39 -07:00
zeekayandClaude Opus 4.8 f88d36f32e polish(type): font-synthesis:none kills faux-bold app-wide; Basel Medium headings + tabular numerals
Basel ships only Book (400) + Medium (500). Every heading/label requested
600-900 was browser-SYNTHESIZED into a smeared faux-bold. One global invariant
(`* { font-synthesis: none }`, wins over Tamagui's runtime reset) makes every
requested heavy weight fall to the real Medium face — crisp, never synthetic.

- globals.css: font-synthesis:none (universal !important + body); add .hz-mono
  (Geist Mono + tabular) for dense data/code, keep .hz-tnum for display numerals.
- shared primitives normalized to the Book/Medium system: PageHeader, EmptyState,
  Metric (MetricCard/Panel/LegendDot) titles -> 500 with tight tracking.
- LivingOverview tiles: hero KPI / donut / distribution / series numbers -> Basel
  Medium + tabular figures (display numerals stay elegant; mono is reserved for
  tables/IDs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:28:32 -07:00
zeekayandClaude Opus 4.8 7dc17c8399 fix(console): null-guard 14 pre-existing next/navigation errors so next build goes green
`useSearchParams()`/`usePathname()` return `T | null` under React 19 / Next 15;
strict tsc flags 14 unguarded uses in 6 untouched files (auth/callback,
DashboardShell, Breadcrumbs, ComingSoon, Containers, OrgIntegrations) that
already failed `next build`'s type-check on clean main. DRY fix at each hook
site: `usePathname() ?? ''` and `useSearchParams() ?? new URLSearchParams()`.
Unblocks the Tenants / White-Label board deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:58:52 -07:00
zeekayandClaude Opus 4.8 769d21ffc7 feat(console): Tenants / White-Label board — data-driven tenant/package/domain/brand management (v8.4.72)
The global-admin surface for launching, branding, domain-binding, and managing
white-label tenants + resold sub-orgs. The MANAGEMENT UI over the platform's
provisioning — tenant/package/domain/brand RECORDS are the single source of truth,
nothing hardcoded as the canonical path; honest-state everywhere (real where a
backend answers, honest not-connected where a platform endpoint isn't bound yet).

- TenantsModule (admin: true, category Platform) + tenants/: tenants list COMPOSED
  from IAM orgs (brand) + admin cockpit (plan/wallet/status) + platform clusters;
  reseller TREE derived from metadata.parentOrg or (honestly flagged) owner email;
  package catalog read from the platform (DATA, seeded from platform-seed/packages.json,
  never a hardcoded const); New-tenant create (real IAM org); per-tenant manage
  (brand write REAL via IAM org fields, cluster provision REAL, domain list+bind,
  IAM apps, package grant, suspend/reactivate REAL).
- BFF: no new proxy — all platform calls ride the existing /paas catch-all (light up
  when the platform serves them; honest 404 today). Only server change:
  add/update/delete-organization added to the /admin/iam allow-list (global-admin
  gated, org-name pinned) so tenant-create + brand-write are REAL.
- Data-driven brand resolver: TenantsApi.brandConfig(host) + TenantBrandConfig
  replace the hardcoded BRANDS/HOST_BRANDS map in config.ts (marked deprecated with
  the precise migration; not swapped this pass — it's a build-time OAuth boundary).
- Missing platform endpoints flagged for the foundation phase: GET /v1/packages +
  package table, POST/DELETE /v1/org/{org}/package/{id} (composite provisionPackage),
  GET|POST /v1/org/{org}/domain (auto ingress+DNS+cert), GET /v1/brand?host=,
  parentOrgId column.
- Tests: +41 (packages normalizer+seed, model compose/tree/infer, tenants-API
  path/normalizer) all green; tsc clean for all new files; /tenants + /tenants/packages
  render 200 with zero page errors through the admin-gated catch-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:58:52 -07:00
hanzo-dev 33ab064acc fix(nav): resolve Automation + ML Pipelines URLs — zero 404 nav items (8.4.74)
Full-console walkthrough flagged /automation, /automations, /ml-pipelines,
/mlpipelines as raw 404s. Root cause: the nav opens each entry by its canonical
id (auto → auto.hanzo.ai external launch; kubeflow → in-console module), so a
directly-navigated/bookmarked conventional slug matched no entry and hit
notFound(). DNS + Zero-Trust already render honest BackendStateCard degraded
states (verified — no change).

- ML Pipelines: rename entry id kubeflow → ml-pipelines so the id IS the
  intuitive slug (label was already 'ML Pipelines'; Kubeflow is the engine, in
  the description). /ml-pipelines is now the canonical route.
- Slug aliases: ONE SLUG_ALIASES table in match-core (the single resolver) maps
  automation/automations → auto, mlpipelines/kubeflow → ml-pipelines. canonicalSlug
  rewrites the head segment up front in resolveProductView, so every branch reasons
  over the canonical id. A module alias resolves to the real route; an external
  target resolves to a new 'external' view kind.
- Catch-all: an 'external' view renders ProductInterstitial (the in-console
  discover page with an Open button that launches the product's own domain) —
  never a 404 for a hand-typed external-product URL.

DRY: aliasing in one place, no duplicate modules, no fake soon states, honest
by construction. tsc clean; vitest 1534/1534 (+10 match-core alias/external);
next build ✓ 14/14.
2026-07-03 18:55:58 -07:00
68b4cb71a7 fix(auth): admin.<brand> logs into admin-console IN the reserved admin org (#88)
On an admin console host the operator could not sign into the cockpit: after the
admin-guard passed, console2s own app-login failed with

  oauth2 invalid_grant: the token is for wrong application,
  application.Name:[hanzo-cloud], token.Application:[admin-console]

Root cause: admin.<brand> switched the OAuth APP to admin-console but never
switched the ORG, so login resolved into the brand tenant org (hanzo) instead of
the reserved global-admin org (admin) where admin-console is registered — the
code was minted in the wrong org and the token audience mismatched.

BUG #1 (src/config/index.ts resolveConfig): iamOrgName never switched on an admin
host. The app and the org now travel together — a sibling of the existing
app-switch: const admin = isAdminHost(host); app = admin ? adminApp : iamApp;
org = admin ? ADMIN_ORG : b.iamOrgName. NEXT_PUBLIC_* override precedence
unchanged. ADMIN_ORG (=admin) is ONE global org across every brand.

BUG #2 (src/lib/auth/iam-login.ts): the direct credential login hardcoded
organization: (a TENANT multi-org behaviour) on every host, so the admin host
never pinned org=admin. Now organization = isAdminHost(window.location.hostname)
? config.iamOrgName : — admin.<brand> authenticates INTO admin, tenants keep the
resolve-across-orgs-by-email behaviour (unchanged). The login wire already
derives its host + redirectUri client-side from window.location, so the SSR
build-time default host never enters the login flow.

Tenant (console.hanzo.ai) auth and the admin-guard org-membership gate are
untouched.

Tests: resolveConfig(admin.hanzo.ai) -> {admin-console, admin-console, admin};
resolveConfig(console.hanzo.ai) unchanged; new iam-login.test.ts proves the
credential POST carries client_id/application=admin-console + organization=admin
on admin.hanzo.ai and brand-app + empty org on a tenant host.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 18:39:53 -07:00
8b6f0ebb6a test(scope): lock the project-scope -> X-Project-Id header contract (#86)
The org > project selector ships already (ScopeSwitcher in the shell,
ScopeProvider in app/(dashboard)/layout.tsx, selection persisted via
lib/scope + localStorage). scope.test.ts proves the STORE; nothing proved
the WIRE — that a selected project actually reaches X-Project-Id on every
cloud call (the header visor attributes org>app>project usage by, spoof-
proofed at cloud.SanitizeIdentity).

Black-box test through a real get() over a stubbed global fetch:
  - org-level (no project): org-scoped call, NO X-Project-Id
  - project selected: X-Project-Id stamped, still under X-Org-Id
  - project cleared: X-Project-Id drops back to org-level
  - active environment rides along as X-Environment

No production change; locks the contract a customer scoping by project relies on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 17:57:20 -07:00
b36f2ad450 fix(playground): resilient model catalog (no 502) + promoted-Zen default (8.4.73) (#85)
Live: console.hanzo.ai/playground → "Could not reach the backend — HTTP 502",
model selector empty ("Choose a model"), Run blocked. ROOT CAUSE: the
ChatPlayground catalog fetch (aicatalog.fetchCatalog via useModels) hard-depended
on /v1/pricing/models — that endpoint 502s on the live ingress and its restGet had
NO .catch, so the whole Promise.all rejected even though /v1/models (200, the full
~59-model DO-first catalog incl the zen5 family) succeeded right beside it. The
working /v1/models result was discarded → 502 card → no model preselected.

- Fix 1 (catalog reachability, DRY): fetchCatalog now makes /v1/models the PRIMARY,
  always-routed source and /v1/pricing/models a best-effort overlay (the EXACT
  resilience CloudModelApi.list already uses). When pricing 502s it falls through to
  the live set; live-only entries are normalized name<-id, provider<-owned_by so the
  picker row never renders blank (also fixes a pre-existing latent blank-name).
  Throws only if the live /v1/models set itself is unreachable. Marketplace +
  ModelCatalog (the other fetchCatalog consumers) get the same resilience.
- Fix 2 (auto-select promoted Zen): new pure default-model.ts (defaultModelId,
  extracted from useModels so it's node-testable without the hook's UI imports;
  re-exported, callers unchanged). Default = latest PROMOTED Zen flagship: honor an
  explicit catalog `featured` flag first (auto-tracks zen6 with no code change), else
  the bare Zen flagship by name (zen5) over a mini/flash/coder tier, else any servable
  text model. ModelOption carries `featured`. ChatPlayground seed effect is retry-safe.

tsc --noEmit=0; vitest 1502 pass (+9 default-model, +2 aicatalog 502-resilience;
the pre-existing canonical-paths.test.ts s3 case is RED on origin/main HEAD — the
v8.4.70 storage.ts->cloudProxyV1Url move left that expectation stale — UNRELATED:
storage.ts/canonical-paths.test.ts/client.ts untouched); next build 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:39:32 -07:00
bf26207abd fix(console): restore main to green + lock the s3/framework + family↔brand contracts (v8.4.72) (#84)
The v8.4.70 build break (framework/client.ts + storage.ts imported the
cloudProxyV1Url that #81 DELETED → tsc TS2305; next build type-checks, so CI
shipped no new image) blocked BOTH live CTO reports from deploying: the authed
overview (sound code — UsageApi.overview → /v1/billing/usage, LivingOverview
degrades to an honest ErrorState) and the Qwen/Llama/DeepSeek model brand
icons (already fixed in 6dfa9c059: Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE).

The build fix landed concurrently in c458efa8f (re-add cloudProxyV1Url — the
prod-correct variant: the live ingress does NOT rewrite bare /v1/s3, /v1/framework
to the console app, so those heads address /cloud EXPLICITLY). But it left vitest
RED: canonical-paths.test.ts still asserted the old prefix-free
StorageApi.buckets → /v1/s3/buckets. This restores green + pins both invariants:

- canonical-paths.test.ts: drop the stale prefix-free s3 assertion; add a
  documented cloud-proxy-exceptions block pinning s3 + framework to /cloud/v1/*
  (so a future canonicalization can't repoint them to a bare /v1/ that 403s live).
- families.test.ts: a families↔brand contract test — every curated family logo
  resolves through the ONE normalizeBrand→BRANDS resolver to a real colour + icon,
  keyed off the exact live pricing.json providers (Qwen/Meta/DeepSeek). Permanent
  guard for the 'icons blank' report.

Test-only (runtime fixes already on main). tsc 0; vitest 1507/1507 (121 files);
next build ✓ 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:35:12 -07:00
hanzo-dev 8ee5d0289b docs(cms): v8.4.70 routing fix + S3-backend-500 infra note (LLM.md) 2026-07-03 17:23:41 -07:00
hanzo-dev a8b2a41d73 feat(integrations): customer-facing Connect page over /v1/integrations
Org-authed Integrations page: a logged-in org connects Slack/GitHub via
Connect buttons (card grid) that run the OAuth flow through the canonical
/v1 client — answers 'I can't just hit an API endpoint unauthed'.

- src/lib/api/integrations.ts (+.test.ts): IntegrationsApi (list/get/connect/
  disconnect) over originV1Url('integrations/...') + pure defensive normalizers,
  mirroring crm.ts. 9 tests.
- OrgIntegrationsModule.tsx: PageHeader + wrapping flexWrap card grid; per-card
  ProviderLogo/StatusTag/connectedAt + Connect (nav to authorizeUrl) / Disconnect
  (confirm+refetch); ?connected/?error callback-return toasts; available:false →
  disabled Connect + 'Not yet available'. Honest loading/BackendStateCard/empty.
- ProviderLogo: inline-SVG Slack (4-color hash) + GitHub (octocat) marks.
- StatusTag: 'connected' → green.
- registry: repoint 'integrations' entry → OrgIntegrationsModule (category
  Settings, slug /integrations = callback target); removed the old read-only
  DataTable surface (one integrations surface).
- Allow-listed 'integrations' in next.config.mjs CLOUD_V1_HEADS + proxy-allow.ts
  CLOUD_HEADS (defense in depth).

typecheck clean; vitest 1448/1448; next build ✓ (14/14).
2026-07-03 17:18:32 -07:00
hanzo-dev c458efa8f2 fix(client): re-add cloudProxyV1Url — the framework/s3 clients import it (v8.4.70 build fix)
v8.4.70's framework + storage clients import cloudProxyV1Url to address the /cloud
bearer proxy explicitly (a bare /v1/framework or /v1/s3 hits hanzoai/gateway with no
principal → 403). But that helper had been deleted from client.ts by the earlier
/v1-canonicalization, so the build failed: "'cloudProxyV1Url' is not exported from
'./client'". Re-add cloudProxyBase + cloudProxyV1Url (the <origin>/cloud/v1/<path>
builder) so the CMS/ERP/Help framework calls and the media DAM S3 calls resolve and
reach the working proxy. next build green (14/14).
2026-07-03 16:58:34 -07:00
hanzo-dev 1b07d623bf fix(s3): storage client uses the /cloud bearer proxy, not bare /v1 (CMS media DAM)
Same ingress-bypass class as the framework fix: StorageApi built its URLs with
originV1Url → `/v1/s3/*`, which console.hanzo.ai's ingress routes DIRECTLY to
hanzoai/gateway (bypassing Next), so the request lands with no principal → 403
"valid principal required". This broke BOTH the S3 file-manager product AND the CMS
media DAM (media-upload.ts presigns uploads via StorageApi).

Fix: cloudProxyV1Url → `/cloud/v1/s3/*` (the `/cloud` route reaches app/cloud's
bearer proxy; `s3` is allow-listed in proxy-allow.ts CLOUD_HEADS). Presigned PUT/GET
URLs are absolute S3 and unaffected — only the minting calls (buckets/objects/
presign) move to the proxy. Tests updated to the corrected /cloud/v1/s3 path (15
pass). Ships in v8.4.70 alongside the framework fix.
2026-07-03 16:52:22 -07:00
hanzo-dev 8b278dc279 fix(cms): framework client must use the /cloud bearer proxy, not bare /v1 (v8.4.70)
The CMS "Content" page showed "Not enabled for your account" for a real user whose
org HAS the cms module installed. Root cause: the framework client built its URLs
with originV1Url → `/v1/framework/*`, but on console.hanzo.ai the INGRESS routes
`/v1/*` DIRECTLY to hanzoai/gateway (bypassing the Next.js app), so the next.config
`/v1/framework → /cloud/v1/framework` rewrite never runs. The gateway has no
principal for that path and returns 403 "valid principal required" → the module
renders its honest access-denied card.

Fix: build framework URLs with cloudProxyV1Url → `/cloud/v1/framework/*` (the same
per-tenant bearer-proxy path CRM/Prompts/Agents use, allow-listed as the `framework`
head in proxy-allow.ts). The `/cloud` route DOES reach Next's app/cloud proxy, which
mints a short-lived user-bound token and forwards to cloud-api with the org resolved
from the token owner. Verified live: `/cloud/v1/framework/doctypes` = 200 with the
real doctypes for maxpower; `/v1/framework/doctypes` = 403 (gateway). One-line import
swap to an already-exported, already-used helper; tsc clean, framework client tests
pass.
2026-07-03 16:49:08 -07:00
hanzo-dev f930c0f504 release: v8.4.69 — branch integration (trading-bots, 7stars/yotoda brands, nodes-chains #71, inference-log-detail #58); 14 superseded branches verified already in main 2026-07-03 16:42:47 -07:00
hanzo-devandGitHub 1edd87c226 feat(inference): clickable log rows open a real per-call detail drawer (v8.4.39) (#58)
The Inference · Logs view already streamed the org's REAL recorded inference
calls (one commerce-usage-ledger row per billed call), but the rows were not
clickable and the LogLine projection discarded the rich per-call fields. Close
that gap — each row now opens the shared DetailPane showing what actually
happened for that call: model, provider, outcome, cost, prompt/completion/total
tokens, streamed, tier, product/agent attribution (only when the ledger tagged
it), request + transaction id, and time — every value REAL from the ledger
record, honest em-dash for absent. The full prompt/response TEXT is not on the
ledger row, so it is honestly stated as streaming from observability once its
trace runtime is connected — never fabricated.

DRY, no new surface: enrich LogLine with its source UsageRecord + one pure
logDetailFacts projection (logic.ts), one openLogDetail slide-over over the
existing DetailPane (panes.tsx, identical descriptor form to openEndpointDetail),
and wire onRowPress + a chevron affordance (LogsView.tsx). Reuses the shared Fact
row, StatusDot, and PrimaryButton — same look and feel as every other detail
surface.

Verification: tsc --noEmit clean; vitest 1136/1136 (+4 logDetailFacts /
LogLine.record); next build compiled successfully. Live authenticated render is
gated behind the console's server-cookie AuthGate (no backend session locally),
so verified via the component prop-level tests + a clean /[...slug] compile that
serves /inference/logs 200 in the dev server.
2026-07-03 16:38:46 -07:00
c100ba40c7 feat(nodes): surface primary-network chains per network (getBlockchains) (#71)
The Nodes surface (Network category, enabled on lux/zoo/pars + hanzo) showed
validators + peers per luxd primary network, but not the network's chains. This
adds the live primary-network chain set — the letter chains X C D Q A B T Z G K
plus the P-Chain — read from `platform.getBlockchains` through the same
same-origin, session-gated, method-allowlisted `/nodes` proxy.

- `/nodes` proxy: `platform.getBlockchains` added as the 5th (and only new)
  allowlisted luxd read method. A network counts as reporting if validators,
  peers, OR chains answered; chains are best-effort (a network can report
  validators yet not answer getBlockchains → honest empty chain list, never
  fabricated chains).
- `nodes.ts`: `RawBlockchain`/`ChainInfo` types + PURE `normalizeChains`
  (prepends the P-Chain, which getBlockchains omits; preserves reported order;
  drops id-less chains). `NetworkInventory.chains` added.
- `NodesModule`: renamed to "Networks & Nodes"; per-network card gains a Chains
  count + live chain chips; a Chains table (Network · Chain · Blockchain ID · VM)
  renders above the validators/peers table, honoring the network filter.
- Tests: +3 normalizeChains cases over the real devnet wire shape (33/33 pass).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:37:09 -07:00
9ae33beea4 fix(o11y): canonical /v1/o11y reads + un-trap Observations/Users nav (8.4.68) (#83)
The still-valid residual of #77 (which #81's canonicalization couldn't carry — #77
imported the now-deleted cloudProxyV1Url). Two real bugs, redone canonically on main:

1. o11y annotation-queues + users reads hit the DIRECT-cloud origin (`v1Url` ->
   config.cloudUrl). The o11y runtime scopes tenancy by the minted bearer's owner and
   403s a cookie-only call in prod, so those 4 reads (annotationQueues/annotationQueue/
   annotationQueueItems/users) were dead on the deployed console. Switched to the ONE
   canonical `originV1Url('o11y/…')` -> `/v1/o11y/…`; next.config rewrites the o11y head
   to the `/cloud` bearer proxy (server) and the static embed reaches it directly — the
   exact transport every other o11y read (ServiceMap/Alerts) already uses. The #41 sweep
   only missed these because they were `v1Url`, not the deleted prefixed helper.

2. The `observations` + `users` catalog entries were TRAPPED inside registry.tsx's
   opening JSDoc block (the `/**` never closed before them), so they never registered in
   nav/routing despite ObservationsModule/UsersModule existing, being imported, and
   fetching real data. Closed the comment and moved both entries into the active catalog
   under Observe (beside Annotation Queues), so they render in the sidebar and route via
   the catch-all like their siblings.

Supersedes #77 (unmergeable — it referenced the deleted helper). Verify: tsc --noEmit
= 0; vitest 1475/1475 (120 files); next build ✓ (14/14, the /[...slug] catch-all that
renders the catalog compiles). Authenticated visual e2e (the 4 o11y reads returning
real data; Observations/Users in the sidebar) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:33:39 -07:00
dcc8a3f8dd feat(brands): add 7stars + yotoda white-label cloud tenants
Two new white-label brands recognized by the unified console so their hosts
render branded, mirroring lux/zoo/pars:

- config: BrandId gains '7stars' | 'yotoda'; BRANDS + HOST_BRANDS entries.
  Both are general Hanzo-cloud customers seeded AS ORGS in the hanzo IAM
  (hanzo.id) — they have NO own .id issuer, so iamUrl = https://hanzo.id with
  the per-brand iamOrgName (7stars/yotoda) + iamApp (7stars-cloud/yotoda-cloud).
  Login resolves against hanzo.id, org-scoped by the JWT owner (aud=<brand>-cloud),
  matching how the orgs/apps were provisioned. Own billing.<domain>/docs.<domain>.
  HOST_BRANDS suffixes 7stars.dev / yotoda.tech cover every subdomain
  (cloud.*, console.*, admin.*) via the endsWith('.'+suffix) match.

- brand-scope: BRAND_CATEGORIES null (FULL AI-cloud catalog, like hanzo — they
  are general cloud customers, not web3-only like the sovereign-chain brands).
  BRAND_NODE_NETWORKS [] — they own no chain, so the Nodes surface reports on no
  networks (never another brand's chain).

- branding/brands: BRANDS registry gains 7Stars/Yotoda with their own
  brandName/orgName/websiteUrl/adminDomain (adminDomain is the admin-gate email
  boundary — @7stars.dev / @yotoda.tech match the seeded owners z@7stars.dev /
  z@yotoda.tech). Logo falls back to the generic Hanzo blocky-H mark (no bespoke
  asset yet); swap logoContent when a real mark ships.

Tests: index.test.ts (host resolution, hanzo.id issuer, per-brand billing/docs,
admin app) + registry-brand.test.ts (full-catalog scope, zero node networks).
npm test 975/975 green. tsc/next build add zero new type errors (diff-proven
identical to origin/main; the pre-existing next/navigation nullable errors are a
local Node 26 vs CI Node 24 toolchain drift, not from these files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:31:47 -07:00
hanzo-dev 105656e4fb fix(trading): DataTable onRowPress→onOpen (main's @hanzo/data API) 2026-07-03 16:23:25 -07:00
139a9890fd chore(console): v8.4.66 — Trading + Markets (Lux DEX bots + economy dashboard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
11370836a3 console(markets): Lux Economy dashboard — DeFiLlama-style DEX analytics
Adds a "Markets" product (Web3) — the analytics/management plane for the Lux DEX
economy, the twin of the Trading (deploy/manage) module.

- lib/api/economy.ts — client + pure normalizers for the `dex` subgraph
  (markets/fills/day-data). Honest to the CLOB reality: 24h volume, trades, book
  depth, best-bid/ask, last price are real fields; USD TVL is NOT fabricated (a
  CLOB has depth, not pooled TVL); the day-history series is empty until the
  subgraph's MarketDayData producer emits.
- overview/living: fromLuxIndexer adapter + a `lux-economy` LivingOverview config
  (KPIs, volume/trades/depth donuts, recent-trade feed, maker-health row) — the
  reusable board machinery, one config + one adapter, no new overview UI.
- app/economy/[...path]/route.ts — session-gated, brand-scoped GraphQL proxy to
  graphd's `dex` subgraph (ONE fixed query, no client GraphQL); honest not-reporting
  when unreachable.
- MarketsModule — the living board + the DeFiLlama-style per-market table, both over
  the /economy proxy. Registered under Web3 (lux.cloud shows it).

Data source: luxfi/graph `dex` subgraph (markets/fills) + the maker :2112 metrics.
Tests: 23 new (economy normalizers + fromLuxIndexer); full suite 1468 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
8a25a1c287 console(trading): Trading module — deploy + manage the Lux DEX bots as cloud apps
Adds a first-class "Trading" product under Web3: deploy the market-maker and
trader bots to the Hanzo PaaS from a config form, list the org's deployed bots,
watch each one's live quote quality (the maker's :2112 metrics) and DEX order
book, and control them (start/stop/redeploy/logs).

- lib/products/trading/templates.ts — the two deployable-app definitions
  (maker + trader) with a typed config schema; toCreateAppInput maps a filled
  config → a PaaS git app (BuildKit builds luxfi/{maker,trader} → GHCR).
  Signer keys are secretRef fields (KMS-synced), never typed in the browser.
- lib/api/trading.ts — pure Prometheus-metrics + order-book normalizers.
- app/trading/[...path]/route.ts — session-gated, brand-scoped, method-allowlisted
  proxy (mirrors /nodes): scrapes the maker :2112 metrics + reads the DEX book,
  honest not-reporting when unreachable.
- components/products/TradingModule.tsx (+ trading/{logic,DeployForm}) — the
  list/status/orderbook views + deploy/start/stop/redeploy/logs, over the existing
  PaasApi control plane (one deploy path; the bots are ordinary PaaS git apps).
- registry: Trading entry (Web3, brand-agnostic; data brand-scoped in the proxy
  so lux.cloud sees only Lux networks).

Tests: 34 new (templates/normalizers/logic), full suite 1311 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
hanzo-dev fb49a9522f feat(cms): finish the native CMS — Payload-parity Lexical WYSIWYG, content-type builder, DAM, project scope (v8.4.67)
Every /cms/* URL now renders the native DocType renderer over /v1/framework/* — no
iframe, no raw JSON, no 404. Fixes the live console.hanzo.ai/cms/collections/Article
-> {"error":"Not found"} bug (the deployed image lacked the wired CMS sub-routes, so
the catch-all resolved them to notFound() and a Next RSC navigation served a JSON
404). The routes were declared correctly; this ships them complete.

- Rich text = native Lexical (the same engine Payload's MIT richtext-lexical uses),
  built fresh + thin on core lexical@0.46.0 and registered over @hanzo/data's
  `richText` type in Provider.tsx (registerField override, no fork). Toolbar:
  bold/italic/underline, H1-3 + paragraph + quote, bullet/number lists, links,
  undo/redo. Stores the Lexical EditorState JSON; read view -> sanitized HTML via
  $generateHtmlFromNodes. Pure serialization round-trips + migrates legacy plain
  Text bodies (never throws). A DocType field typed RichText renders it.
- Content-type builder: "New collection" defines a DocType's name + typed fields
  on-page (add/remove/reorder/require/list, every framework fieldtype with the extra
  inputs each needs). Pure builder-logic.ts.
- Media = real DAM: drag/drop or pick -> uploads to the org's own S3 (cms-media
  bucket, the same /v1/s3 SeaweedFS presigned-PUT as Storage) -> Media doc with the
  stable object key -> thumbnails presigned on-view; delete removes doc + object.
- Publish/Unpublish (+ Submit/Cancel) in the record editor.
- Project scope: the org->project ScopeSwitcher filters the records list and stamps
  new records, only on collections that declare a `project` field. One engine,
  project is a filter — no per-project/per-org CMS instances.

tsc clean; vitest 1418 pass (+richtext/builder/media/project/richText round-trip);
next build 14/14. Needs cloud v1.786.52+ (RichText fieldtype) deployed to accept a
RichText field live.
2026-07-03 16:22:02 -07:00
hanzo-dev a743e75ae4 feat(console): surface Hanzo Auto (workflow automation) as an Automation tile
hanzoai/auto (auto.hanzo.ai) — visual AI workflow automation over 400+ MCP tools
and agents (the n8n/Zapier surface) — was not in the console. Add it to the AI
category as an external launch tile: it's a standalone app with its own full UI
on shared Hanzo IAM, so the tile opens it already-signed-in (like the Lux/Zoo
chain apps). Scoped brands:['hanzo'] so the auto.hanzo.ai URL never leaks onto a
Lux/Zoo white-label console.

Tests: 90 registry/brand tests pass; tsc clean.
2026-07-03 16:13:42 -07:00
62901a6f3a fix(console): finish /v1 canonicalization — delete the last 3 prefixed URL builders (#81)
The CTO contract is "nothing before /v1/". PR #79 (482251e) canonicalized 7 clients
but 3 helpers still hand-rolled a service-prefixed `<origin>/<svc>/v1/` URL —
aiV1Url (/ai), cloudProxyV1Url (/cloud), commerceProxyV1Url (/commerce) — fanning
out to 6 data-product clients AND ~13 product modules. DELETING them (not just
redefining) makes a non-canonical path COMPILER-IMPOSSIBLE: there is now ONE url
builder for the whole /v1 surface (originV1Url), exactly like billing/visor/
provisioning post-#79. Every remaining caller builds the bare, prefix-free
`/v1/<resource>`; next.config rewrites each head to its UNCHANGED same-origin BFF
proxy (app/ai, app/cloud, app/commerce — service-token / user-bearer injection intact).

- delete aiV1Url/cloudProxyV1Url/commerceProxyV1Url + aiBase/cloudProxyBase/
  commerceProxyBase; repoint all callers to originV1Url (compiler-enforced, no caller left).
- aicatalog/embeddings: /ai/v1/{pricing,plans,models,embeddings} -> /v1/… ; add
  `pricing`+`plans` to AI_V1_HEADS (already in the /ai proxy ALLOWED set).
- functions/paas/framework + Builds/Environments/Pipelines/Releases modules:
  /cloud/v1/<h> -> /v1/<h>; new CLOUD_PRODUCT_V1_HEADS (functions/framework/
  environments/pipelines/builds/releases) rewrites -> /cloud (already in proxy-allow
  CLOUD_HEADS). apm(o11y) + paas(platform) heads were already rewritten.
- commerce: /commerce/v1/<x> -> the canonical namespace /v1/commerce/<x>, ONE rewrite
  -> /commerce/v1/ (the billing twin) — collision-proof vs the generic store heads
  (product/order/user/store). Local cUrl() namespaces once, in one place.
- extend canonical-paths.test.ts: aicatalog/apm/commerce/embeddings/functions/paas each
  assert /v1/<resource> + never /<svc>/v1/. Realign functions.test + client-retry
  illustrative URLs to the canonical form.

grep -rE '/(cloud|vm|ai|billing|org|commerce)/v1' src/lib/api/*.ts is clean.
tsc --noEmit ok; vitest 1439 pass; next build ok. Every console API call is now /v1/<resource>.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:10:32 -07:00
f1e97b20f9 debrand: langfuse -> o11y/observability in our prose & comments (#80)
Drop the Langfuse brand from our own strings (code comments, docs,
config labels), mirroring the signoz->o11y product rename. Meaning preserved;
comments/docs/labels only, no functional change.

Intentionally KEPT (references to the external Langfuse product / upstream
dependency / integration contract, not our brand):
- LiteLLM success_callback/failure_callback ["langfuse"] + LANGFUSE_* env
  var names (the litellm langfuse-callback contract; renaming breaks emission)
- infra/k8s/langfuse/* (deploys upstream langfuse/langfuse:3 OSS image)
- o11y/langfuse-otlp-fanout.yaml + console-langfuse-keys (trace-fanout lane)
- console NOTICE (MIT attribution to Langfuse GmbH for clean-room UX)

Trace pipeline (ai emit -> collector -> backend -> console Observe) unchanged.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:06:18 -07:00
84b53753a6 fix(playground): image/video gen calls the /ai bearer proxy directly (v8.4.66)
On console.hanzo.ai the ingress routes /v1/* straight to cloud-api, bypassing the
keyless /ai bearer proxy — so a /v1 image call reaches cloud with NO user Bearer
and 401s (premium image gen requires auth). Call /ai/v1/images|videos/generations
directly (this app → forwardWithUserBearer mints the user-bound bearer) so the
Playground Image/Video tabs generate real, per-user-metered media.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:56:30 -07:00
hanzo-dev 8e2718f98d Merge native ERP + Help Center over the generic DocType renderer — kill the last iframe (console v8.4.66)
feat/erp-help-native (e2baba7): ERP + Help Center now render through the SAME
generic DocType renderer (src/components/doctype/*) that draws CMS, over the ONE
framework surface /v1/framework/*. Deletes the EmbeddedApp iframe subtree
entirely: EmbeddedApp.tsx, ProvisionPanel.tsx, embed-hosts, embed-probe,
api/embed, api/cms, api/erp, and the app/{cms,erp}/[...path] proxy routes
(-1872 lines). ERP + Help + CMS are ALL native now — zero iframe in the console.

Resolved package.json version to 8.4.66 (above live v8.4.65).
2026-07-03 15:54:44 -07:00
482251e389 fix(console): route all data-product clients through the canonical /v1/* client (#79)
Decomplect: make a non-canonical API path architecturally impossible for the
data-product surface. The 7 clients that hand-rolled a service-prefixed
/<svc>/v1/… path (billing, aimetrics, compute, visor, platform, provisioning,
storage) plus the Settlement component now build a bare /v1/<resource> via the
one originV1Url helper; next.config rewrites each head to its hardened
same-origin BFF proxy (service-token / user-bearer injection unchanged). Also
stamp X-Actor-Id (the signed-in user) in baseHeaders alongside
X-Org-Id/X-Project-Id, so org+project+user pass on EVERY call.

- billing/aimetrics: /billing/v1/<x> -> /v1/billing/<x>  (rewrite -> app/billing/v1)
- compute:  /cloud/v1/gpus[/alerts|/pools] -> /v1/gpus…  (rewrite -> /cloud)
- visor:    /cloud/v1/machines… -> /v1/machines…; /vm/v1/{regions,sizes} ->
            /v1/{regions,sizes}; /vm/v1/gpus (catalog) -> /v1/gpu-sizes
            (DISTINCT head: /v1/gpus is the cloud-api INVENTORY, not the catalog)
- platform: /cloud/v1/{clusters…,org/…/cluster} -> /v1/…  (rewrite -> /cloud)
- provisioning/storage: /cloud/v1/{sql,vector,…,s3/…} -> /v1/…  (rewrite -> /cloud)
- delete the per-client base-path builders (billingUrl / vm / clustersUrl-via-cloud);
  grep -rE '/(cloud|vm|ai|billing|org)/v1' src/lib/api/*.ts is clean (only the
  client.ts BFF-helper docs for the out-of-scope clients remain).
- X-Actor-Id sourced from a new lib/actor-scope (SessionProvider keeps it in
  lockstep with the resolved account: the auth twin of org-scope).

tsc --noEmit ok; vitest 1432 pass; next build ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:47:19 -07:00
79d495efe8 fix(console): honest machine-load errors, chat Enter-to-send, collapse chat reasoning, logs default tab (#78)
Frontend CX defects found in a live deep-test of console.hanzo.ai:

- Compute/Machines: a non-200 from /cloud/v1/machines (esp. 403/5xx) rendered
  the empty "Launch your first machine" state — a permission/load error
  masquerading as "you have none", the opposite of the page's "nothing is
  fabricated" promise. interpretVisorError now maps 401 -> sign-in, 403 ->
  honest permission state, and any other non-200 -> a retryable load error;
  CustomerMachines shows the empty/launch state ONLY on a real 200-with-zero.

- Chat: Enter did nothing but insert a newline. @hanzogui/input swallows the
  onKeyPress prop (never wired to the DOM) and forwards onKeyDown; the newline
  default fires on keydown, so the send handler must live there. Enter sends,
  Shift+Enter is a newline, IME composition never sends.

- Chat: model chain-of-thought leaked into the answer bubble. New pure
  splitThinking() separates a final answer from <think> reasoning (streaming-
  safe); the bubble renders only the answer with reasoning behind an optional,
  collapsed disclosure.

- Observe/Logs: landed on the empty "Application logs" tab while "Request
  activity" (always real for the org) had data. Request activity now leads and
  is the default tab.

Team members (#4) already routes through the single canonical /org/iam
get-users path at HEAD — no dead-endpoint waterfall remains to remove.

Build gate: tsc --noEmit, vitest (1418 tests), next build — all green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:09:14 -07:00
z 3595ad2644 chore(console): v8.4.65 — release image + video playground tabs 2026-07-03 14:55:46 -07:00
hanzo-dev e2baba7c8e feat(erp+help): native ERP + Help Center over the generic DocType renderer; kill the last 2 iframes (8.4.64)
ERP and Help Center join CMS as NATIVE lanes on the Hanzo Framework — thin
hosts scoping the SAME generic renderer (components/doctype/*) to module=erp /
module=help, with ZERO per-doctype UI code (the DRY proof). This finishes the
Great Unification: CMS + CRM + ERP + Help all native DocTypes, all iframes dead.

- ErpModule/HelpModule rewritten as thin hosts (like CmsModule): collections
  browser + records list + record detail, routed under /erp/collections and
  /helpdesk/collections. Install CTA installs the lane's DocTypes/hooks;
  submit/cancel + status flow come from the schema (no ERP/Help-specific UI).
- registry: erp + helpdesk -> native module routes (collections/:doctype +
  :name), repo hanzoai/cloud, native descriptions.
- CollectionsBrowser: additive optional setupDescription/setupBullets so the
  pre-install empty state reads correctly per lane. CMS default byte-identical
  -- no behavior/permission/proxy change (the RED-passed path is unchanged).
- Kill the iframe/embed subtree ENTIRELY (finishes the unification): the
  Frappe/Payload proxy route handlers app/erp + app/cms are Next catch-alls that
  SHADOWED the native /*/collections SPA routes (a route handler wins over the
  [...slug] page) -> deleting them unshadows native ERP AND native CMS (CMS was
  latently shadow-broken since 8.4.63). Removed the now-dead EmbeddedApp /
  ProvisionPanel / EmbedApi / CmsApi / ErpApi + embed-hosts + embed-probe + their
  tests. No iframe/embed path remains anywhere in the console.

typecheck clean; vitest 1360/1360 (109 files); next build green (the /cms +
/erp proxy routes are gone from the manifest, so /*/collections reach the SPA).
2026-07-03 14:39:58 -07:00
zandClaude Opus 4.8 af7ae5a085 feat(playground): image + video generation tabs (text→image/video via /v1)
Adds Image and Video tabs to the Playground, symmetric to Audio/Chat:
- ImagePlayground: Zen image model + prompt + size → POST /v1/images/generations
  → renders the real image (hosted url or inline b64).
- VideoPlayground: Zen video model + prompt → POST /v1/videos/generations
  → renders the real clip (base64 MP4 blob or url).
- PlaygroundApi.images/videos (src/lib/api/playground.ts) ride the SAME keyless
  /ai bearer proxy chat/audio use; invalidateBalance() after each (metered).
- Open images/videos in the /ai proxy allow-list (route.ts) and the
  next.config.mjs AI_V1_HEADS rewrite. No new auth, no billing bypass.

Zen-brand model ids only; pickers filter to the image/video families.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:37:23 -07:00
hanzo-dev 6dfa9c0598 fix(catalog): consistent brand marks — Zen always ensō, real third-party logos
The model catalog rendered logos from each model's raw provider string, so the
Zen family flip-flopped (models tagged 'hanzo' → block-H, others → ensō) and
every third-party family fell through to a gray initials chip ('QW'). Fixes:

- Rows + detail now render the model's FAMILY brand (not raw provider), so a
  family is internally consistent: Zen is ALWAYS the ensō; Qwen/OpenAI/etc. each
  show one mark. (Directive: Zen provider always uses Zen.)
- Extract pure brand resolution into ui/brand.ts (normalizeBrand + BRANDS
  registry) — unit tested, no GUI deps. Every curated family key resolves to a
  real brand-colored tile (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google + Anthropic/
  GLM/Kimi/MiniMax/Nvidia/xAI); only genuinely-unknown providers get neutral
  initials (honest, no fabricated trademark logos).
- Mobile: row hides the context column on phones + narrows numeric columns so
  rows never overflow (mobile-first, $md restores the desktop layout).

Tests: 27 pass (brand 6 + families 21). tsc --noEmit clean.
2026-07-03 14:19:27 -07:00
hanzo-dev 2d7f5b1512 feat(embeddings): Ingest surface (text/GitHub/crawl) — no bespoke jobs, async→Tasks
Replaces the 'Jobs' tab/JobsView (a bespoke async-tracker) with an Ingest surface over
the ONE /v1/docs/ingest endpoint: three real sources (pasted text · GitHub repo · website)
+ a target collection. Text indexes inline; a repo or crawl returns a durable hanzoai/tasks
workflow id and the UI links to the ONE Tasks product to track it ('Track in Tasks →',
/tasks/<org>/<wid>) — there is no second async system. Lower panel = the store's REAL
indexed files (get-files), reframed honestly as 'Indexed files' not a job log. Tab + subpage
renamed jobs→ingest. EmbeddingsApi gains ingestGitHub/ingestCrawl; IngestStats gains
async/workflowId. tsc clean.
2026-07-03 14:10:23 -07:00
hanzo-dev bb88a5bdbc feat(cms): native CMS on the Hanzo Framework — kill the Payload iframe (8.4.63)
Replaces the cms.<brand> Payload iframe/Studio embed with a NATIVE, metadata-
driven surface over the LIVE /v1/framework/* DocType engine. Ships the DRY
foundation the ERP/CRM/Helpdesk lanes reuse: ONE generic framework client + ONE
generic DocType renderer (the 'one engine + one renderer renders every app' model).

- src/lib/framework/{types,client,fields}.ts — the ONE FrameworkApi client
  (doctypes/records/modules/roles over the /cloud bearer proxy, allow-listed as
  the new 'framework' head) + the pure mapper DocType metadata <-> @hanzo/data
  FieldDefinition/record for EVERY fieldtype (relation/select/currency/attach/
  check/datetime/…), relation label enrichment, slugify (URL-safe names),
  publish/media/collection helpers. 32 pure unit tests.
- src/components/doctype/* — the generic renderer over @hanzo/data's RecordsView/
  RecordDetail/RecordForm: CollectionsBrowser (module doctypes + first-run install
  + new-collection), DocTypeRecords (table, or the MediaGrid gallery for a media
  doctype; inline edit sends the FULL validated body), DocTypeDetail (view/edit/
  create/delete + publish/unpublish + submit/cancel). Zero per-doctype code.
- CmsModule.tsx is now a thin host scoping the generic renderer to module=cms.
- proxy-allow: the 'framework' head; registry: cms native routes, repo hanzoai/cloud.

Names are slug/hex only (slugify + isValidDoctypeName) so they are space-/%-free —
correct on the live engine AND through the console's own pathIsClean bearer proxy.
Per-org + honest-empty by construction: the engine enforces tenancy (principal.
Tenant) + per-DocType permissions server-side.

Cloud side (hanzoai/cloud): the CMS content model (Page/Post/Article/Media/
Navigation/Author, module 'cms') + the generic app-lane install
(POST /v1/framework/modules/cms/install).

Verify: tsc clean; vitest (framework 32); next build ✓ (14/14 pages).
2026-07-03 12:43:15 -07:00
d00eef1853 feat(observe): wire Logs + trace-search to the live o11y (SigNoz) runtime (8.4.62) (#76)
o11y's last two query signals — application LOGS and trace search — are the
composite POST /api/v3/query_range (GET /api/v1/logs is a stub). Added to the
existing ApmApi (DRY, one o11y client, same /cloud/v1/o11y/* convention as
ServiceMap + Alerts): logs()/traceSearch() + pure builders/parsers
(listQueryPayload, parseListRows, toIso, normalizeLogRow/Logs, normalizeTraceSpan/
Spans). LogsModule is now two real lenses — Application logs (live o11y logs,
range + severity/service filters, honest RuntimeNotice/empty states) and the
prior Request activity ledger lens (kept, always-real fallback). Traces/
Observations stay on /v1/evals (LLM domain), Metrics on VictoriaMetrics — no
regression. tsc 0 errors; vitest 1373/1373 (+13 apm); next build ✓.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 12:20:13 -07:00
hanzo-dev 90c7501c8e chore(console): release 8.4.61 — fun adjective-animal name auto-fill on machine launch
The LaunchDrawer pre-fills a fun `adjective-animal` name (dark-llama, cosmic-axolotl,
turbo-wombat) so launch is click-and-go; a 🎲 button re-rolls it, it re-rolls after each
launch, and it stays fully editable. Pure curated word lists (src/lib/naming.ts), no deps.

tsc clean; vitest 1355/1355; next build ✓. Completes #43.
2026-07-03 11:21:28 -07:00
hanzo-dev 38a588f905 feat(machines): fun random name auto-fill on the launch form
Pre-fill the machine/GPU launch drawer's Name field with a Docker/Heroku-style
adjective-animal name (dark-llama, cosmic-axolotl, turbo-wombat) so a user can
click-and-go and rapid-launch. A 🎲 button re-rolls on demand, and the name
re-rolls after each successful launch so repeat-clicking Launch keeps getting a
fresh fun name. Still fully editable.

- src/lib/naming.ts — pure adjective-animal generator (curated lists, no deps);
  randomName({ suffix }) adds a short base36 token only when uniqueness is needed.
- LaunchDrawer: lazy useState(randomName) auto-fills on mount; post-launch
  re-roll (the drawer instance persists across the DetailPane close/reopen);
  the 🎲 re-roll button sits beside the field.

The launch POST already carries this name (VisorApi.launch → /v1/machines/launch);
end-to-end launch is gated on the in-flight /v1/machines gateway route.

tsc clean; vitest 1279/1279 (+3 naming); next build ✓.
2026-07-03 11:18:20 -07:00
hanzo-dev 98fcb41e13 feat(admin): operator cockpit surfaces — Customers/Revenue/Analytics + Enablement (8.4.60)
admin.hanzo.ai fleet management (admin:true, global-admin gated via getAdminGate aggregate proxy):
- Customers (fleet-customers): live customer list + detail + AUDITED actions (grant credit, suspend/reactivate).
- Revenue (fleet-revenue): balances/spend/MRR/ARPU + per-customer table + spend trend.
- Analytics (retention): cohort retention HEATMAP + growth/churn/DAU-WAU-MAU/ARPU, honest-empty via computed[] (no fabricated curves).
- Enablement (enablement): global off/beta/ga tri-state board (#30/#31).
- Beta features (customer, non-admin): self-service opt-in (scoped to caller's own org).

Wiring: +customers/revenue/analytics/enablement to ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS; +enablement to
CLOUD_V1_HEADS + CLOUD_HEADS (user proxy); PUT on the admin aggregate route (enablement set). Client:
AdminCockpitApi (casibase) + EnablementApi (plain JSON). Reuses DataTable/Charts/MetricCard/States, @hanzo/gui v5.
Verify: tsc clean, vitest 1337/1337 (+3 wiring), next build ✓ (/admin/aggregate registered).
2026-07-03 10:30:04 -07:00
hanzo-devandGitHub 49eff594c1 feat(compute): user-facing PaaS over cloud /v1/platform (App Platform) (#75)
A minimal, honest console for the per-org Hanzo PaaS — cloud's native
/v1/platform control plane (hanzoai/cloud clients/platform). A signed-in org
member manages their OWN container apps: list + live status, deploy/stop/start,
source-tagged deployment logs (cloud#75), KMS-sealed env (secret values ALWAYS
masked), and verified custom domains (DNS challenge records + Verify).

DISTINCT from the admin `applications` fleet board (/v1/apps) and from
internal-admin platform.hanzo.ai. Org-scoped by the Bearer owner via the /cloud
bearer proxy (the raw session cookie never reaches cloud-api).

- lib/api/platform-apps.ts — typed plain-REST client for /v1/platform/* over
  originV1Url → /cloud proxy (`platform` already allow-listed in proxy-allow.ts;
  added to next.config CLOUD_V1_HEADS so /v1/platform/* rewrites to /cloud).
- components/products/PlatformAppsModule.tsx — list + SlideOver detail
  (overview/deploy, env masked, domains + verify, source-tagged logs). Honest
  states throughout: Loader, EmptyState (create-via-CLI), BackendStateCard for a
  /v1 failure — never fabricated rows.
- components/products/platform-apps/logic.ts (+ .test.ts, 9 tests) — pure view
  logic; maskedEnvRows ASSERTS a secret's plaintext never renders.
- registry: one new 'app-platform' Compute entry.

Verify: tsc --noEmit clean, vitest 1343/1343, next build ✓ compiled. Authed
visual e2e is post-deploy (console convention).
2026-07-03 09:43:13 -07:00
zeekayandClaude Opus 4.8 eca3c36e9f chore(console): release 8.4.59 — uniform BFF CSRF gate + wallet/training authz hardening
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:32:49 -07:00
zeekayandClaude Opus 4.8 098a236645 harden(console): uniform same-origin CSRF gate on every hand-rolled BFF route
The same-origin CSRF guard (`sameOriginOK`) was enforced ONLY inside the shared
`forwardWithUserBearer` (the user-bearer proxies: /cloud, /ai, /vm, /commerce,
/cms, /superbase, /tasksd, /admin/aggregate). Every HAND-ROLLED cookie-auth
mutating route lacked it — so a cross-site page carrying the victim's auto-sent
cookie could drive a state change: KMS secret create/rotate/delete (/admin/kms),
PaaS control-plane deploy/scale/delete (/paas), IAM user/org/project mutations
(/admin/iam, /org/iam), billing writes + wallet credit, key mint/revoke, org
onboard, waitlist join, login/logout. `hz_session` is SameSite=Lax, but the
fallback casibase cookie's SameSite is not controlled by the console — so this
defense-in-depth guard is required, not optional.

Decomplected into ONE guard, `csrfRefusal(req, shape)` (co-located with the pure
`sameOriginOK` in bearer-proxy.ts): null on a same-origin request or a safe
method, else a fail-closed 403 in the caller's error envelope. Reads only headers
(never the body), so it composes before any req.text()/json(). `forwardWithUserBearer`
now calls it too — one policy, one place, applied to the WHOLE BFF.

Applied at the top of: forwardIam (→ /admin/iam + /org/iam), /admin/kms, /paas,
/billing/v1, /billing/v1/topup/wallet, /training, /keys, /onboard, /waitlist,
/auth/{session,refresh,signup}.

Also hardened, same "never trust the client" principle:
- /training now server-resolves X-Org-Id via `orgFor` (pins a non-global admin to
  their own org) instead of forwarding the raw browser header — matches /paas +
  /admin/kms, so a brand admin can't drive another tenant's training jobs even if
  the backend trusted the forwarded header.
- /billing/v1/topup/wallet now requires a session (`resolveUser`) and credits the
  SERVER-RESOLVED billing subject, never the client-supplied `userId` (which let a
  caller credit an arbitrary account); stamps X-Org-Id for correct ledger
  namespacing. (Commerce must still dedupe on (network, txHash) — RED handoff.)

Tests: +6 csrfRefusal cases; full suite 1340 passing, tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:32:49 -07:00
8c95f1ba33 feat(embed): terminate console server routes at the cloud one-binary (task #41) (#74)
Retire the remaining standalone Next server routes so the console is a pure
static SPA calling same-origin /v1/console/* (served by the go:embed one-binary,
routed through the gateway in the split deploy — one way, both topologies):

  - waitlist, embed-status, billing/v1/topup/wallet -> ported to cloud
    /v1/console/{waitlist,embed-status,topup/wallet} (real server work: Go
    handlers land in hanzoai/cloud).
  - keys, onboard -> repointed to the already-merged cloud /v1/console/{keys,
    onboard} (completes cloud#74's console side; they were still calling the old
    /keys,/onboard handlers, which broke under the static export).
  - docs -> a client redirect page (app/docs/page.tsx): a host->docsUrl map the
    browser already has (config.docsUrl); no server work, so no handler. Resolves
    the brand in an effect (no SSR/CSR hydration mismatch). Replaces the 308
    route the static export cannot run.

All calls go through the central client's v1Url() (config.cloudUrl, same-origin),
so the error envelope, cookie creds, and retry/refresh are unchanged. The ported
route.ts handlers are deleted (build:embed already stashed every route.ts; these
simply no longer exist).

Verified: tsc --noEmit clean; vitest 1334/1334 green; `npm run build:embed`
emits the full static out/ (real @hanzo/gui bundle, /docs prerendered).

NOTE: deploy the cloud image carrying the /v1/console/* handlers BEFORE this
console build (the SPA now depends on them). The remaining BFF proxies
(/cloud,/ai,/commerce,/billing catch-alls) are a separate, larger repoint for
full embed functionality and are out of this change's scope.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 09:30:34 -07:00
zeekayandClaude Opus 4.8 14401da5ca chore(console): release 8.4.58 — design unification (Basel Grotesk + Geist Mono typography)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:58:26 -07:00
Hanzo AI edd24e3f31 chore: release 8.4.57 — in-console Square card top-up 2026-07-03 04:16:51 -07:00
Hanzo AI 0b86e0dd67 feat(billing): in-console Square card top-up → canonical ledger
Replace the external-portal / HUSD-only dead-end with a real card top-up IN the
console. 'Add credits' (BillingOverview, HomeSummary), 'Top up' (SidebarWallet),
and the Billing → Credits tab now open /billing/credits: a Square Web Payments
card form that credits the org's canonical cloud-credit balance — the SAME ledger
the gateway debits for AI usage.

- BillingCredits.tsx: amount picker + Square card iframe (PCI SAQ-A — the PAN is
  entered into Square's iframe and tokenized in-browser; we only ever hold the
  single-use nonce). Pay → POST /billing/v1/topup/token via the same-origin proxy
  (service token + server-pinned subject) → commerce charges + credits the org.
  Button locks in-flight (no double-submit); nonce is single-use (no double-charge);
  honest states (config-unavailable, decline, success). Sandbox badge + test-card
  hint when the deployment is Square sandbox.
- lib/billing/square.ts: typed Web Payments SDK surface, fail-safe env→CDN map
  (non-'production' → sandbox tokenizer), pure amount validators, idempotent loader.
- BillingApi.paymentConfig() + topupWithCard() over the existing /billing/v1 proxy.
- HUSD crypto stays a secondary option (link to /wallet); no external billing.hanzo.ai.

Tests: square.test.ts (11) green; tsc strict + next build clean; full suite 1324 green.
2026-07-03 04:16:51 -07:00
hanzo-dev e4b4ce0605 release: v8.4.56 — go-live UX fixes (signup, API-key CTA, per-org metrics/logs) 2026-07-03 04:06:38 -07:00
hanzo-dev 3fe306224b console: go-live UX — email signup, Get API key CTA, per-org Metrics/Logs 2026-07-03 04:05:20 -07:00
zandGitHub 178e447f04 Merge pull request #73 from hanzoai/feat/design-landings-ship
feat(design): RailwayDeploy animation + ProductLanding kit + embeddings uplift (8.4.55)
2026-07-03 03:13:48 -07:00
hanzo-dev d1ae703521 chore: release 8.4.55 — RailwayDeploy animation + ProductLanding kit + embeddings uplift 2026-07-03 03:13:41 -07:00
hanzo-dev 87341083d9 fix(landing): use maxW shorthand on the design-reference route
next build's strict type-check (onlyShorthandStyleProps) rejects the maxWidth
longhand on a Stack; tsc --noEmit did not surface it. Also relabel the route
honestly (it is a reachable design-reference, not 'not shipped').

(cherry picked from commit 9ce0625ce413e8c3e52eaadddd327c0d2f30a759)
2026-07-03 03:13:16 -07:00
hanzo-dev 5b05304535 wip(product-landings): pick up prior agent RailwayDeploy + ProductLanding kit + embeddings uplift
Preserved from prior agent (uncommitted) before rebase onto main.

(cherry picked from commit c05ccacffceef218e54db8e6b2f67afedb9df81f)
2026-07-03 03:13:16 -07:00
zandGitHub 249edbf5e0 Merge pull request #72 from hanzoai/fix/console-e2e-bugs
fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
2026-07-03 02:34:27 -07:00
hanzo-dev 9b1d45ca7c fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
Five "advertised-but-broken" surfaces the live E2E suite flagged, fixed honestly
in the client (no fabrication):

- Vector module rendered nothing: normalizeResourceList validates + unwraps the
  provisioning list at the transport boundary (bare array, or a
  data/items/results/resources/collections/list/rows wrapper incl. one level of
  nesting e.g. Qdrant result.collections), honest [] fallback. A wrapped 200 body
  was reaching the list view's for..of and throwing behind the error boundary while
  SQL/KV (bare arrays) rendered. ONE place, every kind.
- /chat reply now STREAMS token-by-token via AiApi.ragChatStream (grounded RAG
  headers ride PlaygroundApi.streamChat). SSE parser canonical home moved to
  lib/api/stream.ts (one definition, re-exported from playground/stream.ts). The
  error card's Retry now re-runs the last user turn (was a no-op).
- Functions list self-freshens: useReloadOnFocus refetches on window focus /
  tab-visible so an API/CLI-deployed function appears without a reload; + Refresh.
- Sign-out redirects deterministically to /signin after DELETE /auth/session
  (AuthGate's reactive redirect could be pre-empted by an in-flight session
  re-hydrate, stranding the user on /).
- CRM summary rollup lag is BACKEND (materialized rollup eventual consistency);
  the console already refetches /v1/crm/summary after every create/delete —
  flagged, NOT faked.

tsc --noEmit clean · vitest 1290/1290 (3 new suites) · next build ok.
2026-07-03 02:32:42 -07:00
zeekayandClaude Opus 4.8 47310fd53a chore(console): v8.4.53 — canonical Hanzo typography + no-blank model rows
Integrates the Basel Grotesk (UI) + Geist Mono (code) typography pass with the
family model-browser blank-row fix. Strict superset of v8.4.52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:58:34 -07:00
zeekayandClaude Opus 4.8 4c722b11bc fix(models): no blank rows — exclude meta-routers + id fallback label
The Zen family showed two nameless rows: the gateway's meta-routers (router:general,
…) bucket into Zen by provider but carry no display name, and modelDisplayName
returns '' when a record has no name. Fix both: isChatModel now excludes router:*
(a routing policy, not a pickable model — it lives in the Routing tab), and the row
label falls back to the raw id when there's no display name (displayLabel). 21 unit
tests (router exclusion + never-blank label).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:58:12 -07:00
zeekayandClaude Opus 4.8 ef8f531319 design: Basel Grotesk (UI) + Geist Mono (code) typography
Converge the console onto the canonical Hanzo typography without ripping the
Tamagui mechanism:
- Self-host Basel Grotesk (Book 400 + Medium 500) via @font-face in
  app/globals.css and override the @hanzo/gui (Tamagui) v5 body + heading font
  family to 'Basel' in gui.config.ts, so every Text/Paragraph/H* renders Basel
  (one place, whole product). Replaces the default system-font stack.
- Geist Mono for code/data via CDN import + a code/pre/kbd/samp rule.

Sidebar toggle (lucide PanelLeft) + true-black tokens already shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:49:19 -07:00
zeekay 7962ca05e6 Merge remote-tracking branch 'origin/main' into fix/console-true-black
# Conflicts:
#	app/globals.css
#	package.json
#	src/components/products/ModelCatalogModule.tsx
2026-07-03 01:42:46 -07:00
zeekayandClaude Opus 4.8 ea47eb0246 chore(console): v8.4.52 — unified family model browser + Linear-caliber craft
Release: the Models module is now the unified, family-grouped model browser at
chat parity (Zen first + Qwen/Meta Llama/DeepSeek/Mistral/Google Gemma/OpenAI
GPT-OSS), true-black surface-depth ladder, skeleton loading, tabular numerals
across all metric cards + the model browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:38:43 -07:00
zeekayandClaude Opus 4.8 fc96e45617 polish(console): Linear-caliber craft on the model browser + surface depth
CTO design bar (Linear/Vercel/Stripe): elevate the flagship model surface and the
whole shell's depth.

- Surface depth ladder over true-black: $color1 #050505 (resting panels), $color2
  #0a0a0a, $color3/$color4 #171717/#1f1f1f (interactive/elevated). Cards now read
  with real depth over the #000 canvas instead of flat black — applied globally, so
  every panel (agents, metrics, cards) gains the same layering. Text-contrast scale
  ($color10–12) untouched.
- Model browser: designed skeleton loading (shimmer family cards, no spinner),
  staggered fade-in entrance (40ms), tabular numerals on every numeric column
  (context / $-per-Mtok / counts / stats) so figures align, hairline stat dividers,
  tighter type scale (family $5/800, stat $7/800 -0.5 tracking, uppercase labels),
  crisp hover rows, and a proper icon empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:37:35 -07:00
zeekayandClaude Opus 4.8 88d729ed01 style(console): pure #000 first paint (SSR bg + themeColor)
The <html> inline background and viewport themeColor were still #0a0a0a — a hair
off the true-black the .t_dark CSS override paints. Match them to #000000 so the
very first paint (before CSS) and the mobile browser chrome are pure black too,
consistent with hanzo.ai + hanzo.chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:34:09 -07:00
zeekayandClaude Opus 4.8 a2551bf302 feat(models): family-grouped model browser (chat parity)
Replace the flat 444-row catalog table with a family-grouped browser matching
hanzo.chat's picker exactly: collapsible sections per family — Zen first (with
zen5-mini flagged Default), then Qwen · Meta Llama · DeepSeek · Mistral · Google
Gemma · OpenAI GPT-OSS — each nesting its current-gen chat models with real
context, $/Mtok price, and live-vs-catalog availability. Click a model for the
full specs/pricing/features detail panel (unchanged). Search filters across every
family; a stats strip shows families / models / available-now. Reuses ProviderLogo
+ formatters; grouping is the pure, unit-tested groupByFamily. One console home for
model selection, the same families the user sees in chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:33:36 -07:00
zeekayandClaude Opus 4.8 ccbf455f53 feat(models): curated chat-family taxonomy + live-Zen catalog merge
The unified model browser groups the live catalog into hanzo.chat's exact 7
families — Zen (house brand) first, then Qwen · Meta Llama · DeepSeek · Mistral ·
Google Gemma · OpenAI GPT-OSS. Data-driven from /v1/pricing/models joined with
/v1/models: provider-defined families match the provider string; the named slices
(Gemma ⊂ Google, GPT-OSS ⊂ OpenAI) match an id slice, so provider-grouping's
Gemini/GPT-5 noise and the HuggingFace hub mirror stay out. Current-gen chat only
(drops zen4/qwen2 sunset gens + embedding/rerank/tts/asr/image/guard modalities +
:free dup aliases). Empty families are dropped — honest to what the gateway serves.

fetchCatalog now merges live-only models the older pricing bundle omits (the
current Zen set: zen5-flash/coder/nano-*), deduped by id and name, marked Available.

Pure + unit-tested (19 cases): chat-exact curation, distill disambiguation, slice
matching, sunset filtering, Zen-first ordering, zen5-mini default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:30:35 -07:00
zandGitHub e3b174dac8 Merge pull request #70 from hanzoai/fix/build-embed-static-export
fix(embed): neutralize root-layout headers() so build:embed static export succeeds
2026-07-03 01:20:18 -07:00
hanzo-dev 32146f8cea fix(embed): neutralize root-layout headers() so build:embed static export succeeds
`output: 'export'` prerenders every page THROUGH app/layout.tsx, whose
generateMetadata reads the Host header (next/headers `headers()`) to brand the
SSR <title> per host. A static export has no request, so that request-time read
throws in the Server Components render for ALL pages — the export aborted on
/_not-found (and /signin), so `npm run build:embed` emitted no out/ and the
hanzoai/cloud one-binary silently shipped the fallback shell instead of the real
console.

build-embed.mjs now, for the export ONLY, drops the layout's next/headers import
and resolves the host to undefined (→ the build-time default brand; the embed is
same-origin and re-resolves the real brand client-side from window.location),
then restores the pristine layout in the finally. The normal `npm run build`
(server build, per-host SSR <title>) is untouched.

Verified: build:embed now emits out/ (index.html ~369 KB, /_next/static
assets); app/layout.tsx is restored to pristine after the run.
2026-07-03 01:17:03 -07:00
6b838994e6 feat(console): admin AI-Providers control board — enable/disable/set-primary over gated /v1/admin/providers (#67)
* feat(console): admin AI-provider control board — enable/disable + set-primary over gated /v1/admin/providers

Adds the platform-wide provider MANAGEMENT dashboard (admin.hanzo.ai) for the
shared-gateway upstream providers (do-ai, openrouter, fireworks, openai-direct,
zen): one row per provider with a working Enabled toggle, a Primary badge +
Make-primary action, model count, a key present/missing pill (NEVER the key),
and an honest DERIVED health verdict (enabled+keyPresent=Ready, enabled+no-key=
No key, disabled=Off — labeled derived, not a live probe). DISTINCT from the
customer 'providers' catalog entry (model catalog + BYOK per-org CRUD).

Server: 'providers' added to the global-admin-gated admin-aggregate heads
(ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS); the aggregate route now
exports POST (same getAdminGate fail-closed 403 + same-origin CSRF the shared
forwardWithUserBearer already enforces on any mutating method — no new trust
boundary). New originPost in client.ts pins the mutation to the console's OWN
origin so a split-origin NEXT_PUBLIC_CLOUD_URL can't route around the gate.

Client: typed ProviderAdminApi (list/toggle/setPrimary) + optional-safe
normalizers + a pure deriveHealth. keyPresent is strict-true (fail-closed — a
provider key is never modeled or surfaced).

Integration flag: disabling OpenRouter here also gates it out of the pricing
catalog (the DO-first ENABLE_OPENROUTER sync reads provider-enabled state).

Tests: provider-admin.test.ts (deriveHealth matrix, normalizer key-leak
safety, same-origin /v1/admin/providers URL shape for GET+POST, never the
cloud host) + admin-aggregate providers head. tsc --noEmit clean; vitest
1179/1179 (97 files); next build clean (17/17, /admin/aggregate + /[...slug]
registered). Authenticated visual e2e is post-deploy.

* fix(console): admin-aggregate proxy targets cloud /v1/admin/* (integration-path fix)

The console admin-aggregate proxy (app/admin/aggregate/[...path]/route.ts) rebuilt
the upstream path as `admin/<head>` and forwarded it verbatim to CLOUD_API_URL (an
origin, no /v1), so a browser call to /v1/admin/providers hit
cloud-api.hanzo.svc:8000/admin/providers. But cloud serves EVERY admin route under
/v1/admin/* (hanzoai/ai's `/v1/*` beego glob for /v1/admin/providers{,/toggle,/primary};
cloud's own clients/admin `app.Get("/v1/admin/{overview,finance,compute,...}")`). There
is no bare /admin/* route → the provider dashboard's list/toggle/primary all 404'd.
(The overview/finance/compute boards masked the same mis-path behind LivingOverview's
honest usage-ledger fallback; provider-admin has no fallback, so it was visibly broken.)

Fix (server-side upstream path only; the browser-facing clean /v1/admin/* is unchanged):
- route.ts: build `v1/admin/<head>` (was `admin/<head>`) so the verbatim forward lands
  on cloud's real /v1/admin/* route. The rewrite destination (/admin/aggregate/<head>)
  is the internal Next route and correctly carries no /v1/ — this handler adds it.
- admin-aggregate.ts allowAdminSurface: validate the exact forwarded shape
  `v1/admin/<allowed-head>` (segs[0]==='v1' && segs[1]==='admin' && ALLOWED.has(segs[2])),
  refusing v1/admin/iam, v1/admin/kms, bare v1/admin, the pre-fix bare admin/<head>, and
  every traversal. The two-layer pathIsClean + allow-list defense (raw AND WHATWG-normalized
  path) is intact on the new shape: v1/admin/providers/../iam is refused at layer 1 (literal
  ..) and its normalized form v1/admin/iam at layer 2 (iam not allowed).

Beneficial side-effect: overview/finance/compute/orgs/audit/products/usage now also target
/v1/admin/* correctly (all shared this one proxy). next.config.mjs is untouched — its rewrite
already fires for every ADMIN_V1_HEAD incl providers, GET and POST.

Also (RED LOW-1): ProviderAdminModule toggle no longer flips the row optimistically before
the server confirms — a slow 403 never briefly renders an unauthorized 'on'; the enabled
state changes ONLY on a 2xx (the switch is disabled via `busy` in flight).

Tests: admin-aggregate.test.ts rewritten to the v1/admin/<head> shape (+ refuses the
pre-fix bare admin/<head>); bearer-proxy.test.ts +7 end-to-end forward tests proving the
upstream URL is cloud/v1/admin/providers (not /admin/providers), GET+POST forward, and
traversal / iam / kms 404 without ever fetching. tsc --noEmit clean; vitest 1187/1187
(97 files); next build ✓ (/admin/aggregate/[...path] registered).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 01:07:44 -07:00
zeekayandClaude Opus 4.8 f7d8d118c0 chore(console): v8.4.51 — true-black theme release
Bump past the live v8.4.50 so the true-black change ships as a clean immutable
semver tag (SEMVER-only build; a branch build without a bump collides with an
existing tag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:58:33 -07:00
hanzo-dev c8a4e2db16 Merge remote-tracking branch 'origin/main' into feat/obs-ui-modules 2026-07-03 00:57:48 -07:00
hanzo-dev 9f54156829 feat(console): APM Service Map — per-service RED metrics + dependency graph
New 'Service Map' product (Observe) over the real o11y/SigNoz APM controllers
via the same-origin /cloud user-bearer proxy: /v1/o11y/v1/services (RED per
service), /dependency_graph (call edges), /service/top_operations. Every KPI/
row/edge folds over what the runtime returned — honest empty/RuntimeNotice on
503/404/403/401, never fabricated APM data. Tap a service row → detail rail
(RED overview, top operations, up/downstream deps).

- lib/api/apm.ts: ApmApi client + normalizers (services/deps/ops/hosts/pods/
  nodes/exceptions/dashboards) with a stable ApmWindow.
- observability/apm-format.ts: pure RED/number/duration formatters.
- ServiceMapModule.tsx: the mounted module; wired into registry as 'service-map'.
- index.ts: barrel exports (apm NodeRow aliased ApmNodeRow — distinct from the
  blockchain nodes.ts NodeRow).

Verified: 35/35 tests pass, tsc --noEmit clean (0 errors).
2026-07-03 00:56:53 -07:00
zeekayandClaude Opus 4.8 3d93e81f73 style(console): true-black dark theme to match hanzo.ai + hanzo.chat
Override the @hanzo/gui (Tamagui) .t_dark base --background to pure #000 so the
console reads as ONE black brand with the marketing site (--background:#000)
and hanzo.chat's OLED .dark theme. Panels sit a hair above pure black (#050505
press / #171717 hover-elevated) for depth. defaultTheme was already dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:48:04 -07:00
hanzo-devandGitHub e970fa4828 Merge pull request #68 from hanzoai/feat/console2-calm-shell
console: calm, spacious design language + responsive love (v8.4.50)
2026-07-03 00:00:51 -07:00
hanzo-dev c5ea2daa53 console: calm, spacious design language + responsive love (v8.4.50)
Refine the console toward a quiet, spacious, low-glare feel that's healthy to
work in for a full day (Linear-esque FEEL, our own @hanzo/gui tokens).

Calm tokens (app/globals.css, scoped html:root.t_dark/.t_light so they win over
the generated @hanzo/gui theme on specificity — DRY, whole app calms at once):
- softer cool-charcoal surfaces with gentle elevation STEPS so cards read as
  calm panels, not flat voids on pure black
- off-white primary text (no pure-#fff glare) + muted secondary steps
- low-contrast hairline borders; comfortable body line-height + antialiasing

Sidebar (DashboardShell):
- calm neutral section headers (Linear-style) instead of a saturated rainbow;
  per-product COLOR now lives only on the icons (restrained accent)
- softer active state ($color4, not the loud $color5 block)
- more breathing room: taller category headers, larger row + section gaps,
  responsive rail padding (collapsed vs expanded)

Big desktop: content is a centered, capped column (max 1680) with padding that
scales up at xl — wide screens read comfortably, not stretched full-bleed.

Responsive:
- PageHeader wraps (flex + minW) so long subtitles wrap on mobile instead of
  running off-screen; actions drop below the title when narrow (DRY, every page)
- Inference "Usage Overview" overlap fixed: MetricStat no longer flex-collapses
  (flex-basis:0 in an auto-height column made the label overlap the value)

typecheck 0 errors · 1167 tests · next build green.
2026-07-02 23:58:28 -07:00
hanzo-dev b39a3ae95c feat(models): model detail 'Try in Playground' preselects the model (deep-link ?p=)
The Model Catalog rows already open a rich detail pane, but the detail's primary
action pushed a bare /playground and DROPPED the model the user was looking at — it
looked wired but lost the selection. Now it deep-links /playground?p=<share> carrying
that model, so the Playground opens PRESELECTED on it (the composer restores the model
from the ?p= share state it already reads on mount). Same fix applied to the
Marketplace 'Try' CTA — both reuse ONE pure helper.

- New pure playgroundPathForModel(modelId) in playground/share.ts — the ONE way to
  deep-link the Playground onto a model (URI-safe; empty prompt, default settings).
- ModelCatalogModule detail: 'Open in Playground' → prominent 'Try in Playground'
  (theme=light) via the helper; routing/copy reuse the derived modelId.
- MarketplaceModule.open(): available model → playgroundPathForModel(id) (was bare
  /playground); catalog-only → /models. DRY, one deep-link path.

tsc clean; +2 share tests (round-trip + slash/space id), marketplace 16/16.
2026-07-02 23:56:32 -07:00
hanzo-dev d94e62e53d feat(base): Twenty-grade records surface (@hanzo/data 1.2.0); CRM = Base views
Base records (RecordsModule) now render the @hanzo/data RecordsView — table <-> board,
filter/sort/group, inline cell edit, kanban drag — over REAL per-org Base data via the
/superbase proxy (new base-data/CollectionView; inline edits + board moves persist through
BaseDataApi.updateRecord, honest error banner). CollectionTable superseded (one way).
RecordDetailView gains a titled detail panel; DnsModule onRowPress -> onOpen (new DataTable API).

CRM = Base views: CrmModule renders companies / contacts / opportunities through the SAME
RecordsView — CRM entities expressed as @hanzo/data FieldDefinition schemas + pure record
mappers (crm/collections: money->currency, epoch-s->ms, companyId->named relation chip);
opportunities get a stage PIPELINE board; company relation options injected for filter.
Live create stays; delete preserved + upgraded to bulk-delete via row selection. Read-only
views over live data (no /v1/crm update endpoint yet) — honest by construction.

@hanzo/data 1.1.0 -> ^1.2.0. Rebased on latest main (8.4.49 -> 8.4.50).
tsc clean; vitest 1198 green (+ crm/collections); next build green.
2026-07-02 23:56:07 -07:00
hanzo-dev 21a72fdd0f feat(gpus): tap-to-launch customer GPU catalog — every accelerator row opens the launch drawer preselected
The customer GPU catalog rendered as non-clickable rate rows (Overview 'Popular
accelerators', the GPUs-tab 'GPU catalog', and the Pricing tab) — real live visor
data, but it read like a static brochure. Now every accelerator row is TAP-TO-ACT:
tapping one opens the shared LaunchDrawer (kind=gpu) preselected on that accelerator's
size slug (the drawer already supports initialSize), so the price you see is the price
you launch. Same pattern as machines PR#57's MachineCatalog → onLaunch.

- launch() now takes an optional initialSize (memoized); launchGpu(row) = launch(row.slug).
- onRowPress wired on all three catalog DataTables + the Pricing tab (new optional onLaunch prop).
- Header / empty-state / settings 'Launch' buttons fixed to () => launch() (no event-as-size).
- Honest copy: 'tap to launch' hints; no fabricated data (empty catalog still honest).

tsc clean; gpus vitest 10/10.
2026-07-02 23:52:42 -07:00
bf8bd89f1e feat(console): Overlord admin god-view + Web Search/Crawl product panel (v8.4.49) (#66)
Two surfaces, both over the ONE /v1 surface with real data + honest states,
reusing the existing LivingOverview + design system (DRY, no new UI systems).

Surface 1 — admin.hanzo.ai "Overlord" overview (god-view of EVERYTHING):
- New living-overview config `overlord` + pure adapter `fromOverlord` composing
  THREE real sources: the operator inventory (PlatformApi.apps → the platform-wide
  PRODUCT HEALTH board + product/healthy/needs-attention counts + distinct-org
  count — the centerpiece), the all-orgs `/v1/admin/overview` aggregate
  (usage/spend/top-models/activity/alerts) when routed, and the real commerce
  usage ledger (all-orgs) as the honest fallback so the board is never blank.
- New `overlord` catalog entry (Observe, admin:true) rendered by the ONE
  LivingOverview. GLOBAL-ADMIN ONLY: hidden from every customer's nav/launcher/
  palette (visibleCatalog filters admin entries), the catch-all shows the managed
  notice for a non-admin, and `/v1/admin/overview` is server-gated by getAdminGate.
- Pure health-tally helpers (`healthTally`, `orgsFromApps`) — every product-count
  KPI is derived from the real inventory, never fabricated (empty → honest em-dash).

Surface 2 — Web Search + Crawl product panel (SearXNG + Crawl4AI, LIVE):
- New `WebSearchApi` (lib/api/websearch.ts) over cloud `/v1/websearch/*`; search
  wired same-origin prefix-free `/v1/websearch/search` → the hardened `/cloud`
  user-bearer proxy (added `websearch` to CLOUD_HEADS + CLOUD_V1_HEADS — minimal,
  additive; distinct arrays from the concurrent providers lane).
- New tabbed `SearchModule` (Overview · Try Search · API · Engines · Config) —
  a REAL live search box, honest live-probe health (no health endpoint exists),
  the two endpoints + copy-paste curl, the deployed engine set (read-only), and
  the honest deployed config. HONEST GAPS surfaced, not hidden: usage is not
  metered yet (no cloud_usage rows for websearch), and scrape is documented but
  NOT a live try-it (it needs the shared WEBSEARCH_API_KEY, not a user session —
  so the console can never drive a scrape; no secret is ever exposed).
- The `websearch` + `crawl` catalog entries render the one module (crawl upgraded
  from a native-overview stub — one product, cross-linked, no duplicate surface).
  Tab slugs are non-base (search/api/engines/config) so they never collide with
  the shared Settings/Status/Logs/Metrics per-product sub-pages.

Verification: tsc --noEmit clean; vitest 1191/1191 (98 files; +28: 7 Overlord
adapter, 8 websearch normalizers, 9 search logic, 1 websearch allow-list, +3
registry-consistency now covering the new configs); next build ✓ (all routes;
/overlord + /websearch/* + /crawl all resolve 200 on the dev server, catch-all
compiles clean). Live authenticated visual e2e (admin session) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 23:39:47 -07:00
zandGitHub 11b622d454 Merge pull request #65 from hanzoai/fix/broken-links
fix: repair broken links (deep-audit workflow)
2026-07-02 23:02:28 -07:00
hanzo-dev 87c3df0de3 fix: correct 3 broken links (cost→billing route, embeddings docs path)
- SidebarWallet balance row: router.push('/cost') → '/billing' (no 'cost'
  module in the registry; billing is the canonical balance/spend surface).
- HomeSummary 'View cost' button: same /cost → /billing fix.
- Embeddings SettingsView docs button: docsUrl/embeddings → docsUrl/docs/embeddings
  (Fumadocs serves only under /docs/*; matches the docsUrl() /docs convention).

Also updated the stale SidebarWallet docstring that referenced the removed /cost.
2026-07-02 22:49:23 -07:00
d7d142fc04 fix(console): per-host brand in SSR <title> (white-label) + release 8.4.48 (#64)
The document <title> is SSR metadata resolved from the build-time default host,
so console.lux.cloud / console.zoo.cloud tabs read 'Hanzo Cloud Console' — a
white-label violation (Hanzo name on a Lux/Zoo surface). Read the request Host
header in generateMetadata and resolve the brand per host, so the tab title is
'Lux Cloud Console' / 'Zoo Cloud Console'. The visible shell was already correct
(client resolves brand from window.location); only the SSR title leaked.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:29:01 -07:00
zandGitHub b7ecab0c2f Merge pull request #63 from hanzoai/chore/release-8.4.47
chore(release): console 8.4.47 — customer Domains panel + Apps view
2026-07-02 22:02:45 -07:00
hanzo-dev daa24fa7a7 chore(release): console 8.4.47 — ship customer Domains panel + BYO custom-domain verify (#62) 2026-07-02 22:02:38 -07:00
zandGitHub 406aaa54bc Merge pull request #62 from hanzoai/feat/paas-domains
feat(paas): customer Domains panel — add/verify BYO custom domains from console
2026-07-02 22:01:45 -07:00
hanzo-dev 35e9d66247 feat(paas): customer Domains panel — add / verify BYO custom domains from console
Gap 1 (console self-serve domains UI) over the live /v1/platform/.../domains
surface (via the /cloud bearer proxy, org from the Bearer owner):

- lib/api/paas.ts: PaasDomain + listDomains/addDomain/verifyDomain/removeDomain.
- DomainsPanel.tsx: a Domains section on the app detail — lists the default host,
  org-subtree hosts, and BYO custom domains with an HONEST per-domain status from
  the operator CR (live / provisioning / awaiting deploy / unverified, never
  fabricated); an Add-domain form; per-domain Remove; and — for a pending custom
  domain — the exact DNS records to publish (TXT + CNAME, with copy) plus a Verify
  action that flips it live with TLS. Default host is non-removable.
- paas/logic.ts (+tests): pure isPendingCustom / canRemoveDomain / domainStatusLabel
  / orderDomains. Wired into PaasApplications AppDetail.

Drive-by: fix a pre-existing origin/main tsc error in overview/living/
open-edition.test.ts (vi.fn<[Args],Return> → single-function-type form the
installed vitest requires) so `tsc --noEmit` is green. Not caused by / related to
this feature; next build (which excludes tests) never surfaced it.

Verify: tsc --noEmit clean; vitest 1167/1167 (+12 domain logic); next build ✓.
2026-07-02 21:58:24 -07:00
hanzo-devandGitHub b4e3996a8c feat(embed): build:embed static export for the cloud one-binary (task #41) (#61)
The hanzoai/cloud Go binary go:embeds the console SPA and serves it at its own
web root; the Dockerfile already looks for `npm run build:embed` to produce the
static bundle. This adds that target — the last piece of 'True 1-binary FE'.

- next.config.mjs: CONSOLE_EMBED=1 → output:'export' + images.unoptimized and
  DROPS the aiSurfaceRewrites (a static export cannot run rewrites, and does not
  need them: the clean /v1/<head> the SPA already builds terminates directly at
  the embedded cloud's mounted /v1 subsystems, which is what the rewrites used to
  forward to via the Next BFF). The normal `npm run build` (server build) is
  UNCHANGED, so the standalone console deployment never regresses.
- scripts/build-embed.mjs: prepares the app for output:'export' for ONE build and
  ALWAYS restores it (finally block; main stays byte-identical):
    (1) stashes every app/**route.ts — a static export has no runtime for a
        server route handler; the BFF proxies collapse to cloud /v1/* and the two
        standalone routes (keys/onboard) are ported to cloud /v1/console/*.
    (2) overlays the two dynamic client pages ([...slug], discover/[id]) with a
        force-static server wrapper (generateStaticParams + dynamicParams=false)
        so they satisfy output:'export'; the real client body still ships in the
        JS bundle (client nav hydrates it) and deep links hit the host's SPA
        fallback (cloud/webui.go serveIndex → index.html) which re-resolves them.
- build:embed npm script.

Proven: `npm run build:embed` → out/ (47 files, 6 html incl. discover/ + the
SPA-shell index.html at 376 KB); working tree restored clean after.
2026-07-02 21:52:15 -07:00
hanzo-dev 872d5179df fix(base): drop the last competitor name from the assistant prompt + green main
The whole-repo sweep after v8.4.42 caught the one remaining user-facing Base
competitor reference: the built-in assistant's system prompt described Base as a
'Firebase-style backend' (from the v8.4.43 grounded-assistant lane). Rewrote it in
Hanzo's own voice — 'a realtime backend — spin up per-org Bases with content types,
records, and auth'. The console is now free of Supabase/Firebase user-facing copy.

Drive-by: open-edition.test.ts (#60) used the old vitest vi.fn<[Args],Return>() form
that vitest v3.2.4 rejects — main's tsc was RED. Migrated to the v3 single-fn-type
form. tsc clean, vitest 1163/1163, next build green.
2026-07-02 21:24:22 -07:00
hanzo-devandGitHub 77ad2ec7af feat(overview): Open Edition (run-for-pay) living overview + visual gate (#60)
Add the Open Edition / run-for-pay overview as a living-overview config
(add-a-product = 1 config) that renders from the REAL commerce usage
ledger scoped to the `open-edition` product tag. Spend billed = the
served-revenue figure R (cost + 25% resell margin) per the pricing spec.

- living/registry.ts: `openEditionOverview` config + `open-edition` map
  key. Reuses UsageApi.overview({ product: 'open-edition' }) + the
  existing fromCloudUsage adapter (DRY) — honest-empty when no run-for-pay
  usage, never a mock.
- products/registry.tsx: `livingOverviewModule('open-edition')` + a
  catalog entry (Observe category) → the board lives at /open-edition.
- living/open-edition.test.ts: behavioral proof the loader forwards the
  `open-edition` product scope, maps real data through fromCloudUsage,
  and rolls up honest-empty (4 tests).
- e2e/open-edition.spec.ts: dedicated Playwright visual gate asserting the
  run-for-pay header, the cost+25% Spend KPI caption, and the spend/tokens
  KPIs render; captures a full-page screenshot.
- e2e/pages.spec.ts: add `open-edition` to the all-pages screenshot sweep.

Canonical model: universe docs/architecture/run-for-pay-pricing.md (price)
+ run-for-pay-and-contributor-revenue.md (settlement).

Additive only (no deletions); does not touch the concurrent session's
staged adapters.ts / registry.test.ts.
2026-07-02 21:17:39 -07:00
hanzo-dev 2fdd72162c console: real per-product Status/Logs/Metrics/Settings — one metadata-driven system (v8.4.45)
Make every product's shared base sub-pages real, correct, and per-product via ONE
metadata source, no fabrication.

- Metrics: consolidate the ledger-scope decision to sources.ts metricsScopeFor (kill
  the dead MetricsFeed/O11Y_METRICS_PRODUCTS dup); every product filters by
  metadata.product===id (honest-empty, never the org total); the inference surface
  (inference/models/api/gateway) reads the whole ledger with an explicit honest
  'org-wide inference' banner + scope-aware subtitle.
- Status/Logs: verified SERVICE_OVERRIDE (models, bot->bot-gateway, helpdesk->help)
  grounded in the live /v1/apps inventory; fix console spec service console2->console;
  neutral+honest 'no operator row' copy; Logs-specific honest managed card.
- Settings: settingsConfigFor surfaces real product-specific config (reuse the overview
  spec facts+actions, else category default) — a real Configuration card, not a dead form.
- Overview: console spec health repointed; defaultSpec stays honest.

Only subpage/*, overview/*, overview/living/* + per-product metadata. Did NOT touch
AgentsModule/agents/* or assistant/*. tsc clean; vitest 1140/1140; next build green.
2026-07-02 21:16:26 -07:00
hanzo-dev 9daefa19bf fix(agents): key single-agent detail + delete on NAME not the display id (v8.4.44) 2026-07-02 21:15:03 -07:00
zandGitHub acdecfbef2 Merge pull request #59 from hanzoai/feat/compute-admin-clusters-functions
feat(compute): Clusters + Functions admin boards (kind spectrum)
2026-07-02 21:13:27 -07:00
hanzo-dev 0825f9b328 release(console): v8.4.43 — built-in assistant is a grounded Hanzo-suite expert
Make the console's built-in chat assistant a genuine expert on the whole Hanzo
suite, GROUNDED in real sources (the product registry + docs RAG), never
hallucinated — ONE shared system prompt across all three chat surfaces.

- src/lib/assistant/: decomplected into a PURE builder (prompt-content.ts,
  unit-tested) + a thin registry-bound wrapper (system-prompt.ts). Catalog
  section generated FROM the live registry (visibleCatalogByCategory) — all
  products, per-category, name+description+GCP-analog+deep-link; complete,
  current, brand-white-labeled, admin-gated for customers. Plus a curated
  accurate 'what Hanzo is' overview + an honest behavior contract (never invent;
  say so when Hanzo lacks a thing).
- Wired DRY into ChatConversation (=> FloatingChat bubble + full /chat page) via
  AiApi.ragChat, and CommandPalette >/? via commandBarSystemPrompt (base + NAV
  contract) — replaces the old nav-only prompt.
- AiApi.ragChat gains optional history (backward-compatible) for grounded
  multi-turn; X-Retrieval store 'docs' shared, degrades gracefully.

tsc clean; npm test 1142/1142 (+10); next build green. Rebased on v8.4.42.
2026-07-02 21:11:48 -07:00
hanzo-dev 9e7600529e feat(base): Bases manager — create/configure per-org Base instances, drop competitor copy
Base was a content-type dashboard over the SuperBase orchestrator's own
collections (contacts/tenants/users), so the org saw one shared Base and had no
way to make another — 'stuck with a single base'. Rework it into the Base
INSTANCES manager over the real tenants API (each row = a Base on its own
<slug>.base.hanzo.ai), so the user can SEE all their Bases, CREATE a new one, and
CONFIGURE one (name/size/status/delete). One Base binding — the /superbase proxy
(user bearer + X-Org-Id from the JWT owner, derive-once). Clean split from
Records (which browses a Base's collections + records).

- NEW lib/base-data/tenants.ts (BaseTenantsApi over the tenants collection)
- NEW base/bases-logic.ts (+test): slug/validate/size presets/status
- NEW base/BasesManager.tsx: list + New Base + configure
- BaseModule -> BasesManager; registry routes ''|new|:base
- Drop the Supabase/Firebase copy + gcp:'Firebase' from the Base entry
- Remove the superseded content-type dashboard (BaseDashboard/CollectionBuilder/logic)

Copy: no competitor names in the Base UI or docstrings.
2026-07-02 21:04:25 -07:00
hanzo-dev dbd42ef40b feat(compute): Clusters + Functions admin boards (kind spectrum)
Extend the admin compute-analytics boards from bot/machine to the full
kind spectrum so DOKS clusters, node pools and Functions surface in
admin.hanzo.ai like Bots/Machines.

- admin-compute.ts: widen ComputeKind to bot|machine|cluster|nodepool|
  function; asKind now canonicalizes over the whole spectrum (fallback
  machine, mirror of visor CanonicalKind) so a cluster/nodepool/function
  row never pollutes a sibling board via keepKind.
- ComputeModule.tsx: KIND_UI entries for cluster/nodepool/function;
  ClustersModule (kind=cluster) + FunctionsModule (kind=function) thin
  wrappers over the ONE ComputeBoard, mirroring Bots/Machines.
- registry.tsx: register cluster-fleet + function-fleet (admin:true,
  category Observe), distinct from the customer clusters/functions
  products (same split as vms vs machines).
- admin-compute.test.ts: cover the widened spectrum (cluster/nodepool/
  function fold + kind-filtering, unknown-kind fallback).

Honest-empty until the emitters + cloud read land. DRY: one ComputeBoard,
one datastore aggregate, one kind canonicalizer.
2026-07-02 20:39:24 -07:00
hanzo-dev f5a82a152d console: bump 8.4.41 (Authz + Settlement repoint) 2026-07-02 20:31:27 -07:00
hanzo-dev d30108ec04 console: repoint Authz→cloud /v1/authz/policies (real Casbin rules) + Settlement→commerce /v1/billing/payouts (real payout ledger)
- AuthzModule: off dead /paas onto /cloud user-bearer proxy → cloud authz
  subsystem (hanzoai/authz) GET /v1/authz/policies; per-org enforcer picked
  from the Bearer-derived X-Org-Id. Match FE type to the Casbin [sub,obj,act]
  tuple (effect always allow); honest-empty + PlatformStateCard on failure.
- SettlementModule: off dead /paas onto the existing /billing/v1 commerce
  proxy → GET /v1/billing/payouts (ListPayouts), org-scoped server-side.
  Match FE type to commerce payoutResponse (amount cents/currency, status,
  destinationType/Id, created). 501 when COMMERCE_TOKEN unset → honest card.
- authz head already admitted in proxy-allow CLOUD_HEADS + next.config
  CLOUD_V1_HEADS.
2026-07-02 20:30:53 -07:00
hanzo-dev cb6d3418ae feat(console): connect Indexer + Oracles pages to cloud /v1/*
Repoint the last two "not connected" chain-data pages off the admin
/paas proxy onto the native cloud user-bearer proxy, backed by the new
cloud clients/graph subsystem:

- IndexerModule: restGet(paas('indexers')) -> cloudProxyV1Url('indexers')
- OraclesModule: restGet(paas('oracles'))  -> cloudProxyV1Url('oracles')

Admit both heads on the /cloud proxy: add indexers,oracles to
next.config.mjs CLOUD_V1_HEADS and proxy-allow.ts CLOUD_HEADS. Bump
8.4.39 -> 8.4.40 so a fresh image builds.
2026-07-02 20:29:56 -07:00
hanzo-dev cb4be5be13 console: repoint Alerts to native o11y /v1/rules; honest Logs; bump 8.4.39
Alerts: restGet('/paas/alerts') -> cloudProxyV1Url('o11y/v1/rules'), the real
hanzoai/o11y alert-rule-states route (cloud mounts /v1/o11y/*, reverse-proxies to
the o11y Deployment which rewrites /v1/o11y/* -> /api/v1/rules; listRules ->
ListRuleStates). Normalize the flattened GettableRule envelope
{status,data:{rules:[…]}} to the flat Alert row: name<-alert, severity<-labels.
severity, status<-state (or 'disabled'), condition<-description. lastFired left
empty honestly (rule-list carries no last-fired timestamp).

Logs: kept honest. o11y's GET /v1/o11y/v1/logs is a hardcoded empty stub and the
only real log read is a composite POST query_range — no clean logs-list route to
repoint to. Copy now names the true gap (needs a real logs-list route) instead of
the stale "VictoriaLogs not deployed"; nothing fabricated.

Status: unchanged — already on native VictoriaMetrics up{} via /telemetry (real
data). Repointing to platform apps would regress to a different, empty concern.

Heads: add 'o11y' to next.config CLOUD_V1_HEADS + server/proxy-allow CLOUD_HEADS
so /cloud admits the o11y sub-surface.
2026-07-02 20:28:39 -07:00
hanzo-dev 773d1a6522 fix(theme+gpus): apply org accent via Tamagui props (className not forwarded on Button) + Pools honest-empty
Theme (the real apply): v8.4.36's accent used a CSS class (hz-accent-fill) on the
accent surfaces, but Tamagui does NOT forward className to a Button's DOM node
(only to Stacks), so the org's saved brand color never recolored anything. Rebuilt
the mechanism to be Tamagui-native and verified LIVE: src/lib/theme/accent.ts now
holds the resolved accent in a tiny external store (setOrgAccent, called on load by
OrgAccentProvider AND on save by SettingsModule) exposed via useAccent(); the genuine
accent surfaces (PrimaryButton, the active GPU/Settings tabs, the active sidebar nav
item) recolor with real @hanzo/gui props — inline bg + readable contrast text (light
accent -> black text, dark -> white), the nav via an accent left-bar — reverting to
the default monochrome when the org disables its theme or the hex is invalid.
Verified in a browser: enable green -> primary button green + white text; yellow ->
black text; disable -> reverts (no inline style). Dead globals.css accent block +
classNames removed.

GPUs Pools tab: now honest-empty 'No GPU node pools yet' whenever the org has no
reachable GPU clusters (none provisioned, or the native /cloud/v1/clusters endpoint
isn't live) — a pools-specific state distinct from the Clusters tab, never a
fabricated pool. (Pools are derived from the org's real clusters.)

Rebased on v8.4.37 (Inference dashboard) — strict superset. tsc clean; vitest
1132/1132 (+2 accentFor); next build green.
2026-07-02 19:55:49 -07:00
hanzo-dev 6307b49d3d feat(inference): rich endpoints dashboard + Status/Logs + shared per-product Metrics — all real data (v8.4.37)
Redesign the Inference page to the endpoints-dashboard mockup, wired to REAL sources
(honest '—'/empty where unexposed — never the mockup's placeholder numbers). Sidebar +
topbar untouched; only the Inference module content + the shared Metrics sub-page changed.

- Endpoints = managed model catalog (/v1/models) merged with the org's deployed KServe
  InferenceServices (cloud /v1/ml/models; new 'ml' /cloud head in proxy-allow + rewrite).
  Per-endpoint Requests(24h) + trend sparkline = REAL usage ledger by model id; KServe phase
  from live status.conditions; P95/uptime honest '—'.
- Hero 'Connected to Hanzo Cloud' (honest managed copy + purple SVG accent), purple Deploy
  Endpoint CTA (real POST /v1/ml/models), right rail Usage Overview (real ledger window +
  prior-period deltas) + Quick Actions + Need help (real routes/links).
- Inference OWNS Status + Logs as :tab views (health board + real recorded inference
  activity), declared as specific subpages so the router renders them.
- Shared per-product Metrics -> product-parameterized LivingOverview over the real usage
  ledger scoped by metadata.product; new byStatus + tokens breakdowns. P95 honest '—'.

tsc clean; vitest 1130/1130; next build green. Rebased on origin/main (v8.4.36) -> v8.4.37.
2026-07-02 19:25:56 -07:00
hanzo-dev d47424a5a7 fix(gpus): real tabbed customer GPU view + wire org accent theme
GPU tabs (the customer bug): a non-admin's GPU page rendered the same static
catalog for every sub-tab (Clusters/Pools/Pricing/Alerts all showed the GPU
catalog) because CustomerGpus was one static component with no tab bar. It is now
TABBED like AdminGpus — the shared GpuTabBar + GPU_TABS (one source of truth,
router.push navigation), active tab from params.tab — with DISTINCT, customer-
scoped, per-org content per tab:
 - Overview/GPUs: visor catalog (/vm /v1/gpus) + the org's own GPU machines.
 - Clusters: the org's own clusters (PlatformApi.listClusters -> user-bearer
   /cloud/v1/clusters), honest-empty; reuses ClustersTab (DRY).
 - Pools: GPU node pools derived from the org's real clusters (honest-empty).
 - Pricing: the REAL per-accelerator price list from the live visor catalog.
 - Alerts: real alerts derived from the org's own GPU machines' health; reuses
   AlertsTab (DRY), honest-empty when healthy.
 - Settings: honest per-org GPU settings + real counts.
AdminGpus behavior is unchanged (now shares GpuTabBar/GPU_TABS). Pure derivations
(gpuPoolsFromClusters/gpuAlertsFromMachines/catalog stats) are unit-tested — real
per-org data or honest-empty, never fabricated.

Theme accent (folded in): the org's saved brand color (themeData.colorPrimary +
isEnabled) was persisted but never applied. Added src/lib/theme/accent.ts
(applyOrgAccent -> one root --hz-accent CSS var + data-hz-accent) applied on load
(OrgAccentProvider) AND immediately on save (SettingsModule). Genuine accent
surfaces read the one var via hz-accent-fill/hz-accent-bar (PrimaryButton, active
sidebar nav, active tabs) — DRY, reverts to default when disabled/invalid.

tsc clean; vitest 1092/1092 (+16 new); next build green.
2026-07-02 19:20:58 -07:00
hanzo-dev 7821fda0f1 feat(nav): collapsible category accordion in the sidebar product nav (v8.4.35)
Each level-1 CATEGORY is now a collapsible section: the header is a clickable
button with an obvious rotating chevron (▸ collapsed, ▾ expanded), keyboard-
toggleable + aria-expanded, over its product rows revealed in order when open.
The ~120-item flat list condenses to 13 tidy topic headers so the nav stops
overwhelming the user.

- Pure model: src/lib/products/nav-accordion.ts (categoryIsOpen/toggleCategory,
  10 unit tests). Default COLLAPSED for most; the active route's category is
  always open (navigating reveals it, even if collapsed); filtering opens every
  matching group so search is never hidden; clearing search restores collapse.
- Persisted per-user via usePreferences (account-backed + localStorage cache,
  the existing sidebarCollapsed idiom) under navCategoriesOpen — survives reloads
  + navigations, never clobbers other choices.
- CategorySection in DashboardShell; body animates height (grid-rows 0fr<->1fr) +
  opacity via .hz-acc, chevron rotates via .hz-chevron, both reduced-motion-
  guarded; collapsed body is inert (out of tab order). Shared by the desktop
  sidebar AND the mobile drawer (one SidebarNav). Items/icons/colors/routes/
  active-state unchanged; only the grouping is now collapsible.

tsc 0 errors, vitest 1086/1086 (+10 nav-accordion), next build green.
2026-07-02 19:18:31 -07:00
hanzo-dev 84bebb264e fix: P0 fetch-binding regression + live-shape corrections + Playground multi-image/image-only (v8.4.34)
P0 [CRITICAL]: v8.4.33 resilientFetch called the global fetch as a METHOD
(deps.doFetch(url,init) → this=deps) → the browser threw 'Failed to execute fetch
on Window: Illegal invocation' on EVERY cloud/BFF call — the whole API layer broke
(Analytics/Models/CRM/CMS/... all 'Could not reach the backend'). The client-retry
unit tests passed because they injected a MOCK doFetch (no this requirement) — the
exact class a mock hides. Fixed: destructure const doFetch = deps.doFetch and call it
BARE (this=undefined) — works for a raw global fetch AND a wrapped one. New regression
test simulates a global-only fetch (throws unless this is the global) and asserts the
bare invocation. Live console was rolled back to v8.4.31 on detection; this is the fix.
LESSON: verify a shared-fetch refactor by RENDERING a live data page, not just unit tests.

live-shape (caught by the same live pass):
- CMS: Payload SQLite INTEGER ids ({id:3}) were read as strings → id='' (rowKey
  collisions); number-aware idStr. Media bytes url carries ?prefix=<tenant> that the
  filename-reconstruction dropped → cmsMediaSrc proxies the doc's real url through /cms.
- Commerce: /v1/store/current wraps the record as {store:{}} → currentStore unwraps .store.

RED LOW-1: /erp allow-list pinned to EXACTLY {Account, Item, Sales Order} (was any
DocType) so an entitled brand member can't over-read User/Salary Slip/etc. through the
shared ERP_API_TOKEN. RED verdict on v8.4.33: 0 crit/high/med, cross-tenant isolation
SOUND across CMS/ERP/Help/Analytics — SHIP.

Playground (coordinator, real user bugs):
- Multi-image upload: composer attachment (single) → attachments[] (append; multi-select
  dialog OR successive uploads/drag-drop accumulate); file input 'multiple'; thumbnail
  strip with count + per-image remove; buildRunMessages pushes one image_url part per image.
- 'Run does nothing' (image-only): validateRun now counts an attached image as user
  content — an image-only vision prompt is valid and Run proceeds; blocks ONLY a
  genuinely-empty message, and the reason renders PROMINENTLY right above the Run button.

tsc clean; vitest 1076/1076 (89 files); next build green.
2026-07-02 18:34:43 -07:00
hanzo-devandGitHub 4ad6691a4b fix(machines): interactive live catalog (tap-to-launch) + native cloud /v1 wiring (#57)
Customer Machines showed a real but NON-interactive catalog (region chips + every size row were static text) under the "launch your first machine" state, so a data-rich priced catalog you couldn't click read as "not clickable / fake." Make it a real launch surface; finish the last /paas->/cloud data wiring in the cluster path.

- MachineCatalog: every size row is now clickable -> opens the real LaunchDrawer preselected on that size (and region); region chips are selectable filters (filter the size list via pure filterSizes + preset the launch region). Rows reflow to a mobile-friendly 2-column layout (no fixed 4-column table). No fabricated data: with no onLaunch the rows stay informational and an empty catalog still renders nothing.

- LaunchDrawer: accepts initialSize/initialRegion to open preselected.

- CustomerMachines: one openLaunch(preset) opener feeds the header button, empty-state CTA, and the catalog.

- visor: capture the per-size regions[] visor already returns (was dropped in normalizeSize) + pure filterSizes(sizes, query, region), unit-tested.

- platform: repoint provisionCluster from the /paas service-token proxy to native cloud /v1 (/cloud/v1/org/{org}/cluster, user IAM session) - the last /paas call in the cluster path; cluster reads already use /cloud/v1/clusters.

tsc --noEmit clean; visor.test.ts 16/16 (added regions + filterSizes cases). Verified locally headless: tap a size -> drawer opens with the correct $24/mo quote; mobile 0px overflow. Live data lights up once the console redeploys (this branch's /cloud allow-list already admits machines/clusters/gpus) against cloud v1.786.26 which now serves GET /v1/machines.
2026-07-02 18:03:50 -07:00
hanzo-dev c5c72cd8e9 fix(api): resilient shared fetch — transient upstream errors auto-retry (v8.4.33)
Backend rolls invisible to customers. Root cause of a real 'Could not load — Upstream
service is unavailable' (Dave/maxpower, Models catalog): cloud is single-replica
Recreate, so a deploy-roll has a brief downtime window; a read landing in it got a
502/503/504 and the console showed a scary manual-Retry card.

Fixed in the ONE shared fetch (client.ts authedFetch → the pure, injectable
resilientFetch) that BOTH the casibase-envelope (request) and plain-REST (restRequest)
paths flow through — covers EVERY client fetch (Models, Overview, Billing, CRM, CMS,
ERP, commerce, analytics, agents, prompts, …), DRY.

- Transient upstream (502/503/504 or a network connection error) on an IDEMPOTENT read
  (GET/HEAD) → auto-retry with exponential backoff (300→900→2000ms, up to 3) BEFORE the
  honest 'Could not load' card, so a momentary roll self-heals; the card shows ONLY on a
  persistent outage (after retries exhaust).
- Genuine 4xx (401/403/404/402) → NOT retried (honest state immediately).
- Mutation (POST/PUT/PATCH/DELETE) → NOT auto-retried (a 5xx'd write may have applied —
  re-sending could double-create; the user retries manually).
- Caller-aborted request → honored, never retried.
- The 401 silent-refresh (v8.4.29) is preserved as the second orthogonal resilience,
  guarded against a refresh loop.

+13 tests (client-retry.test.ts): the exact Models-catalog 503→200 self-heal, budget
exhaust → honest error, network retry, 4xx/mutation no-retry, abort honored, 401 refresh
no-loop, + classification helpers.

v8.4.33 = the deployed superset (v8.4.32 native-apps set + this). tsc clean; vitest
1061/1061; next build green.
2026-07-02 17:57:04 -07:00
hanzo-dev b647d9832d feat(apps): native ERP/CMS/Analytics + real commerce over canonical backends (v8.4.32)
Maximize native app coverage in the console — bind ERP, Content (CMS), Analytics,
and Commerce to their canonical backends per-org / entitlement-gated, one canonical
way, no fabricated data. Contracts verified against source repos + live probes.

Analytics — rebound to the FOUR real cloud clients/analytics routes (overview/
timeseries/top/health; the module had called 5 non-existent endpoints with wrong
shapes). LLM lens is REAL live per-org data (hanzo.cloud_usage, prod ClickHouse);
web/commerce lenses honest-empty via the backend 'available' flag. Dropped the
fabricated Real-Time tab (no backend). Tabs: Overview + LLM (top models).

Content (CMS) — tabbed: NATIVE Collections + Media/DAM read live over Payload REST
through a new /cms user-bearer proxy. Payload's multi-tenant plugin isolates rows by
the IAM owner claim → each org reads ONLY its own (per-org, backend-enforced);
allow-list admits only the two tenant-scoped collections + media bytes, never the
users/tenants registry. Studio tab keeps the entitlement-gated admin embed.

ERP — tabbed + entitlement-gated (Frappe is single-tenant → brand-org/global-admin
only). Overview drives a REAL /v1/platform deploy of the ERPNext app (idempotent
create-project+app+deploy, live status). Accounting/Items/Sales are NATIVE Frappe
REST summary views (real erpnext-v15 DocType fields) over a new /erp proxy (Frappe
token auth, SSRF-clamped, read-only resource lists) — honest 'deploy ERP' until an
instance is live. Desk embeds the real desk once reachable.

Commerce — Products full CRUD (create+delete over /v1/product; validator needs
name+sku+slug); Store settings reads the org's real storefront (/v1/store/current).
Orders/Customers/Inventory/Promotions stay real per-org reads. Via the /commerce
bearer proxy (org from token owner). hanzoai/commerce is the ONE authority — NOT Medusa.

GPUs (drive-by) — KPI reconciled: was distinct-model count (6) vs the catalog table +
Launch drawer configs (9); now shows launchable configs with model count in the sub.

tsc clean; vitest 1050/1050 (+22); next build green (/cms + /erp routes registered).
2026-07-02 17:47:32 -07:00
hanzo-dev 9cff39e59f console: repoint ServiceMesh + Edge to native /v1 (close zt loose end)
The zt cloud client bound /v1/mesh/services + /v1/edge/nodes and the heads +
proxy-allow landed, but the two module fetches were left on the /paas proxy.
Switch both to cloudProxyV1Url (the user-bearer /cloud proxy) like the other
repointed modules. Networks already used /v1/networks. Now all 12 infra pages
(compute/DO/platform/zt) read the native cloud /v1 gateway.
2026-07-02 17:43:07 -07:00
hanzo-dev 0e20f0322e feat(console): wire 9 cloud modules to native /v1 (repoint /paas → /cloud)
The unified cloud binary now serves these surfaces per-org at /v1/*; repoint
each module from the /paas control-plane proxy to the native cloud /v1 gateway
via the user-bearer /cloud proxy (org resolved from the Bearer owner).

Compute (visor-backed):
- Machines: VisorApi.machines/quote/launch → /cloud/v1/machines[/launch];
  add terminate (DELETE /v1/machines/:id) + a Terminate action on the customer
  view. Catalog (regions/sizes/gpus) stays on visor /vm.
- GPUs: ComputeApi.gpus/alerts/pools → /cloud/v1/gpus[/alerts|/pools].
- Clusters: PlatformApi.listClusters/getCluster → /v1/clusters (org-scoped by
  the Bearer owner; drop the org arg — 5 callers updated); add node-pool
  add/scale/delete (POST/DELETE /v1/clusters/:cid/pools[/:pid[/scale]]) + a
  Node pools management UI. apps() + provisionCluster() stay on /paas.

DO-native (full CRUD):
- VPC: list + create + delete (GET/POST/DELETE /v1/vpcs[/:id]).
- Load Balancers: list + create + delete (GET/POST/DELETE /v1/load-balancers[/:id]).

Platform aggregates (list-only, read-only):
- Environments / Pipelines / Builds / Releases: restGet(cloudProxyV1Url(...)).

Heads: add machines,gpus,clusters,vpcs,load-balancers,environments,pipelines,
builds,releases to proxy-allow CLOUD_HEADS; add vpcs,load-balancers to
next.config CLOUD_V1_HEADS.

tsc --noEmit clean; compute/visor/logic/proxy-allow tests pass.
2026-07-02 17:39:07 -07:00
zandGitHub e19aea466d Merge pull request #56 from hanzoai/feat/console-apps
feat(console): Apps — hanzo.app buildable-sites round-trip over /v1/projects
2026-07-02 17:37:56 -07:00
hanzo-dev 897a0986c7 feat(web3): brand-scoped launch tiles for the deployed Lux/Zoo chain-app suite
Surface the standalone, already-deployed Lux/Zoo web3 apps (Explorer,
Exchange, Bridge, Faucet, Safe, DEX, Wallet) as launch tiles in the Web3
category — not rebuilt in-console, opened at their own domains.

Modeled as a restored `kind: 'external'; href` CatalogEntry member (the
honest sum type for a standalone app that owns no in-console route) — it
slots into the `kind !== 'module'` fail-closed guards the module-only
collapse deliberately preserved, so productSubpages/resolveProductView/
destinationsFor/productModules never manufacture a dead route for it.
`openProduct` becomes the ONE opener: a module navigates to `/<id>`, an
external opens `href` in a new tab. Every card seam (nav, launcher, ⌘K,
category page, level-2 siblings) routes through it, so no external tile
can 404.

Per-entry brand scope (`brands?: BrandId[]` + pure `entryInBrandScope`,
mirroring `nodeNetworksForBrand`) keeps the two suites from cross-leaking
inside the shared Web3 category: Lux tiles show only on lux, Zoo only on
zoo. Every href is a real, verified deployment — no fabricated URLs.

Tests: per-entry brand-scope predicate (no cross-leak); tsc clean; full
suite 1019 passing.
2026-07-02 17:37:08 -07:00
hanzo-dev 095c2250a6 feat(console): Apps — the org's hanzo.app buildable-sites over /v1/projects, with Edit-in-hanzo.app deep-links
Closes the console→app round-trip: a Platform › Apps module lists the org's
buildable/deployed sites from the shared org-scoped cloud clients/projectsvc
store (/v1/projects, same-origin user-bearer /cloud proxy — the exact per-tenant
path Agents/CRM use). Per row: Open site (liveUrl) + Edit in hanzo.app
(/dev?project=<slug>). Honest loading/empty/BackendState; never fabricates rows.

- src/lib/api/apps.ts        AppsApi (list/get/deployments) + defensive projectView/
                             deploymentView normalizers + injection-safe builderEditUrl
- src/lib/api/apps.test.ts   normalizers + /v1/projects route contract + deep-link (9)
- AppsModule.tsx             org-scoped list (Site/Framework/Status/Updated/actions) +
                             per-site deploy-history detail rail (:slug route)
- registry.tsx               ONE Platform entry id:apps (distinct from IAM Projects
                             scope + Compute Applications PaaS)

tsc --noEmit clean; vitest 1023/1023 (85 files); next build ✓ (17/17).
2026-07-02 17:36:29 -07:00
zandGitHub 99e600d5fd Merge pull request #55 from hanzoai/feat/console2-admin-fleets
feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
2026-07-02 17:21:19 -07:00
hanzo-dev 592bf16a03 feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
Two GLOBAL-ADMIN operator boards on admin.hanzo.ai (Observe, beside Business +
Finance), two lenses over ONE datastore table split on `kind`: Bots (kind=bot —
@hanzo/bot agents booted, gateway-connected) and Machines (kind=machine — raw VMs
visor opens). Each surfaces per-org/app/project count, active, and spend, grouped
org -> app -> project, sourced from the unified datastore (ClickHouse) via a new
`compute` admin-aggregate head. `/v1/admin/compute?kind=` is server-gated by
getAdminGate (the RED-H1 gate) and rewritten to app/admin/aggregate — no new proxy
or trust boundary.

- lib/api/admin-compute.ts: kind-parameterized, optional-safe client + pure
  foldEvents/buildTree over both pre-aggregated {leaves} and raw {events} (9-col
  datastore schema: org, app, project, kind, event, machine_id, size, price_cents,
  ts). Rollup {count,active,spendCents}; normalizeCompute(raw, kind) filters to kind.
- components/products/ComputeModule.tsx: ONE ComputeBoard({kind}); BotsModule /
  MachinesModule are thin wrappers (collapsible org->app->project tree + KPIs,
  honest loading/403/404/empty states — honest-empty until the emitter lands).
- registry: `bots` + `vms` (Machines) entries, admin:true; the admin machines module
  is aliased to avoid the clash with the per-org customer Machines (visor).
- admin-aggregate.ts + next.config.mjs: `compute` added to the admin read heads.
- Pairs with cloud GET /v1/admin/compute (hanzoai/cloud#62).

tsc clean; npm test 1012/1012; next build green.
2026-07-02 17:07:37 -07:00
hanzo-dev 896d6584f5 fix(auth): two cookies — small identity (Path=/) + chunked refresh (Path=/auth) — browser-safe (v8.4.31)
Casdoor refresh tokens are ALSO ~3.6KB full-user JWTs, so v8.4.30 sealed cookie
was still 5560 bytes (> browser 4KB cap → a real browser would reject it). Split:
- hz_session (Path=/, sealed {access-exp, projected claims}, ~1KB): resolveUser/BFF.
- hz_rt (Path=/auth, sealed refresh token, chunked hz_rt0/hz_rt1): sent ONLY to
  /auth — never to /v1 or the BFF, so no header bloat / gateway-431 risk.
Both sealed (integrity). Live-verified: establish/GET/refresh-rotate all 200.
2026-07-02 17:05:57 -07:00
hanzo-dev 0fbcdad02b fix(auth): seal PROJECTED claims + refresh (not the ~10KB access JWT) — browser-safe hz_session cookie (v8.4.30)
The v8.4.29 sealed cookie held the raw Casdoor access token (whole user object,
~9.8 KB) which exceeds the browser 4 KB per-cookie limit → a real browser would
reject it (curl does not). sealSession now projects to the small display/authz
claim set + refresh token + exp → a bounded ~1 KB cookie. Live-verified.
2026-07-02 16:50:17 -07:00
hanzo-dev d758305ee3 feat(auth): silent token-refresh — durable console OAuth session, no mid-task logout (v8.4.29)
Adds a console-owned hanzo-console OAuth session (access + rotating refresh, sealed
AES-256-GCM in httpOnly hz_session) as the preferred identity source for the AuthGate
and the /cloud bearer-proxy, silently refreshed via grant_type=refresh_token
(proactive timer + reactive single-flight on 401 + self-heal-on-load). Casibase
session kept as the graceful fallback; strictly additive, zero regression.

- src/lib/server/session.ts: token manager (password/refresh grants, AEAD seal, claims)
- app/auth/session/route.ts: establish (gated, MFA-safe) / current / signout
- app/auth/refresh/route.ts: rotation-aware refresh, no-clear-on-fail (multi-tab safe)
- resolveUser prefers the console session; client proactive+reactive refresh
- +27 tests; tsc + next build green
2026-07-02 16:35:22 -07:00
hanzo-dev 089a58cba2 feat(kubeflow): real ML Pipelines module over the live Kubeflow bridge
The last ComingSoon stub is now a real module. The cloud mlsvc
(hanzoai/cloud clients/ml) fronts the Kubeflow-family CRDs as REST, so
KubeflowModule is the read-only orchestration + control-plane lens over
that live surface (distinct from Fine-tuning's train-my-model wizard):

- Control-plane health strip from a REAL probe (GET /v1/train/health) —
  which Kubeflow operators/CRDs (Trainer/trainjobs, Katib/experiments)
  are actually served; honest connected/degraded/not-reporting states.
- Pipelines = Katib Experiments (GET /v1/train/experiments).
- Runs = trainer TrainJobs (GET /v1/train/jobs).

Pipelines/runs REUSE TrainApi (one client, no duplication); the new
KubeflowApi adds only the control-plane probe the console lacked (a
tolerant fetch that reads the 503 body restGet would discard). The
/training proxy allowlist gains train/health (additive, read-only).
Registry: kubeflow flips soon -> enabled, routes -> KubeflowModule.
Honest states only, no fabricated data. tsc clean; 84 registry tests pass.
2026-07-02 16:16:30 -07:00
hanzo-dev 62644a072a test(e2e): pages pass signs in ONCE, reuses session (was 94 logins → rate-limit)
The 94-page screenshot sweep signed in per test (beforeEach), so ~94 logins as
z@hanzo.ai tripped IAM's 'too many login attempts' rate-limit around page 35 —
that's the security feature working, not a page failure. Switch to a shared
serial context: one signIn in beforeAll, every page reuses the cookie. Faster
(~1 login vs 94) and no rate-limit, so the full sweep completes.
2026-07-02 15:19:45 -07:00
hanzo-dev a409e8a71b test(e2e): fix 3 stale assertions surfaced by the live prod run
- off-list secrets path: accept 401 OR 404 (both block the tunnel; prod hits the
  auth gate → 401 before the 404 allow-list check). A 2xx would be the real bug.
- API key extraction: match only a FULL hk- token (16+ chars, no ellipsis) so it
  never grabs the masked 'hk-2f18…' account-card display as a credential.
- /v1/messages: pick a model that is ACTUALLY in /v1/models right now instead of
  hardcoding claude-sonnet-4-6 (not provisioned on DO → correct 'not available').
All verified against live prod; the surfaces themselves (proxy gating, key mint,
Anthropic-compat inference) already work — these were test-data/expectation drift.
2026-07-02 15:16:45 -07:00
hanzo-dev 7983dfb24c chore(release): console 8.4.28 — per-vendor COGS donut on the finance board 2026-07-02 14:47:26 -07:00
hanzo-dev b1096575f5 feat(finance): per-vendor COGS donut on the finance board (v8.4.27)
Extends the EXISTING finance living-overview to read the now-multi-vendor
/v1/admin/finance (cloud enriches its cost side from commerce /v1/costs):

- FinanceCost gains {configured, totalCents, vendors[], period}; margin/spend now
  reflect the whole-platform COGS (DO compute + LLM providers), not DO MTD alone.
- fromFinance projects cost.vendors onto a 'vendorCogs' distribution (donut) and
  makes the headline spend + margin the commerce COGS — decoupled from DO, so a
  missing DO_API_TOKEN no longer blanks COGS/margin (DO stays the credit/runway
  treasury view + burn-down series only).
- registry: 'COGS (all vendors)' headline + a 'COGS by vendor' donut beside the
  burn-down; profitability verdict now gates on commerce COGS, not DO.
- reads ONLY /v1/admin/finance — no console-side /costs proxy (the admin-cogs
  duplicate is superseded and dropped).
- tests: vendor donut, zero-line pruning, DO-off-COGS-still-flows, COGS-off honest
  empty. 380 unit tests + tsc + next build green.
2026-07-02 14:46:27 -07:00
hanzo-devandGitHub 3c6a3dddd4 Merge pull request #54 from hanzoai/feat/console2-record-form-refresh
Record-form data-loss fix + Memory/Datasets delete + 5-min-logout diagnosis (v8.4.27)
2026-07-02 14:26:07 -07:00
hanzo-dev a7a50312e9 fix(console): record-form data-loss (registerDefaultFields) + Memory/Datasets delete key fixes; flag 5-min session as backend TTL (v8.4.27) 2026-07-02 14:25:37 -07:00
zandGitHub 618c7339ff Merge pull request #53 from hanzoai/fix/console-qa
fix(console): wire Status/Metrics to VictoriaMetrics + fix all dropdowns (native select) + mobile
2026-07-02 14:21:24 -07:00
hanzo-dev abb385b461 fix(console): wire Status/Metrics to live VictoriaMetrics, fix broken FieldSelect dropdowns, mobile-scroll tables
Issue 1 — Status/Logs/Metrics not wired (o11y):
- /v1/o11y is NOT 503 (stale) — it's a 403 (auth) reverse-proxy to SigNoz, whose
  runtime is un-set-up (setupCompleted:false, no data). Status read /paas/apps which
  reports ZERO apps; Logs read /paas/logs which 401s (no such endpoint). Neither ever
  showed data. The live signal is VictoriaMetrics (up{job=*-health}, ~29 targets).
- New read-only same-origin proxy app/telemetry/[...path] -> VictoriaMetrics query API
  (authenticated, GET-only, allow-listed to /api/v1/query|query_range|series|labels|
  label/*/values, traversal-hardened, honest 501 when VM_URL unset).
- lib/api/telemetry.ts (pure parse + service-health helpers, unit-tested).
- StatusModule: real up{} service-health board (down-first, healthy/down counts).
- MetricsModule: real VM infra dashboard (KPIs, healthy/targets-over-time, health
  donut, down-now) — replaces the unwired 'metrics' NativeOverview; distinct from
  AI Metrics. LogsModule: honest 'no log store deployed' state (no fabricated grid).

Issue 2 — launch-machine form: FieldSelect used @hanzo/gui <Select native>, which in
gui 7.3.0 emits bare <option> with NO <select> wrapper — every dropdown app-wide (27
usages) rendered as a non-interactive flat list. In the launch drawer this buried the
Quote + Launch button. FieldSelect now renders a real native <select> (theme-var
styled, native mobile picker), fixing the region picker and all other dropdowns.

Issue 3 — mobile: the FieldSelect fix repairs every form's pickers; DataTable now
scrolls horizontally on overflow instead of clipping wide tables (cut-off columns).

Verified live (VictoriaMetrics + real z@hanzo.ai session): Status 29 services/22
healthy/7 down, Metrics dashboard, Logs honest state, launch drawer native region
select with Launch visible, mobile table scroll. tsc clean; 964 vitest pass.

Also: dev-only next.config rewrite (DEV_CLOUD_ORIGIN, inert in prod) to run the
console locally against a real backend; removed orphaned observability/metrics.ts.
2026-07-02 14:17:42 -07:00
11ae61d541 feat(templates): visual preview banners + a clear one-click deploy flow (#46)
The Templates gallery cards were text-only and dead-ended after fork
("Draft — deploy it to go live" with no action). Two fixes:

1. Preview banner: render the gallery screenshot (t.preview) with a branded
   gradient fallback (stable per category + framework glyph) when it's absent
   or 404s — cards are visual immediately and auto-upgrade to the real shot
   once gallery.hanzo.ai serves it. No broken images, no fabricated screenshots.
2. Deploy flow: fork→draft now shows a clear "Deploy" button that ships the
   project live via projectsvc git deploy (POST /v1/projects/:slug/deploy
   {source:git}); building → "Check status" → "Open site" (liveUrl). Each
   phase shows exactly one next step, so "how to deploy" is never ambiguous.

TemplatesApi gains deploy()/status()/isLive()/normalizeDeployResult over the
existing same-origin /v1 surface. 18 vitest tests, next build green (14/14).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 13:26:46 -07:00
zandGitHub 25550dd595 Merge pull request #51 from hanzoai/feat/fork-to-builder
feat(fork): Open-in-builder — fork a starter → customize by prompt → talk-and-edit
2026-07-02 13:06:09 -07:00
hanzo-dev 85ef4cdfe6 feat(templates): fork → 'Open in builder' loop (customize by prompt)
Add an 'Open in builder' primary CTA to each starter card that deep-links to
the hanzo.app builder pre-seeded to customize this template by prompt:
<app>/dev?template=<source>&prompt=<seed>&action=edit. A small inline input
takes an optional free-text customization; the seed prompt carries the template
context (title/framework/description) so the builder auto-starts the first
generation.

- buildBuilderUrl(template, userText, appBase) + customizePrompt(): pure,
  injection-safe (single URL-encoded query params), unit-tested (+7 tests).
- config.appUrl (NEXT_PUBLIC_APP_URL, default https://hanzo.app).
- 'Fork / deploy' kept as the secondary action + honest 404 gallery fallback.

tsc --noEmit clean; vitest 881/881.
2026-07-02 13:04:39 -07:00
hanzo-devandGitHub a7d1938fd6 Merge pull request #50 from hanzoai/feat/console2-honest-states
Honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25)
2026-07-02 12:45:17 -07:00
hanzo-dev 7f3ddfd709 fix(console): honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25) 2026-07-02 12:44:51 -07:00
hanzo-devandGitHub 647eeb6eee harden(console): server-side entitlement gate for CMS/ERP/Help embeds (v8.4.24) — RED (#49)
RED reviewed v8.4.22's embeds (0 critical; SSRF clamp, iframe SOP, no-credential-
injection, honest normalizers all REFUTED). Fixes the console residuals RED flagged:

- Server-side entitlement gate: /embed-status resolves the caller's org (token owner)
  and returns `entitled` per app. cms/erp/help are all brand-owned single instances,
  so a non-owning (customer) org gets entitled:false + NO embed URL + no probe -> the
  module shows the provision panel. Only a brand-org member / global admin embeds.
  This is now the AUTHORITATIVE gate (the client check was cosmetic). Client normalizer
  fails closed (entitled strict-true; stale server -> provision panel, never a frame).
- Help is brand-owned too (was embed-for-all) -> a customer never frames the shared
  Frappe Helpdesk (removes unverified cross-org ticket-visibility risk).
- Dropped the false 'org==tenant enforced server-side' claims in EmbeddedApp/
  embed-hosts/module docstrings (the console gates WHO it frames; the shared app still
  owes its own per-org isolation -> separate CMS-side fix).
- Trimmed the iframe sandbox (dropped allow-top-navigation-by-user-activation,
  allow-popups-to-escape-sandbox, clipboard-read).
- /waitlist: bind recorded email to the session account; stop forwarding forgeable XFF.

tsc clean; vitest 952/952 (+13 entitlement/brand-org/fail-closed); next build green.
2026-07-02 12:30:04 -07:00
hanzo-devandGitHub 9bb582d8cf Merge pull request #48 from hanzoai/feat/console2-subpages-base
Real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23)
2026-07-02 12:05:40 -07:00
hanzo-dev 69a9af6d51 feat(console): real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23) 2026-07-02 12:05:25 -07:00
hanzo-devandGitHub 638baf7fb5 feat(console): de-link-out Content + native ERP/Help Center — embedded Business-OS apps (v8.4.22) (#47)
CMS was a window.open link-out; ERP/Help were 'soon' placeholders. Port all three
into the console as EMBEDDED (SSO iframe) or HONEST-provision surfaces, binding to
the canonical Payload/Frappe backends (not reimplemented). CRM stays the native
/v1/crm reference; these are the embed half of the Business-OS.

- EmbeddedApp: the one way to frame a canonical app IN the console shell (full-height
  iframe, scoped sandbox, real loading + honest 'Open full screen' fallback — never a
  fabricated load verdict). ProvisionPanel: DRY honest pre-provision surface over the
  real /waitlist intake.
- embed-hosts.ts (PURE): white-label cms|erp|help.<brand> host derivation.
- /embed-status BFF + pure embed-probe.ts: session-gated reachability probe, NO
  god-mode, SSRF-clamped to known brand domains, AbortSignal-bounded.
- CmsModule: embed the Studio for a brand-org member/global admin ONLY (customer org
  gets an honest provision panel — no cross-tenant framing of the shared instance).
- ErpModule: erp.<brand> is 502 -> honest 'Deploy ERP' panel; SAME gate embeds the
  real desk once live. HelpModule: embeds the live shared brand support desk (Frappe
  scopes tickets per-user via SSO).
- registry: erp + helpdesk soon -> enabled native modules.

Verified against live cluster + repos (single shared HANZO_ORG=hanzo instances; no
per-customer-org isolation yet) so nothing claims tenancy it doesn't have.
typecheck 0 errors; vitest 914/914 (+21); next build green (/embed-status registered).
2026-07-02 11:53:45 -07:00
zeekayandClaude Opus 4.8 69eef199e7 harden(console): bound every request-time server fetch (no upstream can hang) — v8.4.21
Investigated the "brand host (cloud.lux.network) hangs during render, while
console.hanzo.ai is fast" report. It does NOT reproduce in the app and cannot by
design: the page-render path (app/layout.tsx + (dashboard)/layout.tsx, the only
server components) does ZERO per-brand network fetch — no next/headers, cookies(),
generateMetadata, or server-only render fetch. Brand resolves from window.location
in the browser; SSR uses the build-time NEXT_PUBLIC_DEFAULT_HOST, so the server
HTML is byte-identical for every brand host (verified: cloud.lux.network and
console.hanzo.ai return the SAME md5, <title>Hanzo Cloud Console</title>, HTTP 200
in ~4-18ms for lux/zoo/pars/hanzo). The prod origin difference is an ingress/
routing artifact, not app SSR (out of scope; app code only).

The real in-code "no timeout → the route wedges" hazard (the described failure
class) IS fixed: every request-time server fetch — the /v1/* BFF proxies plus IAM/
cloud identity resolution — had NO upstream timeout, so a reachable-but-silent
backend blocked the route until the client gave up.

Fix, DRY: one src/lib/server/fetch-timeout.ts (fetchWithTimeout) — a bounded
AbortSignal COMPOSED with any caller signal (init.signal / req.signal), so a
request aborts on EITHER a client disconnect OR the timeout. Default 10_000ms, env
HANZO_UPSTREAM_TIMEOUT_MS. On timeout it rejects like an aborted fetch, so every
existing catch keeps its honest fallback (resolveUser → null → 401; proxies → 502).
Threaded through identity.ts (all 5 IAM/cloud calls), bearer-proxy.ts (the shared
proxy engine → cloud/ai/vm/tasksd/commerce/superbase), iam-proxy.ts, and the
custom proxies (/paas, /training, /admin/kms, /billing, /billing/topup/wallet,
/waitlist). The /nodes per-brand luxd RPC probe was already bounded — left as-is.

Verification: npm run typecheck 0 errors; npm test 823/823 (69 files, +6 new
fetch-timeout tests); next build green (all routes); Host-header curl test returns
200 fast for BOTH cloud.lux.network and console.hanzo.ai.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:01:43 -07:00
16246565b7 fix(console): pin non-global admins to their own org, dropping stale cross-tenant scope (v8.4.20) (#45)
A tenant (non-global) admin like `hanzo/z` can only ever act in their OWN org —
the server pins them there and every cross-tenant call 403s. But `OrgGate`
restored a persisted "last org" (`hz_last_org` / `hanzo.console.org`) on sign-in
WITHOUT checking the user can access it. So a leftover `adnexus` scope (from a
prior global-admin switch) made every org-scoped call — e.g.
`/org/iam/get-organization-projects?organization=adnexus` — 403 for z.

Fix: in the OrgGate seed effect, a non-global admin is ALWAYS hard-pinned to
`owner`. If a stale cross-tenant scope is active it's reset and the page reloads
once (guarded on `currentOrg() !== owner`, so it can't loop) — self-healing
existing stale state and preventing recurrence. Global admins keep the
switch-and-restore behavior. Complements the v8.4.18/19 gates on the admin
aggregates; this closes the org-SCOPED 403s.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:53:11 -07:00
Darkhorse7stars a24a14e1ae chore(console): v8.4.19 — complete the tenant admin 403 gate (get-organizations + org logo)
Follows v8.4.18 (#44), which gated only the overview aggregate + /paas probe.
This release also gates the OrgSwitcher/CommandPalette org list and BrandLogo's
org-logo lookup, so a per-org (non-global) admin's dashboard load fires ZERO
cross-tenant /admin/iam calls — no more 403 noise in the browser console.
2026-07-02 08:28:21 -05:00
Darkhorse7stars c61e422c86 fix(console): gate get-organizations + get-organization on isGlobalAdmin (complete the tenant 403 gate)
v8.4.18 (#44) gated the platform-overview loader (`/v1/admin/overview` + the
`/paas/apps` health probe) on `isGlobalAdmin`, but the dashboard chrome still
fired two more CROSS-TENANT admin calls for every user on every load:

  - `/admin/iam/get-organizations?owner=admin` — the OrgSwitcher + CommandPalette
    org list, server-gated to global (admin-org) admins.
  - `/admin/iam/get-organization?id=admin/<tenant>` — BrandLogo's org-logo lookup.

For a per-ORG admin who is NOT a global admin (e.g. `hanzo/z`) both 403, so a
tenant still saw a wall of red 403s in the browser console on every dashboard
load even after v8.4.18.

Gate them with the SAME `useIsGlobalAdmin` signal the overview loader + nav use:

  - OrgSwitcher / CommandPalette: skip `get-organizations` for a non-global-admin
    (they still see their current org + "Create organization"; just no cross-tenant
    switch list).
  - BrandLogo: a tenant reads its OWN org logo via the org-scoped `/org/iam` proxy
    (`TeamApi.organization`, which authorizes any member and pins to the caller's
    own org) instead of the admin-gated `/admin/iam` proxy — the logo still works,
    minus the 403.

Global admins are unchanged. No permission or server change; server gates still
enforce. typecheck + 887 tests + next build all green.
2026-07-02 08:28:13 -05:00
hanzo-dev 5339d28657 chore(console): v8.4.18 — release the tenant admin-overview 403 gate (#44) 2026-07-02 08:14:17 -05:00
d4a87ddfee fix(console): gate admin-overview + paas probe on isGlobalAdmin so tenant admins don't 403 (#44)
The platform overview home (`livingOverviewModule('overview')`) is the default
landing for every signed-in user, but its loader fired the CROSS-TENANT
`/v1/admin/overview` aggregate (and the `/paas/apps` health probe) for everyone.
Those are server-gated to global (admin-org) admins, so a tenant user — even an
org's OWN admin, e.g. `hanzo/z` — got a wall of repeated `403 (Forbidden)` in the
browser console on every dashboard load.

It "worked" only because the loader caught the 403 and fell back to the org-scoped
usage ledger; the board rendered, but spammed the console with doomed requests.

Fix: thread `isGlobalAdmin` (already resolved by `useIsGlobalAdmin`, the same
signal the nav/launcher use to hide admin surfaces) through the ONE loader call
site into `OverviewContext`, and in the platform-overview loader skip the admin
aggregate + apps probe for non-global-admins — going straight to the org-scoped
usage ledger (the exact source the catch-fallback already used). Global admins are
unchanged. `withHealth` gains an optional `probeApps` (default true) so the four
other, already admin-gated overviews are untouched.

Net: a tenant admin's overview renders identically, minus the 403 console noise;
no permission or server change.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:08:48 -07:00
fddf5f3aec feat(console): Business-OS suite — CRM + Content + ERP/Help Center + Accessibility over native /v1 (v8.4.17) (#43)
* feat(console): consolidate CRM + Accessibility Business-OS modules over native /v1

CRM — the first Business-OS brick — as ONE canonical module over the native-Go
cloud /v1/crm surface (cloud clients/crm on Base/SQLite: companies, contacts,
opportunities; a port of Twenty's core model), per-org via the user-bearer /cloud
proxy. Consolidates the two competing CRM PRs (#38 feat/console2-crm and #39
crm-work): keeps #39's richer routed-tab views + defensive normalizers + tests,
re-paths its API from the explicit /cloud/v1 form to the canonical same-origin
originV1Url('crm') (matching Agents/Evals/Prompts — the one way), and grafts #38's
next.config `crm` head rewrite + per-row delete. crm.ts is the single typed mirror
of the /v1/crm contract (one method per route); every row is org-scoped SERVER-SIDE
from the token owner claim, with honest loading/empty/error states in @hanzo/gui.

Accessibility — a Wix-style WCAG scanner for the site being built. Runs Deque's
axe-core against the current page 100% client-side (engine lazy-loaded into its own
chunk, never the main bundle); pure sort/summarize/WCAG-label logic in
~/lib/a11y/scan is unit-tested without a browser or engine.

- next.config.mjs / proxy-allow.ts: allow-list the `crm` head on both the rewrite
  and the bearer proxy (same least-privilege path as the 5 existing surfaces).
- registry: CRM + Accessibility in the Apps catalog.
- version 8.4.16 -> 8.4.17; axe-core 4.12.1 (lazy import, own chunk).

typecheck clean; 887 unit tests pass (13 new: 9 crm + 4 a11y); next build green.

* feat(console): fold Content Studio + ERP/Help Center into the Business-OS suite (v8.4.17)

Consolidates the three overlapping Business-OS PRs into ONE canonical superset.
Onto the #39 base (canonical CRM over originV1Url → /v1/crm direct + per-row
delete, and the client-side axe-core Accessibility scanner), fold in #42's:
- CmsModule (Content Studio: honest in-console home for the live Payload CMS)
- registry entries cms (Content), erp + helpdesk (honest soon → ComingSoon)

Reconciliation: kept #39's originV1Url CRM (hits /v1/crm directly, the majority
agents/prompts/evals pattern) over #42's /cloud-prefixed cloudProxyV1Url; kept
#39's CrmModule (superset — adds delete); dropped #42's stray gcp:'Content';
regenerated package-lock.json for axe-core 4.12.1 (was package.json-only).

Supersedes #38, #39, #42.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 05:32:16 -07:00
hanzo-dev 509daa10e9 test(e2e): finalize live billing-admin confirmation spec for v8.4.16
Deep-links /billing/reports (now unshadowed) and asserts the god-view gate matches
the account grant (z is a hanzo org-admin → honest 403 + managed fallback; an
admin-org member → 200 KPI board). No app change.
2026-07-02 01:14:15 -07:00
hanzo-dev 54f8182467 fix(billing): namespace data proxy under /billing/v1/* so tab URLs reach the SPA (v8.4.16)
The per-tenant commerce DATA proxy (app/billing/[...path]/route.ts) claimed the
whole /billing/* URL space; a Next route handler wins over the catch-all page for a
matching segment, so every billing tab except Overview (/billing/reports, budgets,
invoices, subscriptions, payment-methods, credits) resolved to the proxy and
returned commerce {"error":"not found"} / raw JSON instead of the UI. This
blocked the v8.4.15 Reports cost-dimension (product/agent) surface in production.

Namespace the data proxy under /billing/v1/* (the 'always /v1/' convention) so it
can never collide with a UI tab slug; tab URLs fall through to app/(dashboard)/[...slug].
- move app/billing/[...path] -> app/billing/v1/[...path]; topup/wallet likewise
  (forward target commerce/v1/billing/<path> unchanged — v1 is now a static segment)
- billingUrl() (billing.ts + aimetrics.ts) and wallet.ts appUrl() prepend v1/
- tests: aimetrics.test.ts asserts /billing/v1/usage; billing-isolation e2e fetches /billing/v1/<p>

tsc clean; 874/874 tests; next build green (routes /billing/v1/[...path],
/billing/v1/topup/wallet, /[...slug] catch-all serves the tabs).
2026-07-02 01:02:24 -07:00
zandGitHub 23f12107e0 Merge pull request #41 from hanzoai/feat/saas-finance
feat(admin): SaaS finance dashboard — DO burn-down + revenue + margin/runway (console2)
2026-07-02 01:00:37 -07:00
hanzo-dev e23b9722a4 feat(finance): admin-only SaaS profitability dashboard (Finance)
Add a global-admin-only Finance board to admin.hanzo.ai over the ONE
LivingOverview system — DigitalOcean credit burn-down (our primary ~$40k
venue), month-to-date spend, MRR, total revenue, gross margin %, runway,
and a profitability health verdict. TRUE by construction: every tile reads
the real `/v1/admin/finance` aggregate; no fallback fabricates numbers.

- lib/api/finance.ts: FinanceApi.finance() + normalizeFinance — optional-safe
  map of `/v1/admin/finance` onto Finance. Reads via originGet (same-origin
  `<origin>/v1/admin/finance`), so the request rides the global-admin-gated
  aggregate proxy. runwayDays preserves NULL (never coerced to 0 = fake alarm).
- overview/living/adapters.ts: fromFinance (payload → OverviewData, honest-empty
  on unconfigured DO / commerce) + pure financeHealth verdict (green profitable /
  yellow thin-margin <20% / red burning-faster / '' unknown when DO off).
- overview/living/registry.ts: `finance` living config — 6 KPI tiles + credit
  burn-down bar series + health/alerts. No try/catch fallback (finance is
  true-by-construction; a denied/not-routed backend renders the honest error).
- products/registry.tsx: `finance` catalog entry, `admin: true` (Observe) —
  hidden from every customer's nav/launcher/palette; the catch-all renders a
  managed notice for a non-admin. Reuses livingOverviewModule; no new UI.
- SECURITY: financial data is Hanzo-internal. Routed through the `/admin/
  aggregate` bearer proxy (getAdminGate: verified @hanzo.ai + IAM global-admin,
  fail-closed 403) — NOT the generic /cloud product proxy. `finance` added to
  ADMIN_AGGREGATE_HEADS (admin-aggregate.ts) + ADMIN_V1_HEADS (next.config.mjs);
  the allow-list still REFUSES admin/iam + admin/kms. A non-admin can never see
  or reach the board or the data.

tsc --noEmit: 0 errors. vitest: 874/874 (incl. +8 finance: normalizeFinance,
fromFinance, financeHealth, allowAdminSurface finance head). next build: ✓
compiled, 14/14 pages.
2026-07-02 00:57:59 -07:00
hanzo-dev aa507916ad fix(admin,billing): RED fixes — god-view server gate + attribution + row cap (v8.4.15)
H1 (HIGH): the admin business god-view had NO console-side server gate — /v1/admin/*
was not rewritten and rested solely on an unverified cloud-side gate. Add
app/admin/aggregate/[...path] behind getAdminGate (global-admin only, fail-closed
403) → forwardWithUserBearer; rewrite /v1/admin/{overview,usage,orgs,audit,products}
to it (iam/kms untouched). New originGet pins AdminApi to the console origin so a
split-origin NEXT_PUBLIC_CLOUD_URL can't bypass the gate. Least-privilege surface is
the pure, tested lib/server/admin-aggregate.ts (refuses admin/iam, admin/kms).

L1 (LOW, proven): agentUsageFor id-OR-name Set union conflated two agents within an
org. Prefer exact id, fall back to name only when id matched nothing. +collision tests.

L2 (LOW, proven): unbounded cost table under high agent/product cardinality. New pure
capRows (COST_ROW_CAP=100) bounds the DOM to top-by-spend with an honest Show-all
affordance (never hides real data). +tests.

RED-refuted vectors verified safe (fallback scope, metadata forgery, DoS, honest
state, client gating). typecheck clean; 853/853 tests; next build green (new route
registered).
2026-07-02 00:32:23 -07:00
hanzo-dev 738ebee6e6 feat(billing,admin): per-agent/product cost dimension + admin business board (v8.4.14)
Usage/billing visibility:
- aimetrics UsageRecord extracts metadata.{product,agent} (canonical contract);
  perAgent + agentUsageFor rollups over the SAME charged commerce ledger.
- Cost Reports add product + agent breakdowns (presentDimensions gates each on
  real data — honest until spend is tagged); BillingReports renders them.
- Agents detail pane shows per-agent cost from the ledger (agentUsageFor), not a
  hardcoded/registry metric; honest '—' until attributed.

admin.hanzo.ai business dashboard (global-admin only):
- New admin-business living overview (MRR/revenue/usage-cost/orgs/customers,
  revenue-by-product + plan-mix + top-agents-by-cost donuts, alerts, activity,
  fleet health) over /v1/admin/overview allOrgs, honest usage-ledger+operator
  fallback; reuses the ONE LivingOverview system.
- admin-overview gains an optional named-distributions map (revenue/plans/
  topAgents), present only when the backend sends it; fromAdminOverview projects
  each into distribution[key].
- Registry catalog entry 'business' (Observe, admin:true) — gated by getAdminGate
  + useIsGlobalAdmin; the aggregate is server-gated.

Mobile-responsive by construction (gui v5 shorthands + flexWrap rows, no fixed
grids). typecheck clean; 841/841 tests; next build green. /v1 only.
2026-07-02 00:32:23 -07:00
zandGitHub 29b9587914 Merge pull request #40 from hanzoai/feat/template-fork
feat(fork): template → project fork (console2)
2026-07-01 23:48:04 -07:00
hanzo-dev 7d6b2c8e25 feat(templates): Fork/deploy creates a real project from a template
The gallery "Fork / deploy" button now creates a REAL project in-console via
POST /v1/projects/fork (cloud projectsvc) instead of only opening the gallery
source URL — the ONE way to start a project from a template.

- lib/api/templates.ts: TemplatesApi.fork(slug, {name?}) POSTs to the
  same-origin /v1/projects/fork (originV1Url, no prefix) and normalizes the
  returned projectsvc Project (normalizeForkedProject, pure/tested).
- TemplatesModule: per-card idle -> forking -> created state; on success shows
  the new project (Open site when a liveUrl exists, else honest draft). On a 404
  (older backend without the fork route) it falls back to opening the gallery
  source, so the button is never dead.
- next.config.mjs + proxy-allow.ts: add the `projects` head so /v1/projects*
  (incl. /fork) routes through the hardened /cloud bearer proxy, org-scoped from
  the Bearer owner.

Tests: normalizeForkedProject + TemplatesApi.fork (exact same-origin URL, body
with/without name override, 404 ApiError for the fallback); proxy-allow admits
the projects fork subtree. tsc clean; vitest 834/834.
2026-07-01 23:42:03 -07:00
hanzo-dev c72ee8d5f0 feat(agent-builder): superset builder — hanzo.chat advanced config folded into the ONE builder
console2's canonical, decoupled agent builder becomes the true superset: its
host-injected loader seam (unchanged) + hanzo.chat (@hanzo/ai)'s advanced
generation config. One component over the ONE /v1/agents backend — hanzo.chat
and console v8 render the identical builder.

Contract (types.ts):
- AgentConfig: temperature/topP/topK/stream/thinking/useTools/webSearch +
  reasoningEffort, all with defaults; folded onto AgentSpec as OPTIONAL config +
  knowledge, so a simple agent is unchanged.
- ReasoningEffort = the full Hanzo/Claude ladder low·medium·high·xhigh·max·
  ultracode (ultracode = xhigh+workflows, top tier, use sparingly) — supersedes
  @hanzo/ai's old 3-level enum.
- AgentCreateBody: the pruned wire value the builder emits; createAgent now
  takes the body (no more 'as AgentSpec' cast at the call site).

Pure logic (logic.ts): defaultConfig/clampConfig/pruneConfig +
normalizeList(→tools,knowledge, DRY). toCreateBody prunes every default knob, so
opening Advanced never changes what a simple agent posts. clampNum fixed: NaN→min
but ±∞ clamp to the nearest bound (slider-to-top lands on max, not min).

UI (AgentBuilder.tsx): hidden-by-default Advanced section (sliders/switches/select
over the existing Field primitives). loaders.ts + NewAgentBody pass config+knowledge
through to POST /v1/agents.

Verified: all pure transforms proven via a standalone node harness (22/22 incl.
backward-compat, clamp edge cases, prune, ultracode). tsc/vitest run in CI.
2026-07-01 23:03:53 -07:00
hanzo-dev 9ce9a78f14 console: publish ghcr.io/hanzoai/console (drop the 2) — repo renamed console2→console
CI workflow now builds+pushes ghcr.io/hanzoai/console:v<version> directly
(no more console2→console retag hack). registry product source-links and the
build/deploy doc updated to the renamed repo. Image name == repo name, one way.
2026-07-01 19:58:18 -07:00
hanzo-dev f9e6c28e91 Merge remote-tracking branch 'origin/main' into feat/console2-observe-langfuse-port
# Conflicts:
#	next.config.mjs
#	package.json
2026-07-01 19:42:31 -07:00
hanzo-dev f3e32fa480 console: shared "Hanzo Cloud 8.4" product-release label (v8.4.12)
One product, two build lineages: console ships app-semver 8.4.x, cloud
ships its own Go-module v1.786.x (never above v1 — Go module semantics +
the standing rule). Unify only the STORY under a "Hanzo Cloud <MAJOR.MINOR>"
umbrella, single-sourced from the console app version (no second place holds
it): next.config injects NEXT_PUBLIC_APP_VERSION from package.json;
config derives branding.release ("8.4") + branding.productLine
("Hanzo Cloud 8.4"), shown on the sign-in screen. Convention documented in
LLM.md. Nothing in Go changes. typecheck clean, vitest 793/793.
2026-07-01 19:21:25 -07:00
hanzo-dev 10c9dad2cd templates: Gallery starter-kit browser (Apps › Templates) over /v1/templates — category filter + search + fork/deploy handoff 2026-07-01 19:18:23 -07:00
zeekayandClaude Opus 4.8 7e0683b4c1 release: v8.4.11 — verified-green build (typecheck 0 errors, 793 tests, next build)
Cut a fresh semver from a verified-clean main HEAD so CI publishes
ghcr.io/hanzoai/console2:v8.4.11. Tree already type-checks + builds
clean (concurrent lanes resolved the earlier RecordsModule maxW /
metrics.test null-type breakages); this bump is the publish trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:17:33 -07:00
hanzo-dev 6143c61c2f fix(observe): close RED honesty findings — non-finite/negative enrichment + tag coercion
RED review of v8.4.9 (0 critical/high/medium): 2 LOW honesty one-liners + 1 INFO, all closed at the DRY adapter layer.

1. Non-finite/negative enrichment: toTrace/toObservation coerce latency/cost/tokens
   via nonNeg (Number.isFinite && >= 0 → null, else 0 for usage members), so a
   malformed row (1e999→Infinity, -5, NaN) degrades to an em dash and never skews
   metric folds. fmtLatency/fmtCost mirror the guard. toScore drops non-finite
   values (keeps legit negatives).
2. Tags: toTrace filters non-string tags (a non-string tag crashed TraceDetailView).
3. INFO: getTrace/session throw ApiError(404) on a hollow 200-empty instead of
   synthesizing a trace/session — honest not-found → BackendStateCard.

+6 pure-logic tests (1e999 / -5 / NaN / negative). tsc clean · vitest 795/795 ·
next build green.
2026-07-01 19:14:46 -07:00
hanzo-dev a2ac5244d5 prompts: Starter library browse+import over /v1/prompts/catalog (107 curated starters; honest org-empty until imported) 2026-07-01 18:56:20 -07:00
hanzo-dev 4af523b87b console2: rename the 'Object Storage' product to 'S3' (v8.4.10)
The user asked to call it just 'S3'. Rename the user-visible product
name everywhere it surfaces — the catalog entry (registry.tsx label +
description), the StorageModule page header title, and the Zero Trust
data-plane posture row — plus the two resource-console doc comments that
list the kinds. The provisioning kind id stays 's3' and the file-manager
backend (/v1/s3) is unchanged; this is a label rename only.

typecheck clean, vitest 778/778 green.
2026-07-01 18:51:16 -07:00
6dabff3e77 feat(records): browse + edit any Base collection as a CRM/CMS (click-through) (#37)
Makes Base usable BY CLICKING, not just via the API — the gap the live Playwright
check found (the base admin UI is read-only). A 'Records' product (Data category):
browse a collection, open a record, edit it, create new ones — all rendered from
the collection's OWN field schema through @hanzo/data (DataTable for the list,
RecordDetail/RecordForm for detail; every field type is now editable per
hanzoai/ui#232). Data flows through console2's per-user /superbase proxy (IAM
bearer minted server-side; the proxy allow-list gains the collections + records
paths). Routes: /records (index) · /records/:collection · /records/:collection/:id
(:id=new → create).

base-data/{api,fields} map Base schema → @hanzo/data FieldDefinition[] and do the
list/get/create/update/delete; CollectionTable + RecordDetailView are the views.
Registered next to Base (the backend) — Base is the store, Records is the app on
top. 19 base-data tests pass; RecordsModule + registry typecheck clean (the 4
remaining tsc errors are pre-existing: @hanzo/dash local-only + metrics.test).

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:51:12 -07:00
hanzo-dev 493dba9d0f feat(observe): retarget the Langfuse Observe surface to native /v1/evals (v8.4.9)
The whole Observe surface (Traces, trace-detail span-tree/waterfall, Observations,
Sessions, Scores, Score Configs, Datasets, Dataset Items, Dataset Runs, Metrics
dashboard) now reads the NATIVE cloud clients/eval /v1/evals contract instead of
the retired /v1/o11y proxy. One-way, no duplicate modules.

- evals.ts: the ONE native client — datasets/items/evaluators/score-configs/scores/
  traces(+detail)/observations/sessions/runs, mapping wire rows into the canonical
  Langfuse view-models (toTrace/toObservation/toScore) so SpanTree + metrics render
  unchanged. Missing enrichment → null (em dash); unbound endpoint → typed ApiError
  → honest BackendStateCard. Never a fabricated row.
- o11y.ts: O11yApi retargeted to delegate to EvalsApi (traces/sessions/observations/
  scores/score-configs native); annotation-queues/users stay on /v1/o11y. TraceDetail
  fixed to Omit observations/scores from Trace before intersecting. Trace gains
  totalTokens (Langfuse Usage column).
- DatasetsModule: wired to the real native endpoints (listDatasets/listDatasetItems
  per-dataset/listRuns) — no longer forward-compatible stubs.
- TracesModule: adds the Tokens column; format.ts adds shared fmtTokens.
- evals.test.ts: 11 pure-logic adapter tests (null-safety, ms→s, token/usage fold).
- NOTICE: MIT attribution for the Langfuse-derived screen layout/flows.

Static-export-safe (data via same-origin /v1 client fetches, no new server routes).
tsc clean · vitest 789/789 · next build 14/14.
2026-07-01 18:44:05 -07:00
hanzo-dev 2da0f024f4 revert: drop console dual-tag — unbreak the release build
The CI token can push the existing console2 ghcr package but lacks
write_package to CREATE a new ghcr.io/hanzoai/console package, so the atomic
buildx --push of both tags failed and v8.4.9 published NEITHER image. Back to
single console2 tag so releases build again. The console2 → console image
rename needs the ghcr  package created + repo-linked first (an
org/settings step), then the tag can be added.
2026-07-01 18:24:00 -07:00
hanzo-dev e135c81422 feat(analytics): native /v1/analytics Observe module (web+commerce+LLM over datastore)
Per-org Analytics product over cloud clients/analytics: Overview (hanzo.events +
events_daily), Real-Time (live sessions/feed), LLM (cloud_usage + observations).
Same-origin /v1/analytics/* via the /cloud bearer proxy; analytics head added to
proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS. Registered under Observe.
Every metric a real warehouse query; honest-empty otherwise.
2026-07-01 18:20:05 -07:00
hanzo-dev f9ced604dd release: v8.4.9 — publish ghcr.io/hanzoai/console:v8.4.9 (rename transition)
Dual-tag release so the console image exists at this version before the operator
CR + gateway cut over from console2 → console in one coordinated deploy.
2026-07-01 18:06:01 -07:00
hanzo-dev 373385b0b0 build: dual-tag ghcr console + console2 (rename step 1, additive)
'console2' was only ever a working name — the package is already @hanzo/console
and it's v8. Start the rename by publishing ghcr.io/hanzoai/console:<ver>
ALONGSIDE console2:<ver>. Purely additive — nothing consumes the console image
yet, so no rollout can break. The cutover (operator CR + gateway k8s Service
console2→console, in ONE deploy) and dropping the console2 tag follow once the
new image has published on a release.
2026-07-01 18:04:10 -07:00
hanzo-dev 3cff611498 feat(ai): console2 AI surface LIVE over same-origin /v1/* + canonical agent builder (v8.4.8)
Rebased onto main v8.4.7 (Prompts/Agents/Evals live via next.config /v1 rewrites,
dynamic model+prompt agent builder, ComboBox). Was fe3ee26 (v8.4.5).
Cross-tenant eval fix ships in cloud v1.786.10.
2026-07-01 16:49:19 -07:00
hanzo-dev 40001fb502 feat(compute): real Launch drawer (GPUs+Machines) + full live catalog + Hanzo-brand Tasks/Functions (v8.4.7)
Launch GPU/Machine opened docs — now opens a REAL launch flow (POST /vm/v1/machines/launch,
per-org, metered; proven live vs vm:0.1.10). ONE shared LaunchDrawer (kind cpu|gpu) in the
DetailPane from both pages: complete live catalog (172 sizes / 9 GPUs, searchable) + regions,
OUR market price ($/hr+$/mo == the dryRun quote == what launch charges, visor HanzoPrice one
source), 402→'add credits'. Docs demoted to secondary.

Pricing: fixed $/mo (was priceHourly×730=693.5, now authoritative priceMonthly=706.8).
Catalog: MachineCatalog shows ALL sizes (search+scroll) not top-6; GPUs shows all 9.
Branding: Tasks drops 'Temporal'→Hanzo Tasks; Functions drops 'Fission'→Serverless/Hanzo.

VisorApi.quote/launch added (casibase-envelope unwrap). tsc clean; vitest green; build 14/14.
2026-07-01 16:34:55 -07:00
hanzo-dev dbae61c06e feat(nav): per-category color-coding + category-overview color wiring (v8.4.6)
Products inherit a per-category color family by default (one color per category:
AI/Compute/Data/Security/…), user override still wins, no-category callers keep
their legacy curated pick. colorOf/keyOf thread the entry's category so the sidebar
icons, collapsed rail, and L2 header recolor per category in one place. Category
landing (/category/<slug>, CategoryOverview — already built) tiles + header glyph +
the L1/L2 category labels are tinted the category color. colors.ts pure + tested.

tsc clean, vitest 711/711, next build green.
2026-07-01 16:20:33 -07:00
hanzo-dev 0cb0763e9b feat(nav): persistent product filter at level 2 — quick-jump across products from any level (v8.4.5)
The sidebar product filter is hoisted out of the level-1 slide panel into a
persistent header above the two-level slide, so a user deep in a product's
sub-pages can filter + jump straight to another product without going Back.
showLevel2 yields to the product list while filtering; selecting a result
clears the filter and slides to that product's level-2 sub-nav. One input,
one predicate (entryMatches), one list. Mobile drawer inherits it.

tsc clean, vitest 706/706, next build green.
2026-07-01 16:01:09 -07:00
hanzo-dev 4e6ba0fbce test(billing): fix groupSpend provider assertion — openai(400)>hanzo(350) sorts first
groupSpend sorts cents-desc (see the by-model test); the provider test
asserted the reverse and had been red on main. No logic change.
2026-07-01 15:46:31 -07:00
hanzo-dev 5817ff5c92 feat(storage): native S3 file manager + KMS repoint to embedded cloud KMS (v8.4.2)
PRIORITY 1 — S3 file manager. Upgrades the 'Object Storage' catalog entry from
the generic provisioning resource card to a REAL S3 file manager over the
org-scoped /v1/s3 control plane in the unified cloud binary (hanzoai/cloud
clients/s3). One console, one backend — no external s3.hanzo.ai UI.

- src/lib/api/storage.ts (StorageApi): buckets list/create/delete, object list
  (folder-style via prefix), delete, and presigned upload/download. Metadata ops
  go through the same-origin /cloud user-bearer proxy (cloudProxyV1Url — s3 is
  already allow-listed in proxy-allow.ts). Upload/download use PRESIGNED URLs:
  the backend mints a time-boxed URL scoped to the exact bucket+key and the
  browser transfers bytes DIRECTLY to S3 — bypassing the proxy (which buffers the
  body as text + forces JSON content-type, corrupting binary) and never exposing
  the admin credential. Defensive normalizers (billing.ts style); org is
  server-authoritative (never a browser claim).
- src/components/products/StorageModule.tsx: bucket list (create/delete) plus an
  object browser with breadcrumb folder navigation, upload (file picker to
  presigned direct-to-S3), download, delete. Honest loading/empty(first-run)/
  BackendState (503/404/403) states — never a fabricated bucket/object. hanzo/gui
  v5 shorthands; mirrors FunctionsModule.
- registry.tsx: s3 entry now routes to StorageModule (repo hanzoai/s3).

PRIORITY 2 — KMS repoint. src/lib/server/identity.ts KMS_URL default
http://kms.hanzo.svc (legacy Infisical fork) to http://cloud.hanzo.svc:8000
(embedded cloud KMS serving /v1/kms/orgs/{org}/secrets natively, HIP-0106). The
/admin/kms proxy forwards to kmsBaseUrl + /v1/kms/orgs/{org}/secrets — matches.
CAVEAT (documented, not a blocker): the embedded KMS is health-only (secret ops
503) until CLOUD_KMS_MASTER_KEY_REF is provisioned + secrets migrated; the KMS
module shows its honest 'not initialized' state until then.

Tests: vitest storage.test.ts 15/15 (normalizers, folder detection, presign,
traversal-safe key encoding, /cloud-proxy URL construction, direct-to-S3 upload).
tsc --noEmit clean. next build green (14/14 pages). Version 8.4.1 to 8.4.2.
2026-07-01 15:42:31 -07:00
hanzo-dev 6adacc7e4e feat(commerce): Commerce store dashboard in the console — Products/Orders/Customers/Inventory/Promotions/Store, per-org (v8.4.3)
A new Commerce category surfaces the hanzoai/commerce merchant store natively
inside the console (no admin.commerce.hanzo.ai subdomain). Six pages —
Products, Orders, Customers, Inventory, Promotions, Store settings — each a
native module over the real commerce backend, scoped to the signed-in org.

- BFF: app/commerce/[...path]/route.ts forwards to commerce.hanzo.svc:8001 via
  forwardWithUserBearer (mints a short-lived user IAM token; commerce EdgeAuth
  resolves the org from the owner claim). Least-privilege allow-list
  (allowCommerceSurface / COMMERCE_HEADS): only the merchant REST heads
  (product/order/user/variant/discount/collection/store/…) — /billing, /checkout,
  /_/commerce/tenants are NOT reachable (money stays on the /billing proxy).
- Client: src/lib/api/commerce.ts (CommerceApi) — defensive normalizers over the
  real {count,models,facets} envelope; empty store → honest empty, never faked.
- UI: one CommerceResource list (fetch → loading/empty/error via BackendStateCard)
  + six thin pages; Store settings notes Square/Billing. @hanzo/gui v5 shorthands.
- Category: 'Commerce' added to brand-scope (hanzo shows it; web3 brands don't).
- No Stripe, no new DB, no billing-engine change. Payments remain Square via
  hanzoai/commerce /v1/billing.

Verification: tsc --noEmit clean; vitest 52 green (commerce/logic/proxy-allow/
brand-scope); next build ✓ (/commerce/[...path] compiled). The one failing test
(billing/logic.ts groupSpend ordering) is pre-existing on origin/main, unrelated.
2026-07-01 14:56:23 -07:00
hanzo-dev d13dc49e69 fix(compute): make every Compute page read CONNECTED as a customer (v8.4.2)
Live browser pass as Dave (maxpower) found pages that were connected-but-read-as-broken:
- Machines: /vm/v1/machines 403s for a signed-in customer (visor authorizes the
  public catalog but denies the per-org list) → the page said 'Sign in to view your
  machines' next to the real region/size catalog. interpretVisorError now maps 403→
  connected-managed (only 401 = sign-in); CustomerMachines shows 'Launch your first
  machine' + the live catalog, never a sign-in wall.
- platform/state forbidden: reframed to 'Connected · managed by Hanzo' (green check, no
  warning triangle, no Retry) so Containers/Edge/Applications read connected, not error.
- Applications: repointed from casibase IAM OAuth apps (get-applications) to the DEPLOYED
  app services (/v1/apps) — connected/managed/empty states + deploy-via-Functions/Agents.
- Agents: 'Connected · no agents yet' banner on the live 200-empty state.
- Proxy defaults hardened (vm/cloud/tasksd): '|| default' (not '??') so an env
  reconciled to an EMPTY string still resolves the in-cluster service.

tsc clean; vitest green (visor test updated for 403→connected).
2026-07-01 14:42:53 -07:00
hanzo-dev ec75af5859 feat(compute): wire all Compute pages per-org to real backends + rich Agents dashboard (v8.3.2)
Agents: rebuilt AgentsModule into a rich dashboard over /cloud/v1/agents (was
/paas): 5 stat cards, invocations area chart, health donut, agents table with
status tabs + pagination + version badges, recent-activity feed, top-agents bar
list, 30d resource-usage panel. Every number real/derived; polished
create-first empty state + real New-Agent flow. New lib/api/agents.ts (+22 tests)
+ agents/{parts,forms}.tsx.

Machines: customer branch shows the real visor region/size catalog + pricing
(MachineCatalog) under the launch state — never blank.

GPUs: role-routed like Machines — customer sees the real visor GPU catalog +
their GPU machines (CustomerGpus); admin keeps the /paas fleet. Overview route
is role-aware (GpusOverview). +4 visor catalog normalizer tests.

Containers: surface the apps 403 as a graceful 'Managed control plane' card
(was a masked bare-empty table). Edge: honest coming-soon/managed state.

platform/state.tsx: split 401/403 (forbidden → 'Managed control plane') from
501 (not-configured → admin token hint) so customers never see the false
PAAS_SERVICE_TOKEN message across every /paas module.

visor.ts: add regions()/sizes()/gpus() catalog + normalizers.

tsc clean; vitest 639+ green; next build 14/14.
2026-07-01 13:47:15 -07:00
hanzo-dev 8cc9baa514 release: billing center — GCP-grade unified billing (Overview/Reports/Budgets/Invoices/Subscriptions/Payments/Credits) + billing-only shell mode for billing.hanzo.ai 2026-07-01 13:30:51 -07:00
hanzo-dev 10fef57400 Merge remote-tracking branch 'origin/feat/billing-center' into integrate/billing-center
# Conflicts:
#	LLM.md
2026-07-01 13:30:51 -07:00
hanzo-dev bfad0b8bf6 feat(billing): unified GCP-grade Billing Center + billing-only shell mode
Part A — Billing Center (one `billing` catalog entry, tabbed):
- Consolidate the scattered Cost/Subscriptions/Payment-methods entries into ONE
  `BillingModule` (registry ''+:tab) under Observe. Delete superseded CostModule.
- Overview: balance/credits + month-to-date spend + clearly-labelled linear
  projection + daily-spend trend (real /v1/billing/usage ledger, pure logic.ts).
- Reports: cost breakdown by real ledger dimension (model/provider — no invented
  project/SKU), filterable table + BarChart + spend-share Donut over a range.
- Budgets: REAL create+list over commerce spend-alerts (GET/POST /v1/billing/
  spend-alerts). Edit/delete withheld pending commerce per-alert ownership check.
- Invoices: GET /v1/billing/invoices + download; honest empty.
- Subscriptions/Payment methods/Credits: reuse existing modules verbatim as tabs.
- Add `scopedBillingBody` (billing-scope) so a write body's subject is pinned
  server-side — create-budget works without the browser knowing its subject and a
  forged body subject can't widen scope. Wired into the /billing proxy write path.

Part B — billing-only shell (billing.<brand> = same image, filtered):
- config.billingOnly (host billing.<brand> OR NEXT_PUBLIC_BILLING_ONLY=1).
- visibleCatalog / DashboardShell nav filter to the Billing Center sub-pages,
  full chrome kept; default route redirects / -> /billing.
- cmd+K + AppLauncher source from visibleCatalog so scoping is consistent.

Honest states everywhere; no fabricated data. New unit tests: billing/logic,
scopedBillingBody, config billing-only host. e2e/pages.spec updated for the
consolidation. Docs in LLM.md.
2026-07-01 13:25:45 -07:00
hanzo-dev b27f7165a0 release: console2 v8.3.1 — Nodes + DNS Network modules consolidated onto main
Build Docker Image / docker (push) Successful in 2m59s
One authoritative build ending the deploy-war. Two genuinely-unmerged Network
modules land (nodes: per-node luxd validators/peers; dns: per-org managed DNS);
all other session branches were already on main (git cherry-verified) and are
pruned, not re-merged. The api.hanzo.ai-gateway default change was rejected (CR
documents in-cluster CF-403 on public hosts; safe default is cloud.hanzo.svc).
Fixed pre-existing observability/metrics.test.ts null-override types so
tsc --noEmit is fully green.

tsc --noEmit clean; next build is the authoritative Node-24 gate (on-cluster
Kaniko, no GitHub builders).
2026-07-01 13:22:39 -07:00
hanzo-dev 4038efd556 fix(registry): dedupe DNS — render real DnsModule from the Network-cluster entry, drop the duplicate
The DNS module cherry-pick (bee05edc) added a second id:'dns' catalog entry while
main already carried a DNS overview stub in the Network cluster. Upgrade that
well-placed stub to render the real DnsModule (zones/records over /v1/dns) and
remove the duplicate — one id, one entry, one way.
2026-07-01 13:19:16 -07:00
1ca63e1836 feat(nodes): per-node blockchain infrastructure module (validators + peers)
Add a real `nodes` catalog entry (Network category) surfacing individual luxd
node infrastructure — validators (P-chain platform.getCurrentValidators) + peers
(info.peers) — across networks, wired to LIVE luxd RPC. Complements the Bootnode
`networks` module (network-level counts) with per-NODE inventory. REAL data only;
honest "not reporting" per unreachable network, honest empty otherwise.

- app/nodes/[...path]/route.ts: same-origin proxy mirroring the /bootnode security
  pattern — session-gated, brand/org-aware (brandFromHost), least-privilege. Only
  path is v1/inventory; only the four read methods getCurrentValidators/peers/
  getNodeVersion/getHeight are called server-side. Per-network RPC hosts in a small
  env-overridable map; unreachable host -> honest not-reporting, never fake rows.
- lib/api/nodes.ts (extended; cluster-capacity logic untouched): pure
  normalizeValidators/normalizePeers/combineInventory -> uniform NodeRow, dedupe by
  nodeID (validator wins, version enriched from peer), parseUptimePct/parseHeight/
  fmtWeight; NodesApi browser client over the proxy.
- lib/products/brand-scope.ts: nodeNetworksForBrand DATA scope — hanzo=all networks
  (super-admin/infra view), lux/zoo/pars scoped to their own chain. Nodes lives in
  Network so category scope admits it on every brand.
- components/products/NodesModule.tsx: per-network summary cards + network filter +
  DataTable (Network/Role/Node ID/Version/Status/Uptime/Height); BackendStateCard/
  EmptyState honest states.
- Confirmed live (2026-07-01): lux mainnet/testnet/devnet, pars-mainnet. zoo has no
  confirmed public host yet -> honest not-reporting.

Tests: nodes normalizers (real captured wire shapes) + brand->network scoping.
typecheck clean, vitest 404/404 (37 files), next build green. package 8.2.1->8.2.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:17:15 -07:00
865833e049 feat(dns): per-org DNS module — zones + records over /v1/dns (hanzodns)
A Network-category console module: lists org-scoped DNS zones and their records
on the unified /v1/dns surface (api.hanzo.ai gateway → hanzodns → CoreDNS +
Cloudflare sync). Honest BackendStateCard states (401/404/503) until the route
is bound — never fabricates a zone/record. Mirrors the Networks module; uses
@hanzo/data DataTable + the X-Org-Id the cloud client stamps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:14:11 -07:00
hanzo-dev cbfe1c090b release: console UX batch — model catalog logos+providers, cmd+K scroll, docs links, editable branding, Linear icons, category overviews, org-wide observability
Consolidates 5 reviewed branches (PRs #24 #25 #28 #29 #35):
- Model catalog: real Zen ensō + Hanzo H marks; Providers link
- cmd+K: scrolls (mouse + keyboard-follow); docs deep links resolve (/docs)
- Settings: editable org branding (name/logo/colors/theme) with real save
- Linear-style ProductIcon tiles across cmd+K, sidebar, overview headers
- Per-category overview pages (12) + the AI hub
- Observability org-wide on login (Langfuse metrics, no per-project gate)
2026-07-01 12:05:21 -07:00
hanzo-dev 3e7db2c7e1 Merge remote-tracking branch 'origin/fix/console-observability' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 23e9df90aa Merge remote-tracking branch 'origin/feat/console-category-overviews' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev c18cada283 Merge remote-tracking branch 'origin/fix/console-branding-and-icons' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 087f35d0d0 Merge remote-tracking branch 'origin/fix/cmdk-scroll-and-docs-links' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev c654b714b2 Merge remote-tracking branch 'origin/fix/console-model-catalog' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 4a9ba2af8f fix(console): observability is org-wide on login (Langfuse metrics, no per-project gate)
The Langfuse-derived views (metrics/logs/traces/sessions/scores/observations)
share the o11y REST client. Document + lock the tenancy contract: every call
stamps X-Org-Id (currentOrg(), always set on login) and adds X-Project-Id ONLY
when a project is selected — so these views are ORG-WIDE by default and narrow to
a project only when one is picked. No project required to 'just log in and see
metrics'. Adds a real MetricsModule (org rollups over /v1/o11y + /v1/metrics),
pure metrics logic, and its test. Honest states preserved (loading / not-
initialized 503 / empty) — no fabricated data.
2026-07-01 12:02:18 -07:00
098b04b75d fix(base): transpile @hanzo/dash (was @hanzo/dashboard) — unbreak build; lockfile + v8.2.13 (#33)
next.config transpilePackages still listed the OLD package name, so Next parsed
@hanzo/dash's shipped TSX source as plain JS → 'Unexpected token' on export type.
Rename the transpile entry, regenerate the lockfile onto @hanzo/dash@0.3.0.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:54:24 -07:00
hanzo-devandGitHub 9ea65311a5 Merge pull request #32 from hanzoai/claude/wallet-live-balance
fix(wallet): live cloud-credit balance — refetch on focus + after completion/top-up (v8.2.12)
2026-07-01 11:50:09 -07:00
987dee1e08 Base: show 'Bases' scoped to Org → Project (IAM-native) via @hanzo/dash@0.3.0 (#31)
* feat(base): render Bases under Org → Project (drop the 'tenant' noun)

A superbase tenant IS a full Hanzo Base instance → label the page 'Bases' + show
the Org → Project scope (top-bar ScopeSwitcher) as a breadcrumb, consistent with
every resource module. Passes labels + context to the shared @hanzo/dashboard
screens (props added in superbase feat/base-labels-context). Needs that package
republished + a version bump here to build.

* chore: @hanzo/dashboard → @hanzo/dash@0.3.0 (coherent SDK name) + Base labels/context

Renames the dashboard SDK dep + imports to the canonical @hanzo/dash (was the
incoherent @hanzo/dashboard). 0.3.0 carries the labels/context props so the Base
page renders 'Bases — <org> / <project>' (Org → Project scope) instead of the
'Tenants' noun.

* chore: v8.2.12 — Bases-under-Org via @hanzo/dash@0.3.0 (merge main)

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:49:27 -07:00
hanzo-dev bcc6d41bba fix(wallet): live cloud-credit balance — one shared store, refetch on focus + after completion/top-up (v8.2.12)
Dave (maxpower) saw his real $99.74 on mount but it never CHANGED without a
reload — "not seeing increase in the wallet view". The balance READ path was
correct (every surface hits /billing/balance, server-scoped to the caller's
commerce subject = maxpower, the exact subject the gateway debits), but liveness
was missing: SidebarWallet only polled 30s; WalletModule, CostModule fetched on
mount only; nothing refetched on window focus or after a balance-affecting
action. So a completion (debit) or an external billing.hanzo.ai top-up (credit)
was invisible until a manual refresh/reload.

Fix — ONE shared reactive balance store (src/lib/billing/live-balance.ts):
- owns the single /billing/balance fetch (in-flight de-dupe + freshness window
  so N mounted consumers cause ONE call);
- refetches on mount, on window focus + tab-visibility (returning from the Square
  top-up portal now shows the new balance with no reload), and on ONE ref-counted
  30s poll (paused when hidden);
- invalidateBalance() forces an immediate refetch after any balance-affecting action.
useCloudBalance() (useSyncExternalStore) is consumed by SidebarWallet, the Wallet
page cloud-credit card, and the Cost page balance card — every money surface now
shows the SAME live number.

Wire the completion seam: PlaygroundApi.chat (Chat/Playground/cmd-K) and the
streaming runner both invalidateBalance() on a finished completion, and the wallet
top-up success does too — so spend/credit reflects immediately.

Also: /billing proxy responses now send Cache-Control: no-store (a per-tenant
money response must never be cached). And drop the broken self-referential
node_modules 120000 symlink that origin/main re-tracked (it breaks
npm install / vitest / next build locally).

Tests: new live-balance.test.ts (10 — dedupe, freshness, phase mapping,
invalidate, no-flicker). tsc clean, vitest 573/573, next build green (14 pages).
2026-07-01 11:48:17 -07:00
hanzo-devandGitHub 19536e947a harden(security): pathIsClean rejects any surviving %XX + matrix-param (RED final, defense-in-depth, v8.2.11) (#30)
RED's final re-review: double-encoded traversal CLOSED, recommends ship. Two LOW
residuals remained — both inert against the Go backend fleet, but Blue closes them
so the boundary is robust INDEPENDENT of any downstream decode behavior (don't rely
on 'the upstream is Go, single-decodes'):
- R1: N>=3 encoding (%25252e) / overlong UTF-8 (%c0%ae) — pathIsClean now rejects
  ANY surviving percent-escape /%[0-9a-f]{2}/i (a legit segment carries none after
  Next's single decode; a residual %XX is a multi-encoding/overlong tell). Does NOT
  over-reject literal-% names (50%off -> %of, 'o' not hex -> allowed).
- R2: '..;' matrix-param traversal — reject any ';' segment (matrix params unused by
  these REST APIs).

The authoritative post-normalization new URL() gate (v8.2.10) is unchanged; this is
the fast defense-in-depth first gate. Non-blocking per RED. RED INFO (/paas + /billing
bypass bearer-proxy) is by-design — they have their own gates (admin+service-token /
billing-scope.ts).

NOTE: local tsc/vitest are down (env upgraded to Node v26 mid-session + shared
node_modules symlink loop); change is a monotonic regex broadening over the
563-test-passing v8.2.10 guard. Validated via CI next build (Node 24) + live (%252e->404,
clean->401 no over-reject).
2026-07-01 11:26:55 -07:00
hanzo-dev 078cebdcf1 feat(console): per-category overview pages + an AI hub
Each product CATEGORY now has its own landing page at a stable
`/category/<slug>` route (AI, Compute, Data, Network, Security, …) — the
category-level twin of the native product overview. It shows the category name,
an honest one-line description of what the category is, and a grid of every
product in it (each card opening the product's native route). `soon` products
are shown with the existing SOON affordance — never hidden, never faked.

The "AI" overview is the hub for all things AI: it groups Models, Providers,
Inference, Agents, Embeddings, Playground, and Prompts, and leads with prominent
shortcuts to the Model Catalog and Providers.

- Routing follows the SAME pattern as products: one `ProductModule` with a
  `:slug` `ProductRoute` (`categoryRouteModule`) in `productModules`, resolved by
  the same `resolveRoute` and rendered by the same catch-all. It is deliberately
  NOT a `catalog` entry — a category is a grouping of products, not a product, so
  it never appears as a card in the nav / home / launcher. Unknown or
  out-of-brand slugs `notFound()` (propagated cleanly by ProductErrorBoundary).
- All content is derived from the registry (`visibleCatalogByCategory`, brand-
  and admin-scoped) — zero fabricated data. A new product appears on its
  category page for free.
- Nav wiring: the sidebar level-1 category headers, the sidebar level-2
  "More in <category>", and the catalog-home category headers all link to the
  category overview. Breadcrumbs render `Home / Category / <Name>`.
- Pure taxonomy helpers `categorySlug` / `categoryFromSlug` / `CATEGORY_SUMMARY`
  in brand-scope.ts (dependency-free), re-exported from the registry; unit-tested
  in registry-brand.test.ts (round-trip, unique slugs, complete summaries).

No version bump.
2026-07-01 11:07:28 -07:00
hanzo-dev 755389b935 fix(console): editable org branding + Linear-style product icon tiles
Branding (Settings → Branding was read-only host config):
- BrandingTab is now an editable org-branding form (display name, website,
  logo URL, favicon URL, primary color + "apply custom theme") with a real
  Save that round-trips the FULL org record to Hanzo IAM via
  TeamApi.updateOrganization → the /org/iam self-service proxy. Honest states:
  saving / saved / error; a non-admin sees the fields read-only with a gated
  notice (the server proxy 403 surfaced as the same honest access message).
  No fake success. The prior host-runtime values stay as a read-only
  "Runtime (resolved per host)" section.
- Server: /org/iam proxy now allows update-organization (org-admin-only via
  requireAdminForWrite), pinned to the caller's OWN org by BOTH the ?id name
  and the record's body name (forwardIam), so a brand admin can't retarget
  another tenant. admin.ts adds a ThemeData type + Organization.themeData.

Icons (Linear-style):
- New src/components/ui/ProductIcon.tsx — a rounded-square tile filled with the
  product's accent color and the glyph knocked out in near-white (the same
  raw-hex fill + #fff glyph technique as SwatchButton / ProviderLogo); neutral
  $color12/$color1 chip when no color.
- Swapped into CatalogRow (CommandPalette), the sidebar NavRow collapsed +
  expanded and its L2 detail header (DashboardShell), and the NativeOverview
  product header — each preserving the product's colorOf() accent.

Tests: iam-proxy.test adds org-name body extraction (the parsing the new
update-organization write guard depends on).
2026-07-01 11:07:22 -07:00
hanzo-devandGitHub f44b575c45 fix(security): close double-encoded (%252e) path-traversal bypass — RED re-review HIGH (v8.2.10) (#27)
RED's re-review confirmed 4/5 fixes closed but found a residual HIGH: pathIsClean
validated the PRE-normalization string, so a double-encoded %252e%252e (Next decodes
once -> %2e%2e, survives the guard) then normalized to real ../ inside undici's URL
parser at fetch time -> reached /v1/get-account, Base _superusers, escaped CLOUD_HEADS
on /cloud /vm /superbase. Live-proven (401 = passed the guard).

Robust fix (validate what fetch ACTUALLY sends):
- forwardWithUserBearer now re-parses the built target URL with new URL() and runs the
  AUTHORITATIVE pathIsClean + allow() gate on the NORMALIZED pathname (relative to the
  target base), then fetches that normalized dest — so %2e, double-encoding, and any
  future encoding are all gated on the exact path undici will request. One-way, DRY:
  fixes every helper proxy (/cloud, /vm, /superbase, /tasksd) at once.
- pathIsClean also now rejects %2e (not just %2f) as a fast defense-in-depth first gate
  (catches the Next-single-decoded %2e%2e before URL construction).

New tests: 4 double-encoded pathIsClean cases (red today, green now). typecheck clean,
vitest 563/563, next build green. RED re-review of bearer-proxy path validation requested.
2026-07-01 10:59:43 -07:00
hanzo-dev bc5b35c0b8 fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:52:55 -07:00
hanzo-devandGitHub a1e0f8e792 Merge pull request #26 from hanzoai/claude/console2-route-error-boundary
fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
2026-07-01 10:49:30 -07:00
hanzo-dev 1608de5fd9 fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
Product modules mount CLIENT-ONLY under the catch-all route (the authed shell
renders a loader during SSR, so the /playground server HTML carries no module
markup — verified). With NO error boundary anywhere in the app, a throw in one
module's first client render bubbled to Next's root fallback and white-screened
the whole console with "Application error: a client-side exception has occurred"
— but only on a DIRECT load / REFRESH; in-app nav re-renders fresh and hid it.
That matches the reported /playground, /prompts, /gpus crashes exactly.

Fix (one place, closes the class for every product route — DRY):
- ProductErrorBoundary wraps the resolved module in the catch-all page. A module
  throw now keeps the shell + nav and shows an honest, retryable card instead of
  a white screen. Re-throws Next control flow (notFound/redirect/CSR bailout) so
  routing still works; auto-recovers a ChunkLoadError (rolling-deploy skew) with
  ONE guarded reload (no loop).
- app/(dashboard)/error.tsx: Next-native backstop for throws above the module
  (the resolver), rendered inside the shell.
- boundary-logic.ts: pure decisions (chunk detect / control-flow detect / reload
  gate), 11 unit tests. Proven end-to-end against a production build: a real
  throw renders the card with zero uncaught pageerror (screenshots).

e2e/deeplink-refresh.spec.ts locks the reported scenario: /playground, /prompts,
/gpus (+ controls) must render on direct load AND refresh with no white-screen.

tsc clean · vitest 562/562 · next build green.
2026-07-01 10:48:48 -07:00
hanzo-dev 8f60abb1a6 fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:47:20 -07:00
3318ca32a9 refactor(auth): call the account surface under /v1/iam/* (#20)
* refactor(auth): call the account surface under /v1/iam/*

Pairs with hanzoai/ai serving signin/signout/get-account/update-preferences
under the organized /v1/iam/ namespace. The client account calls (account.ts)
and the server-side session resolve (identity.ts resolveUser) now target
/v1/iam/*; all remaining references (doc comments) updated for accuracy.

Deploy order: cloud-api (with V1IamRewriteFilter) MUST ship before this, so
/v1/iam/* resolves. No top-level fallback is kept — forward-perfect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cloud): default to cloud.hanzo.svc, drop the cloud-api alias name

The API binary Service is canonically `cloud` (universe renamed cloud-api →
cloud; the cloud-api ClusterIP is a transitional alias). The console2 CR already
sets CLOUD_API_URL=cloud.hanzo.svc, but the server-route code DEFAULTS still
named the dead alias — so any deployment without the explicit env (local dev)
would dial a name slated for removal. Point both defaults (identity.ts,
training proxy) at cloud.hanzo.svc — one name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:35:14 -07:00
hanzo-dev 03523a317c fix(console): cmd+K scrolls + keyboard-follows; docs deep links resolve
Command palette (cmd+K):
- The results list clipped instead of scrolling: the body had maxH:420 but the
  inner ScrollView had no flex bound. Add overflow:hidden on the body + flex:1
  on the ScrollView so the list scrolls (mouse/trackpad) past the fold, and show
  the scroll indicator.
- Keyboard ↑/↓ moved selection off-screen with no follow. Tag the active row
  (id=cmdk-active) and scrollIntoView({block:'nearest'}) on selection change, so
  arrowing always keeps the highlighted item visible. All 50 results are now
  reachable and discoverable.

Docs deep links:
- docs.hanzo.ai serves the Fumadocs site under the /docs base path
  (docs.hanzo.ai/docs/<slug>), but the console linked bare docs.hanzo.ai/<slug>
  → every 'Full docs' / resource docs link 404'd. Point DOCS (registry) and
  docsUrl(kind) (resource/logic) at .../docs. Matches the created product pages.
2026-07-01 10:33:33 -07:00
hanzo-devandGitHub 289d51f37f fix(security): RED findings — cross-tenant projects + %2f allow-list bypass (v8.2.8) (#23)
* fix(vm proxy): default VISOR_URL to visor.hanzo.svc:19000 (was :80)

Cosmetic source fix — the live CR + universe already set VISOR_URL to :19000, which
governs runtime. This corrects the fallback constant so an env-less run also targets
the port visor actually serves (its Service exposes :19000 only; :80 → 502 upstream
unreachable). No version bump; rides the next build (the compute session owns /vm).

* fix(security): RED findings — cross-tenant projects, %2f allow-list bypass, scope headers, error DNS (v8.2.7)

Adversarial review (RED, PR#18/#19) found live holes in the BFF auth path. Fixes:

- [CRITICAL] Cross-tenant project enumeration. /org/iam/get-organization-projects
  with an OMITTED/empty ?organization passed the 'validate-if-present' check
  (ownerOk(null)=true), and IAM drops its WHERE on empty -> returned EVERY org's
  projects (IAM bypasses Casbin for project routes, so the proxy is the only gate).
  CONFIRMED LIVE (Dave/maxpower saw dhd, 7stars-dev projects). Fix: forwardIam now
  PINS ?organization to the caller's orgScope for a non-global admin on org-keyed
  segments (pinnedSearch, server-authoritative like X-Org-Id); global admins
  unrestricted. Org-keyed WRITES must carry owner+organization == own org (no
  owner='' junk rows). New orgParamSegments={get-organization-projects,add/delete-project}.
- [HIGH] %2f + .. allow-list bypass on /cloud, /superbase, /tasksd. Next decodes
  %2f into segments without re-normalizing dot-segments, and fetch() collapses '..'
  AFTER the allow-list -> 'functions%2f..%2f..%2fiam' slipped a foreign head past
  allowCloudSurface. Fix: forwardWithUserBearer now rejects any '', '.', '..' or
  surviving %2f segment (pathIsClean) BEFORE the allow-list, and trims trailing
  slashes. One guard fixes every helper-based proxy; /ai was already immune (exact-match Set).
- [MEDIUM] Dropped forwardScope on /cloud + /vm — no longer forward browser-controlled
  X-Project-Id/X-Environment (org is authoritative via the Bearer owner; the resources
  are org-keyed). A project-scoped feature must validate membership first.
- [LOW] Redacted 502 bodies (were leaking internal svc host/port) -> generic message +
  server-side console.error. Both bearer-proxy and iam-proxy.
- [bundled] /vm default VISOR_URL -> visor.hanzo.svc:19000 (was :80; visor serves :19000).

New pure tests: pinnedSearch (4), pathIsClean (3). typecheck clean, vitest 551/551,
next build green. Re-review by RED requested.
2026-07-01 10:31:40 -07:00
hanzo-dev b736e97595 release: console2 v8.2.7 (KMS + IAM Users/Roles full CRUD live)
Advance prod (operator-pinned v8.1.1) to current main: KMS secret CRUD, IAM
Users CRUD, IAM Roles CRUD, 89-page e2e harness. Build publishes
ghcr.io/hanzoai/console2:v8.2.7; operator CR bump follows once the image lands.
2026-07-01 10:27:12 -07:00
hanzo-dev 5e1ea787fe Revert "release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console"
This reverts commit 63acec3982.
2026-07-01 10:25:44 -07:00
hanzo-dev 63acec3982 release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console
Cuts a release so prod (operator-pinned) can advance from v8.1.1 to include:
- KMS secret management (create/reveal/delete)
- IAM Users CRUD (create/promote/delete)
- IAM Roles CRUD (create/delete)
- 89-page screenshot e2e harness
All auth via /v1/iam/* + cookie-session /v1/*. Build publishes
ghcr.io/hanzoai/console2:v8.2.7.
2026-07-01 10:22:44 -07:00
hanzo-dev bd2641feb4 chore: gitignore test-results artifacts 2026-07-01 10:21:02 -07:00
hanzo-dev cc19ebb8db console2(iam): Roles full CRUD — completes the IAM management surface
IAM already serves /v1/iam/{add,update,delete}-role (iam controllers/role.go) —
I was wrong that it needed a backend. Wire it: allowlist the 3 role mutations in
the /admin/iam proxy, add addRole/updateRole/deleteRole to IamAdminApi, and give
the Roles tab a create/delete view (RolesAdminView, mirrors UsersAdminView).
All auth flows through /v1/iam/* per the one-path rule. Typecheck clean.

IAM is now fully CRUD in-console: Orgs (list), Users (create/promote/delete),
Roles (create/delete), Applications + Providers (add/edit/delete) — no link-out
for the common lifecycle.
2026-07-01 10:20:13 -07:00
hanzo-devandGitHub 5eed0f5a2f feat(shell): delightful mobile + customizable sidebar — right-side SlideOver drawer/DetailPane, colorful per-product icons, grouped drag-reorder pins, full-screen mobile ⌘K, L2 category links, customer compute via visor (v8.2.6) (#21)
- SlideOver: ONE transform-driven right-side overlay (drawer + DetailPane + account menu); enter+exit animate, backdrop cross-fade, Escape, scroll-lock, focus return, reduced-motion. Full-screen <lg, fixed-width lg+.
- DetailPane: descriptor-driven item detail/edit pane (products write a descriptor, not their own pane).
- Colorful Linear-style icons: pure colors.ts palette (override > curated > hash), per-user overridable; applied in sidebar/palette/launcher.
- Pins: pure pins-core model (groups + order); usePins over account prefs; grouped display + drag-reorder (pointer DnD, no deps) + groups in the Manage/Customize panes.
- Mobile: nav drawer now RIGHT with ⌘K/AI-search + Apps at top; palette full-screen on mobile.
- L2 sub-nav: category breadcrumb + 'More in <category>' sibling jumps.
- #9: customer Machines via user-scoped /vm visor (real machines or graceful 'launch one') — infra 'PAAS_SERVICE_TOKEN' message gated to global admin only.
- favorites reimplemented over usePins (one store). +39 tests (colors/pins-core/Reorder/visor). tsc+vitest(540)+next build green.
2026-07-01 10:17:05 -07:00
hanzo-devandGitHub e50efd1f43 fix(projects): route the Projects page through the /org/iam Bearer proxy (v8.2.4) (#19)
Projects rendered 'not routed' because ProjectApi hit the cloud /v1 cookie path
for IAM endpoints: console.hanzo.ai/v1/iam/get-organization-projects → the gateway
sends /v1/* to the CLOUD binary, which does NOT serve IAM → 404. Proven live:
that exact URL returns 404, while IAM serves the endpoint.

Fix (same BFF-Bearer pattern, existing infra — no new mega-router):
- projects.ts now calls the same-origin /org/iam proxy via makeIamClient, which
  mints a user-bound Bearer server-side and forwards to iam.hanzo.svc. Org resolves
  from the token owner claim (per-tenant), the member-roster pattern.
- /org/iam allow-list gains get-organization-projects (GET) + add-project,
  delete-project (POST, org-admin only via requireAdminForWrite).
- SECURITY: forwardIam now also pins the ?organization param AND the body
  organization field to the caller's org (ownerOk), closing the cross-tenant gap
  for the projects lister/CRUD (ownerOk(null) is a no-op for segments without it,
  so no regression to get-users/get-roles). bodyOwner generalized to bodyField.

typecheck clean, vitest 505/505 (+4 new: projects.test.ts, iam-proxy.test.ts),
next build green. Auth-mint/proxy path touched → hand to RED.
2026-07-01 09:56:13 -07:00
hanzo-dev d5e94b9a7a fix(api): drop dead X-IAM-Org-Id stamp; X-Org-Id is the one canonical org header
The ai data-scoping filters (GetEffectiveOrg, controllers/org_resolver.go)
and the provisioning sub-service both read `X-Org-Id` — NOT `X-IAM-Org-Id`.
cloud mints X-IAM-Org-Id OUTBOUND toward commerce from the validated
principal, so the browser stamp was inert dead weight (and the old comment
claiming GetEffectiveOrg reads X-IAM-Org-Id was drift). Keep the required
X-Org-Id stamp; drop X-IAM-Org-Id; note X-Project-Id is the canonical
project sub-scope evalsvc now reads.
2026-07-01 09:53:19 -07:00
2c352a373b feat(bff): user-bound Bearer for every service proxy — cookie→BFF→Bearer (v8.2.3) (#18)
The data + serverless surfaces (vector/sql/kv/s3/docdb/datastore/search,
functions/prompts/agents) and visor compute now resolve org from the Bearer
JWT owner claim; a cookie-only browser call 403s ('X-Org-Id required'). The
console BFF now mints a short-lived user-bound IAM token server-side (the
proven /ai + /keys pattern) and forwards it, so the browser path works
end-to-end, per-org — no token in the browser, org never browser-supplied.

- src/lib/server/bearer-proxy.ts — ONE shared forwardWithUserBearer(req, opts):
  resolveUser (session cookie) -> adminBearer (shared per-user token cache in
  identity.ts) -> forward with Authorization: Bearer + X-Org-Id=owner, cookie
  NEVER forwarded (dodges the public-gateway 431), response STREAMED (SSE/JSON/
  204). Pure errorBody/upstreamHeaders + proxy-allow.ts allow-lists, unit-tested.
- app/cloud/[...path] — user-bearer proxy to cloud-api for the data + serverless
  heads (allowCloudSurface); provisioning.ts + functions.ts repointed to
  <origin>/cloud/v1/* (was the cookie-only direct path that 403s).
- app/vm/[...path] — user-bearer proxy to visor (allowVisorSurface) for the
  compute surface (regions/gpus/machines); ready for the compute UI.
- DRY: /ai, /tasksd, /superbase refactored onto the shared helper — 3 duplicate
  issue-user-token caches deleted, one adminBearer cache for every proxy.

typecheck clean, vitest 464/464 (+14 new), next build green (/cloud + /vm compiled).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 09:36:17 -07:00
hanzo-dev 03a6167613 chore(release): v8.2.2 — immutable tag for all-pages + living-overview HEAD 2026-07-01 09:29:40 -07:00
8ceffc76e8 console2: all-pages production build — native control planes, no external link-outs (#16)
* feat(console2): make all 14 external products native in-console overviews

The registry declared 14 products with kind:'external' (gateway, dns, cdn,
mpc, cli, sdks, api, ide, desktop, registry, metrics, crawl, studio, console),
but open.ts / match-core / NativeOverview / overviewFor were already collapsed
to a no-external world. Result: those 14 pushed /${id} which resolveProductView
returned notfound → HARD 404 dead links from the overview grid + app launcher.

Convert each external entry to kind:'module' routes:overviewRoutes(id), rendering
the existing NativeOverview (bespoke OVERVIEW_SPECS already merged — real header,
live platform-app health, key facts, native actions, INLINE docs, zero link-out).
Collapse CatalogEntry union to module-only and ProductStatus to enabled|soon;
remove the dead external branches in DashboardShell + OverviewModule and the stale
ext/href config. One way to open anything: a native route.

resolve.test.ts already pins all 14 specs + native-route actions; match-core.test
updated to assert the kind-guard fails closed for a non-module entry.
typecheck 0 errors, 381 vitest pass.

* feat(console2): Subscriptions + Payment Methods + Marketplace pages (real feeds)

Three new production-complete control-plane pages, all backed by REAL /v1 data,
matching the console taxonomy — no new backend needed (they ride existing feeds).

Billing sub-pages (category Observe, alongside cost/plans):
- SubscriptionsModule → GET /v1/billing/subscriptions (commerce, via the /billing
  per-tenant proxy: server-injected COMMERCE_TOKEN, org-scoped, client can't widen).
  Plan / status / seats / price / renewal. Read-only; manage links to the portal.
- PaymentMethodsModule → GET /v1/billing/payment-methods. Card-data MASKED by
  construction: normalizer extracts only brand+last4+expMonth/Year+isDefault — a
  PAN/CVV/token in the payload is dropped and never reaches the display object
  (dedicated leak test asserts it). Renders '••••  last4'. Read-only; add links to portal.
- billing.ts: Subscription+PaymentMethod types + normalizeSubscriptions/
  normalizePaymentMethods (handle Stripe snake_case AND camelCase, nested card obj,
  Unix seconds/ms dates). billing.test.ts: +9 tests incl the PAN/CVV/token leak guard.

Marketplace (category Apps, alongside chat/bot/search):
- MarketplaceModule → the storefront over the REAL model catalog (aicatalog.
  fetchCatalog → GET /v1/pricing/models via the authed /ai proxy). Category tiles,
  featured shelf (real catalog flag), filterable listings w/ real per-Mtok pricing,
  Try-it→Playground CTA. Reuses the existing aicatalog client + ProviderLogo — a
  distinct storefront view over the SAME catalog, not a duplicate of Model Catalog.
- marketplace/logic.ts (categorize/featured/applyFilters/marketStats) + 16 tests
  incl a regex-injection safety test (search is a literal substring filter).

Hardening across all three: read-only, org-scoped (IDOR-proof), no secrets in the
bundle, XSS-safe (plain <Text>, no dangerouslySetInnerHTML), honest loading/empty/
error states (BackendStateCard/ErrorState), every number a real field or '—' —
nothing fabricated. Tamagui shorthand only, dark design language.

typecheck 0 errors · 406 vitest pass (+25 from the new suites).

* docs(console2): document the all-pages build in LLM.md (external→native + billing/marketplace + honest scope)

* fix(console2): billing-proxy tenant isolation — X-Org-Id + full subject-key pinning (RED HIGH)

RED found the /billing proxy's tenant scoping was INERT — a cross-tenant IDOR:
1. It stamped X-Hanzo-Org, but commerce reads X-Org-Id on the service-token path
   (commerce/middleware/accesstoken.go) — the header silently fell back to the
   service org, so every tenant shared one commerce namespace.
2. It pinned only ?user=, but subscriptions filter ?userId=
   (commerce/api/billing/subscriptions.go) — with no userId the query returned
   EVERY subject's subscriptions in the namespace (cross-tenant read).

Fix, mirroring commerce's own edge-auth exactly:
- Send X-Org-Id (like the exemplary /ai proxy) so the namespace resolves per-tenant.
- Pin the FULL subject-key set {user,userId,customerId} to the server-resolved
  subject (= commerce/middleware/edgeauth.go billingSubjectKeys) so NO billing
  endpoint is left unfiltered whichever param it reads; ?org= is still dropped.
- Extract the scoping to a pure src/lib/server/billing-scope.ts (scopedBillingSearch
  + billingSubject) — testable without the Next runtime, same pattern as ai-proxy.ts.
- Defense-in-depth: normalizePaymentMethods clamps last4 to the last 4 digits even
  if commerce puts a full PAN there.

Tests: billing-scope.test.ts (11) — client-forged-subject overwrite, two-tenant
disjointness, non-subject passthrough; billing.test.ts +1 (last4 PAN clamp);
e2e/billing-isolation.spec.ts — live two-tenant disjoint subscription/payment sets;
the 3 new pages added to the 89-route render pass. typecheck 0, 487 vitest, next build ✓.

Rebased on latest main (living-overview #17).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 07:00:04 -07:00
zeekayandClaude Opus 4.8 4863c4f1e7 refactor(web3): drop /bootnode proxy prefix — Networks uses unified /v1/networks
Per the 'one /v1, no extraneous prefix' rule: the Networks module now calls
same-origin /v1/networks (gateway-routed to the bootnode control plane), not a
per-backend /bootnode/* proxy. Deletes app/bootnode. Honest states unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:19:05 -07:00
f472e6385c feat(overview): reusable "living overview" — animated, real-data, config-driven across products (#17)
The admin Platform Overview is now a REUSABLE `LivingOverview` component system
(one component, many configs), not a one-off. Videogame-like: count-up KPIs, live
sparklines, a streaming/virtualized activity feed, throttled polling — tasteful,
60fps, reduced-motion-guarded. Backed by REAL /v1 data, no mocks; every missing
feed renders its honest empty/skeleton/em-dash, never fabricated numbers.

System (src/components/products/overview/living/):
- config.ts   declarative LivingOverviewConfig: tiles in rows + one real-data
              load() => OverviewData + a live block (pollMs/countUp).
- motion.ts   pure count-up/sparkline-ring/poll-clock math (unit-tested).
- hooks.ts    thin rAF/interval drivers (useCountUp animates from the current
              on-screen value on retarget; usePoll/useReducedMotion/usePageHidden),
              all self-cleaning — no leaked frames/timers.
- logic.ts    pure tile decisions: unit-aware formatMetric, deltaOf ("—" w/o basis),
              hasTrend, status/health colors, mergeActivity (stream dedupe),
              windowRows (virtualization), worst/tally (unit-tested).
- tiles.tsx   the 6 animated tiles (reuse ui/Charts verbatim; skeleton/empty/error).
- LivingOverview.tsx  the driver: one throttled poll loop (5s floor, paused when
              hidden/errored), reqRef race guard, background refetch never blanks a
              board with real data. globals.css: hz-skeleton/hz-pulse/hz-row-in.

Real data (adapters.ts, pure + tested): fromCloudUsage (commerce usage ledger),
fromAdminOverview (new lib/api/admin-overview.ts — /v1/admin/overview, optional-safe,
degrades to honest empty on 404), fromFunctions, healthFromApps (operator inventory).

Wired (overview/living/registry.ts — declarative catalog): overview (platform
centerpiece at / and /overview; admin aggregate w/ honest fallback to usage+health),
ai-metrics, functions, gpus. Product route '' renders livingOverviewModule(id); tabbed
products keep :tab, reachable via the sidebar sub-nav (declared subpages) — no dead-end.
Adding a product overview = one config, no UI.

Deletes the superseded OverviewModule + AiMetricsModule (+ aimetrics/{StatTile,
UsageChart,format}) — one overview system, DRY.

typecheck clean (0 errors), 449/449 tests (42 files), next build green (14/14).
Visual proof via headless Playwright: full board renders w/ count-up + live sparklines
+ streaming + donut + health tally, values change across a 5s poll, reduced-motion
snaps to real values, functions/gpus render honest empty/error states w/o crashing.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 06:17:25 -07:00
hanzo-dev 355112b403 e2e: screenshot + render-check every console page (89 routes)
Drives a signed-in session over every registered product route, asserts each
mounts without a hard crash (no Next error overlay, non-blank body, <500), and
captures a full-page screenshot to e2e/screenshots/<id>.png. The 'screenshot it
all, verify every page is wired FE↔BE' pass. Gitignores screenshots/test-results.

Running it against live console.hanzo.ai surfaced a P0: the cloud /v1 backend is
returning 502/503, so sign-in (/v1/signin) fails for all users.
2026-07-01 06:13:07 -07:00
hanzo-dev 864788b42f console2(iam): full user CRUD in-console (casdoor surface, no link-out)
IamModule's Users tab was read-only + linked out to the external casdoor console,
but IamAdminApi already has add/update/delete-user (and the /admin/iam proxy
allowlists those mutations). Surface them: create user (name/email/password,
argon2id-hashed by IAM), promote/demote global-admin (shield toggle), and delete
— all scoped to the active org through the server-gated proxy. Honest states
(loading / operator-required 403 / error / empty). Typecheck clean.
2026-07-01 06:06:32 -07:00
hanzo-dev f6b8b51daa console2(kms): full secret management in-console (one FE for all)
The KmsModule listed metadata and linked OUT to the standalone kms.hanzo.ai
console for everything else. But the /admin/kms proxy + KmsAdminApi already
support full CRUD (list/reveal/create/rotate/remove) over /v1/kms/orgs/{org}/
secrets. Surface it: create/upsert form (path/name/env/value, secure), per-row
reveal (one value, shown once, audited, never cached/listed), and delete — all
through the server-gated admin proxy, scoped to the active org. Keeps the
zero-knowledge stance (no bulk value listing). Removes the external link-out, so
console2 is the single KMS management surface. Typecheck clean.
2026-07-01 05:57:57 -07:00
hanzo-dev 35627404fc fix(tasksd): default TASKS_URL to :7243 (tasks REST port; :80 doesn't exist) — Tasks page now shows real workflows/namespaces 2026-07-01 05:24:21 -07:00
zeekayandClaude Opus 4.8 5a0db2c6dd test(e2e): expand public suite — proxy security gates + routes (no creds)
Adds 4 credential-free live tests proving production posture: root serves
(200/dark #0a0a0a) + /base resolves; server proxies (superbase/bootnode/keys)
reject unauth with 401; proxy allow-lists reject off-list paths with 404 (no
tunnel); unknown route never 5xxs. 5/5 green vs console.hanzo.ai — real CI signal
without the prod superuser password.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:02:21 -07:00
zeekayandClaude Opus 4.8 215354b47a test(brand): prove per-brand catalog scope + extract pure brand-scope (v8.2.1)
Extract the taxonomy + per-brand scope into dependency-free src/lib/products/
brand-scope.ts (pure, brand-passed-in) so it's unit-testable without hostname
mocking or loading the React-heavy registry. registry re-exports it (no API
change). New registry-brand.test.ts PROVES: hanzo=all 12 categories; lux/zoo/
pars=ONLY Web3/Network/Security/Dev/Settings (web3/bootnode), hiding every
AI-cloud category; Networks(Web3) surfaces on lux/zoo. 12 tests green, 381 total,
typecheck + next build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:43:53 -07:00
zeekayandClaude Opus 4.8 f0ad2e0a91 feat(web3): Bootnode Networks module — blockchain-network admin in console (v8.2.0)
The core bootnode ('Web3 Backend in a Box') primitive, ported into console2 so
the lux/zoo web3 consoles manage real blockchain networks (chain/nodes/status/
RPC). Reads the live bootnode control plane via a new per-user /bootnode proxy
(mints the user's IAM bearer; least-privilege to the networks surface + launch/
rpc/scale), rendered with @hanzo/data's DataTable. Honest states on 401/404/503.
Retires the old bootnode-admin app — one console (hanzoai/console), brand-scoped.
typecheck + build + 352 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:24:24 -07:00
Hanzo AI 81af20b738 fix(console): Overview reads real commerce spend + API keys list minted key (8.1.1)
Two customer-facing bugs found in a live real-tenant e2e audit of
console.hanzo.ai, both making the "backend-aware FE" partially broken.

BUG 1 — Overview usage/spend widget showed no spend.
  The Overview called cloud `/v1/get-cloud-usages`, which returns 200 with
  {"status":"error","msg":"usage ledger unavailable: datastore peer not
  connected"} — the cloud usage-ledger's o11y datastore peer is down. Repoint
  `UsageApi.overview()` at the REAL commerce ledger `/v1/billing/usage` (via the
  existing per-tenant `/billing/*` proxy — the SAME source the Cost page and the
  gateway debit against, X-Hanzo-Org scoped). New pure `usage-adapter.ts` rolls
  the raw records up into the rich `CloudUsageOverview` the dashboard already
  renders (totals, prior-period deltas, dense time series, top-N + Other
  spend-by-model, paginated inference activity) — reusing the ONE canonical
  aimetrics parse, so Overview and Cost agree to the cent. Overview UI unchanged.
  Verified live: commerce returns 158 real records for hanzo/z; cloud ledger is
  dead. `UsageRecord` gains additive premium/stream/status/requestId (from the
  commerce metadata) for faithful activity rows.

BUG 2 — API Keys never listed a minted key (uncopyable/unrevocable, re-minted).
  `POST /keys` mints a real working hk- key (User.AccessKey in IAM), but
  `GET /keys` derived hasKey from the cloud `get-account` session claim, which
  returns accessKey='' for a freshly-minted key → the page reverted to the empty
  "Create" state on reload. Read the key AUTHORITATIVELY from IAM
  `get-user?id=<owner>/<name>` (new `getUserKey` server helper) instead. Verified
  live: IAM holds z's accessKey=hk-eeedb378-... while get-account returns ''. The
  key now lists (prefix + last created/rotated date) and revoke works.

- No mocks/fixtures; honest states preserved. Coordinated admin/per-host/ingress
  files untouched. tsc clean, 369 vitest pass (22 new adapter tests), next build green.
2026-06-30 20:48:00 -07:00
zeekayandClaude Opus 4.8 48efb20cfa feat(brand): per-brand catalog scope — lux/zoo/pars = web3/bootnode admin (v8.1.0)
console.hanzo.ai = full AI cloud; console.lux.cloud / console.zoo.cloud /
console.pars.* = web3/bootnode admin only (Web3 + Network + Security + Dev +
Settings — on-chain, networks/nodes/peering, keys/HSM/authz, dev keys, org).
ONE knob: BRAND_CATEGORIES in registry, filtered at the single catalog-consumption
point (visibleCatalog/catalogByCategory/visibleCatalogByCategory) via inBrand +
brandCategoryOrder. Settings already shows brand-resolved info (brand/name/IAM/
billing per host). Hanzo unchanged. typecheck + 345 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:17:42 -07:00
Hanzo AI cfef594484 chore(console2): 8.0.4 — per-host admin login client on admin.<brand> 2026-06-30 20:17:00 -07:00
blueandHanzo AI 3b4a66999e feat(config): per-host admin login client (admin.<brand> → admin-console)
On an admin console host (admin.hanzo.ai) resolve the OAuth client to the
admin-org app `admin-console` so IAM login resolves the global-admin identity
(owner=admin); every normal host keeps the brand cloud client (hanzo-cloud).

Isolated to src/config: `adminApp` per brand (ONE global admin-console app for
the reserved admin org), `isAdminHost()`, and a HOST-keyed cache (admin.hanzo.ai
and cloud.hanzo.ai are the same brand but must resolve to different clients — a
brand-keyed cache would collide). iamAppName + iamClientId travel together;
NEXT_PUBLIC_* overrides still win. Composes with the admin-mode UI (untouched).

+7 vitest (admin/normal host resolution, cache isolation, strict admin. prefix);
tsc --noEmit clean.
2026-06-30 20:17:00 -07:00
zeekayandClaude Opus 4.8 41b5385f73 test(e2e): ungate public sign-in smoke (runs without HANZO_PASSWORD)
The whole suite gated on HANZO_PASSWORD, so CI got zero signal without the prod
superuser secret. Split the credential-free sign-in render check into its own
describe so it always runs — asserts email/password + GitHub/Google + passkey.
Verified green against live console.hanzo.ai. Authenticated flows still gate on
HANZO_PASSWORD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:01:43 -07:00
Hanzo AI bdd27ec829 fix(test): cast entry() helper to CatalogEntry (union-spread) — 8.0.3 build green 2026-06-30 18:19:56 -07:00
Hanzo AI bd68c0ee23 fix: resolve package.json conflict → 8.0.3 2026-06-30 18:18:01 -07:00
Hanzo AI 20f1097810 merge(shell-ia): 2-level nav + Team/Settings + admin↔customer gating + /org/iam hardening → 8.0.3 2026-06-30 18:17:23 -07:00
Hanzo AI f3cb76e35b feat(console): 2-level shell IA + uniform sub-page contract + Team/Settings/Profile + admin↔customer gating (v8.0.1)
Shell + information architecture (owns DashboardShell, nav, header, registry
category/sub-page contract, ⌘K, Team/Settings/Profile). No product content
modules touched (referenced by id).

1. Two-level sidebar nav (Linear-style). Level 1 = categorized product list;
   clicking a product slides into its sub-nav (level 2) with a Back affordance;
   the open product follows the route. CSS-transform slide (.hz-slide,
   reduced-motion aware).
2. Uniform sub-page contract. Every product gets Overview · Settings · Status ·
   Logs · Metrics plus its declared specifics (productSubpages, match-core).
   A sub-page with no backend renders an honest ProductSubpageStub — never a
   404, never fabricated.
3. ⌘K jumps to ANY level. searchDestinations indexes products + declared
   specifics ("queues" → Compute › Tasks › Queues).
4. Category restructure. Deploy→Platform; new Training (Fine-tuning + ML
   Pipelines) and Settings (Team/Settings/Profile); Compute gains
   Kubernetes/Clusters/Tasks; Async category + Jobs entry KILLED (Tasks
   replaces Jobs). Dev/Web3 retained (real products).
5. Sidebar chrome. H mark only (no wordmark), no in-sidebar collapse toggle
   (moved to header), animated collapse (.hz-collapse, 264↔64).
6. Header cleanup. Removed user name + Sign out from the header; Sign out now in
   the footer wallet under Top up; footer user row → Profile; kept org/project/
   network(env) switchers + theme + help + notifications(bell→/alerts).
7. Team (org member mgmt: list/invite/role/remove + read-only Roles), org
   Settings (General/Branding), Profile (Account/Security/API Keys). Member
   mgmt runs over a NEW org-scoped /org/iam proxy so an ORG admin (not only a
   global admin) manages their own org — tenant-isolated server-side, with the
   body-owner cross-tenant write gap closed in a shared forwardIam (also
   hardens /admin/iam). DRY IAM envelope client (iam-envelope).

Admin↔customer surface gating (systemic): admin-only products (Providers, IAM,
KMS, Secrets, Audit, Clusters, Kubernetes) + admin sub-pages (Models › Routing)
are hidden from a customer's nav/launcher/⌘K and render a graceful "Managed by
Hanzo" notice on direct access instead of a hostile 403. Signal = global-admin
(isGlobalAdminAccount, DRY with OrgGate). Customer surfaces (Models browse,
Playground, Chat, API keys, Cost, Team, Compute/data) fully work per-org.

Network×org×project scope was already first-class (client.ts stamps X-Org-Id +
X-IAM-Org-Id + X-Project-Id + X-Environment; ScopeSwitcher env picker) — kept
as-is.

Verify: tsc clean · vitest 308 (incl. new sub-page/routing/destinations/org-
policy tests) · next build green (/org/iam route compiled) · Playwright desktop
+ 390px mobile, both personas.
2026-06-30 17:57:23 -07:00
Hanzo AI 1a8061bc34 chore: console 8.0.2 — Compute (K8s/Containers/Tasks/Training, no async/Jobs) + Playground UX 2026-06-30 17:53:29 -07:00
Hanzo AI c91a641fa9 Merge remote-tracking branch 'origin/feat/playground-ux' into deploy/console-8.0.2 2026-06-30 17:53:06 -07:00
Hanzo AI 0ca75cae70 Merge remote-tracking branch 'origin/feat/compute-pages' into deploy/console-8.0.2
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-30 17:53:06 -07:00
Hanzo AI cbffe1dde4 feat(compute): Kubernetes, Containers, Training & Temporal Tasks consoles (v8.0.1)
Four resource consoles wired to REAL backends with honest states (never fabricated).

- Kubernetes (Compute): real DOKS clusters via PlatformApi (/paas → platform);
  stat cards + cluster table with provisioned CPU/RAM DERIVED from node-pool slugs
  (honest "—" for GPU/unknown); Import (honest) + Create → real provisionCluster.
- Containers (Compute): apps-inventory Workloads + Pods/Images/Namespaces/Events as
  :tab sub-routes over /paas (tolerant shapes, honest states); cluster-detail sidebar.
- Training (AI/Fine-tuning): live mlsvc /v1/train/{jobs,experiments} + /v1/ml/models
  via generalized /training proxy; stat cards, jobs table, training-loss chart,
  checkpoints, models, configs; New-job panel (base model from real aicatalog) →
  POST /v1/train/jobs (402 ResourceMeter surfaced honestly); Save-as-config.
- Tasks (Compute): Temporal console over hanzoai/tasks tasksd via NEW /tasksd
  minted-Bearer proxy; Workflows/Schedules/Queues/Workers/Activities :tab sub-routes;
  stat cards; workflow detail panel + step graph + 8 sub-tabs; engine-health /
  throughput / queue-util / recent rail. Public tasks TLS not live yet → honest
  states until the in-cluster engine is reachable.

Registry: removed the Async category + the Jobs entry; Tasks & Kubernetes → Compute.
Shared: promoted console primitives to ui/Metric.tsx (DRY; gpus/charts re-exports);
nodes.ts capacity derivation (+12 unit tests). tsc 0 / vitest 304 / next build green.
2026-06-30 17:44:21 -07:00
Hanzo AI d1a537c32c feat(playground): UX fixes — provider→model cascade, stop, markdown, per-user history, mobile (v8.0.1)
Fixes Dave's live-Playground complaints on console.hanzo.ai/playground.

- ModelPicker: rebuilt as a keyboard-navigable provider→model CASCADE (Zen-first
  provider rail + model pane with real context badge + $/Mtok + live dot;
  searchable; free-text fallback). New pure providers.ts (Zen-first grouping) +tests.
- ResponsePanel: render the completion as real markdown (new pure markdown.ts
  tokenizer + MarkdownView: fenced code blocks w/ copy, inline code, bold/italic,
  lists, headings, links) instead of plaintext; add a clear Stop control in the
  panel header during streaming (AbortController was already wired end-to-end).
- history.ts: namespace per user (owner/name), auto-persist every completed run
  so History auto-populates; one account never sees another's runs (+tests).
- ModelSettings: consolidated into a collapsible side-pane ATTACHED to the prompt
  builder (desktop) and a bottom sheet on mobile (new SettingsSheet, reusing the
  shell Dialog drawer pattern); shrink the oversized slider thumb + thin the track.
- ChatPlayground/Composer: responsive 3-zone layout stacks cleanly at ~390px with
  no horizontal scroll; a settings toggle opens the desktop pane / mobile sheet.

Verified: tsc --noEmit clean, vitest 311/311, next build green; Playwright desktop
+ 390px (cascade, keyboard nav, stop, markdown, mobile stack, settings sheet).

Wallet "Top up" deliberately NOT rerouted to pay.hanzo.ai: it is ALSO broken
(GET /v1/commerce/tenant -> 404 "unknown tenant", so the Square Web Payments SDK
never initializes). Breakage not moved; root cause reported for a backend fix.
2026-06-30 17:25:14 -07:00
2f1ae6b7c2 Native control planes (zero external link-outs) + Hanzo Functions dashboard (#15)
* feat(console2): native control planes (zero external link-outs) + Hanzo Functions dashboard

Three deliverables, one PR, all over the one /v1 surface.

1) No external link-outs (priority). The catalog's `external` kind is removed:
   CatalogEntry is module-only, ProductStatus is 'enabled' | 'soon'. The 14
   products that used to open another domain (Gateway, DNS, CDN, MPC, CLI, SDKs,
   API, IDE, Desktop, Registry, Metrics, Crawl, Studio, Console) are now native
   in-console routes rendering ONE shared NativeOverview (overviewFor(id) +
   overviewRoutes(id), the DRY twin of soonRoutes): header + summary, a REAL
   health band (probes PlatformApi.apps() with honest not-deployed/not-reporting
   states), key-fact cards, native-route actions, and INLINE docs. Content is a
   pure OverviewSpec per product (overview/spec.ts + resolve.ts, with a
   catalog-derived defaultSpec fallback). The external branches in open.ts,
   DashboardShell, AppLauncher, CommandPalette, ProductInterstitial, and
   OverviewModule are removed.

2) Hanzo Functions dashboard. FunctionsModule rebuilt into a tabbed product
   (Overview · Functions · Deployments · Triggers · Secrets · Settings) over the
   rich lib/api/functions.ts (GET /v1/functions*). Branded "Hanzo Functions" with
   the honest Fission engine badge. Overview: 6 KPI cards derived from real rows
   (deriveOverview, honest "—"), real-series sparklines + trendPct deltas, an
   "Invocations over time" LineChart with 1H/6H/24H/7D/30D toggles, an "Invocation
   status" Donut, and the shared FunctionsBrowser (table + DetailRail). Secrets is
   names-only. Reuses functions/{FunctionsTable,DetailRail,parts}.tsx unchanged.

3) Overview "Explore products" drops the enablement gate: no more
   Enabled/External/Soon badge; every product is open-for-all with Open (native) +
   a "Learn more" affordance to the native /discover/:id interstitial.

Idiom: strictly @hanzo/gui v5 shorthands. New tests: overview/resolve.test.ts.
npm run typecheck clean (0 errors); npm test 298/298 (31 files); every route
compiles + 200s on the dev server.

Drive-by: remove the bogus tracked node_modules self-symlink blob that broke
npm install/vitest (.gitignore already ignores node_modules/).

* docs(console2): fix overviewFor comment reference

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 17:06:23 -07:00
zeekayandClaude Opus 4.8 48f3c170a0 fix(ci): base image public.ecr node:24-alpine (ghcr hanzoai/nodejs 403'd the runner)
The arcd runner can't pull ghcr.io/hanzoai/nodejs:24-alpine (403) — broke every
build since v0.7.32. Use the public ECR Docker-library mirror (no auth, no rate
limit), the v0.7.9 pattern; align the workflow pre-pull to 24-alpine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:33:27 -07:00
zeekay 9144dc6aba chore: sync lockfile for @hanzo/dashboard + @hanzo/data on 8.0.0 2026-06-30 16:31:09 -07:00
zeekayandClaude Opus 4.8 277abb5e15 feat(base-data): render real Base records via @hanzo/data
Wire published @hanzo/data@1.1.0 (peer @hanzo/gui 7.3.0) into the Base UI
as a composable, honest collection viewer.

- next.config.mjs: transpile @hanzo/data (ships TSX source, like @hanzo/gui)
- src/lib/base-data/fields.ts: pure baseCollectionToFields() mapping a Base
  collection schema -> @hanzo/data FieldDefinition[]. Covers text/number/bool/
  email/url/editor/date/autodate/select+multiSelect/json/relation/file/geoPoint;
  skips hidden+system fields (keeps id); handles modern `fields` and legacy
  `schema`/nested `options`.
- src/lib/base-data/api.ts: tiny BaseDataApi over a Base /v1 (listCollections,
  listRecords) -- raw REST + optional bearer, shares the app's typed ApiError.
- src/components/base-data/CollectionTable.tsx: client component; schema ->
  fields -> records -> @hanzo/data DataTable with honest
  loading/empty/error/not-found states (no fabricated rows).
- src/lib/base-data/fields.test.ts: 10 vitest cases for the mapping.

Verify: `npm run typecheck` clean; `npm test` 58/58.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:27:18 -07:00
zeekayandClaude Opus 4.8 b7595715ff feat(console2): embed Hanzo Base + Base look-and-feel (v0.7.11)
Base product module rendering the shared @hanzo/dashboard screens (published
on npm) via a per-user /superbase proxy (mints the user's IAM bearer); catalog
entry + page /base · /base/new. Plus the Base look: black #0a0a0a surface
aligning to the zinc-on-black identity. Consumes @hanzo/dashboard@0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:26:59 -07:00
592e575ae3 feat(chat): demo-ready visual polish (presentation only) (#14)
Polish the Chat UI without touching any data wiring — Chat still calls the
real /v1/chat/completions through the keyless /ai proxy, and every state/API
call, prop, and honest empty/down state is preserved.

- Bubbles: assistant turns read as open text with a sparkle medallion +
  name + timestamp and comfortable line-height (dropped the heavy bordered
  card); the user turn is a refined right-aligned accent bubble. Both render
  light markdown — fenced code blocks (monospace, tinted card, optional lang),
  inline code chips, and bold.
- Markdown: new dependency-free renderer (chat/markdown.tsx) reusing the
  existing `fontFamily: 'monospace'` idiom — no heavy remark/rehype tree added
  (console2 ships no markdown lib).
- Welcome/empty state: sparkle avatar + "How can I help?" + 3–4 clickable
  suggested-prompt chips that fill the composer on click.
- Composer: one rounded, elevated input with a code-insert ({}) and a circular
  send affordance (hover/press states) over a subtle muted hint row.
- ChatView: read-only history thread matches the new bubble look + markdown.
- ChatListView: name cell reads as a link (weight + hover); table unchanged.

All Tamagui shorthands (bg/maxW/rounded/items/justify/self/p/px/py/gap) per
onlyShorthandStyleProps. tsc --noEmit clean; 245 vitest tests pass.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 16:04:25 -07:00
Hanzo AI 68cfbc1cd5 chore: console2 v0.7.32 — Playground parity (single-model 3-col compose) onto org-switcher main 2026-06-30 16:00:11 -07:00
Hanzo AI fa52cc74e1 Merge remote-tracking branch 'origin/feat/playground-parity' into deploy/playground-0.7.32 2026-06-30 15:59:54 -07:00
Hanzo AI 48e84ee58d feat(playground): single-model 3-column composer to mockup parity (v0.7.31)
Redesign the Playground from the multi-model compare board into the polished
3-zone surface in the mockup, reusing the existing AI binding (runner/stream/
PlaygroundApi) — nothing about model output, tokens or cost is fabricated.

Layout (per mockup):
- Header "Playground" + subtitle + Save prompt / </> code / Share actions.
- Tabs: Chat · Completions · Embeddings · Audio · Vision.
- Composer (left): model chip with a REAL "context" badge + searchable picker
  over the live catalog (aicatalog.fetchCatalog, 375 models), System prompt +
  user/assistant turns with char counts, and a footer of Add message / Upload
  (image→vision) / {} Variables / Run (⌘↵ + caret). Completions collapses to a
  single Prompt card.
- Examples (labelled starters w/ model chips) + History (real local prior runs,
  honest-empty) beneath the composer.
- Right rail: Response/Logs panel (real completion, USAGE tokens + COST from the
  model's real $/Mtok, "—" when absent) and Model settings (Temperature 0.7,
  Top P 0.9, Max tokens, Stop, Advanced: freq/presence penalty + seed).

Real behaviors, no backend: Save prompt (local library → Examples), Code (real
cURL/JSON request preview), Share (encode composer into ?p= link, restore on
load), {{variables}} substitution, ⌘↵ to run.

Wiring: Temperature/top-p/max-tokens/stop + advanced penalties/seed flow through
paramsOf into the request (sent only when set). Removes the compare-only files
(ComparePlayground/CompareColumn/AddModel/useCompare/SettingsControls).

Tests: +37 unit tests (params/variables/share/prompts/request-preview/compose/
relative); 282 total green. tsc clean, next build green.
2026-06-30 15:40:45 -07:00
Hanzo AI 11763882f8 fix(org): working org switcher + create-org (multi-tenant onboarding)
The OrgSwitcher collapsed to a STATIC non-clickable label whenever the admin-gated
org list returned empty (every tenant: /admin/iam is global-admin-only → 403), and
there was NO create-org affordance. Now: the trigger is ALWAYS an interactive
Popover (current org always shown + filter + switch), with a 'Create organization'
flow that posts to /onboard and scope-switches into the new org. /onboard relaxed:
an existing-org user can create an ADDITIONAL org (created WITHOUT moving them — a
move would strip a global admin's status + orphan their current org); zero-org
first-run still creates+joins. v0.7.31
2026-06-30 15:03:37 -07:00
6a74c141d5 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#13)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:27 -07:00
Hanzo AI df5bf34218 feat(data): KV/SQL/Datastore/Object Storage/Vector pages to mockup parity
Decomplect the shared resourceModule factory (backs all managed-data kinds)
into ONE spec-driven console, upgrading every data page at once:

- resource/logic.ts   pure per-kind ResourceSpec + fleet/status/endpoint/
  snippet helpers (RESOURCE_SPECS, 16 unit tests). The list API carries
  lifecycle facts only, so usage metrics are honest "absent" (null -> "—"),
  never fabricated.
- resource/parts.tsx  ResourceStat (honest "—" + "Awaiting metering"),
  StatusDonutCard, TabBar, SnippetBlock, CopyField, SectionCard.
- resource/ResourceListView.tsx  the polished home console: REAL fleet stat
  row + tabs (Overview / <instances> / Access / Metrics / [tool] / Settings)
  + right rail (status donut, quick actions, quick start) + the real create
  flow (POST /v1/<kind>, password shown once).
- resource/ResourceInstanceView.tsx  tabbed single-instance console
  (Overview / Access / Settings) with a real confirmed delete.
- ResourceModule.tsx  thin router; same resourceModule/resourceRoutes
  exports -> zero registry churn, all 5 graded kinds (+docdb/search) tuned.

Honest by construction: status breakdown from real lifecycle, create/delete
real, the data-plane tool tab (Query/Browser/Explore) is an explicit
"coming soon" with the real connect snippet — never a fake success.

Gates: tsc --noEmit clean, vitest 245 pass, next build 14/14. Bump 0.7.30.
2026-06-30 14:16:22 -07:00
Hanzo AI 3c54be90f7 feat(catalog): unify model/provider/plan client; Models & Providers parity
One spine for the Models, Providers, and (later) hanzo.ai / @hanzo/dev / desktop
surfaces, all read through the authenticated /ai proxy. Real data, honest states.

aicatalog (the spine):
- Joins the TWO real catalog record shapes — first-party Zen (`context`+`name`)
  and third-party (`contextWindow`+`id` like openai/gpt-5). Fixes context showing
  "—" on 339/375 models and cross-references availability by the stable id.
- Adds priceBucket/matchesQuery/modelTypes/CONTEXT_BUCKETS filters, the provider
  `verified` flag, modelDisplayName (strips "OpenAI: " row prefixes), and
  fetchPlans() reading the real /v1/plans subscription tiers (rpm/tpm/quota),
  honest-empty on 401/404.

Components (self-contained, prop-driven, liftable to @hanzo/ui):
- ProviderLogo — monochrome avatar resolved by provider name: first-party Sparkles
  mark, curated known-provider glyphs, initials chip otherwise. No external URLs.
- SelectMenu — reusable Popover dropdown for the Providers filter row.

Models page: ProviderLogo in rows + provider column, search box, collapsible
type/provider/pricing filters, stacked in/out pricing, real context via the join,
and subscription-plan badges in the detail (model tier -> /v1/plans limits).

Providers page: Explore / My Providers / Custom Models / BYO Weights tabs (the
latter three honest, pointing at the real add/deploy flow), search + type/context/
pricing/verified/sort row, ProviderLogo + Verified badge on cards and detail.

/ai proxy: allow v1/plans through the authenticated proxy (least-privilege add).
Overview: already at parity on the real usage ledger — verified, not rebuilt.

Gates: tsc --noEmit clean, 230/230 vitest (incl. 12 new aicatalog tests),
next build green (14/14). Bump 0.7.28 -> 0.7.29.
2026-06-30 13:21:49 -07:00
Hanzo AI 38c035022f Merge remote-tracking branch 'origin/feat/mobile-responsive-chat'
# Conflicts:
#	package.json
2026-06-30 13:03:06 -07:00
Hanzo AI b9e200f58a Merge remote-tracking branch 'origin/feat/playground-multi-model' 2026-06-30 13:03:06 -07:00
Hanzo AI 1552bb1874 Merge remote-tracking branch 'origin/feat/finetuning-unsloth-class'
# Conflicts:
#	src/components/products/FinetuningModule.tsx
2026-06-30 13:03:06 -07:00
Hanzo AI ca8017f958 fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine (ghcr-only policy)
Re-pin off public.ecr.aws (rate-limited builds + violates ghcr-only). nodejs is the
Node.js runtime mirror (hanzoai/node is the blockchain node). COPY-before-install
(the real Kaniko --single-snapshot fix) is preserved.
2026-06-30 13:02:43 -07:00
hanzo-dev 1d2b2b040d feat(routing): cloud.hanzo.ai is the canonical console host
- default SSR/build host -> cloud.hanzo.ai (was console.hanzo.ai); brand still
  resolves by hostname suffix, so console.hanzo.ai/console2.hanzo.ai (which now
  301 to cloud.hanzo.ai at the gateway) still map to brand hanzo.
- cloudUrl() stays SAME-ORIGIN: the SPA calls cloud.hanzo.ai/v1, and the gateway
  routes that /v1 to the cloud package (global IAM-JWT + rate-limit) — so
  "everything goes through the gateway" holds with the cookie first-party (no CORS).
- console product + experiments external links -> cloud.hanzo.ai.
2026-06-30 12:35:48 -07:00
hanzo-devandGitHub e3a400b1ac Merge pull request #12 from hanzoai/fix/console2-base-image
fix(ci): console2 base image → public ECR node:22-alpine (unblock v0.7.28 build)
2026-06-30 12:28:40 -07:00
Hanzo AI 62fad7cb48 fix(ci): base image → public ECR node:22-alpine (the one CI pre-pulls)
The v0.7.26/27/28 image builds 403'd: today's base-image thrashing pointed the
Dockerfile at ghcr.io/hanzoai/nodejs:24-alpine, which is PRIVATE — the host
builder's ghcr login is scoped to console2's own package and can't read the
separate nodejs package (403 Forbidden on the base pull).

build-image.yml still pre-pulls + caches public.ecr.aws/docker/library/node:
22-alpine (its comment even says the Dockerfile uses it). Re-align the Dockerfile
to that public base: no auth, no 403, and the warm ARC runner serves it straight
from the cache the pre-pull step populates. node 22 is the version v0.7.25
shipped on. The Kaniko COPY-before-install + retry-hardened npm install fixes are
untouched.
2026-06-30 12:28:32 -07:00
hanzo-devandGitHub 6470f8e41b Merge pull request #11 from hanzoai/feat/zero-trust-landing
feat(zero-trust): operational landing — KPIs, post-quantum posture, mesh topology
2026-06-30 12:24:19 -07:00
Hanzo AI 7a6e3f3f62 Merge remote-tracking branch 'origin/main' into feat/zero-trust-landing
# Conflicts:
#	package.json
2026-06-30 12:23:52 -07:00
Hanzo AI 890d98d32d feat(zero-trust): operational landing — KPIs, PQ posture, mesh topology
Upgrades the Zero Trust surface from five plain tabbed tables into a cloud-console
landing page (the Image #11 operational style), wired to the REAL /v1/zt/* mesh:

- Overview: KPI cards (routers/services/identities/sessions/policies — real
  counts or honest em-dash), a router→service→identity topology strip (only
  nodes that came back; status-coloured), and a post-quantum POSTURE card tying
  the data plane together: Object Storage (S3) ⇄ Zero Trust over Hanzo zap
  (ML-KEM-768 key exchange · ML-DSA-65 signatures). PQ-session % shows only when
  the backend reports a real cipher — never guessed.
- Honest by construction: each of the 5 sections loads independently; when the
  zt backend isn't mounted on this host every section degrades to ONE
  BackendStateCard while the (true) PQ posture still shows. No fabricated rows,
  trends, or telemetry.
- DRY: the five detail tabs reuse the SAME zeroTrustSurfaces + ForwardSurface
  (now exported from ConsoleFeatureModule) — one zero-trust surface, not two.
  ZeroTrustModule moved out of the generic table shell into its own rich module
  (mirrors Embeddings/Overview); registry repointed.
- Pure logic in zt/logic.ts (counts, topology, posture) with vitest coverage.

tsc --noEmit (strict) clean. v0.7.26.
2026-06-30 12:23:08 -07:00
Hanzo AI ef8e8fbdf6 fix(ci): COPY before npm install — Kaniko --single-snapshot dropped node_modules
ROOT CAUSE (proven in the build log): the install's own 'test -f next/dist/bin/next'
PASSED, then 'COPY . .' ran, then the build RUN couldn't find next. Under Kaniko
--single-snapshot, a COPY that follows RUN npm install in the same stage drops the
RUN's freshly-created node_modules. The old multi-stage Dockerfile avoided this (no
COPY after install); my single-stage reorder reintroduced it. Fix: COPY all source
FIRST, then install, then build — node_modules is created by the last RUNs so nothing
clobbers it. This (not the base registry or the .bin symlink) was the real blocker.
2026-06-30 12:21:00 -07:00
Hanzo AI d78dd1ff52 fix(ci): retry-hardened install + assert next is present (self-heal partial tree)
The full @hanzo/gui dep tree intermittently installs ~80 packages short (incl next)
— npm reports success but skips them, surfacing later as 'next not found'. Now:
retry-hardened fetch, an explicit 'npm install next' repair if its bin is missing,
and a hard 'test -f' assert so a partial install fails loudly at the install step
(with a clear cause) rather than at build.
2026-06-30 12:15:02 -07:00
Hanzo AI cf4c66e977 fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine + invoke next directly
Two fixes for the broken builds:
1. Base image: use ghcr.io/hanzoai/nodejs:24-alpine (our mirror of node:24-alpine)
   per the ghcr-only policy. (public.ecr.aws rate-limited; mirror.gcr.io was a
   stopgap. hanzoai/node is the blockchain node — nodejs is the Node.js runtime.)
   Bumped 22→24.
2. Invoke next via node_modules/next/dist/bin/next, not the .bin/next symlink: the
   @hanzo/gui RN dep tree intermittently drops the symlink under the build npm
   ('sh: next: not found') even though the next package installs. Direct invocation
   is symlink-independent. Build + runner CMD both updated.
2026-06-30 12:07:14 -07:00
Hanzo AI 15b2dedd42 fix(ci): base image → mirror.gcr.io (ECR-public was rate-limiting builds)
public.ecr.aws/docker/library/node:22-alpine started returning TOOMANYREQUESTS on
the base-image pull (the real cause of the build failures — a partial pull also
surfaced as the misleading 'next: not found'). mirror.gcr.io/library/node:22-alpine
is Google's Docker Hub mirror — reliable, not rate-limited. Combined with the
single-stage install+build, the v0.7.27 admin-fix build is unblocked.
2026-06-30 11:58:13 -07:00
Hanzo AI 117f19e6cf fix(ci): single-stage install+build — stop losing node_modules/.bin/next in Kaniko
The deps→build cross-stage COPY of node_modules intermittently dropped
node_modules/.bin/next under Kaniko (sh: next: not found at 'npm run build',
despite a clean install adding ~850 packages). Reproduced: local install is fine,
so it's the cross-stage symlink handoff. Install + next build now run in ONE stage
on the same filesystem — the seam is gone. Runner stage unchanged (runtime next
start worked through its COPY all along). Unblocks the v0.7.27 admin-fix build.
2026-06-30 11:53:36 -07:00
Hanzo AI 69c2b3535d chore: v0.7.27 — admin redirect-loop fix 2026-06-30 09:47:18 -07:00
Hanzo AI e703c25bc6 fix(console): P0 admin.hanzo.ai redirect-loop — admin-org membership = global admin
IAM never populates isGlobalAdmin AND admin-org members carry isAdmin=false, so the
gate's '&& isAdmin' made NO ONE a global admin → every legitimate admin was bounced
from admin.hanzo.ai to the console (redirect loop). Fix: membership in the reserved
'admin' org alone = global admin. Client OrgGate + server identity.ts. Tenants
(owner!=='admin') stay non-global → admin remains locked to them. v0.7.26
2026-06-30 09:46:54 -07:00
z 8435eb6fa1 merge origin/main (metrics-led Overview) — lead home with the comprehensive OverviewDashboard
Resolve the home-lead fork to the full OverviewModule (Image-#3 vision: metric
cards + sparklines + tokens/spend/by-model charts + activity + quick actions +
wallet + system status) over the narrower AiMetricsModule lead. AiMetrics stays
its own route. One home dashboard, one way.
2026-06-30 00:34:18 -07:00
z a5da0bd863 merge Functions — canonical ui/Charts.tsx wins (its dup was unused orphan; removed)
Functions' add/add ui/Charts.tsx was dead code (no consumer imported CHART_COLORS/
STATUS_COLORS). Take the canonical superset (Sparkline/LineChart/BarChart/Donut/
BarRows). One chart module, one palette name, no backwards-compat alias. Fixed the
legend swatch to the file's own styled-div idiom (was a themed bg with a hex).
2026-06-30 00:29:29 -07:00
Hanzo AI 47f296b2fb feat(console): metrics-led Overview — lead home with the real AI usage dashboard
The Overview was a product grid; now it leads with the same tested AI Metrics
dashboard (requests/tokens/spend over time + per-model breakdown, real per-org
data) — the Langfuse-style project home — with the product catalog below for
navigation. DRY: reuses AiMetricsModule exactly. v0.7.25
2026-06-30 00:29:22 -07:00
z 584a73ff53 Merge remote-tracking branch 'origin/feat/gpus-page' into integrate/console2-v0.7.23 2026-06-30 00:26:56 -07:00
z c4ba6846b8 Merge remote-tracking branch 'origin/feat/machines-page' into integrate/console2-v0.7.23 2026-06-30 00:26:48 -07:00
z 286b1af431 merge main (v0.7.24: AI Metrics + mobile shell) → v0.7.25 2026-06-30 00:26:48 -07:00
z 884772402c console2: consolidate Overview+Embeddings charts → one ui/Charts.tsx (DRY)
Merge Overview + Embeddings; collapse the two duplicate chart implementations
(components/charts/Charts.tsx + components/ui/Charts.tsx) into ONE canonical
ui/Charts.tsx superset built on pure @hanzo/gui primitives (promotable to the
shared @hanzo/gui package as a charts category). Repoint OverviewModule +
embeddings/OverviewView. tsc clean.
2026-06-30 00:26:37 -07:00
z 53f188d131 console2: Functions — serverless (OpenFaaS-class) on real /v1, honest states
Compute → Functions (6 tabs: Overview/Functions/Deployments/Triggers/Secrets/
Settings): functions table, invocations-over-time, status donut, selected-function
detail rail (about/triggers/recent invocations + View/Edit/Delete). Wired to the
real functions backend with honest not-configured/empty/error states; metrics from
the usage ledger degrade to '—'. currentOrg-scoped; destructive actions confirm /
honest-disabled. Fixed 2 leftover tsc errors (icon→ReactElement; dropped dead
?? true after non-nullish !bool). tsc clean.
2026-06-30 00:22:32 -07:00
z 7f0f7a683f console2: GPUs — real GPU/cluster inventory + honest derived/not-configured states
Compute → GPUs (7 tabs): GPU model+count derived from cluster nodeSize slug
(gpuSpecOf, pure+tested), per-GPU rows/telemetry from /paas/gpus when present.
Three honest renders: not-configured (501) · clusters-only (real derived counts +
distribution donut + top-clusters) · inventory-live (full table+telemetry).
Telemetry-only metrics (util/mem/temp/sparklines) and GPU-hours have no backend →
'—'. Est-cost reads the usage ledger, degrades to '—' (never relabels account
total as GPU cost). New compute.ts (zero platform.ts edits); registry gpus entry
upgraded. tsc clean · next build 14/14 · 18 compute tests green.
2026-06-30 00:20:31 -07:00
z ddd5aeda18 console2: Machines — real DOKS-cluster-node inventory, honest states
Compute → Machines as one node of a real DOKS cluster pool (GET /v1/org/{org}/
cluster → machinesFromClusters: sum(pool.count) rows, vCPU/RAM from the DO slug,
monthly cost from the platform bill-from table labeled 'est.'). 8 tabs, 7 metric
cards, status donut, machine table, selected-machine right rail. Per-node CPU%/
MEM%/GPU/uptime/IP the control plane doesn't expose → '—' (never fabricated).
Reboot/Terminate honest-disabled (no real endpoint at that altitude). currentOrg-
scoped; not-configured (501, PaaS token disabled) is the honest first state.
Additive platform.ts only. tsc clean · 20 machines tests green.
2026-06-30 00:20:11 -07:00
Hanzo AI b78f48e0b3 Merge branch 'feat/ai-metrics-o11y' 2026-06-30 00:18:54 -07:00
Hanzo AI c75da83217 feat(console): AI Metrics page + fix Cost usage mapping (real model/tokens, cents)
The /v1/billing/usage ledger carries per-request rows (metadata.model, totalTokens,
amount in CENTS). The Cost page flattened it with a generic reader → every row
'Usage', no tokens, and cost ×100 ($1.06 shown as $106). Now: looksLikeLedger()
+ perModel() roll up by real model name with correct cents — shared (DRY) with the
new AI Metrics module (StatTiles: requests/tokens/spend/balance, usage-over-time,
per-model breakdown, recent activity) reading the same real per-org data. o11y
RuntimeNotice points at AI Metrics (which has data) when traces are uninitialized.
32 unit tests (billing/aimetrics/format), tsc+build green.
2026-06-30 00:18:52 -07:00
z 4679d1af14 release(console2): v0.7.23 — Playground multi-model + UX sweep live to prod
Ships the merged work (multi-model compare Playground with abort-safe billing,
the unified EmptyState/Cost sweep) as a public semver. Overview/Embeddings/
Machines/GPUs/Functions land in v0.7.24 as the conflict integration completes.
2026-06-30 00:08:07 -07:00
Hanzo AI f084d89918 Merge remote-tracking branch 'origin/feat/embeddings-page' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/components/products/stores/StoreListView.tsx
2026-06-30 00:02:06 -07:00
Hanzo AI d284a8e076 Merge remote-tracking branch 'origin/feat/overview-dashboard' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
2026-06-30 00:00:16 -07:00
Hanzo AI 55e14e85f3 feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:52:56 -07:00
z 44435ca291 console2: Embeddings — 6-tab vector product on real /v1, honest-empty (upgrade StoresModule)
Upgrades the thin StoresModule in place into the Embeddings product (Overview·
Explore·Collections·Jobs·Models·Settings): collections=per-org vector stores
(get-stores), Explore=POST /v1/search on {owner}-{store}-docs (server-resolved
owner — store is only a query param), Models+generate=/v1/models + /v1/embeddings
via the keyless /ai proxy. currentOrg-scoped throughout; no secret reaches the
browser; /ai allow-list NOT widened. Honest-empty everywhere a field/endpoint is
absent (get-cloud-usages not merged yet → metric cards degrade to —; RRF drops
score → —; no vector point-lookup → empty) — never fabricated. Deletes the dead
StoresModule/StoreListView (registry was the only consumer); reuses StoreEditView.
Drive-by: corrects the stale 'built-in' admin-policy assertion to false (gate code
untouched). tsc clean · vitest 67/67 · next build 14/14.
2026-06-29 23:48:33 -07:00
39d88d0cfd feat(console): side-by-side multi-model compare Playground (#7)
* feat(console): side-by-side multi-model compare Playground

Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.

* fix(console): cancel upstream on abort + release reader + honest stopped state

Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-29 23:45:35 -07:00
Hanzo AI 1fc3c6eca6 fix(console): cancel upstream on abort + release reader + honest stopped state
Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).
2026-06-29 23:43:16 -07:00
Hanzo AI 86e26e5e54 feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:41:42 -07:00
Hanzo AI a2c925ab7b feat(finetuning): Unsloth-class, HuggingFace-native cloud training UI
Upgrades the Fine-tuning module from a read-only job list into a full training
surface wired to the cloud broker (hanzoai/ai /v1/finetune/*) via a same-origin,
user-scoped proxy.

- app/training/[...path]/route.ts  same-origin proxy → /v1/finetune/* (cookie
                                   forward, the get-account pattern; allow-listed
                                   sub-paths; org re-resolved server-side). No key
                                   or HF token ever reaches the browser.
- src/lib/api/finetune.ts          typed client (unwraps the {status,msg,data}
                                   envelope incl. the HTTP-200 error shape)
- finetuning/HfPicker.tsx          browse/search HuggingFace models + datasets;
                                   private/gated flagged; pick → start a job
- finetuning/NewJobPanel.tsx       base-model + dataset pickers, LoRA/QLoRA/full,
                                   Recommended preset that just works, GPU with a
                                   live time/cost estimate, Start + Save-as-config
- finetuning/JobsView.tsx          jobs list (honest empty/error states)
- finetuning/JobDetail.tsx         live status polling, checkpoints, GPU-hours,
                                   Deploy-to-inference, Cancel
- finetuning/logic.ts(+test)       pure formatters + cost mirror + config store (17
                                   vitest cases)
- FinetuningModule.tsx             tabbed shell (Jobs/New/Models/Datasets/Configs);
                                   internal nav, no new registry routes

tsc --noEmit clean · 17 finetuning vitest pass · next build clean.
2026-06-29 23:37:33 -07:00
Hanzo AI a3f7412653 feat(overview): real-data Overview dashboard — the cloud coherence centerpiece
Build the Overview page wired end-to-end to live data (no mock data):
- metrics / charts / spend-by-model / recent activity ← /v1/get-cloud-usages
  (the hanzo.cloud_usage ledger), org-scoped by the X-Org-Id header;
- wallet balance ← commerce /v1/billing/balance (WalletApi.cloudBalance);
- quick actions ← the real product registry (no dead links).

- src/lib/api/usage.ts: typed UsageApi.overview client (+ allOrgs god-view).
- src/components/charts/Charts.tsx: dependency-free SVG charts (sparkline, line,
  bar, donut) — console2 ships no chart lib; these theme to the dark shell and
  render in the Next web DOM alongside @hanzo/gui.
- src/components/products/OverviewModule.tsx: the dashboard — time range
  (24H/7D/30D/Custom), 4 metric cards w/ sparkline + "vs prior" delta, tokens
  line, spend bar, spend-by-model donut + ranked list, recent activity (filter
  tabs + pagination), quick actions, honest system-status footer, wallet.
  Uniform loading/empty/error states; honest "—" for data with no live source
  (GPU/latency → linked to the Status page rather than faked).
- app/(dashboard)/page.tsx: the home renders the Overview (the Explore-products
  catalog is kept below).
- src/lib/products/registry.tsx: 'overview' module at /overview — also the
  all-orgs admin surface via <OverviewDashboard allOrgs/>.
2026-06-29 23:25:54 -07:00
Hanzo AI d3a47c44d6 feat(console): side-by-side multi-model compare Playground
Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.
2026-06-29 23:23:03 -07:00
Hanzo AI 937ef0f0d8 chore(console): model + provider click-through detail pages → v0.7.22 2026-06-29 23:08:39 -07:00
Hanzo AI 75064e8d42 feat(console): provider detail page (metadata + provider's model list)
Click a provider card → ProviderDetailPanel: provider name + stats (models, max
context, from-price, available) + the provider's full model list (name, type,
context, in/out price, status). Pairs with the model click-through. v0.7.21
2026-06-29 23:08:39 -07:00
a464a12efb feat(console2): unified EmptyState + cost/billing + wired AI pages (#6)
* fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval

The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.

* console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)

The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
Co-authored-by: Hanzo <z@hanzo.ai>
2026-06-29 23:05:59 -07:00
z 1823b4e94f console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)
The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.
2026-06-29 23:05:26 -07:00
Hanzo AI f5ff37fc34 feat(console): click-through model detail (specs/pricing/features + config actions)
Catalog rows are clickable → ModelDetailPanel: full specs (arch/params), context,
all pricing (input/output/cache read+write), tier, features, status + actions
(Open in Playground, Configure routing → /models/routing/<name>, Copy ID). v0.7.20
2026-06-29 23:04:19 -07:00
Hanzo AI 39d690072f fix(console): ChunkGuard — auto-recover stale-deploy chunk errors
Open tabs reference the prior build's hashed chunks after a deploy; those URLs
fall through to the app shell (HTML), so the browser throws ChunkLoadError /
'Unexpected token <' and blanks. ChunkGuard catches that exact failure and does
ONE guarded full reload to pull fresh HTML + current chunks. v0.7.19
2026-06-29 22:50:23 -07:00
Hanzo AI d4c361f3b5 fix(console): carry available-count in ProvidersExplore state (RichModel has no .available) 2026-06-29 22:40:39 -07:00
Hanzo AI 330c247bfb feat(console): polished Models + Providers on real rich catalog
- aicatalog: ONE source (/v1/pricing/models via /ai proxy) — 444 real models,
  15 real providers, with context/pricing/specs/tier/provider. No fabrication.
- Model Catalog: polished table (model+params, type, context, in/out $/Mtok,
  TRUE provider, live Available status) + stats bar. Fixes the qwen/glm 'Hanzo
  (Zen)' mislabel — real provider per model.
- Providers: Explore grid (action cards + real provider cards + stats), was
  '0 built-in'. /providers/manage keeps the custom-provider CRUD.
- Brand: our models show as 'Zen', never 'Hanzo (Zen)'. v0.7.18
2026-06-29 22:39:11 -07:00
Hanzo AI 43062cfa5e Merge remote-tracking branch 'origin/fix/ai-surfaces-tenant-scope-rag' into feat/console2-complete-cloud-ux 2026-06-29 22:21:35 -07:00
Hanzo AI 8c5d4e5902 fix(console): add missing SidebarWallet import (v0.7.17 build) 2026-06-29 22:08:30 -07:00
Hanzo AI a382542fd7 feat(console): always-visible wallet (balance + top-up) bottom-left of sidebar
Pinned wallet widget on every page — per-tenant balance via the /billing proxy +
Top-up deep-links to billing.hanzo.ai/topup (pay.hanzo.ai Square/crypto checkout),
never rebuilds payment. Collapsed mode = wallet icon. v0.7.17.
TODO: promote to @hanzo/ui as a reusable cross-app component (one way, composable).
2026-06-29 22:07:12 -07:00
Hanzo AI 92c8155ee9 fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval
The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.
2026-06-29 22:04:41 -07:00
Hanzo AI fb2a276496 chore(console): scrub upstream brand names from comments — 'admin' org + IAM, our way. v0.7.16 2026-06-29 21:59:21 -07:00
Hanzo AI 9b59decccf chore(console): standardize the global-admin org on 'admin' (drop casdoor built-in dual-recognition) — matches commerce/ai/gateway. v0.7.15 2026-06-29 21:57:24 -07:00
Hanzo AI 9289698825 fix(console): admin.hanzo.ai = GLOBAL admins only (org admins were leaking in)
SECURITY: an org owner (Dave/maxpower, org-level isAdmin) could reach the admin UI.
- gateAllows (server authority for /admin/* proxies): require isGlobalAdmin, NOT
  isAdminGranted (which accepted org-level isAdmin). Verified brand-email stays as
  2nd factor. Org admins now 403 on admin ops even with @adminDomain email.
- OrgGate: redirect non-global-admins OFF admin.hanzo.ai to the console host +
  render-guard so the admin console never flashes. Banner already isGlobalAdmin.
- isGlobalAdmin now recognizes BOTH metadata orgs (admin + built-in), matching
  admin-policy ORG_METADATA_OWNERS.
Bundles v0.7.13 (models owner/name token fix + banner gate). v0.7.14.
2026-06-29 21:51:58 -07:00
Hanzo AI ad4d6ca8aa fix(console): models 'wrong token count' + admin banner leak to org admins
1. identity.ts: user.id must be <owner>/<name> (IAM GetOwnerAndNameFromId), not the
   bare casdoor UUID — fixes Model Catalog 'Could not authorize: wrong token count
   for ID <uuid>' for org-member accounts (Dave/maxpower) that carry an id field.
2. OrgGate.tsx: the admin.hanzo.ai ops banner gated on isAdmin (org-level) so an
   ORG owner (Dave/maxpower) saw it. Gate on isGlobalAdmin (admin/built-in org) —
   org admins are not cross-tenant admins. v0.7.13.
2026-06-29 21:46:56 -07:00
Hanzo AI 790c65b951 feat(billing): per-tenant billing BFF — Wallet/Cost show real balance/usage/invoices
The one remaining cross-service gap (v0.7.x already proxies AI/IAM/KMS). wallet
still hit cookie-only /v1/billing/balance -> 404. New app/billing/[...path] proxy
forwards /billing/* -> commerce with the service token + server-resolved own-org
scope (X-Hanzo-Org + BillingSubject; client cannot widen). Matches the /admin/*
proxy pattern + resolveUser. Wallet.cloudBalance repointed same-origin. v0.7.12.
2026-06-29 21:31:25 -07:00
hanzo-dev 55819f81d4 fix(console2): register @hanzogui/core config augmentation; tsc clean (0 errors)
gui.d.ts was augmenting @hanzogui/web which is not a direct dep (resolves only
via pnpm .pnpm path). Add @hanzogui/core augmentation (which is a direct dep) so
GuiCustomConfig → Conf flows through and shorthand props (bg/px/py/items/justify
etc.) are typed correctly. Also fix Button `color=` → `theme=` in OrgGate banner.

Before: 371 type errors. After: 0.
2026-06-29 13:56:38 -07:00
hanzo-dev 76a4380d9e feat(console2): ProviderListView shows global built-in + per-org custom providers (v0.7.11)
List both get-global-providers (Hanzo platform-keyed, read-only, Built-in badge)
and per-org custom providers in one view. Users see all providers enabled out of
the box via Hanzo's DO-AI keys; adding a custom provider overrides/extends per org.
2026-06-29 13:53:37 -07:00
hanzo-dev 6c745bc4dc fix(console2): OrgGate — admins get banner not hard-block; add Playwright e2e (v0.7.11)
- OrgGate: replace hard hard-block for hanzo-org users with a dismissible
  amber banner pointing at admin.hanzo.ai; staff can use console.hanzo.ai
  normally for cloud work (models, API keys, AI inference etc.)
- OrgGate: restore last selected org from localStorage on sign-in so the
  scope remembers where the user left off
- Add e2e/ Playwright tests: sign-in, admin banner, API key create/confirm,
  /v1/models verification, OpenAI + Anthropic inference tests
- Add playwright.config.ts targeting https://console.hanzo.ai by default
- package.json: add e2e + e2e:headed scripts
2026-06-29 13:50:10 -07:00
zeekayandClaude Opus 4.8 8da1f80314 feat(console2): complete console IA parity port — feature-module shells (v0.7.10)
Port the remaining old-console surfaces into console2 as forward-compatible
modules: Zero Trust, Integrations, Referrals, Experiments, Dashboards,
Score Analytics, plus prompt-create/metrics and dataset items/runs sub-modules.

Each surface points at its real/planned /v1 endpoint and renders real rows when
present; a 404/405/503 collapses to the shared honest BackendState card (no demo
rows). console2 now carries the old console's full information architecture while
backend routes light up independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:13:14 -07:00
zeekay 55409ef473 ci(console2): avoid Docker Hub base image pulls (v0.7.9) 2026-06-29 00:21:41 -07:00
zeekay ad5f082926 feat(console2): first-run org onboarding and waitlists (v0.7.8) 2026-06-29 00:19:12 -07:00
hanzo-dev 88d4c68c2a ci(console2): raw buildx on host builder — fix Docker Hub 429 + artifact quota
Builds were red: docker/setup-buildx-action + docker/build-push-action
spin up an ephemeral buildkit container that can't see the host image
cache, so it re-pulled node:22-alpine from Docker Hub every run and hit
the unauthenticated 429 pull-rate limit; the action's build-summary
artifact upload also hit the Actions storage quota.

Switch to the canonical hanzoai/ci pattern: raw `docker buildx build
--push` on the host builder, which reuses the cached base layer (no
re-pull, no artifact upload). Adds a cache-aware base-image guard that
retries a cold pull with linear backoff. SEMVER tag + GHCR push unchanged.
2026-06-28 23:15:26 -07:00
hanzo-dev 3e0325bb14 feat(console2): scope the PaaS surface by org → project → environment
The /paas proxy forwarded only the god-mode service token, so PaaS
resources couldn't scope per tenant. Now it forwards the full tenant
path the browser stamps:

- X-Org-Id is RE-RESOLVED server-side via the admin policy (orgFor): a
  global admin's switched org (the X-Org-Id it already sends) is honored,
  a brand admin is pinned to their own — authoritative, never the raw
  spoofable claim. Same trust boundary as the IAM/KMS admin proxies.
- X-Project-Id + X-Environment pass through verbatim as sub-scopes within
  that org.

Forward-correct: the control plane scopes by tenant once it reads these,
harmless until then. Adds scope.test.ts (9 tests: store round-trip, merge
semantics, persistence, projectEnvironments intrinsic-3 + custom). 38 green.
2026-06-28 23:10:40 -07:00
hanzo-dev ce67bb16e8 fix(console2): staff gate — collapse "admin. <domain>" gap + header "<Brand> Admin"
The staff redirect button passed two children ("Go to admin." + the
{adminDomain} expression); Tamagui Button lays children out with its
icon-gap, so the two text nodes rendered with a stray space
("Go to admin. hanzo.ai"). Collapse to a single template-literal child.
Header retitled "<Brand> staff" → "<Brand> Admin" (white-label via brand).
2026-06-28 23:07:32 -07:00
hanzo-dev 75f78e1a17 feat(console2): org → project → environment multi-tenancy scope
Projects under the org, each with intrinsic mainnet/testnet/devnet
environments (+ custom), scoping every module at once.

- lib/scope.ts: module-level active { org, project?, environment } the
  non-React API client reads synchronously to stamp headers. Org pinned
  to the brand org; STOCK_ENVIRONMENTS = mainnet/testnet/devnet.
- lib/api/client.ts: baseHeaders stamps X-Project-Id (when a project is
  selected) + X-Environment on every cloud call — one change scopes all
  existing modules (o11y, api-keys, deploys, …) with no per-module edits.
- lib/api/projects.ts: ProjectApi over the REAL Hanzo IAM contract
  (/v1/iam/get-organization-projects | add-project | delete-project),
  keyed (owner, name) with indexed organization = the brand org.
- lib/scope-context.tsx: ScopeProvider — loads the org's projects once,
  holds the active selection, mirrors it into the module scope +
  localStorage, derives per-project environments. Honest: an unrouted
  projects endpoint resolves to org-level only, never a fabricated row.
- components/ScopeSwitcher.tsx: project + environment pickers in the top
  bar (next to OrgSwitcher); env dots keyed to network tier.
- components/products/ProjectsModule.tsx: projects CRUD + "Use" to set
  active scope; honest not-routed/empty states, no fakes.
- registry: Projects flips external → in-console module (Deploy).

Environments are a console-side scoping dimension (IAM Project has no
environments column); X-Environment is sent canonically for backend
adoption. tsc --noEmit clean.
2026-06-28 23:04:49 -07:00
zeekay 98a7088be7 feat(console2): models-first, docs-out, collapsible+filterable sidebar, cmd+K actions (v0.7.4)
Build Docker Image / docker (push) Successful in 3m5s
The 'I don't see any models' wave + sidebar/command UX.

- Models is catalog-first: the default tab is the LIVE model list (~49 Zen
  models via the /ai proxy); routing policy moves to a secondary 'Routing' tab
  (/models/routing). Retires the duplicate empty-by-default 'Model Catalog' nav
  entry — one product, real models on the obvious click.
- Docs are EXTERNAL (brand docsUrl, new tab): a top sidebar 'Docs' entry, the
  header '?' icon, and a server /docs -> docs.<brand> redirect (no in-app 404).
  ComingSoon's API-docs link repointed off the 404-ing ${cloudUrl}/docs.
- Sidebar collapses/expands from the brand 'H' mark (icon-only <-> full),
  persisted to the account; the grid launcher stays. A filter box narrows the
  whole sidebar to find any product fast; Overview/Docs are fixed top links.
- cmd+K runs ACTIONS too (toggle theme, browse all apps, open settings, switch
  org, ask AI / search docs, sign out, per-org switch), ranked alongside catalog
  nav — no dead entries. Removed unused openMode.
- DRY: switchOrg() shared by the switcher + palette; pure match-core (resolveRoute
  + entryMatches) is unit-tested without the GUI tree. Default pins fixed
  (dead 'billing' -> 'models','chat'). tsc + 29 vitest + next build clean.
2026-06-28 22:44:30 -07:00
zeekay 28c186b508 fix(console2): customer console scopes to the user's own org (v0.7.3)
The org-scope module defaults the active org to the brand org (for the cross-org
admin). On a customer host, seed the signed-in user's OWN org as the active scope
the first time (only when no explicit switch is in effect), so the OrgSwitcher
chip and X-Org-Id reflect the real tenant (e.g. maxpower) instead of the brand
org. Never clobbers an admin's deliberate switch.
2026-06-28 22:16:30 -07:00
zeekay bba56046e4 fix(console2): mask the password field on the sign-in form (v0.7.2)
The @hanzo/gui Input ignores the RN secureTextEntry/keyboardType props on web,
so the password rendered as type=text (visible while typing). Set the web input
type explicitly (type="password") so it masks; also corrects autoComplete to
current-password.
2026-06-28 21:58:56 -07:00
zeekay 948a627c72 ci(console2): resolve semver tag without node (ARC runner has no node)
The build step used `node -p require('./package.json').version`; node is not on
the ARC runner PATH, so the substitution silently yielded the tag `:v` and the
operator deploy 404'd. Resolve the version with grep/sed and fail-loud if empty.
2026-06-28 21:45:33 -07:00
zeekay bdda312f5e feat(console2): multi-tenant login (org by email) + /paas admin gate (v0.7.1)
Login resolves the user's ORG from their email instead of pinning the brand's
own org: a customer in any org signs into the brand console with email+password.

- iam-login.ts: POST /v1/iam/login with organization: (cross-org email
  resolution) → OAuth code → existing completeSignIn → /v1/signin exchange.
  Replaces the SDK org-pinned redirect that made non-brand-org users
  un-signinable. MFA (NextMfa/RequiredMfa) hands off to IAM's same-site hosted
  flow — IAM sets iam_session_id SameSite=Lax, so cross-site fetch MFA can't
  complete here; no faked inline step.
- SignInForm: email+password state machine; social (getProviderSigninUrl) kept.
- OrgGate: the console runs in the user's CUSTOMER org — blocks the internal
  brand org (owner===config.iamOrgName → admin.<domain>) and the zero-org case.
- SECURITY /paas/[...path]: gate with getAdminGate (403 if not a brand admin) +
  runtime nodejs. The forwarded PAAS_SERVICE_TOKEN is control-plane god-mode and
  was previously reachable by any authenticated browser.
- getAdminGate: orgScope=user.owner (was brand.id) so the IAM/KMS proxies pin a
  non-global admin to their OWN org; require a VERIFIED email (authoritative IAM
  recheck for thin claims). admin-policy + tests updated (14 pass).
2026-06-28 21:37:42 -07:00
zeekay 4cfcd1f779 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	package-lock.json
#	package.json
2026-06-28 21:23:02 -07:00
zeekay b3fc76186f feat(console2): live admin data + org switching (v0.7.0)
Root cause of empty models + broken org switcher: /v1/{iam,kms,models} 404/401
(cookie-only) on the console host. Route every privileged call through console2's
own server proxies (user bearer + admin gate), and make org scope a switchable value.

- models-catalog: repoint /v1/models -> /ai proxy (shared aiV1Url in client.ts);
  catalog now populates with live Zen models. playground reuses the shared aiBase.
- org-scope.ts: currentOrg/setCurrentOrg/isScopedAway/filterOrgs. client.ts stamps
  X-Org-Id: currentOrg() so data modules re-scope on switch.
- OrgSwitcher: lists ALL visible orgs, filter box, in-place re-scope (set+reload).
- IamModule/AuditModule read currentOrg(); KmsModule now a names-only inventory
  over KmsAdminApi.list (the /admin/kms proxy), values never fetched.
- admin-policy.ts: pure gateAllows/ownerAllowed/orgFor extracted from the gate +
  routes (decomplect) so the shipped predicate is the tested one.
- vitest: 22 tests RED->GREEN (gate allow/deny+scoping, scope+filter, catalog /ai).
  tsc --noEmit + next build clean.
2026-06-28 21:19:50 -07:00
hanzo-dev fecaf7d49d fix(console2): Pager props + O11yUser barrel export (tsc clean) 2026-06-28 20:53:43 -07:00
hanzo-dev 1d9c2a9225 feat(console2): port Observations + Users observability views from old console
Two more old-console pages as real o11y modules: Observations (the flat
cross-trace span/generation view, GET /v1/o11y/observations) and Users (per-user
analytics rollup, GET /v1/o11y/users). O11yApi extended with both endpoints +
the O11yUser type. Same paged DataTable + honest RuntimeNotice pattern as the
other o11y modules; registered under Observe. tsc clean.
2026-06-28 20:52:55 -07:00
hanzo-dev 7a3b13d55e feat(console2): build the 25 remaining products into real modules
Flip every 'soon' product (ComingSoon stub) to a real, enabled in-console module
— attestations/oracles/indexer/tokens/settlement, alerts/logs, pipelines/
releases/builds/environments, hsm/authz/service-mesh/load-balancer/vpc, jobs/
edge/functions/containers/machines/gpus, agents/inference/finetuning. Each
follows the established module pattern: typed restGet over the same-origin /paas
proxy, DataTable, honest interpretPlatformError→PlatformStateCard not-configured
state, Refresh. Registry: status soon→enabled + routes wired. Full tsc clean.

Catalog is now 61 enabled modules + 16 external; no 'soon' stubs remain.
2026-06-28 20:06:44 -07:00
z bc7f92665e docs(brand): add hero banner 2026-06-28 20:05:13 -07:00
z 509e511b6e chore(brand): dynamic hero banner 2026-06-28 20:05:12 -07:00
hanzo-dev ab9f8751b7 feat: brand-aware animated logo in console loader/sign-in
The mark now follows config.brand — each brand's OWN published, interactive
animated SVG (@hanzo/logo, @luxfi/logo, @zooai/logo: getAnimatedSVG, load→hover→
press). Static block-H during SSR/first paint (no hydration mismatch). The logo
is now the brand's playable 'AI' on every loading + sign-in surface.
2026-06-28 19:49:01 -07:00
zeekay 5ea5cde253 feat(console2): add MPC product (Security) so console.hanzo.ai shows IAM/KMS/MPC/PaaS
MPC = threshold signing & multi-party computation, external→mpc.hanzo.ai.
Completes the 4 platform apps the launcher surfaces: IAM (module), KMS (module),
MPC (external), PaaS (projects→platform.hanzo.ai, embedded PlatformModule).
Verified locally: dev recompiled clean, tsc --noEmit clean.
2026-06-28 19:26:29 -07:00
zeekay f28d79673a wip(console2): agent admin-console IAM/KMS savepoint (proxy+gate+branding, in progress) 2026-06-28 19:20:38 -07:00
zeekay 40a6236aa3 fix(console2): API keys — drop reload() that unmounted the view via AuthGate (v0.6.1)
Build Docker Image / docker (push) Successful in 2m37s
The mint/revoke success path called useSession().reload(), which flips the
session loading flag -> AuthGate renders its Loader -> the dashboard (and
ApiKeysView) unmounts -> the freshly-minted key (local newKey state) is lost on
remount, so 'Your new API key' never showed. The session masks accessKey anyway,
so the reload provided no benefit. Show the new key from local state only.

Verified live (v0.6.0): /keys POST 200 + /ai chat proxy 200 (real 'PONG'/Hanzo
answer); only the keys UI render was affected.
2026-06-28 18:54:02 -07:00
zeekay 97c385f5ca feat(console2): working AI (keyless proxy) + API keys + chrome polish (v0.6.0)
Build Docker Image / docker (push) Successful in 2m25s
P0 — make it work for Dave:
- Root cause of 'chats/playground don't work': /v1/chat/completions requires
  Authorization: Bearer; the browser sent cookie-only -> rejected. Fixed with a
  keyless server-side AI proxy (app/ai/[...path]) that mints a short-lived
  user-bound IAM token (issue-user-token, hanzo-console app-on-behalf) and
  forwards to the gateway. No key in the browser, no rotation per turn.
- API keys: app/keys route mints/rotates/revokes the per-user hk- key via IAM
  (mint-user-keys/revoke-user-keys); ApiKeysModule is now create/copy/rotate/
  revoke with show-once secret handling.
- Chat is interactive (ChatConversation) over AiApi.chat; honest 402 billing state.
- src/lib/server/identity.ts: server-only trust boundary (resolveUser + IAM ops).

P1 — chrome polish:
- Header/sidebar show the Hanzo H mark + 'Console' (HanzoMark, BrandLogo with
  org-logo fallback).
- Fullscreen Launchpad-style app launcher (AppLauncher) with filter, from the
  header 'Apps' button, sidebar grid icon, and the command palette.
- cmd+K palette gains a 'Browse all apps' affordance.

Verified end-to-end (curl, real creds): minted hk- key + issued user JWT both
200 on api.hanzo.ai/v1/chat/completions. typecheck + next build clean.
2026-06-28 18:19:38 -07:00
zeekay 7cf8015c07 chore(console2): remove unused lib/zap client + @zap-proto deps
Zero importers (grep lib/zap = 0). Drops src/lib/zap/* and the
@zap-proto/{web,zap} deps. typecheck + build clean. One-way hygiene.
2026-06-28 05:45:21 -07:00
zandGitHub 5874ec5acc Merge pull request #4 from hanzoai/feat/memory-tasks-modules
feat: Memory + Tasks modules (v0.5.0) — unify work/tasks/memory in the console
2026-06-27 22:16:00 -07:00
zeekay 01844deb23 catalog: Tasks under appended Async category (Tasks/Temporal)
Async is the canonical home for Tasks per the org taxonomy. Appended as an 11th
category so the ten GCP-equivalent groups keep their exact labels and order
(no reorder); catalogByCategory is data-driven, so it renders generically.
2026-06-27 22:11:27 -07:00
zeekay f7a90ffe65 release: v0.5.0 — Memory + Tasks modules 2026-06-27 22:06:52 -07:00
zeekay 7f7a9341da feat(console): Memory + Tasks modules in the unified catalog
Memory (Data): list + search (kind filter + text) + detail/edit + add/delete,
honest 'initializing' card until /v1/memory deploys. Tasks (Compute, GCP Cloud
Tasks): namespace selector + Workflows/Schedules tabs + cluster strip, workflow
detail + durable history, on the LIVE /v1/tasks engine. Both render honest
states on a gated/absent route; mutations report through the shared toast.
Entries appended (no reorder); grouped by category.
2026-06-27 22:06:48 -07:00
zeekay 13978c0ca9 feat(api): Memory + Tasks /v1 clients
Memory (hanzoai/ai /v1/memory): remember/search/list/recall/update/remove/facts
over restGet/restPost; per-user, server-scoped. Tasks (hanzoai/tasks /v1/tasks):
namespaces/workflows/workflow/history/schedules + cluster health, contract
verified against pkg/tasks/embed.go. Both plain-REST, cookie-credentialed; org
scoping is server-side.
2026-06-27 22:06:41 -07:00
zandGitHub d564d3c488 Merge pull request #3 from hanzoai/feat/shell-foundation
feat: shell foundation v0.4.0 — cmd+K, AI assist, toasts, theme, org-switcher, breadcrumbs
2026-06-27 21:47:44 -07:00
zeekay bd933e5984 chore: v0.4.0 2026-06-27 21:45:52 -07:00
zeekay c0da836191 feat(shell): mount the foundation blocks once in the dashboard shell
Dashboard layout wraps the shell in ToastProvider + CommandPaletteProvider. The
shell top bar gains the command search box (opens the palette), theme toggle, a
help launcher (opens the palette in docs mode), and the org switcher; a breadcrumb
bar sits below it. ResourceModule create/delete now report through the toast, so
the one feedback primitive is actually used.
2026-06-27 21:45:48 -07:00
zeekay abb45c9481 feat(console): command palette + org switcher
CommandPalette is ONE command surface (Cmd/Ctrl+K) with modes selected by the
query: default fuzzy-filters the catalog and jumps to any product; > asks the AI
to find a product (NAV <id>) or answer; ? asks the docs knowledge store (RAG).
searchCatalog is the dependency-free fuzzy ranker. OrgSwitcher shows the account's
org and, only for a real multi-brand membership, switches to that brand's console
host. Honest throughout — AI/RAG/IAM failures degrade to truthful states.
2026-06-27 21:45:41 -07:00
zeekay a09e9483c8 feat(ui): toast, theme toggle, breadcrumbs primitives
Toast is the one feedback primitive — ToastProvider + useToast() with a portalled
top-right viewport, theme-aware accents, auto-dismiss. ThemeToggle flips dark/light
via next-theme. Breadcrumbs derive Home/Category/Product/detail from the route +
catalog, so a new product gets correct crumbs for free. GUI primitives only.
2026-06-27 21:45:35 -07:00
zeekay 5a61a9571c feat(api): one AI client over the cloud /v1 (chat + ragChat docs + listModels)
AiApi composes the single OpenAI-compatible gateway binding (PlaygroundApi):
plain chat, listModels, and ragChat grounded in a knowledge store (docs by
default) via the built-in retrieval path — the X-Retrieval-Store header turns
on RAG and names the store; the org owner is resolved server-side. restPost and
PlaygroundApi.chat gain an optional headers arg so RAG and plain chat share ONE
binding. No parallel AI client; failures throw ApiError for honest states.
2026-06-27 21:45:29 -07:00
zandGitHub 7b9a02ab9a Merge pull request #2 from hanzoai/feat/billing-per-brand-url
Per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
2026-06-27 20:01:54 -07:00
zeekay 03d80356bf feat(config): per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
billingUrl moves from SHARED to the per-brand BRANDS table, resolved like
iamUrl. Each brand's console (Cost product, PlansModule manage-billing) links
to ITS billing host, scoped to ITS org by the brand JWT. Cloud backend stays
shared/multi-tenant.
2026-06-27 19:24:08 -07:00
zeekay 35e0ea4c30 merge: page-port wave 2 (trace-graph tree, score-configs, annotation-queues) — v0.3.0
Build Docker Image / docker (push) Successful in 2m25s
2026-06-27 18:52:04 -07:00
zeekay cf44eff091 feat(console2): port-wave2 — span tree, score-configs, annotation-queues (v0.3.0)
Native @hanzo/gui modules over the REAL /v1/o11y REST surface; honest
RuntimeNotice on 503/404 — no fabricated data.

- Traces detail: flat observations table -> nested span TREE waterfall
  (SpanTree) built from parentObservationId, with a Tree|Table toggle.
  Orphans/cycles surface as roots so every observation appears once.
- Score Configs: new Observe module on /v1/o11y/score-configs (read-only).
- Annotation Queues: new Observe module on /v1/o11y/annotation-queues
  (real public REST list — contradicts the earlier 'no backend' finding).

Skipped (honest, no usable Hanzo backend or would duplicate/fake):
dashboards/widgets + score-analytics (no saved-dashboard REST, charts
would be fabricated), llm-connections/mcp (covered by Providers/ModelCatalog),
agents (backend not routed at gateway — real paths 404), org/projects deep
settings (already covered by Settings + IAM, no write endpoints), and the
Langfuse integration surfaces (feature-flags/entitlements/automations/
batch-exports/developer-tools/slack/mixpanel/blobstorage).

catalog 73 -> 75 entries; appended only (no reorder).
2026-06-27 18:44:19 -07:00
Hanzo AI e3c4d52bb4 build: cap Node heap (NODE_OPTIONS=6144) to fix Next build OOMKill (exit 137) 2026-06-27 18:35:53 -07:00
zeekay da83b67e89 release: v0.2.0 — consolidate page-port wave 1 (o11y traces/sessions/scores, playground/evals/datasets/prompts, settings/models/api-keys)
Build Docker Image / docker (push) Successful in 2m45s
Merges port-{settings-models,prompts-evals,o11y-core}. 73 catalog entries across the 10 categories; new modules wired to real /v1 backends with honest states (o11y 503 until runtime init). tsc + next build green.
2026-06-27 18:19:45 -07:00
zeekay a13de116e8 merge: port-o11y-core (Traces/Sessions/Scores)
# Conflicts:
#	src/lib/api/index.ts
#	src/lib/products/registry.tsx
2026-06-27 18:17:56 -07:00
zeekay 725beb1d98 merge: port-prompts-evals (page-port wave 1)
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-27 18:17:16 -07:00
zeekay 21c1016292 merge: port-settings-models (page-port wave 1) 2026-06-27 18:16:09 -07:00
Hanzo AI bc76e6fc76 release: v0.1.9 — REST client fix over v0.1.8 (working REST, not dead ZAP /zap WS) 2026-06-27 18:10:23 -07:00
Hanzo AI 24f9b14e69 fix(providers): use working REST client, not the dead ZAP /zap WS
ProviderListView/ProviderEditView imported ~/lib/zap, but the cloud /zap
WebSocket face is not served (edge returns SPA HTML, not a WS upgrade — per
lib/zap/client.ts LIVE STATUS), so Providers rendered 'Failed to load
providers'. Switch both to ~/lib/api (identical surface). Part of v0.1.8.
2026-06-27 17:46:04 -07:00
zeekay ac0ecfcb84 feat(console2): port prompts/playground/datasets/evals as native @hanzo/gui modules
Port the old console (Langfuse-fork) eval surfaces into console2 as native
modules on @hanzo/gui + @hanzogui/lucide-icons-2 — no antd/shadcn/tremor.
Wired to the REAL cloud /v1 backend; honest loading/empty/unavailable states
on 404/503 (NEVER fabricated prompts/datasets/scores).

- Playground (AI): GET /v1/models + POST /v1/chat/completions — fully working
  model run (system prompt, message thread, sampling params, token usage).
- Evals (Observe): POST /v1/evals/runs (real per-item run summary) +
  GET /v1/evals/scores (real scores list), Run/Scores tabs.
- Datasets (Observe): POST /v1/evals/datasets + /v1/evals/dataset-items (real
  create); forward-compatible GET list with honest unavailable card.
- Prompts (AI): forward-compatible GET /v1/prompts probe; honest deep-link card
  (no /v1 prompts route mounted yet).

Shared: ModelPicker (one way to pick a gateway model), BackendState (honest
/v1 error → card). API: lib/api/{playground,evals}.ts via the REST client.
Registry: playground+evals stubs upgraded in place (no reorder); prompts+
datasets appended. tsc --noEmit + next build both green.
2026-06-27 17:43:45 -07:00
zeekay e547011c96 feat(console2): native Traces/Sessions/Scores modules under Observe
Port the old console's observability core (Langfuse-shaped) to native
@hanzo/gui modules:
- Traces: list + detail (overview, I/O, observations, scores) at /o11y
- Sessions: list + detail (overview + traces) at /sessions
- Scores: list at /scores

All read the REAL /v1/o11y endpoints and render honest states (loading /
runtime-initializing / empty) on 503/404 — never fabricated traces or
charts. Replaces the Traces deep-link placeholder (ObservabilityModule
removed); appends Sessions + Scores entries (no reorder of existing).
tsc --noEmit and next build both pass.
2026-06-27 17:43:28 -07:00
zeekay 164f073b50 feat(console2): shared observability primitives
DRY pieces for the o11y surfaces: pure formatters (date/latency/cost/
score value/JSON), shared detail parts (DetailRow/Badge/Tags/JsonCard),
the honest RuntimeNotice (503 not-initialized / 404 unrouted / access /
error), and the list Pager. No fabricated data.
2026-06-27 17:43:16 -07:00
zeekay 6df405adcd feat(console2): o11y API client (traces/sessions/scores)
Typed client for the /v1/o11y surface over the plain-REST transport
(restGet/v1Url): list endpoints return { data, meta }, detail endpoints
one object. 503/404 surface as typed ApiError so callers render honest
states. Tenancy is server-side (cookie credentials only).
2026-06-27 17:43:16 -07:00
zeekay f50dbdd9a6 feat(console2): port settings + model catalog pages as native @hanzo/gui modules
Port hanzoai/console's deep settings + models pages into console2 as native
modules wired to the REAL /v1 + /v1/iam endpoints. Honest loading/404/401/503
states everywhere — never fabricates keys, models, or settings.

New modules (registry, appended — no reorder):
- Model Catalog (AI): real GET /v1/models + best-effort /v1/pricing/models
  overlay; provider filter, premium tier, $/Mtok columns. Read-only catalog
  (routing lives in Models, credentials in Providers).
- API Keys (Dev): the account's real cloud credential (accessKey/accessSecret
  from get-account), masked by default with explicit reveal + copy; honest
  "managed by gateway" state when no key material is exposed to the browser.
- Settings (Security): tabbed General / API Keys / Members / Branding. General
  reads real account (get-account) + org (/v1/iam/get-organization); Members
  reads /v1/iam/get-users; API Keys embeds the shared ApiKeysView (DRY);
  Branding shows the real per-host runtime config. Identity mutations deep-link
  to IAM rather than being re-implemented.

Shared + API:
- ui/States.tsx: one honest async-state renderer (honestError + ErrorState +
  asApiError) with per-surface copy overrides. AdminModule refactored onto it
  (removes its duplicate honestError/ErrorCard).
- api/models-catalog.ts: CloudModelApi over the REST (non-envelope) /v1/models.
- api/admin.ts: IamAdminApi.organization(name) single-org getter.

Shell: top-bar account name now links to /settings (canonical account menu).

Gates: tsc --noEmit exit 0; next build exit 0.

Skipped (no real console2 backend — porting as shells would be slop): Langfuse
feature-flags (2 internal flags), developer-tools (Langfuse-branded copy),
automations/batch-exports/integrations (tRPC+Prisma only). Members/Audit/
Secrets/LLM-connections/Billing already exist as IAM/Audit/KMS/Providers/Cost.
2026-06-27 17:40:53 -07:00
Hanzo AI b4274bda4a release: v0.1.8 (cumulative — supersedes parallel v0.1.7) 2026-06-27 17:37:11 -07:00
Hanzo AI c9adc16cad merge: integrate fix/paas-live-data (CTO v0.1.7 paas wiring) into main
main already supersedes it: same real platform contract (/v1/apps +
/v1/org/{org}/cluster) PLUS X-Org-Id (resource modules), Bot/Wallet honest
states, Clusters real-contract rewrite, and the PaaS token fix. Recording the
integration so the line is single; cutting v0.1.8 as the cumulative release.
2026-06-27 17:36:51 -07:00
Hanzo AI f30cdbbee5 fix(modules): wire every embedded module to the real /v1 backend + cut v0.1.7
Live Playwright verification surfaced real wiring bugs; fixed all in console2
(honest states everywhere, no fakes):

- client.ts: stamp X-Org-Id (brand org) on every cloud call — the provisioning
  service 403'd 'X-Org-Id required' on the direct cloud-api path. Fixes the 7
  data modules (vector/sql/kv/s3/datastore/docdb/search) → real data / empty.
- platform.ts: rework to the REAL platform contract — GET /v1/apps (apps
  inventory) + GET|POST /v1/org/{org}/cluster; drop dead /v1/clusters + k8s
  passthrough. Status = real health board; Kubernetes = real workloads per
  cluster; Clusters = real dedicated-DOKS list (honest empty).
- platform/state.tsx: upstream 401/403 → honest 'not configured'.
- BotModule: /v1/bot/health 404 → honest 'not routed on this host'.
- WalletModule: /v1/billing/balance 404 → honest 'not available'.
- StatusTag: understand platform health verdicts (green/yellow/red).

typecheck + build clean. Operator CR token repointed (universe) to the correct
paas-console-token (the old hanzo-paas/MASTERTOKEN is rejected by platform).
2026-06-27 17:34:03 -07:00
zeekay a307d64b07 fix(paas): wire Clusters/Kubernetes/Status to real platform /v1 surface
Build Docker Image / docker (push) Successful in 2m36s
The PaaS modules targeted an assumed platform surface (/v1/clusters,
/v1/org/{org}/cluster/{id}/k8s/{kind}) that does not exist — the live
platform.hanzo.ai/v1 REST API serves /v1/org/{org}/cluster (org-scoped
dedicated clusters) and /v1/apps (the workload/drift board). Re-point the
single data layer (src/lib/api/platform.ts) at the real endpoints:

- listClusters → /v1/org/{org}/cluster (unwrap {clusters}); org from config.
- add AppsApi.listApps → /v1/apps (unwrap {apps}) — the live deploy data.
- drop the dead k8s-browse client (no backend) + CLUSTER_ROUTES guess.

Status + Kubernetes now render the real /v1/apps workloads (cluster,
namespace, image, health) instead of gating on a non-existent k8s-browse
behind a (for hanzo) empty dedicated-cluster list. StatusTag tones the
drift-board health (healthy/warning/down). Auth is unchanged: the /paas
proxy already sends Authorization: Bearer; the fix is the token VALUE
(operator CR → paas-console-token) + these paths.
2026-06-27 17:28:37 -07:00
zeekay c32bb30154 docs: correct catalog home comment for the 3-state enablement model 2026-06-27 16:44:35 -07:00
zeekay c27a62d271 catalog: canonical 10-category Open AI Cloud (GCP-compatible) + all-services Status
Build Docker Image / docker (push) Successful in 2m33s
Rebuild the product catalog to the canonical ten categories, in order:
AI · Compute · Data · Network · Security · Dev · Deploy · Observe · Web3 · Apps.
66 entries; each names its Google Cloud equivalent. Honest 3-state enablement:
enabled (23, in-console modules) / external (16, live Hanzo surfaces) / soon (27).
Every real working module is preserved (recategorized, not broken).

Add Status (Observe): live health of every Hanzo service across clusters, from
REAL data only — composes PlatformApi.listClusters + KubernetesApi deployments
over the /paas control plane, with honest not-configured/unavailable/empty
states and no fabricated dots.

Decomplect: one coming-soon surface for all soon leaves; delete the dead
duplicate PaaS client (PlatformModule + lib/paas) and the unused coming-soon
factory. Release v0.1.6.
2026-06-27 16:43:30 -07:00
zeekay 102a37a48c release: v0.1.5
Build Docker Image / docker (push) Successful in 4m57s
Phases 1-5: nine-category catalog (mirrors hanzo.ai), in-console admin
(Identity/Secrets/Audit), Kubernetes workloads browser + real Clusters over
/paas, and an honest Observability category. tsc --noEmit + next build green.
2026-06-27 16:10:25 -07:00
zeekay 6beb670744 o11y: honest Observability category (traces/evals/prompts)
Phase 5 — add the Observability category with truthful entries, no fake charts:

- Observability (/o11y): console-native module that probes the REAL /v1/o11y
  runtime and reports status (online / not-initialized 503 / not-routed 404 /
  access / error), deep-links to the full observability surface for traces,
  evals, and prompts, and marks the native in-console browser as coming
  (HIP-0106). Never renders placeholder telemetry.
- Insights (external) + Analytics (relocated here) round out the category.

This is the staged first step for the largest old-console surface; the deeper
native port is honestly deferred, not faked.
2026-06-27 16:06:34 -07:00
zeekay 82e81ddf75 k8s: Kubernetes workloads browser + real Clusters over /paas
Phase 4 — one platform transport: route ALL control-plane calls through the
same-origin /paas proxy (server-side token injection, no CORS, honest 501
when unset) instead of direct cross-origin platform.hanzo.ai.

- platform.ts: clusters now go via /paas; add KubernetesApi over
  /v1/org/{org}/cluster/{id}/k8s/{deployments,pods,services,ingresses,events,crs}.
- KubernetesModule: cluster picker + resource tabs, defensive per-kind columns,
  honest loading/not-configured/backend-unavailable/error/empty states.
- ClustersModule: flip 'soon' -> real; render the shared honest state card for
  not-configured (501) / backend-unavailable (404).
- New shared platform/state.tsx interprets /paas errors one way.
2026-06-27 16:01:25 -07:00
zeekay 1ad8ff3fa0 admin: in-console Identity, Secrets (KMS), and Audit modules
Phase 3 — wire the console to the existing identity/secrets subsystems over
the canonical /v1 surface (HIP-0111), each an honest module:

- Identity (/iam): tabbed Organizations / Users / Roles (RBAC) over Hanzo
  IAM /v1/iam/get-{organizations,users,roles} (casdoor envelope). One generic
  AdminListView drives all three; 404/401/empty are explicit honest states.
- Audit (/audit): identity & access event log over /v1/iam/get-records.
- Secrets (/kms): KMS is zero-knowledge (encrypted names+values, token/ZAP
  auth, no list-values endpoint) — the module states the model, probes the
  real /v1/kms surface to report reachability, and deep-links to the KMS
  console. It never fabricates a secret table.

iam/kms flip from external links to modules; ext.iam/ext.kms removed.
2026-06-27 15:56:31 -07:00
zeekay 631dc417b2 catalog: mirror hanzo.ai's nine product categories
Replace the ad-hoc AI/Data/Apps/Identity/Infrastructure/Commerce grouping
with the exact nine categories + order from the marketing site product
dropdown (navigation-data.ts productsNav): AI & Agents, Developer, Apps,
Compute, Data, Async, Platform, Observability, Web3. One taxonomy across
every Hanzo surface. Empty groups don't render (catalogByCategory skips them).
2026-06-27 15:50:57 -07:00
zeekay 57371b1c9d feat(console2): Wallet & HUSD top-up module + verify-and-record endpoint; v0.1.4
Build Docker Image / docker (push) Successful in 2m47s
- WalletModule (Commerce): connect a non-custodial wallet on Hanzo Mainnet
  (36900) via ethers/EIP-1193, show wallet HUSD + cloud credit balances, and
  top up credit with HUSD. Honest states throughout (no wallet, HUSD greenfield,
  chain unreachable, endpoint unconfigured) — never a fabricated balance.
- src/lib/wallet/hanzo-evm.ts: one canonical Hanzo Mainnet + HUSD definition
  (ethers v6), env-overridable RPC; HUSD address is public (NEXT_PUBLIC), never
  a secret.
- src/lib/api/wallet.ts: cloud balance via the real GET /v1/billing/balance, and
  recordWalletTopup → the console's own POST /billing/topup/wallet.
- app/billing/topup/wallet/route.ts: server route (mirrors /paas) — verifies the
  HUSD transfer on-chain, then records to commerce as a husd crypto payment and
  credits the balance. Hosted here because billing.hanzo.ai is a static export
  and commerce is owned elsewhere. Server-only config (KMS, never NEXT_PUBLIC).
- registry: wallet entry; api/index: WalletApi export. Adds ethers 6.17.0.
2026-06-27 14:56:35 -07:00
zeekay 0076f411b0 console2: per-product discover interstitials (docs + GitHub OSS + dividends); v0.1.3
Build Docker Image / docker (push) Successful in 2m29s
'Interstitial screens to discover guides/docs/how-tos + link to GitHub OSS for
each product' + the OSS-gets-paid hook:
- /discover/<id> route + ProductInterstitial: identity + open/get-started CTA,
  Docs & guides + Open-source (GitHub) link cards, and an 'Open source gets paid'
  card stating the model (25% of cloud revenue -> OSS contributors, computed from
  each deployment's SBOM, settled on-chain in HUSD) with Contribute + OSS-dividends
  links. Catalog cards gain a Learn-more (info) affordance; non-enabled Get-started
  routes to the interstitial.
- OSS_PROGRAM constant (one source of truth: 25% / HUSD / SBOM / dividends dashboard),
  aligned with hanzo.ai sbomRevenueConfig + the commerce contributor/payout system.

tsc clean; next build green (/discover/[id] live).
2026-06-27 14:22:42 -07:00
zeekay 9b04ea848c ci: semver image tags only (no sha, no :latest); v0.1.2
Build Docker Image / docker (push) Successful in 2m32s
Per directive 'use proper semver for all, no sha': build-image.yml now tags
ghcr.io/hanzoai/console2 with the semver version only — a v* git tag publishes
that exact version, a main push publishes v<package.json version> (bump to
release). Drops the sha-<sha7> and floating :latest tags. Bump 0.1.1 -> 0.1.2
(GCP-grade resource detail + Plans & Pricing + Team launcher).
2026-06-27 13:58:21 -07:00
zeekay e30e9c7b3e console2: GCP-grade resource detail + Plans & Pricing + Team launcher
The 'manage/create databases ala GCP' + 'discover, enable, pay' surface,
all on REAL backend contracts (no invented controls):

- ResourceModule gains an instance DETAIL view (list+detail in one component,
  mirroring ProvidersModule's params.name): click a resource -> GET /v1/<kind>/<name>
  overview (status/kind/endpoint/user/db/created) + connection guidance + danger-zone
  delete. New resourceRoutes() helper binds index + :name to one instance (DRY);
  the 7 data products (sql/vector/datastore/kv/search/s3/docdb) use it. Resources
  are serverless/create-by-name (POST /v1/<kind> takes only {name}) — no fake
  tier/size/region knobs.
- PlansModule: GCP-style Plans & Pricing on the live rate card (GET /v1/pricing):
  per-tier cards (vCPU/RAM/SSD/transfer, monthly+hourly, feature bullets, free/popular
  badges) + block-storage metering. Paying delegates to config.billingUrl (the one
  money surface, never reimplemented). New typed PlansApi (plans.ts).
- Team launcher: hanzo.team catalog entry (Apps, external team.hanzo.ai).

tsc --noEmit clean; next build green (9/9).
2026-06-27 13:26:41 -07:00
zeekay f057352fc3 feat(console2): Bot module — in-console /v1/bot status + operator deep-links
Flip the existing 'bot' catalog entry from an external link to an in-console
module. New BotModule reports live gateway status from /v1/bot/health (via a
small BotApi on the REST layer, since the bot speaks plain JSON, not the
casibase envelope) and deep-links the operator surfaces (bot home, control UI,
docs, source). Backend (hanzoai/bot -> bot-gateway) is already routed at
/v1/bot/* by the unified gateway.
2026-06-27 12:45:58 -07:00
zeekay a0db49f282 fix(console2): cloud fallback → api.hanzo.ai (gated gateway), not the SPA host
Found via testing: cloud.hanzo.ai serves the SPA catch-all (200 text/html for any /v1 path), NOT the backend; the real /v1 is behind the unified gateway api.hanzo.ai (hanzoai/ingress→hanzoai/gateway v2.13.0 → cloud / separate services / per-org k8s, rate-limited+gated+priced). Browser uses same-origin /v1 (each console host's ingress proxies to the gateway); this SSR fallback now points at the gateway.
2026-06-27 12:21:14 -07:00
zeekay c09943eb08 feat(console2): one image, multi-brand by hostname (hanzo/lux/zoo)
Resolves the brand at RUNTIME from the request hostname (console.hanzo.ai→hanzo, console.lux.cloud→lux, console.zoo.cloud→zoo). Tenancy: ONE cloud /v1 backend serves all orgs (same-origin per host → first-party cookie, no CORS), each brand authenticates against its OWN live IAM (hanzo.id / lux.id / zoolabs.id / pars.id; client_id <org>-cloud per HIP-0111). config is a brand-aware Proxy so the /v1 client + IAM SDK go per-host with no consumer changes; cloudUrl is same-origin (window.origin); NEXT_PUBLIC_* still override. Dockerfile + CI no longer bake NEXT_PUBLIC_* (that pinned one brand) → one brand-agnostic image (sha-<sha7>+latest). Fixed a 'Hanzo' subtitle brand-leak. Verified: tsc clean + next build green. NOTE: cloud backend must accept all brand issuers/auds; each console host's ingress must proxy /v1 to the backend + register the redirect URI in that brand's IAM app.
2026-06-27 12:11:09 -07:00
Hanzo AI 0e2ee1f394 feat(console2): brand polish — favicon, monochrome, sized loader, social sign-in
- favicon: canonical hanzo.app ▼/H mark via app-router icon files
  (favicon.ico, icon.svg, apple-icon.png); viewport themeColor #000000
- loader: monochrome HanzoMark ~40–48px centered (not viewport-filling),
  replacing the oversized @hanzo/gui Spinner in AuthGate + OAuth callback
- monochrome: black/white/grey chrome, white (theme="light") primary
  buttons; drop every blue/green/red/yellow/violet theme + $color accent
  across products, status tags, dashboard badges, default store color
- sign-in: dedicated GitHub + Google buttons (provider_hint) + Hanzo ID,
  all via hanzo.id OIDC (client_id=hanzo-cloud); IAM owns provider OAuth,
  console never reconstructs github.com/accounts.google.com URLs
2026-06-26 19:22:26 -07:00
Hanzo AI c6f55d8646 docs(LLM): canonical issuer hanzo.id + hanzo-cloud app + deploy reality [skip ci] 2026-06-26 18:27:53 -07:00
Hanzo AI 14519c41c5 fix(ci): emit sha+latest tags via GITHUB_OUTPUT heredoc
The mainnet branch wrote a two-line tags value with $'\n', which GitHub
Actions rejects for key=value outputs ("Invalid format ...:latest") —
so every Build Docker Image run failed at 'Compute per-env config' and
no image was ever produced by CI. Use the multi-line heredoc output form
so build-push-action receives both newline-separated tags.
2026-06-26 18:15:50 -07:00
Hanzo AI 1f80429f97 fix(auth): sign-in via canonical issuer hanzo.id, not iam.hanzo.ai
NEXT_PUBLIC_IAM_URL is inlined at build time, so the deployed image sent
the browser authorize redirect to https://iam.hanzo.ai — which mints
iss=https://iam.hanzo.ai, a different issuer than the canonical
https://hanzo.id that the cloud /v1 backend validates. Sign-in dropped
on iam.hanzo.ai and never round-tripped back to console2.

- src/config, .env.example, Dockerfile ARG, mainnet CI build-arg: point
  the browser at https://hanzo.id (the value baked into the image).
- Align iamAppName/iamClientId source defaults to hanzo-cloud (was the
  stale hanzo-console / empty), matching the cloud-api backend binding
  (iamApplication / IAM_AUDIENCE = hanzo-cloud). A default build now
  targets the correct app instead of a non-existent one with no client.

App/client stay hanzo-cloud on purpose: console2 is a front-end of the
shared cloud /v1 backend, which exchanges the code and validates aud as
hanzo-cloud — it is not its own IAM principal.
2026-06-26 18:14:12 -07:00
Hanzo AI 800b3481d7 feat(console2): 10-category cloud axis + embedded PaaS, no fakes
Job 2: reorganize the catalog into the canonical 10-category CLOUD AXIS
(AI/Compute/Data/Network/Security/Dev/Deploy/Observe/Chain/Apps) so console2
reads like a cloud console. Three entry kinds (module/external/soon) => zero
dead links, zero fakes; 'soon' primitives render an honest ComingSoon overview.

Job 3: PaaS embedded natively under Deploy (PlatformModule) wired to the real
platform.hanzo.ai control plane via a same-origin /paas proxy (service token
server-side from KMS). Real apps + declared/running/drift + redeploy; honest
loading/not-configured/empty states.

Job 4: catalog honest by construction; PaaS shows only real data. No placeholder
cards, demo projects, or lorem stats.
2026-06-26 14:48:16 -07:00
Hanzo AI 7e3fa15425 Merge feat/cloud-taxonomy-10cat into main (union)
Union merge — feature's categorized catalog architecture is canonical, with
main's data/storage products + status badges folded in (lose nothing):

- registry.tsx: feature's CatalogEntry discriminated union (category + kind +
  admin + derived productModules) as the base; FOLD IN main's data/storage cloud
  products (vector, sql, datastore, kv, search, s3, docdb, base, clusters) as
  categorized 'module' entries via resourceModule/comingSoon — these are the
  console frontend for cloud's /v1 provisioning control plane. Extend
  ProductStatus to 'enabled'|'available'|'soon'|'waitlist' and add repo? so the
  folded products keep their status badges. Resolve the id 'search' collision:
  managed Search data product keeps id 'search' (owns the route, maps to the
  provisioning kind); feature's external search.hanzo.ai becomes 'ai-search'.
- DashboardShell.tsx: feature's Pinned + categorized NavRow shell; FOLD main's
  SOON/WAITLIST status badge into NavRow (feature had dropped it).
- page.tsx: feature's grouped ProductCard grid; extend StatusBadge to render
  Soon/Waitlist (blue/yellow) alongside Enabled/Available (folds main's badge).

Verify: tsc --noEmit strict CLEAN; next build green (6/6 pages). The repo's
untracked PaaS WIP (ComingSoon/PlatformModule/paas — another agent's, in neither
branch) is left untouched and excluded from this commit.
2026-06-26 14:47:11 -07:00
07db3af7fe feat(products): register full data/storage catalog + enablement status (#1)
* feat(products): register full data/storage catalog + enablement status

Adds `status` ('enabled'|'soon'|'waitlist') + `repo` to every ProductModule so
the console tracks enablement of all Hanzo cloud products in ONE place. Registers
the OSS-Google-Cloud data/storage suite as modules — Vector, SQL, Datastore, KV,
Search, S3, Base (soon) and DocDB (waitlist) — each a ZAP-native Hanzo fork mapped
to its repo, with a ComingSoon placeholder (repo link + status) until its admin
module lands. Nav + dashboard cards render the status badge.

Products → repos: Vector=hanzoai/vector (Qdrant-compat), SQL=hanzoai/sql,
Datastore=hanzoai/datastore (ClickHouse-compat), KV=hanzoai/kv (Redis-compat),
Search=hanzoai/search, S3=hanzoai/s3, Base=hanzoai/base, DocDB=hanzoai/docdb.

* feat(products): add Clusters module (shared Hanzo Cloud vs BYO DOKS)

The data/storage products (Vector/SQL/Datastore/KV/Search/S3/Base/DocDB) are
all live on hanzo-k8s via the operator. Adds the one new control-plane surface
the platform needs: Clusters — choose where workloads run (shared multi-tenant
Hanzo Cloud, or your own/Hanzo-provisioned DOKS cluster, reconciled by the same
operator). Deploy-any-repo stays under Applications.

* feat(products): working data/storage + clusters admin modules

Replace the comingSoon() placeholders for sql/vector/datastore/kv/search/s3/
docdb with a DRY resourceModule() factory over the provisioning REST contract
(POST/GET/DELETE /v1/<kind>): list, create-with-once-shown connectionString +
password reveal ("store this now"), and per-row delete. Tenancy is server-side
(gateway injects X-Org-Id), so the browser sends cookie creds only.

- lib/api/client.ts: plain-REST helpers (restGet/restPost/restDelete + v1Url)
  beside the casibase envelope path — provisioning + platform speak raw JSON /
  201 / 204 / DELETE, reusing the same cookie creds + ApiError. One transport.
- lib/api/provisioning.ts: ProvisioningApi keyed by ResourceKind.
- lib/api/platform.ts: PlatformApi (DOKS clusters) with centralized
  CLUSTER_ROUTES + DOKS region/size options; base = NEXT_PUBLIC_PLATFORM_URL.
- components/products/ResourceModule.tsx: the factory (list/create/delete,
  copyable masked secret reveal, slug validation, loading/empty/error).
- components/products/ClustersModule.tsx: list + provision DOKS + attach (name
  + kubeconfig) against the platform control plane.
- components/ui/StatusTag.tsx, lib/slug.ts: shared/DRY across both modules.
- registry: swap routes to the working modules and clear 'soon'/'waitlist'
  status so nav badges clear. Base stays a placeholder (no single-resource).
- fix two pre-existing type errors in ComingSoonModule (maxWidth->maxW,
  theme active->blue) so the branch typechecks clean.

kind map: sql->databases, vector->vector, datastore->datastore, kv->kv,
search->search, s3->storage, docdb->docdb.

Verified: npm ci + tsc --noEmit => 0 errors (CI-equivalent flat install).

* console: native product naming + gate Clusters to coming-soon

Native naming (Hanzo brand rule — product name only, no upstream OSS
name in any surface):
- ResourceKind wire kinds align to the cloud /v1 contract exactly:
  databases→sql, storage→s3 (vector/datastore/kv/search/docdb unchanged).
- Strip every upstream name from product descriptions + connectionHints
  (Postgres/Qdrant/ClickHouse/Redis/Meilisearch/Mongo/-compatible → the
  Hanzo product name). Zero forbidden names remain in registry.tsx.

Clusters: register as status:'soon' rendering the coming-soon placeholder
(repo hanzoai/operator). The platform attach/provision endpoints in
platform.ts are unconfirmed (PR not merged), so we don't ship a button to
a non-existent endpoint. ClustersModule.tsx + platform.ts stay in the tree
(unreferenced from the live route) to flip back to enabled in one line once
the platform surface lands. Data products (sql/vector/datastore/kv/search/
s3/docdb) stay ENABLED — their /v1 kinds are real and shipping now.

tsc --noEmit: clean.

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-26 13:20:20 -07:00
Hanzo AI 85e68e433e feat(console): unified product hub — catalog, pinnable favorites, account-backed prefs
Turn console2 into the one place to see, enable, and manage every Hanzo
product, with billing → billing.hanzo.ai for all.

- registry: ONE product catalog (categories + module-vs-external + enablement
  status), the single source of truth for nav, overview, and router. Adding a
  product = one CatalogEntry.
- favorites: pin products to the sidebar. Built on a new account-backed
  preferences layer (usePreferences) — customizations persist to the IAM user
  account, so they follow the user across every device/login and product.
  localStorage is only a fast-paint cache.
- shell: Pinned section + categorized catalog; exact-match active (no
  double-highlight); each row opens (in-console route or external tab) + pin toggle.
- overview: product catalog grouped by category with status, pin, and
  Open/Get-started.
- api: AccountApi.updatePreferences → POST /v1/update-preferences (self-scoped).

Typecheck + next build clean.
2026-06-26 11:15:21 -07:00
Hanzo AI 5527bb30dd fix(docker): npm install over npm ci for the @hanzo/gui dep tree
npm ci failed in CI (node:22-alpine npm 10.9) with EUSAGE 'Missing:
react-native-worklets@0.8.3 from lock file' — @hanzo/gui's react-native
optional/platform deps resolve differently across npm versions, so a
lockfile built by one npm is rejected by another's strict ci. npm install
reconciles deterministically for the build platform.
2026-06-25 13:57:42 -07:00
Hanzo AI 1b03d838db deps(console2): declare @zap-proto/web, @zap-proto/zap, superjson
The ZAP-native data layer (src/lib/zap/{client,transport,providers}.ts)
imports @zap-proto/web/client, @zap-proto/zap, and superjson; declare them
so a clean `npm ci` in CI resolves them (the prior build failed: 'Module
not found: @zap-proto/web/client' because the lockfile lacked them).
2026-06-25 13:48:28 -07:00
Hanzo AI 42f591214c feat(providers): cut Providers module to ZAP-native transport
Swap the Providers views from the REST `~/lib/api` to the ZAP-native
`~/lib/zap` (one import line each) — proving the @hanzo/gui + @zap-proto/web
go-forward: same call surface, binary ZAP over WebSocket instead of
JSON-over-HTTP, zero view-component changes.

- src/lib/zap/index.ts: barrel re-exporting ProviderApi (= ProviderApiZap),
  ApiError, Provider — the drop-in twin of ~/lib/api.
- ProviderListView/ProviderEditView: import from ~/lib/zap.
- providers.ts list(): return { rows, total } to match the REST getList
  contract exactly (true drop-in; fixes the list-view shape).

tsc --noEmit (strict) clean. Backs onto cloud's new /zap WS face which
dispatches each call into the same /v1 casibase handlers.
2026-06-25 13:44:26 -07:00
Antje Worring 668f6b8951 ci: env-aware image build (mainnet/testnet/devnet)
workflow_dispatch input 'env' bakes per-env NEXT_PUBLIC_* + tags the image
:dev/:test (mainnet stays :sha-+:latest). Hosts follow svc.env.hanzo.ai. Adds
NEXT_PUBLIC_BILLING_URL build-arg (per-env billing portal).
2026-06-22 03:17:50 -07:00
Antje Worring 7895e104b0 feat(nav): Billing link to the existing billing portal (no rebuild)
Billing is owned by hanzoai/commerce (backend) + hanzoai/billing (portal at
billing.hanzo.ai) — the console must not reimplement payments/balance. Add an
externalLinks registry (orthogonal to product modules) and render it in the
shell; Billing opens config.billingUrl (NEXT_PUBLIC_BILLING_URL, default
https://billing.hanzo.ai) in a new tab.
2026-06-22 02:44:56 -07:00
Antje Worring bfd2a2dd37 fix(auth): treat casibase anonymous-user as logged-out
The backend auto-creates an anonymous-user session for the chat product, so
get-account returns status:ok even with no real sign-in. As an ADMIN console
that made AuthGate show the dashboard for an unauthenticated visitor, while
admin endpoints (get-providers, etc.) rejected the anon session with 'Please
sign in first'. Treat type==='anonymous-user' as null so the console requires
a real IAM sign-in.
2026-06-22 00:39:44 -07:00
Antje Worring cf5d3094ae feat(console2): Models, Applications, Stores, Chat admin surfaces (@hanzo/gui /v1)
Mirror the Providers pattern: product-module + list/edit views on @hanzo/gui +
typed /v1 API modules, registered in the nav. tsc 0 errors, next build green.
2026-06-21 15:58:05 -07:00
Antje Worring 7a3dd59128 fix(ci): ensure public/ exists in image build + skip provenance artifacts
- Dockerfile: mkdir -p public before build (git doesn't track empty public/,
  so the runner COPY /app/public was failing)
- build-image: provenance/sbom false (avoid GitHub artifact-quota upload)
2026-06-21 15:05:32 -07:00
Antje Worring f117a01fac ci: Dockerfile + build-image workflow → ghcr.io/hanzoai/console2
Next.js 15 multi-stage build; NEXT_PUBLIC_* baked at build (same-origin /v1,
IAM client_id=hanzo-cloud to match cloud-api's /v1/signin). Self-hosted ARC
runner, push to ghcr.io/hanzoai/console2.
2026-06-21 15:01:36 -07:00
hanzo-dev 2b4d64c9cf feat: Hanzo Cloud Console (console2) on @hanzo/gui over the unified /v1 backend
Next.js 15 (app router) + @hanzo/gui consumed at runtime via transpilePackages.
Typed /v1 client (Provider/ModelRoute/Application/Store/Chat/Account), Hanzo IAM
OIDC auth, extensible product-module registry, and a full Providers admin surface
(list + view/edit) ported clean from hanzoai/ai onto Gui. BSD-3-Clause.
2026-06-21 14:58:20 -07:00
3823 changed files with 126705 additions and 646446 deletions
-175
View File
@@ -1,175 +0,0 @@
# Agent Guidelines for Langfuse
This is the canonical root agent guide for the repo. The root `AGENTS.md`
should remain only as a discovery symlink so tools that require that filename
continue to work while `.agents/` stays the source of truth.
Langfuse is an open source LLM engineering platform for developing, monitoring,
evaluating, and debugging AI applications.
## Maintenance Contract
- `AGENTS.md` is a living document.
- Keep this file concise and router-like. Push narrow or conditional workflows
into package-local `AGENTS.md` files or shared skills under `skills/`.
- Update this file in the same PR when monorepo-level architecture, workflows,
dependency boundaries, mandatory verification commands, or release/security
processes materially change.
- Update this file and the relevant shared skills when user feedback introduces
a durable repo-level default for future agents. Do not edit this file for
one-off task preferences.
- For package-local material changes, update the nearest package `AGENTS.md` in
the same PR.
## Start Here By Task
- Repo-wide agent setup, `.agents/**`, provider shims, or MCP/bootstrap config:
[`README.md`](README.md),
[`skills/agent-setup-maintenance/SKILL.md`](skills/agent-setup-maintenance/SKILL.md)
- Backend/API work in `web/src/server/**`, `web/src/pages/api/public/**`,
`worker/src/**`, or `packages/shared/src/**`:
[`skills/backend-dev-guidelines/SKILL.md`](skills/backend-dev-guidelines/SKILL.md)
- Model pricing work in `worker/src/constants/default-model-prices.json`,
`packages/shared/src/server/llm/types.ts`, or related pricing files:
[`skills/add-model-price/SKILL.md`](skills/add-model-price/SKILL.md)
- Code review tasks:
[`skills/code-review/SKILL.md`](skills/code-review/SKILL.md)
- Changelog drafting for completed feature branches:
[`skills/changelog-writing/SKILL.md`](skills/changelog-writing/SKILL.md)
- ClickHouse schema/query review:
[`skills/clickhouse-best-practices/SKILL.md`](skills/clickhouse-best-practices/SKILL.md)
- Monorepo/Turbo task graph changes:
[`skills/turborepo/SKILL.md`](skills/turborepo/SKILL.md)
- User-visible frontend changes, Playwright review, or browser signoff:
[`skills/frontend-browser-review/SKILL.md`](skills/frontend-browser-review/SKILL.md)
- Web UI and frontend entry points:
`../web/AGENTS.md`
- Worker queues and processors:
`../worker/AGENTS.md`
- Shared contracts, exports, schema, and migrations:
`../packages/shared/AGENTS.md`
- EE-only work:
`../ee/AGENTS.md`
Read the minimal set required for the task. More-specific package guides and
shared skills take precedence over this root file for their scoped areas.
## Project Structure
```text
langfuse/
├─ web/ # Next.js app (UI + tRPC + public REST)
├─ worker/ # Queue consumers and background processing
├─ packages/shared/ # Shared domain, DB, queue contracts, repositories
├─ ee/ # Enterprise package consumed by web
├─ generated/ # Generated API clients (do not hand-edit)
├─ fern/ # API definition sources
└─ scripts/ # Repo scripts
```
- Dependency direction:
- `web` -> `@langfuse/shared`, `@langfuse/ee`
- `worker` -> `@langfuse/shared`
- `@langfuse/ee` -> `@langfuse/shared`
- `@langfuse/shared` -> no imports from `web`, `worker`, or `ee`
- Queue payload schemas and queue-name contracts are owned by
`packages/shared/src/server/queues.ts`.
- High-signal shared entry points:
- Domain models: `packages/shared/src/domain/{observations,traces,scores}.ts`
- Postgres schema: `packages/shared/prisma/schema.prisma`
- ClickHouse migrations:
`packages/shared/clickhouse/migrations/{clustered,unclustered}/*.sql`
- Architecture handbook:
[langfuse.com/handbook/product-engineering/architecture](https://langfuse.com/handbook/product-engineering/architecture)
with source markdown in
`../langfuse-docs/content/handbook/product-engineering/architecture.mdx`
## Core Commands
- Install deps: `pnpm install`
- Dev all packages: `pnpm run dev`
- Dev web only: `pnpm run dev:web`
- Dev worker only: `pnpm run dev:worker`
- Lint all: `pnpm run lint`
- Typecheck all: `pnpm run typecheck` / `pnpm tc`
- Build check: `pnpm run build:check`
- Full build: `pnpm run build`
- Full reset/bootstrap (destructive): `pnpm run dx`
- Codex environment bootstrap: `bash scripts/codex/setup.sh`
- Codex environment maintenance: `bash scripts/codex/maintenance.sh`
- Install Playwright Chromium for agent browser review: `pnpm run playwright:install`
Minimum verification matrix:
| Change scope | Minimum verification |
| --- | --- |
| `web/**` only | `pnpm --filter web run lint` + targeted web tests |
| `worker/**` only | `pnpm --filter worker run lint` + targeted worker tests |
| `packages/shared/**` (non-schema) | `pnpm --filter @langfuse/shared run lint` + one targeted web check + one targeted worker check |
| `packages/shared/prisma/**` or `packages/shared/clickhouse/**` | `pnpm --filter @langfuse/shared run lint` + `pnpm run db:generate` + targeted web/worker regressions |
| Public API contract (`web/src/pages/api/public/**`, `web/src/features/public-api/types/**`, `fern/apis/**`) | web lint + targeted server API tests + Fern update/regeneration; never hand-edit `generated/**` |
| Cross-package refactor (`web` + `worker` + `shared`) | `pnpm run lint` + `pnpm run typecheck` + targeted tests per impacted package |
## Repo Rules
- Keep changes scoped; avoid unrelated refactors.
- Prefer package-local implementation details in package `AGENTS.md` files.
- Do not hand-edit generated/build artifacts:
- `generated/*`
- `web/.next/*`
- `web/.next-check/*`
- `*/dist/*`
- `packages/shared/prisma/generated/*`
- Public API contract changes must update Fern sources in `fern/apis/**` and
regenerated outputs; never hand-edit `generated/**`.
- Keep tests independent and parallel-safe.
- For bug fixes, write the failing test first, confirm it fails, then fix the
bug.
- For user-visible frontend changes in `web/**`, review the affected flow in a
real browser with the Playwright MCP server before signoff. Use
`skills/frontend-browser-review/SKILL.md` and `../web/AGENTS.md` for the
browser-review loop.
- Never commit secrets or credentials. Keep `.env*.example` files in sync with
required env vars.
## Shared Agent Setup
- `.agents/AGENTS.md` is the canonical root guide.
- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`.
- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`.
- Shared agent/tool config lives in `config.json` and shared skills live in
`skills/`.
- Project-scoped provider discovery files are generated local artifacts. Edit
the canonical files under `.agents/` instead of editing generated tool
directories by hand.
- If you change `.agents/config.json`, `skills/**`, or the shim-generation
workflow, run:
- `pnpm run agents:sync`
- `pnpm run agents:check`
- Do not commit generated provider config or shim outputs under `.claude/`,
`.cursor/`, `.codex/`, `.vscode/`, or `.mcp.json`.
- Durable cross-tool guidance belongs in root/package `AGENTS.md` files or
`skills/**`, not only in tool-specific config directories.
## Commit, PR, and Release Rules
- Commit messages and PR titles must follow Conventional Commits:
`type(scope): description` or `type: description`.
- PR titles are validated by `.github/workflows/validate-pr-title.yml`.
- In PR descriptions, list impacted packages and executed verification commands.
- Release workflow is managed at root with `pnpm run release`.
- Promote `main` to `production` via
`.github/workflows/promote-main-to-production.yml` or
`pnpm run release:cloud`.
- Do not change release/versioning flow without updating this file and impacted
package guides.
## Git and Tooling Notes
- Use `gh search issues` for GitHub issue search.
- Do not use destructive git commands such as `reset --hard` unless explicitly
requested.
- Do not revert unrelated working-tree changes.
- Keep commits focused and atomic.
- Remaining `.cursor/rules/*.mdc` files should stay thin wrappers around shared
docs or skills rather than owning durable repo guidance directly.
-181
View File
@@ -1,181 +0,0 @@
# Shared Agent Setup
This directory is the neutral, repo-owned source of truth for agent behavior in
Langfuse.
Use `.agents/` for configuration and guidance that should apply across tools.
Do not put durable shared guidance only in `.claude/`, `.codex/`, `.cursor/`,
or `.vscode/`.
## Layout
- `AGENTS.md`: canonical shared root instructions
- `config.json`: shared bootstrap and MCP configuration used to generate
tool-specific shims
- `skills/`: shared, tool-neutral implementation guidance for recurring
workflows
## `config.json`
`.agents/config.json` contains four kinds of data:
- `shared`: defaults used across tools
- `mcpServers`: project MCP servers and how to connect to them
- `claude`: Claude-specific generated settings inputs
- `codex`: Codex-specific generated settings inputs
- `cursor`: Cursor-specific generated settings inputs
Current shape:
```json
{
"shared": {
"setupScript": "bash scripts/codex/setup.sh",
"devCommand": "pnpm run dev",
"devTerminalDescription": "Main development terminal running the development server"
},
"mcpServers": {
"playwright": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
},
"datadog": {
"transport": "http",
"url": "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp"
}
},
"claude": {
"settings": {}
},
"codex": {
"environment": {
"version": 1,
"name": "langfuse"
}
},
"cursor": {
"environment": {
"agentCanUpdateSnapshot": false
}
}
}
```
## How Shims Are Generated
`scripts/agents/sync-agent-shims.mjs` reads `.agents/config.json` and writes the
tool discovery files that those products require.
Generated local artifacts:
- `.claude/settings.json`
- `.claude/skills/*`
- `.cursor/environment.json`
- `.cursor/mcp.json`
- `.vscode/mcp.json`
- `.mcp.json`
- `.codex/config.toml`
- `.codex/environments/environment.toml`
The repo root discovery files remain committed as symlinks:
- `AGENTS.md` -> `.agents/AGENTS.md`
- `CLAUDE.md` -> `AGENTS.md`
This keeps provider discovery stable while `.agents/` remains the source of
truth.
## When To Edit `config.json`
Edit `.agents/config.json` when you need to:
- add, remove, or update a shared MCP server
- change the shared setup/bootstrap command
- change the default dev command or terminal label used by generated shims
- adjust generated Claude, Cursor, or Codex settings that are intentionally
modeled in the shared config
Do not edit generated shim files by hand. Edit the canonical files in
`.agents/` instead.
## How To Extend `config.json`
### Add an MCP server
Add a new entry under `mcpServers`.
For `stdio` servers:
```json
{
"mcpServers": {
"example": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "some-package"]
}
}
}
```
For HTTP servers:
```json
{
"mcpServers": {
"example": {
"transport": "http",
"url": "https://example.com/mcp"
}
}
}
```
Optional fields:
- `env` for `stdio` servers
- `headers` for HTTP servers
### Change bootstrap or default dev command
Update values in `shared`:
- `setupScript`
- `devCommand`
- `devTerminalDescription`
### Add tool-specific generated inputs
Only add tool-specific fields when they are required to generate a discovery
file for a supported tool. Keep the shared config minimal and neutral.
## Workflow
After editing `.agents/config.json`:
1. Run `pnpm run agents:sync`
2. Run `pnpm run agents:check`
3. Verify you did not stage any generated files under `.claude/skills/` or the
generated MCP/runtime config paths
4. Update `AGENTS.md` or `CONTRIBUTING.md` if the shared workflow materially
changed
`pnpm install` also runs the sync/check flow via `postinstall`.
## Adding Shared Skills
Shared skills live under `.agents/skills/`.
Use them for durable, reusable guidance such as:
- backend implementation patterns
- provider-specific maintenance workflows
- repeated repo-specific review checklists
Do not use skills for one-off task notes or tool runtime configuration.
`pnpm run agents:sync` projects the shared skills into `.claude/skills/` so
Claude can discover the same repo-owned skills.
For the skill authoring workflow, see [skills/README.md](skills/README.md).
-59
View File
@@ -1,59 +0,0 @@
{
"shared": {
"setupScript": "bash scripts/codex/setup.sh",
"devCommand": "pnpm run dev",
"devTerminalDescription": "Main development terminal running the development server"
},
"mcpServers": {
"playwright": {
"transport": "stdio",
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest",
"--isolated",
"--save-trace",
"--output-dir",
".playwright-mcp",
"--test-id-attribute",
"data-testid"
]
},
"langfuse-docs": {
"transport": "http",
"url": "https://langfuse.com/api/mcp"
},
"linear": {
"transport": "http",
"url": "https://mcp.linear.app/mcp"
}
},
"claude": {
"settings": {
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": []
},
"enableAllProjectMcpServers": true
}
},
"codex": {
"environment": {
"version": 1,
"name": "langfuse"
}
},
"cursor": {
"environment": {
"agentCanUpdateSnapshot": false
}
}
}
-109
View File
@@ -1,109 +0,0 @@
# Shared Skills
Shared repo skills for any coding agent working in Langfuse.
Use these from `AGENTS.md`. Claude Code reaches the same shared instructions via
the root `CLAUDE.md` compatibility symlink. Shared skills should stay focused on
reusable implementation guidance rather than runtime automation.
For the shared agent config and generated shim model, start with
[`../README.md`](../README.md).
Claude discovers these shared skills through symlinks under `.claude/skills/`.
Those discovery links are created and verified by `pnpm run agents:sync` and
`pnpm run agents:check`.
Shared skills should use progressive disclosure:
- `SKILL.md` is the short entrypoint with trigger guidance and navigation.
- `AGENTS.md` is optional and should stay concise when it exists.
- `references/` holds focused prose references that agents should open only
when the task needs them.
- `scripts/` holds deterministic helpers for repetitive or fragile steps.
## Available Skills
### agent-setup-maintenance
Use for:
- `.agents/config.json`, `.agents/AGENTS.md`, or `.agents/README.md`
- shared skill additions or shared skill routing changes
- generated shim behavior in `scripts/agents/sync-agent-shims.mjs`
- install-time agent sync behavior and provider discovery paths
Open: [agent-setup-maintenance/SKILL.md](agent-setup-maintenance/SKILL.md)
### frontend-browser-review
Use for:
- user-visible changes in `web/**`
- Playwright MCP browser review before signoff
- checking visible regressions in layout, styling, navigation, or responsive behavior
Open: [frontend-browser-review/SKILL.md](frontend-browser-review/SKILL.md)
### backend-dev-guidelines
Use for:
- tRPC routers and procedures
- public API endpoints
- worker queue processors
- Prisma and ClickHouse backed services
- backend auth, validation, observability, and tests
Open: [backend-dev-guidelines/SKILL.md](backend-dev-guidelines/SKILL.md)
### add-model-price
Use for:
- `worker/src/constants/default-model-prices.json`
- `packages/shared/src/server/llm/types.ts`
- pricing tiers, tokenizer IDs, and model `matchPattern` changes
Open: [add-model-price/SKILL.md](add-model-price/SKILL.md)
### code-review
Use for:
- PR or branch review
- correctness, regression, and risk-focused review tasks
- applying the repo-specific review policy in
`code-review/references/review-checklist.md`
Open: [code-review/SKILL.md](code-review/SKILL.md)
### changelog-writing
Use for:
- changelog entries for completed features
- drafting user-facing release notes
- checking related docs links for changelog posts
Open: [changelog-writing/SKILL.md](changelog-writing/SKILL.md)
## Adding a New Shared Skill
1. Codex may create or refine shared skills under `.agents/skills/` when a
repo-specific workflow becomes repeated enough to justify durable guidance.
2. Create a concise `.agents/skills/<skill-name>/SKILL.md`.
3. Add `.agents/skills/<skill-name>/AGENTS.md` only when the skill benefits
from a short router or checklist on top of `SKILL.md`.
4. Prefer `references/` for detailed prose and `scripts/` for deterministic
execution helpers.
5. Keep the skill tightly scoped to one domain or workflow.
6. Link the skill from `AGENTS.md` if it is relevant across the repo.
7. Run `pnpm run agents:sync` and `pnpm run agents:check` so Claude's projected
`.claude/skills/` view stays in sync.
8. Update `AGENTS.md` or package-local `AGENTS.md` if the new skill changes the
default reusable workflow for future agents.
9. Run the relevant verification for the package or workflow the skill affects.
## Skill Design Rules
- Keep the skill tool-neutral.
- Use `SKILL.md` as the short entrypoint, not the full knowledge dump.
- Prefer `references/` for deeper docs and `scripts/` for deterministic helpers.
- Avoid copying large sections of repo docs into the skill when a stable link is
enough.
- If the skill is web- or package-specific, link the nearest package
`AGENTS.md` or package docs instead of restating them.
-544
View File
@@ -1,544 +0,0 @@
# Add Model Price
Guide for adding or updating model pricing entries in Langfuse. Use this when
editing `worker/src/constants/default-model-prices.json`,
`packages/shared/src/server/llm/types.ts`, model `matchPattern` values,
tokenizer IDs, or pricing tiers.
## Purpose
This guide keeps model pricing changes consistent across providers and runtime
surfaces so Langfuse can calculate token costs accurately.
## How to Use This Skill
1. Read [references/schema-and-tiers.md](references/schema-and-tiers.md) for
the JSON shape and pricing-tier rules.
2. Read
[references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md)
for official pricing URLs, per-token conversion, and provider-specific usage
keys.
3. Read [references/match-patterns.md](references/match-patterns.md) when you
need to add or expand regex coverage.
4. Read
[references/workflow-and-validation.md](references/workflow-and-validation.md)
for the end-to-end edit workflow, validation rules, and common mistakes.
## Deterministic Helpers
- Validate the pricing file:
`node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs`
- Test a regex directly:
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --pattern '(?i)^(openai/)?(gpt-4o)$' --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini`
- Test the regex for an existing model entry:
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model gpt-4o --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini`
## Quick Start Checklist
### Adding a New Model
- [ ] Gather official pricing from the provider documentation
- [ ] Generate a lowercase UUID for the model entry
- [ ] Create a `matchPattern` that covers supported provider formats
- [ ] Add at least one default pricing tier
- [ ] Insert the pricing entry into
`worker/src/constants/default-model-prices.json`
- [ ] Update `packages/shared/src/server/llm/types.ts` if the model should be
selectable in playground or evaluation flows
- [ ] Validate the JSON after editing
### Updating an Existing Model
- [ ] Update the relevant prices, keys, tiers, or regexes
- [ ] Refresh `updatedAt` to today's ISO-8601 timestamp
- [ ] Validate the JSON after editing
## Target Files
- Pricing data:
`worker/src/constants/default-model-prices.json`
- Shared model types:
`packages/shared/src/server/llm/types.ts`
- Validation logic:
`packages/shared/src/features/model-pricing/validation.ts`
- Matching logic:
`packages/shared/src/server/pricing-tiers/matcher.ts`
- Tests:
`worker/src/__tests__/pricing-tier-matcher.test.ts`
## Data Structure
### Complete Model Entry Schema
```json
{
"id": "uuid-generated-with-uuidgen",
"modelName": "model-name-identifier",
"matchPattern": "(?i)^regex-pattern$",
"createdAt": "ISO-8601-timestamp",
"updatedAt": "ISO-8601-timestamp",
"tokenizerConfig": null,
"tokenizerId": "claude|openai|null",
"pricingTiers": [
{
"id": "model-uuid_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.000005,
"output": 0.000025
}
}
]
}
```
### Required Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Unique lowercase UUID |
| `modelName` | string | Primary model identifier |
| `matchPattern` | string | Regex for matching model names |
| `createdAt` | string | ISO-8601 timestamp set on creation |
| `updatedAt` | string | ISO-8601 timestamp refreshed whenever the entry changes |
| `pricingTiers` | array | At least one pricing tier |
### Optional Fields
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `tokenizerId` | string | `null` | `"claude"`, `"openai"`, or `null` |
| `tokenizerConfig` | object | `null` | Custom tokenizer settings |
## Pricing Tier Structure
### Default Tier
Every model must have exactly one default tier:
```json
{
"id": "{model-id}_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {}
}
```
Rules for the default tier:
- `isDefault` must be `true`
- `priority` must be `0`
- `conditions` must be `[]`
### Additional Tiers
Use extra tiers for context-window or usage-based pricing:
```json
{
"id": "uuid-for-tier",
"name": "Large Context (>200K)",
"isDefault": false,
"priority": 1,
"conditions": [
{
"usageDetailPattern": "(input|prompt|cached)",
"operator": "gt",
"value": 200000,
"caseSensitive": false
}
],
"prices": {}
}
```
Supported condition operators: `gt`, `gte`, `lt`, `lte`, `eq`, `neq`
## Official Pricing Sources
Always fetch pricing from the provider's official docs before editing. Do not
infer or estimate missing values.
| Provider | Source |
| --- | --- |
| Anthropic Claude | `https://platform.claude.com/docs/en/about-claude/pricing` |
| OpenAI | `https://openai.com/api/pricing/` |
| Google Gemini | `https://ai.google.dev/pricing` |
| AWS Bedrock | `https://aws.amazon.com/bedrock/pricing/` |
| Azure OpenAI | `https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/` |
Gather:
1. Base input token price per million tokens
2. Output token price per million tokens
3. Cache write price when supported
4. Cache read price when supported
5. Any long-context pricing tiers
6. All model ID formats that Langfuse should match
## Price Conversion
Values in `default-model-prices.json` are per token, not per million tokens.
| Provider Price | JSON Value |
| --- | --- |
| `$5 / MTok` | `5e-6` |
| `$25 / MTok` | `25e-6` |
| `$0.50 / MTok` | `0.5e-6` |
| `$6.25 / MTok` | `6.25e-6` |
Formula:
```text
price_per_token = price_per_mtok / 1_000_000
```
## Common Price Keys by Provider
### Anthropic Claude Models
```json
{
"input": "<base_input_price>",
"input_tokens": "<base_input_price>",
"output": "<output_price>",
"output_tokens": "<output_price>",
"cache_creation_input_tokens": "<cache_write_price>",
"input_cache_creation": "<cache_write_price>",
"cache_read_input_tokens": "<cache_read_price>",
"input_cache_read": "<cache_read_price>"
}
```
### OpenAI Models
```json
{
"input": "<input_price>",
"input_cached_tokens": "<cached_input_price>",
"input_cache_read": "<cached_input_price>",
"output": "<output_price>"
}
```
### Google Gemini Models
```json
{
"input": "<input_price>",
"input_modality_1": "<input_price>",
"prompt_token_count": "<input_price>",
"promptTokenCount": "<input_price>",
"input_cached_tokens": "<cached_price>",
"cached_content_token_count": "<cached_price>",
"output": "<output_price>",
"output_modality_1": "<output_price>",
"candidates_token_count": "<output_price>",
"candidatesTokenCount": "<output_price>"
}
```
## Match Pattern Examples
### Anthropic Claude: API + Bedrock + Vertex
```regex
(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$
```
Matches:
- `claude-opus-4-6`
- `anthropic/claude-opus-4-6`
- `anthropic.claude-opus-4-6-v1:0`
- `us.anthropic.claude-opus-4-6-v1:0`
- `claude-opus-4-6`
### With Version Date
```regex
(?i)^(anthropic\/)?(claude-opus-4-5-20251101|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-5-20251101-v1:0|claude-opus-4-5@20251101)$
```
### OpenAI
```regex
(?i)^(openai\/)?(gpt-4o)$
```
### Google Gemini
```regex
(?i)^(google\/)?(gemini-2.5-pro)$
```
### Pattern Components
| Component | Purpose | Example |
| --- | --- | --- |
| `(?i)` | Case-insensitive match | `gpt-4o` and `GPT-4O` |
| `^...$` | Full-string match | Avoids partial matches |
| `(provider\/)?` | Optional provider prefix | `openai/gpt-4o` |
| `(eu\\.|us\\.|apac\\.)?` | Optional AWS region prefix | `us.anthropic.model` |
| `(:0)?` | Optional version suffix | Bedrock model versions |
| `@date` | Vertex AI version format | `claude-3-5-sonnet@20240620` |
## Step-by-Step Workflow
### 1. Fetch Official Pricing
Open the official provider pricing page and capture the model's input, output,
cache write, and cache read prices.
### 2. Generate a Lowercase UUID
```bash
uuidgen
```
Convert the output to lowercase before using it.
### 3. Create the JSON Entry
Example for a model with $5 input, $25 output, $6.25 cache write, and
$0.50 cache read:
```json
{
"id": "13458bc0-1c20-44c2-8753-172f54b67647",
"modelName": "claude-opus-4-6",
"matchPattern": "(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$",
"createdAt": "2026-03-09T00:00:00.000Z",
"updatedAt": "2026-03-09T00:00:00.000Z",
"tokenizerConfig": null,
"tokenizerId": "claude",
"pricingTiers": [
{
"id": "13458bc0-1c20-44c2-8753-172f54b67647_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 5e-6,
"input_tokens": 5e-6,
"output": 25e-6,
"output_tokens": 25e-6,
"cache_creation_input_tokens": 6.25e-6,
"input_cache_creation": 6.25e-6,
"cache_read_input_tokens": 0.5e-6,
"input_cache_read": 0.5e-6
}
}
]
}
```
### 4. Insert the Entry
Add the entry to the JSON array in
`worker/src/constants/default-model-prices.json`. Keep related models grouped
together.
### 5. Update Shared Model Types When Needed
If the model should be available in the playground or LLM-as-judge flows, add
it to the correct array in `packages/shared/src/server/llm/types.ts`.
Model arrays include:
- `anthropicModels`
- `openAIModels`
- `vertexAIModels`
- `googleAIStudioModels`
Do not add a new model as the first entry in one of these arrays. The first
entry is used as a default model in some test or evaluation paths and newer
models may not be available to all users yet.
### 6. Validate the Change
```bash
jq . worker/src/constants/default-model-prices.json > /dev/null
```
You can also inspect a specific entry:
```bash
jq '.[] | select(.modelName == "claude-opus-4-6")' worker/src/constants/default-model-prices.json
```
## Multi-Tier Example
For models with long-context pricing:
```json
{
"id": "uuid-here",
"modelName": "model-name",
"matchPattern": "...",
"pricingTiers": [
{
"id": "uuid-here_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 5e-6,
"output": 25e-6
}
},
{
"id": "uuid-for-large-context-tier",
"name": "Large Context (>200K)",
"isDefault": false,
"priority": 1,
"conditions": [
{
"usageDetailPattern": "(input|prompt|cached)",
"operator": "gt",
"value": 200000,
"caseSensitive": false
}
],
"prices": {
"input": 10e-6,
"output": 37.5e-6
}
}
]
}
```
## Validation Rules
1. Exactly one default tier must have `isDefault: true`
2. The default tier must have `priority: 0`
3. The default tier must have `conditions: []`
4. Non-default tiers must have `priority > 0`
5. Non-default tiers must have at least one condition
6. Priorities must be unique within a model
7. Tier names must be unique within a model
8. Each tier must contain at least one price
9. All tiers must expose the same usage-type keys
10. Regex patterns must be valid and safe
## Common Mistakes
### Guessing Instead of Using Official Pricing
Wrong:
```json
{
"cache_creation_input_tokens": "input_price * 1.25"
}
```
Correct:
```json
{
"cache_creation_input_tokens": 6.25e-6
}
```
### Using MTok Values Directly
Wrong:
```json
{
"input": 5
}
```
Correct:
```json
{
"input": 5e-6
}
```
### Missing the Default Tier Suffix
Wrong:
```json
{
"id": "some-uuid"
}
```
Correct:
```json
{
"id": "model-uuid_tier_default"
}
```
### Invalid Regex Escaping
Wrong:
```json
{
"matchPattern": "anthropic.claude"
}
```
Correct:
```json
{
"matchPattern": "anthropic\\.claude"
}
```
### Forgetting to Update `updatedAt`
Wrong:
```json
{
"updatedAt": "2025-12-12T15:00:06.513Z"
}
```
Correct:
```json
{
"updatedAt": "2026-03-09T00:00:00.000Z"
}
```
## Testing Model Matching
After adding a model, verify that the regex matches the intended provider
variants:
```javascript
const pattern = new RegExp(matchPattern);
console.log(pattern.test("claude-opus-4-6")); // true
console.log(pattern.test("anthropic/claude-opus-4-6")); // true
console.log(pattern.test("anthropic.claude-opus-4-6-v1:0")); // true
console.log(pattern.test("us.anthropic.claude-opus-4-6-v1:0")); // true
```
## Existing Model Templates
Use nearby entries as templates:
- `claude-opus-4-5-20251101` for Anthropic multi-provider patterns
- `gpt-4o` for a simple OpenAI pattern
- `gemini-2.5-pro` for a multi-tier Gemini entry
-40
View File
@@ -1,40 +0,0 @@
---
name: add-model-price
description: Use when editing worker/src/constants/default-model-prices.json, packages/shared/src/server/llm/types.ts, pricing tiers, tokenizer IDs, or matchPattern regexes for OpenAI, Anthropic, Bedrock, Vertex, Azure, or Gemini model pricing.
---
# Add Model Price
Use this skill for model pricing changes in `worker/` and shared LLM type
updates in `packages/shared/`.
## When to Apply
- Editing `worker/src/constants/default-model-prices.json`
- Editing `packages/shared/src/server/llm/types.ts`
- Adding a new priced model
- Updating provider prices, cache pricing, or tier conditions
- Expanding regex coverage for Bedrock, Vertex, Azure, or provider-prefixed
model names
## How to Read This Skill
- Start with [AGENTS.md](AGENTS.md) for the high-level workflow and helper
scripts.
- Then open only the specific reference file that matches the task.
## Reference Map
| Topic | Read this when | File |
| --- | --- | --- |
| Schema and tier rules | You need the entry shape or pricing-tier invariants | [references/schema-and-tiers.md](references/schema-and-tiers.md) |
| Provider sources and price keys | You need official pricing URLs, per-token conversion, or provider-specific usage keys | [references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md) |
| Match patterns | You are editing `matchPattern` regexes or provider coverage | [references/match-patterns.md](references/match-patterns.md) |
| Workflow and validation | You are applying the end-to-end edit process or checking common mistakes | [references/workflow-and-validation.md](references/workflow-and-validation.md) |
## Deterministic Helpers
- Pricing file validator:
`node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs`
- Match-pattern tester:
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model <modelName> --accept <sample...> --reject <sample...>`
@@ -1,51 +0,0 @@
# Match Patterns
## Anthropic Claude: API + Bedrock + Vertex
```regex
(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$
```
Matches:
- `claude-opus-4-6`
- `anthropic/claude-opus-4-6`
- `anthropic.claude-opus-4-6-v1:0`
- `us.anthropic.claude-opus-4-6-v1:0`
## With Version Date
```regex
(?i)^(anthropic\/)?(claude-opus-4-5-20251101|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-5-20251101-v1:0|claude-opus-4-5@20251101)$
```
## OpenAI
```regex
(?i)^(openai\/)?(gpt-4o)$
```
## Google Gemini
```regex
(?i)^(google\/)?(gemini-2.5-pro)$
```
## Pattern Components
| Component | Purpose | Example |
| --- | --- | --- |
| `(?i)` | Case-insensitive match | `gpt-4o` and `GPT-4O` |
| `^...$` | Full-string match | Avoids partial matches |
| `(provider\/)?` | Optional provider prefix | `openai/gpt-4o` |
| `(eu\\.|us\\.|apac\\.)?` | Optional AWS region prefix | `us.anthropic.model` |
| `(:0)?` | Optional version suffix | Bedrock model versions |
| `@date` | Vertex AI version format | `claude-3-5-sonnet@20240620` |
## Testing Patterns
Use the bundled helper script:
```bash
node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model gpt-4o --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini
```
@@ -1,84 +0,0 @@
# Provider Sources and Price Keys
## Official Pricing Sources
Always fetch pricing from the provider's official docs before editing.
| Provider | Source |
| --- | --- |
| Anthropic Claude | `https://platform.claude.com/docs/en/about-claude/pricing` |
| OpenAI | `https://openai.com/api/pricing/` |
| Google Gemini | `https://ai.google.dev/pricing` |
| AWS Bedrock | `https://aws.amazon.com/bedrock/pricing/` |
| Azure OpenAI | `https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/` |
Capture:
1. Base input token price per million tokens
2. Output token price per million tokens
3. Cache write price when supported
4. Cache read price when supported
5. Any long-context or conditional pricing
6. All model ID variants that Langfuse should match
## Price Conversion
Values in `default-model-prices.json` are per token, not per million tokens.
| Provider Price | JSON Value |
| --- | --- |
| `$5 / MTok` | `5e-6` |
| `$25 / MTok` | `25e-6` |
| `$0.50 / MTok` | `0.5e-6` |
| `$6.25 / MTok` | `6.25e-6` |
Formula:
```text
price_per_token = price_per_mtok / 1_000_000
```
## Common Price Keys by Provider
### Anthropic Claude
```json
{
"input": "<base_input_price>",
"input_tokens": "<base_input_price>",
"output": "<output_price>",
"output_tokens": "<output_price>",
"cache_creation_input_tokens": "<cache_write_price>",
"input_cache_creation": "<cache_write_price>",
"cache_read_input_tokens": "<cache_read_price>",
"input_cache_read": "<cache_read_price>"
}
```
### OpenAI
```json
{
"input": "<input_price>",
"input_cached_tokens": "<cached_input_price>",
"input_cache_read": "<cached_input_price>",
"output": "<output_price>"
}
```
### Google Gemini
```json
{
"input": "<input_price>",
"input_modality_1": "<input_price>",
"prompt_token_count": "<input_price>",
"promptTokenCount": "<input_price>",
"input_cached_tokens": "<cached_price>",
"cached_content_token_count": "<cached_price>",
"output": "<output_price>",
"output_modality_1": "<output_price>",
"candidates_token_count": "<output_price>",
"candidatesTokenCount": "<output_price>"
}
```
@@ -1,96 +0,0 @@
# Schema and Tiers
## Target Files
- Pricing data: `worker/src/constants/default-model-prices.json`
- Shared model types: `packages/shared/src/server/llm/types.ts`
## Complete Model Entry Schema
```json
{
"id": "uuid-generated-with-uuidgen",
"modelName": "model-name-identifier",
"matchPattern": "(?i)^regex-pattern$",
"createdAt": "ISO-8601-timestamp",
"updatedAt": "ISO-8601-timestamp",
"tokenizerConfig": null,
"tokenizerId": "claude|openai|null",
"pricingTiers": [
{
"id": "model-uuid_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.000005,
"output": 0.000025
}
}
]
}
```
## Required Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Unique lowercase ID used by the pricing file |
| `modelName` | string | Primary model identifier |
| `matchPattern` | string | Regex used to match provider model names |
| `createdAt` | string | ISO-8601 timestamp set on creation |
| `updatedAt` | string | ISO-8601 timestamp refreshed whenever the entry changes |
| `pricingTiers` | array | At least one pricing tier |
## Optional Fields
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `tokenizerId` | string | `null` | Usually `"claude"`, `"openai"`, or `null` |
| `tokenizerConfig` | object | `null` | Custom tokenizer settings |
## Default Tier
Every model must have exactly one default tier:
```json
{
"id": "{model-id}_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {}
}
```
Rules:
- `isDefault` must be `true`
- `priority` must be `0`
- `conditions` must be `[]`
## Additional Tiers
Use extra tiers for context-window or usage-based pricing:
```json
{
"id": "uuid-for-tier",
"name": "Large Context (>200K)",
"isDefault": false,
"priority": 1,
"conditions": [
{
"usageDetailPattern": "(input|prompt|cached)",
"operator": "gt",
"value": 200000,
"caseSensitive": false
}
],
"prices": {}
}
```
Supported operators: `gt`, `gte`, `lt`, `lte`, `eq`, `neq`
@@ -1,63 +0,0 @@
# Workflow and Validation
## Step-by-Step Workflow
### 1. Fetch Official Pricing
Open the provider's official pricing page and collect input, output, cache
write, and cache read prices.
### 2. Generate a Lowercase ID
```bash
uuidgen
```
Convert the output to lowercase before using it.
### 3. Create or Update the Entry
Use nearby models in `worker/src/constants/default-model-prices.json` as the
template, then:
- add the new entry near related models
- refresh `updatedAt` when editing an existing entry
- update `packages/shared/src/server/llm/types.ts` when the model should be
selectable in product flows
### 4. Validate the Result
Run the bundled validator:
```bash
node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs
```
## Validation Rules
1. Exactly one default tier must have `isDefault: true`
2. The default tier must have `priority: 0`
3. The default tier must have `conditions: []`
4. Non-default tiers must have `priority > 0`
5. Non-default tiers must have at least one condition
6. Priorities must be unique within a model
7. Tier names must be unique within a model
8. Each tier must contain at least one price
9. All tiers must expose the same usage-type keys
10. Regex patterns must be valid
## Common Mistakes
- Guessing prices instead of using official provider docs
- Using MTok values directly instead of per-token values
- Forgetting the `_tier_default` suffix on the default tier ID
- Forgetting to escape regex metacharacters such as `.`
- Forgetting to refresh `updatedAt`
## Existing Model Templates
Use nearby entries as templates:
- `claude-opus-4-5-20251101` for Anthropic multi-provider patterns
- `gpt-4o` for a simple OpenAI pattern
- `gemini-2.5-pro` for a multi-tier Gemini entry
@@ -1,115 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
const repoRoot = process.cwd();
const defaultFile = path.resolve(
repoRoot,
"worker/src/constants/default-model-prices.json",
);
const args = process.argv.slice(2);
function readOption(name) {
const index = args.indexOf(name);
if (index === -1) {
return null;
}
return args[index + 1] ?? null;
}
function readListOption(name) {
const index = args.indexOf(name);
if (index === -1) {
return [];
}
const values = [];
for (let i = index + 1; i < args.length; i += 1) {
if (args[i].startsWith("--")) {
break;
}
values.push(args[i]);
}
return values;
}
function compilePattern(rawPattern) {
let source = rawPattern;
let flags = "";
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
if (inlineFlags) {
flags = inlineFlags[1];
source = rawPattern.slice(inlineFlags[0].length);
}
return new RegExp(source, flags);
}
let pattern = readOption("--pattern");
const modelName = readOption("--model");
const accepted = readListOption("--accept");
const rejected = readListOption("--reject");
if (!pattern && !modelName) {
console.error("Pass either --pattern <regex> or --model <modelName>.");
process.exit(1);
}
if (accepted.length === 0 && rejected.length === 0) {
console.error("Provide samples with --accept and/or --reject.");
process.exit(1);
}
if (!pattern && modelName) {
const models = JSON.parse(await fs.readFile(defaultFile, "utf8"));
const model = models.find((entry) => entry.modelName === modelName);
if (!model) {
console.error(`Model not found in pricing file: ${modelName}`);
process.exit(1);
}
pattern = model.matchPattern;
}
let regex;
try {
regex = compilePattern(pattern);
} catch (error) {
console.error(`Invalid pattern: ${error.message}`);
process.exit(1);
}
const failures = [];
for (const sample of accepted) {
const matched = regex.test(sample);
console.log(`${matched ? "PASS" : "FAIL"} accept ${sample}`);
if (!matched) {
failures.push(`Expected pattern to match: ${sample}`);
}
}
for (const sample of rejected) {
const matched = regex.test(sample);
console.log(`${!matched ? "PASS" : "FAIL"} reject ${sample}`);
if (matched) {
failures.push(`Expected pattern to reject: ${sample}`);
}
}
if (failures.length > 0) {
console.error("");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("");
console.log(
`Pattern is valid for ${accepted.length + rejected.length} sample(s).`,
);
@@ -1,150 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
const defaultFile = "worker/src/constants/default-model-prices.json";
const repoRoot = process.cwd();
const filePath = path.resolve(repoRoot, process.argv[2] ?? defaultFile);
const failures = [];
function compileMatchPattern(rawPattern, label) {
let source = rawPattern;
let flags = "";
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
if (inlineFlags) {
flags = inlineFlags[1];
source = rawPattern.slice(inlineFlags[0].length);
}
try {
return new RegExp(source, flags);
} catch (error) {
failures.push(`${label}: invalid matchPattern (${error.message})`);
return null;
}
}
function keysOfPrices(prices) {
return Object.keys(prices).sort();
}
const raw = await fs.readFile(filePath, "utf8");
const models = JSON.parse(raw);
if (!Array.isArray(models)) {
throw new Error("Expected the pricing file to be a JSON array.");
}
for (const model of models) {
const label = model.modelName ?? model.id ?? "<unknown-model>";
if (!model.id || typeof model.id !== "string") {
failures.push(`${label}: missing string id`);
}
if (!model.modelName || typeof model.modelName !== "string") {
failures.push(`${label}: missing string modelName`);
}
if (!model.matchPattern || typeof model.matchPattern !== "string") {
failures.push(`${label}: missing string matchPattern`);
} else {
compileMatchPattern(model.matchPattern, label);
}
if (Number.isNaN(Date.parse(model.createdAt ?? ""))) {
failures.push(`${label}: invalid createdAt timestamp`);
}
if (Number.isNaN(Date.parse(model.updatedAt ?? ""))) {
failures.push(`${label}: invalid updatedAt timestamp`);
}
if (!Array.isArray(model.pricingTiers) || model.pricingTiers.length === 0) {
failures.push(`${label}: pricingTiers must be a non-empty array`);
continue;
}
const defaultTiers = model.pricingTiers.filter((tier) => tier.isDefault);
if (defaultTiers.length !== 1) {
failures.push(`${label}: must have exactly one default tier`);
}
const seenPriorities = new Set();
const seenNames = new Set();
let expectedPriceKeys = null;
for (const tier of model.pricingTiers) {
const tierLabel = `${label}/${tier.name ?? tier.id ?? "<unknown-tier>"}`;
if (seenPriorities.has(tier.priority)) {
failures.push(`${tierLabel}: duplicate tier priority ${tier.priority}`);
} else {
seenPriorities.add(tier.priority);
}
if (seenNames.has(tier.name)) {
failures.push(`${tierLabel}: duplicate tier name ${tier.name}`);
} else {
seenNames.add(tier.name);
}
if (!tier.prices || typeof tier.prices !== "object") {
failures.push(`${tierLabel}: missing prices object`);
continue;
}
const priceKeys = keysOfPrices(tier.prices);
if (priceKeys.length === 0) {
failures.push(`${tierLabel}: prices object must not be empty`);
}
for (const [usageType, price] of Object.entries(tier.prices)) {
if (typeof price !== "number" || Number.isNaN(price) || price < 0) {
failures.push(`${tierLabel}: invalid price for ${usageType}`);
}
}
if (tier.isDefault) {
if (tier.priority !== 0) {
failures.push(`${tierLabel}: default tier priority must be 0`);
}
if (!Array.isArray(tier.conditions) || tier.conditions.length !== 0) {
failures.push(`${tierLabel}: default tier conditions must be []`);
}
} else {
if (!(tier.priority > 0)) {
failures.push(`${tierLabel}: non-default tier priority must be > 0`);
}
if (!Array.isArray(tier.conditions) || tier.conditions.length === 0) {
failures.push(
`${tierLabel}: non-default tiers must define at least one condition`,
);
}
}
if (!expectedPriceKeys) {
expectedPriceKeys = priceKeys.join(",");
} else if (expectedPriceKeys !== priceKeys.join(",")) {
failures.push(
`${tierLabel}: price keys must match the other tiers for ${label}`,
);
}
}
}
if (failures.length > 0) {
console.error("Pricing validation failed:\n");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`Validated ${models.length} pricing entries in ${path.relative(repoRoot, filePath)}.`,
);
@@ -1,65 +0,0 @@
---
name: agent-setup-maintenance
description: |
Shared workflow for editing Langfuse's repo-owned agent setup under `.agents/`.
Use when changing AGENTS files, shared skills, `.agents/config.json`,
generated shim behavior, provider discovery paths, or install-time agent sync.
---
# Agent Setup Maintenance
Use this skill when changing the shared agent setup for the repository.
## Start Here
- Read [`../../README.md`](../../README.md) for the shared config and shim model.
- Read root [`../../AGENTS.md`](../../AGENTS.md) for repo-level expectations.
- Inspect [`../../../scripts/agents/sync-agent-shims.mjs`](../../../scripts/agents/sync-agent-shims.mjs)
before changing generated outputs or provider discovery behavior.
- Inspect [`../../../scripts/postinstall.sh`](../../../scripts/postinstall.sh)
and [`../../../package.json`](../../../package.json) when changing install-time
sync behavior.
## Workflow
1. Edit the canonical files under `.agents/`, not generated provider outputs.
2. Keep root `AGENTS.md` and `CLAUDE.md` as discovery symlinks; do not turn
them back into manually maintained copies.
3. Treat tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`,
`.vscode/`, and `.mcp.json` as generated discovery surfaces unless the tool
requires a truly tool-specific feature.
4. Keep root `AGENTS.md` concise and router-like. Move detailed or conditional
workflows into shared skills or package `AGENTS.md` files.
5. When adding or changing a shared skill, update `skills/README.md` and link
it from root `AGENTS.md` if it changes the default reusable workflow.
6. When shared setup behavior changes materially, update `README.md` and
contributor-facing docs in the same PR.
## Docker / Install-Time Constraint
- `pnpm install` runs in environments that may not contain the full repo source
tree.
- In Docker builds, Turbo's pruned install stage can run root `postinstall`
before `scripts/` and `.agents/` are available in the image.
- Keep install-time agent setup logic robust in those pruned contexts: skip
cleanly when the required repo-owned files are not present.
## Required Verification
Run after changing shared agent setup:
- `pnpm run agents:sync`
- `pnpm run agents:check`
Run additional verification when relevant:
- `pnpm run postinstall` when install-time behavior changes
- targeted tests for any scripts you changed
## Design Rules
- Prefer one repo-owned source of truth over duplicated provider-specific files.
- Keep shared setup tool-neutral where possible.
- Only keep provider-specific files in source control when the provider requires
a fixed discovery path or feature that cannot be expressed through the shared
setup model.
@@ -1,577 +0,0 @@
# Backend Development Guidelines
## Purpose
Establish consistency and best practices across Langfuse's backend packages (web, worker, packages/shared) using Next.js 14, tRPC, BullMQ, and TypeScript patterns.
## When to Use This Skill
Use this guide when working on:
- Creating or modifying tRPC routers and procedures
- Creating or modifying public API endpoints (REST)
- Creating or modifying BullMQ queue consumers and producers
- Building services with business logic
- Authenticating API requests
- Accessing resources based on entitlements
- Implementing middleware (tRPC, NextAuth, public API)
- Database operations with Prisma (PostgreSQL) or ClickHouse
- Observability with OpenTelemetry, DataDog, logger, and traceException
- Input validation with Zod v4
- Environment configuration from env variables
- Backend testing and refactoring
---
## Quick Start
### UI: New tRPC Feature Checklist (Web)
- [ ] **Router**: Define in `features/[feature]/server/*Router.ts`
- [ ] **Procedures**: Use appropriate procedure type (protected, public)
- [ ] **Authentication**: Use JWT authorization via middlewares.
- [ ] **Entitlement check**: Access resources based on resource and role
- [ ] **Validation**: Zod v4 schema for input
- [ ] **Service**: Business logic in service file
- [ ] **Error handling**: Use traceException wrapper
- [ ] **Tests**: Unit + integration tests in `__tests__/`
- [ ] **Config**: Access via env.mjs
### SDKs: New Public API Endpoint Checklist (Web)
- [ ] **Route file**: Create in `pages/api/public/`
- [ ] **Wrapper**: Use `withMiddlewares` + `createAuthedProjectAPIRoute`
- [ ] **Types**: Define in `features/public-api/types/`
- [ ] **Authentication**: Authorization via basic auth
- [ ] **Validation**: Zod schemas for query/body/response
- [ ] **Versioning**: Versioning in API path and Zod schemas for query/body/response
- [ ] **Fern API Docs**: Update `fern/apis/server/definition/` to match TypeScript types
- [ ] **Tests**: Add end-to-end test in `__tests__/async/`
### New Queue Processor Checklist (Worker)
- [ ] **Processor**: Create in `worker/src/queues/`
- [ ] **Queue types**: Create queue types in `packages/shared/src/server/queues`
- [ ] **Service**: Business logic in `features/` or `worker/src/features/`
- [ ] **Error handling**: Distinguish between errors which should fail queue processing and errors which should result in a succeeded event.
- [ ] **Queue registration**: Add to WorkerManager in app.ts
- [ ] **Tests**: Add vitest tests in worker
---
## Architecture Overview
### Layered Architecture
```
# Web Package (Next.js 14)
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
│ │ │ │
│ HTTP Request │ │ HTTP Request │
│ ↓ │ │ ↓ │
│ tRPC Procedure │ │ withMiddlewares + │
│ (protectedProjectProcedure)│ │ createAuthedProjectAPIRoute│
│ ↓ │ │ ↓ │
│ Service (business logic) │ │ Service (business logic) │
│ ↓ │ │ ↓ │
│ Prisma / ClickHouse │ │ Prisma / ClickHouse │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
[optional]: Publish to Redis BullMQ queue
┌─ Worker Package (Express) ──────────────────────────────────┐
│ │
│ BullMQ Queue Job │
│ ↓ │
│ Queue Processor (handles job) │
│ ↓ │
│ Service (business logic) │
│ ↓ │
│ Prisma / ClickHouse │
│ │
└─────────────────────────────────────────────────────────────┘
```
**Key Principles:**
- **Web**: tRPC procedures for UI OR public API routes for SDKs → Services → Database
- **Worker**: Queue processors → Services → Database
- **packages/shared**: Shared code for Web and Worker
See [references/architecture-overview.md](references/architecture-overview.md)
for complete details.
---
## Directory Structure
### Web Package (`/web/`)
```
web/src/
├── features/ # Feature-organized code
│ ├── [feature-name]/
│ │ ├── server/ # Backend logic
│ │ │ ├── *Router.ts # tRPC router
│ │ │ └── service.ts # Business logic
│ │ ├── components/ # React components
│ │ └── types/ # Feature types
├── server/
│ ├── api/
│ │ ├── routers/ # tRPC routers
│ │ ├── trpc.ts # tRPC setup & middleware
│ │ └── root.ts # Main router
│ ├── auth.ts # NextAuth.js config
│ └── db.ts # Database client
├── pages/
│ ├── api/
│ │ ├── public/ # Public REST APIs
│ │ └── trpc/ # tRPC endpoint
│ └── [routes].tsx # Next.js pages
├── __tests__/ # Jest tests
│ └── async/ # Integration tests
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
└── env.mjs # Environment config
```
### Worker Package (`/worker/`)
```
worker/src/
├── queues/ # BullMQ processors
│ ├── evalQueue.ts
│ ├── ingestionQueue.ts
│ └── workerManager.ts
├── features/ # Business logic
│ └── [feature]/
│ └── service.ts
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
├── app.ts # Express setup + queue registration
├── env.ts # Environment config
└── index.ts # Server start
```
### Shared Package (`/packages/shared/`)
```
shared/src/
├── server/ # Server utilities
│ ├── auth/ # Authentication helpers
│ ├── clickhouse/ # ClickHouse client & schema
│ ├── instrumentation/ # OpenTelemetry helpers
│ ├── llm/ # LLM integration utilities
│ ├── redis/ # Redis queues & cache
│ ├── repositories/ # Data repositories
│ ├── services/ # Shared services
│ ├── utils/ # Server utilities
│ ├── logger.ts
│ └── queues.ts
├── encryption/ # Encryption utilities
├── features/ # Feature-specific code
├── tableDefinitions/ # Table schemas
├── utils/ # Shared utilities
├── constants.ts
├── db.ts # Prisma client
├── env.ts # Environment config
└── index.ts # Main exports
```
**Import Paths (package.json exports):**
The shared package exposes specific import paths for different use cases:
| Import Path | Maps To | Use For |
| ------------------------------------------ | --------------------------------- | --------------------------------------------------------------- |
| `@langfuse/shared` | `dist/src/index.js` | General types, schemas, utilities, constants |
| `@langfuse/shared/src/db` | `dist/src/db.js` | Prisma client and database types |
| `@langfuse/shared/src/server` | `dist/src/server/index.js` | Server-side utilities (queues, auth, services, instrumentation) |
| `@langfuse/shared/src/server/auth/apiKeys` | `dist/src/server/auth/apiKeys.js` | API key management utilities |
| `@langfuse/shared/encryption` | `dist/src/encryption/index.js` | Encryption and signature utilities |
**Usage Examples:**
```typescript
// General imports - types, schemas, constants, interfaces
import {
CloudConfigSchema,
StringNoHTML,
AnnotationQueueObjectType,
type APIScoreV2,
type ColumnDefinition,
Role,
} from "@langfuse/shared";
// Database - Prisma client and types
import { prisma, Prisma, JobExecutionStatus } from "@langfuse/shared/src/db";
import { type DB as Database } from "@langfuse/shared";
// Server utilities - queues, services, auth, instrumentation
import {
logger,
instrumentAsync,
traceException,
redis,
getTracesTable,
StorageService,
sendMembershipInvitationEmail,
invalidateApiKeysForProject,
recordIncrement,
recordHistogram,
} from "@langfuse/shared/src/server";
// API key management (specific path)
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
// Encryption utilities
import { encrypt, decrypt, sign, verify } from "@langfuse/shared/encryption";
```
**What Goes Where:**
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
| Import Path | Usage | What's Included |
| ------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
| `@langfuse/shared` | ✅ Frontend + Backend | Prisma types, Zod schemas, constants, table definitions, domain models, utilities |
| `@langfuse/shared/src/db` | 🔒 Backend only | Prisma client instance |
| `@langfuse/shared/src/server` | 🔒 Backend only | Services, repositories, queues, auth, ClickHouse, LLM integration, instrumentation |
| `@langfuse/shared/src/server/auth/apiKeys` | 🔒 Backend only | API key management (separated to avoid circular deps) |
| `@langfuse/shared/encryption` | 🔒 Backend only | Database field encryption/decryption |
**Naming Conventions:**
- tRPC Routers: `camelCaseRouter.ts` - `datasetRouter.ts`
- Services: `service.ts` in feature directory
- Queue Processors: `camelCaseQueue.ts` - `evalQueue.ts`
- Public APIs: `kebab-case.ts` - `dataset-items.ts`
---
## Core Principles
### 1. tRPC Procedures Delegate to Services
```typescript
// ❌ NEVER: Business logic in procedures
export const traceRouter = createTRPCRouter({
byId: protectedProjectProcedure
.input(z.object({ traceId: z.string() }))
.query(async ({ input, ctx }) => {
// 200 lines of logic here
}),
});
// ✅ ALWAYS: Delegate to service
export const traceRouter = createTRPCRouter({
byId: protectedProjectProcedure
.input(z.object({ traceId: z.string() }))
.query(async ({ input, ctx }) => {
return await getTraceById(input.traceId);
}),
});
```
### 2. Access Config via env.mjs, NEVER process.env
```typescript
// ❌ NEVER (except in env.mjs itself)
const dbUrl = process.env.DATABASE_URL;
// ✅ ALWAYS
import { env } from "@/src/env.mjs";
const dbUrl = env.DATABASE_URL;
```
### 3. Validate ALL Input with Zod v4
```typescript
import { z } from "zod/v4";
const schema = z.object({
email: z.string().email(),
projectId: z.string(),
});
const validated = schema.parse(input);
```
### 4. Services Use Prisma Directly for Simple CRUD or Repositories for Complex Queries
```typescript
// Services use Prisma directly for simple CRUD
import { prisma } from "@langfuse/shared/src/db";
const dataset = await prisma.dataset.findUnique({
where: { id: datasetId, projectId }, // Always filter by projectId for tenant isolation
});
// Or use repositories for complex queries (traces, observations, scores)
import { getTracesTable } from "@langfuse/shared/src/server";
const traces = await getTracesTable({
projectId,
filter: [...],
limit: 1000,
});
```
### 6. Observability: OpenTelemetry + DataDog (Not Sentry for Backend)
**Langfuse uses OpenTelemetry for backend observability, with traces and logs sent to DataDog.**
```typescript
// Import observability utilities
import {
logger, // Winston logger with OpenTelemetry/DataDog context
traceException, // Record exceptions to OpenTelemetry spans
instrumentAsync, // Create instrumented spans
} from "@langfuse/shared/src/server";
// Structured logging (includes trace_id, span_id, dd.trace_id)
logger.info("Processing dataset", { datasetId, projectId });
logger.error("Failed to create dataset", { error: err.message });
// Record exceptions to OpenTelemetry (sent to DataDog)
try {
await operation();
} catch (error) {
traceException(error); // Records to current span
throw error;
}
// Instrument critical operations (all API routes auto-instrumented)
const result = await instrumentAsync(
{ name: "dataset.create" },
async (span) => {
span.setAttributes({ datasetId, projectId });
// Operation here
return dataset;
},
);
```
**Note**: Frontend uses Sentry, but backend (tRPC, API routes, services, worker) uses OpenTelemetry + DataDog.
### 7. Comprehensive Testing Required
Write tests for all new features and bug fixes. See [testing-guide.md](references/testing-guide.md) for detailed examples.
**Test Types:**
| Type | Framework | Location | Purpose |
| ----------- | --------- | --------------------------------------- | ---------------------------- |
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedures with auth |
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service functions |
| Worker | Vitest | `worker/src/__tests__/` | Queue processors & streams |
**Quick Examples:**
```typescript
// Integration Test (Public API)
const res = await makeZodVerifiedAPICall(
PostDatasetsV1Response, "POST", "/api/public/datasets",
{ name: "test-dataset" }, auth
);
expect(res.status).toBe(200);
// tRPC Test
const { caller } = await prepare(); // Creates session + caller
const response = await caller.automations.getAutomations({ projectId });
expect(response).toHaveLength(1);
// Service Test
const result = await getObservationsWithModelDataFromEventsTable({
projectId, filter: [...], limit: 1000, offset: 0
});
expect(result.length).toBeGreaterThan(0);
// Worker Test (vitest)
const stream = await getObservationStream({ projectId, filter: [] });
const rows = [];
for await (const chunk of stream) rows.push(chunk);
expect(rows).toHaveLength(2);
```
**Key Principles:**
- Use unique IDs (`randomUUID()`) to avoid test interference
- Clean up test data or use unique project IDs
- Tests must be independent and runnable in any order
- Prefer scoped cleanup or unique project IDs over global reset helpers
### 8. Always Filter by projectId for Tenant Isolation
```typescript
// ✅ CORRECT: Filter by projectId for tenant isolation
const trace = await prisma.trace.findUnique({
where: { id: traceId, projectId }, // Required for multi-tenant data isolation
});
// ✅ CORRECT: ClickHouse queries also require projectId
const traces = await queryClickhouse({
query: `
SELECT * FROM traces
WHERE project_id = {projectId: String}
AND timestamp >= {startTime: DateTime64(3)}
`,
params: { projectId, startTime },
});
```
### 9. Keep Fern API Definitions in Sync with TypeScript Types
When modifying public API types in `web/src/features/public-api/types/`, the corresponding Fern API definitions in `fern/apis/server/definition/` must be updated to match.
**Zod to Fern Type Mapping:**
| Zod Type | Fern Type | Example |
| -------- | --------- | ------- |
| `.nullish()` | `optional<nullable<T>>` | `z.string().nullish()``optional<nullable<string>>` |
| `.nullable()` | `nullable<T>` | `z.string().nullable()``nullable<string>` |
| `.optional()` | `optional<T>` | `z.string().optional()``optional<string>` |
| Always present | `T` | `z.string()``string` |
**Source References:**
Add a comment at the top of each Fern type referencing the TypeScript source file:
```yaml
# Source: web/src/features/public-api/types/traces.ts - APITrace
Trace:
properties:
id: string
name:
type: nullable<string>
```
---
## Common Imports
```typescript
// tRPC (Web)
import { z } from "zod/v4";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { TRPCError } from "@trpc/server";
// Database
import { prisma } from "@langfuse/shared/src/db";
import type { Prisma } from "@prisma/client";
// ClickHouse
import {
queryClickhouse,
queryClickhouseStream,
upsertClickhouse,
} from "@langfuse/shared/src/server";
// Observability - OpenTelemetry + DataDog (NOT Sentry for backend)
import {
logger, // Winston logger with OTEL/DataDog trace context
traceException, // Record exceptions to OpenTelemetry spans
instrumentAsync, // Create instrumented spans for operations
} from "@langfuse/shared/src/server";
// Config
import { env } from "@/src/env.mjs"; // web
// or
import { env } from "./env"; // worker
// Public API (Web)
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
// Queue Processing (Worker)
import { Job } from "bullmq";
import { QueueName, TQueueJobTypes } from "@langfuse/shared/src/server";
```
---
## Quick Reference
### HTTP Status Codes
| Code | Use Case |
| ---- | ------------ |
| 200 | Success |
| 201 | Created |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 500 | Server Error |
### Example Features to Reference
Reference existing Langfuse features for implementation patterns:
- **Datasets** (`web/src/features/datasets/`) - Complete feature with tRPC router, public API, and service
- **Prompts** (`web/src/features/prompts/`) - Feature with versioning and templates
- **Evaluations** (`web/src/features/evals/`) - Complex feature with worker integration
- **Public API** (`web/src/features/public-api/`) - Middleware and route patterns
---
## Anti-Patterns to Avoid
❌ Business logic in routes/procedures
❌ Direct process.env usage (always use env.mjs/env.ts)
❌ Missing error handling
❌ No input validation (always use Zod v4)
❌ Missing projectId filter on tenant-scoped queries
❌ console.log instead of logger/traceException (OpenTelemetry)
---
## Navigation Guide
| Need to... | Read this |
| ------------------------- | ------------------------------------------------------------ |
| Understand architecture | [architecture-overview.md](references/architecture-overview.md) |
| Create routes/controllers | [routing-and-controllers.md](references/routing-and-controllers.md) |
| Organize business logic | [services-and-repositories.md](references/services-and-repositories.md) |
| Create middleware | [middleware-guide.md](references/middleware-guide.md) |
| Database access | [database-patterns.md](references/database-patterns.md) |
| Manage config | [configuration.md](references/configuration.md) |
| Write tests | [testing-guide.md](references/testing-guide.md) |
---
## Reference Files
### [architecture-overview.md](references/architecture-overview.md)
Three-layer architecture (tRPC/Public API → Services → Data Access), request lifecycle for tRPC/Public API/Worker, Next.js 14 directory structure, dual database system (PostgreSQL + ClickHouse), separation of concerns, repository pattern for complex queries
### [routing-and-controllers.md](references/routing-and-controllers.md)
Next.js file-based routing, tRPC router patterns, Public REST API routes, layered architecture (Entry Points → Services → Repositories → Database), service layer organization, anti-patterns to avoid
### [services-and-repositories.md](references/services-and-repositories.md)
Service layer overview, dependency injection patterns, singleton patterns, repository pattern for data access, service design principles, caching strategies, testing services
### [middleware-guide.md](references/middleware-guide.md)
tRPC middleware (withErrorHandling, withOtelInstrumentation, enforceUserIsAuthed), seven tRPC procedure types (publicProcedure, authenticatedProcedure, protectedProjectProcedure, etc.), Public API middleware (withMiddlewares, createAuthedProjectAPIRoute), authentication patterns (NextAuth for tRPC, Basic Auth for Public API)
### [database-patterns.md](references/database-patterns.md)
Dual database architecture (PostgreSQL via Prisma + ClickHouse via direct client), PostgreSQL CRUD operations, ClickHouse query patterns (queryClickhouse, queryClickhouseStream, upsertClickhouse), repository pattern for complex queries, tenant isolation with projectId filtering, when to use which database
### [configuration.md](references/configuration.md)
Environment variable validation with Zod, package-specific configs (web/env.mjs with t3-oss/env-nextjs, worker/env.ts, shared/env.ts), NEXT_PUBLIC_LANGFUSE_CLOUD_REGION usage, LANGFUSE_EE_LICENSE_KEY for enterprise features, best practices for env management
### [testing-guide.md](references/testing-guide.md)
Integration tests (Public API with makeZodVerifiedAPICall), tRPC tests (createInnerTRPCContext, appRouter.createCaller), service-level tests (repository/service functions), worker tests (vitest with streams), test isolation principles, running tests (Jest for web, vitest for worker)
**Skill Status**: COMPLETE ✅
**Line Count**: ~540 lines
**Progressive Disclosure**: 7 reference files ✅
@@ -1,43 +0,0 @@
---
name: backend-dev-guidelines
description: Shared backend guide for Langfuse's Next.js 14, tRPC, BullMQ, and TypeScript monorepo. Use when creating or reviewing tRPC routers, public REST endpoints, BullMQ queue processors, backend services, middleware, Prisma or ClickHouse data access, OpenTelemetry instrumentation, Zod validation, env configuration, or backend tests across web, worker, or packages/shared.
---
# Backend Development Guidelines
Use this skill for backend and API work across `web/`, `worker/`, and
`packages/shared/`.
## When to Apply
- Creating or modifying tRPC routers and procedures
- Creating or modifying public API endpoints
- Creating or modifying queue processors, producers, or queue-backed workflows
- Building or refactoring backend services and repositories
- Working on backend auth, middleware, validation, or observability
- Updating Prisma or ClickHouse access patterns
- Adding or fixing backend tests
## How to Read This Skill
- Start with [AGENTS.md](AGENTS.md) when the task spans multiple backend areas
or you need the end-to-end checklists.
- Read only the specific reference file that matches the work when the scope is
narrower.
## Reference Map
| Topic | Read this when | File |
| --- | --- | --- |
| Architecture and package boundaries | You need the web/worker/shared split, request flow, or queue lifecycle | [references/architecture-overview.md](references/architecture-overview.md) |
| Routing and controllers | You are writing tRPC procedures, public API routes, or queue entrypoints | [references/routing-and-controllers.md](references/routing-and-controllers.md) |
| Middleware and auth | You are changing request auth, permissions, or middleware composition | [references/middleware-guide.md](references/middleware-guide.md) |
| Services and repositories | You are placing business logic, repository code, or DI patterns | [references/services-and-repositories.md](references/services-and-repositories.md) |
| Database access | You are touching Prisma, ClickHouse, tenant filters, or query patterns | [references/database-patterns.md](references/database-patterns.md) |
| Configuration | You are adding env vars, startup config, or runtime toggles | [references/configuration.md](references/configuration.md) |
| Testing | You are adding or updating backend tests | [references/testing-guide.md](references/testing-guide.md) |
## Full Compiled Guide
Read [AGENTS.md](AGENTS.md) for the complete backend guide with checklists,
directory conventions, imports, architecture, and cross-cutting practices.
@@ -1,870 +0,0 @@
# Architecture Overview - Langfuse Backend
Complete guide to the layered architecture pattern used in Langfuse's Next.js 14/tRPC/Express monorepo.
## Table of Contents
- [Layered Architecture Pattern](#layered-architecture-pattern)
- [Request Lifecycle](#request-lifecycle)
- [Directory Structure](#directory-structure)
- [Module Organization](#module-organization)
- [Separation of Concerns](#separation-of-concerns)
- [Database Architecture](#database-architecture)
---
## Layered Architecture Pattern
Langfuse uses a **three-layer architecture** with two primary entry points (tRPC and Public API) plus async processing via Worker.
### The Three Layers
```
# Web Package (Next.js 14)
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
│ │ │ │
│ HTTP Request │ │ HTTP Request │
│ ↓ │ │ ↓ │
│ tRPC Procedure │ │ withMiddlewares + │
│ (protectedProjectProcedure)│ │ createAuthedProjectAPIRoute│
│ ↓ │ │ ↓ │
│ Service (business logic) │ │ Service (business logic) │
│ ↓ │ │ ↓ │
│ Prisma / ClickHouse │ │ Prisma / ClickHouse │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
[optional]: Publish to Redis BullMQ queue
┌─ Worker Package (Express) ──────────────────────────────────┐
│ │
│ BullMQ Queue Job │
│ ↓ │
│ Queue Processor (handles job) │
│ ↓ │
│ Service (business logic) │
│ ↓ │
│ Prisma / ClickHouse │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Layer Breakdown
**Layer 1: API Entry Points**
Two types of entry points:
- **tRPC Procedures** - Type-safe RPC for UI
- Located in `features/[feature]/server/*Router.ts`
- Uses middleware for auth/validation
- Types shared between client/server
- **Public REST APIs** - REST endpoints for SDKs
- Located in `pages/api/public/`
- Uses `withMiddlewares` + `createAuthedProjectAPIRoute`
- Versioned with Zod schemas
**Layer 2: Services**
- Business logic and orchestration
- Shared between tRPC, Public API, and Worker
- Located in `features/[feature]/server/service.ts`
- No HTTP/Request/Response knowledge
- Use repositories for complex queries or Prisma directly for simple CRUD
**Layer 3: Data Access**
- **Repositories** for complex data access patterns (traces, observations, scores, events)
- **Direct Prisma** for simple CRUD operations in services
- PostgreSQL for transactional data
- ClickHouse for analytics/traces (accessed via repositories)
- Redis for caching/queues
**Async Processing Layer: Worker**
- BullMQ queue processors
- Same service layer as Web
- Handles long-running operations
### Why This Architecture?
**Testability:**
- tRPC procedures easily testable with type-safe callers
- Services tested independently with mocked DB
- Queue processors tested with vitest
- Clear test boundaries
**Maintainability:**
- Business logic isolated in services
- tRPC provides type safety end-to-end
- Changes to API don't affect service layer
- Easy to locate and fix bugs
**Reusability:**
- Services used by tRPC, Public API, Worker, and scripts
- Business logic not tied to HTTP or tRPC
- Consistent patterns across packages
**Scalability:**
- Worker handles async operations separately
- Easy to add new tRPC procedures
- Clear patterns to follow
- Shared code in packages/shared
---
## Request Lifecycle
### tRPC Request Flow (UI)
```typescript
1. HTTP POST /api/trpc/datasets.create
2. Next.js API route catches request (pages/api/trpc/[trpc].ts)
3. tRPC router resolves procedure:
- Match route to procedure in datasetRouter.ts
4. tRPC middleware chain executes:
- protectedProjectProcedure (authentication)
- hasEntitlement checks
- Input validation with Zod v4
5. Procedure handler calls service:
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createDatasetSchema)
.mutation(async ({ input, ctx }) => {
return await createDataset(input, ctx.session);
}),
})
6. Service executes business logic:
- Validate business rules
- Use repositories for complex queries or Prisma directly
- ClickHouse queries via repositories if needed
7. Database operations:
- prisma.dataset.create({ data })
- clickhouse queries via getTracesTable()
8. Response flows back:
Database Service Procedure tRPC Client
```
### Public API Request Flow (SDKs)
```typescript
1. HTTP POST /api/public/datasets
2. Next.js API route handler (pages/api/public/datasets.ts)
3. withMiddlewares wrapper executes:
- Basic auth verification
- Rate limiting
- CORS handling
4. createAuthedProjectAPIRoute handler:
- Parse and validate request with Zod v4
- Extract auth context (project, user)
5. Handler calls service function:
const dataset = await createDataset({
name: req.body.name,
projectId: req.auth.projectId,
});
6. Service executes (same as tRPC path)
7. Response formatted and returned:
res.status(201).json(dataset);
```
### Worker/Queue Processing Flow
```typescript
1. Job added to Redis BullMQ queue:
await evalQueue.add("eval-job", {
evalId, projectId
});
2. Worker picks up job from Redis
3. Queue processor handles job:
// worker/src/queues/evalQueue.ts
async process(job: Job<EvalJobType>) {
await processEvaluation(job.data);
}
4. Processor calls service:
- Same service layer as Web
- Business logic execution
5. Service performs operations:
- Prisma transactions
- ClickHouse queries
- External API calls (LLMs)
6. Job completes or fails:
- Success: job.updateProgress(100)
- Failure: throw error for retry
```
---
## Directory Structure
### Web Package (`/web/src/`)
```
web/src/
├── features/ # Feature-organized code
│ ├── datasets/
│ │ ├── server/ # Backend logic
│ │ │ ├── datasetRouter.ts # tRPC router
│ │ │ └── datasetService.ts # Business logic
│ │ ├── components/ # React components
│ │ └── types/ # Feature types
│ │
│ ├── public-api/
│ │ ├── server/
│ │ │ ├── withMiddlewares.ts
│ │ │ └── createAuthedProjectAPIRoute.ts
│ │ └── types/ # API schemas
│ │
│ └── [feature-name]/
│ ├── server/
│ │ ├── *Router.ts # tRPC router
│ │ └── service.ts # Business logic
│ ├── components/
│ └── types/
├── server/
│ ├── api/
│ │ ├── routers/ # tRPC routers
│ │ ├── trpc.ts # tRPC setup & middleware
│ │ └── root.ts # Main router combining all
│ ├── auth.ts # NextAuth.js config
│ └── db.ts # Database utilities
├── pages/
│ ├── api/
│ │ ├── public/ # Public REST APIs
│ │ │ ├── datasets.ts
│ │ │ └── traces.ts
│ │ └── trpc/
│ │ └── [trpc].ts # tRPC endpoint
│ └── [routes].tsx # Next.js pages
├── __tests__/ # Jest tests
│ ├── async/ # Integration tests
│ └── sync/ # Unit tests
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
└── env.mjs # Environment config
```
### Worker Package (`/worker/src/`)
```
worker/src/
├── queues/ # BullMQ processors
│ ├── evalQueue.ts # Evaluation jobs
│ ├── ingestionQueue.ts # Data ingestion
│ ├── batchExportQueue.ts # Batch exports
│ └── workerManager.ts # Queue registration
├── features/ # Business logic
│ └── [feature]/
│ └── service.ts
├── __tests__/ # Vitest tests
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
├── app.ts # Express setup + queue registration
├── env.ts # Environment config
└── index.ts # Server start
```
### Shared Package (`/packages/shared/`)
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
| Import Path | Usage | What's Included |
| ------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
| `@langfuse/shared` | ✅ Frontend + Backend | Prisma types, Zod schemas, constants, table definitions, domain models, utilities |
| `@langfuse/shared/src/db` | 🔒 Backend only | Prisma client instance |
| `@langfuse/shared/src/server` | 🔒 Backend only | Services, repositories, queues, auth, ClickHouse, LLM integration, instrumentation |
| `@langfuse/shared/src/server/auth/apiKeys` | 🔒 Backend only | API key management (separated to avoid circular deps) |
| `@langfuse/shared/encryption` | 🔒 Backend only | Database field encryption/decryption |
**Key Structure:**
```
packages/shared/src/
├── server/ # 🔒 All server-only code
│ ├── auth/ # Authentication & authorization
│ ├── clickhouse/ # ClickHouse client & queries
│ ├── redis/ # Redis client & 30+ queue types
│ ├── repositories/ # Data access (traces, observations, scores, events)
│ ├── services/ # Business services (Storage, Email, Slack, etc.)
│ ├── llm/ # LLM integration
│ ├── instrumentation/ # OpenTelemetry
│ └── queues.ts, logger.ts, filterToPrisma.ts, etc.
├── features/ # ✅ Feature types (evals, scores, prompts, datasets)
├── domain/ # ✅ Domain models (automations, webhooks, etc.)
├── tableDefinitions/ # ✅ Table schemas
├── interfaces/ # ✅ Shared interfaces (filters, orderBy)
├── utils/ # ✅ Utilities (JSON, Zod, string checks)
├── encryption/ # 🔒 Encryption utilities
└── db.ts, constants.ts, types.ts, etc.
```
**Common Import Patterns:**
```typescript
// ✅ Main export - Safe for frontend + backend
import {
Prisma,
Role,
type Dataset,
CloudConfigSchema,
} from "@langfuse/shared";
// 🔒 Database - Backend only
import { prisma } from "@langfuse/shared/src/db";
// 🔒 Server utilities - Backend only
import {
logger,
instrumentAsync,
traceException,
redis,
clickhouseClient,
StorageService,
fetchLLMCompletion,
filterToPrisma,
} from "@langfuse/shared/src/server";
// 🔒 API keys - Backend only
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
// 🔒 Encryption - Backend only
import { encrypt, decrypt } from "@langfuse/shared/encryption";
```
---
## Module Organization
### Feature-Based Organization (Recommended)
For most features, organize by domain within `features/`:
```
src/features/datasets/
├── server/ # Backend code
│ ├── datasetRouter.ts # tRPC procedures
│ └── service.ts # Business logic
├── components/ # React components
│ ├── DatasetTable.tsx
│ └── DatasetForm.tsx
├── types/ # Feature types
│ └── index.ts
└── utils/ # Feature utilities
```
**When to use:**
- Any feature with UI + API
- Clear domain boundary
- Multiple related procedures
### Subdomain Organization
For complex features with multiple subdomains:
```
src/features/evaluations/
├── server/
│ ├── evalRouter.ts # Main router
│ ├── evalService.ts # Core service
│ ├── templates/ # Template subdomain
│ │ ├── templateRouter.ts
│ │ └── templateService.ts
│ └── configs/ # Config subdomain
│ ├── configRouter.ts
│ └── configService.ts
├── components/
│ ├── templates/
│ └── configs/
└── types/
```
**When to use:**
- Feature has 10+ files
- Clear subdomains exist
- Logical grouping improves clarity
### Flat Organization (Rare)
For small, standalone features:
```
src/server/api/routers/
├── healthRouter.ts # Simple health check
└── versionRouter.ts # Version info
```
**When to use:**
- Simple features (1-2 procedures)
- No UI components
- Standalone utilities
---
## Separation of Concerns
### What Goes Where
**tRPC Procedures (Entry Layer):**
- ✅ Procedure definitions (query/mutation)
- ✅ Middleware application (auth, validation)
- ✅ Input schemas (Zod v4)
- ✅ Service delegation
- ✅ Error transformation (TRPCError)
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
- ❌ Complex validation (belongs in services)
**Public API Routes (Entry Layer):**
- ✅ Route registration
- ✅ Middleware wrapper application
- ✅ Input validation (Zod v4)
- ✅ Service delegation
- ✅ Response formatting
- ✅ HTTP status codes
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
**Services Layer:**
- ✅ Business logic
- ✅ Business rules enforcement
- ✅ Transaction orchestration
- ✅ Repository calls for complex queries
- ✅ Direct Prisma operations for simple CRUD
- ✅ ClickHouse queries (via repositories)
- ✅ Redis cache access
- ✅ External API calls (LLMs, etc.)
- ❌ HTTP concerns (Request/Response)
- ❌ tRPC-specific types (TRPCError in entry layer)
- ❌ NextAuth session handling (passed as parameter)
**Queue Processors (Worker):**
- ✅ Job registration and configuration
- ✅ Job data extraction
- ✅ Service delegation
- ✅ Progress updates
- ✅ Error handling (retry logic)
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
### Example: Dataset Creation
**tRPC Procedure (Entry Point):**
```typescript
// web/src/features/datasets/server/datasetRouter.ts
import { z } from "zod/v4";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { createDataset } from "./service";
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(
z.object({
name: z.string(),
description: z.string().optional(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
return await createDataset({
...input,
userId: ctx.session.user.id,
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create dataset",
cause: error,
});
}
}),
});
```
**Service (Business Logic):**
```typescript
// web/src/features/datasets/server/service.ts
import { prisma } from "@langfuse/shared/src/db";
import { instrumentAsync, traceException } from "@langfuse/shared/src/server";
export async function createDataset(data: {
name: string;
description?: string;
projectId: string;
userId: string;
}) {
return await instrumentAsync({ name: "dataset.create" }, async (span) => {
// Business rule: Check for duplicate names in project
const existing = await prisma.dataset.findFirst({
where: {
name: data.name,
projectId: data.projectId,
},
});
if (existing) {
throw new Error(`Dataset with name "${data.name}" already exists`);
}
// Create dataset
const dataset = await prisma.dataset.create({
data: {
name: data.name,
description: data.description,
projectId: data.projectId,
createdById: data.userId,
},
});
span.setAttributes({
datasetId: dataset.id,
projectId: dataset.projectId,
});
return dataset;
});
}
```
**Public API (Alternative Entry Point):**
```typescript
// web/src/pages/api/public/datasets.ts
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { createDataset } from "@/src/features/datasets/server/service";
import { z } from "zod/v4";
const createDatasetSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
export default withMiddlewares({
POST: createAuthedProjectAPIRoute({
name: "Create Dataset",
bodySchema: createDatasetSchema,
fn: async ({ body, auth, res }) => {
const dataset = await createDataset({
name: body.name,
description: body.description,
projectId: auth.scope.projectId,
userId: auth.scope.userId,
});
return res.status(201).json(dataset);
},
}),
});
```
**Queue Processor (Async Processing):**
```typescript
// worker/src/queues/datasetExportQueue.ts
import { Job } from "bullmq";
import { exportDataset } from "../features/datasets/exportService";
export async function processDatasetExport(
job: Job<{ datasetId: string; projectId: string; format: string }>,
) {
const { datasetId, projectId, format } = job.data;
await job.updateProgress(10);
// Delegate to service
const exportUrl = await exportDataset({
datasetId,
projectId,
format,
onProgress: (percent) => job.updateProgress(percent),
});
await job.updateProgress(100);
return { exportUrl };
}
```
**Notice:** Each layer has clear, distinct responsibilities!
- **Entry layers** (tRPC/Public API/Queue) handle protocol concerns
- **Service layer** contains all business logic
- **Data layer** accessed via repositories (complex queries) or Prisma directly (simple CRUD)
---
## Database Architecture
### Dual Database System
Langfuse uses two databases with different purposes:
```
┌─────────────────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ ClickHouse │ │
│ │ │ │ │ │
│ │ Transactional│ │ Analytics │ │
│ │ Data │ │ Data │ │
│ └──────────────┘ └──────────────┘ │
│ ↑ ↑ │
│ │ │ │
│ Prisma ORM Direct SQL │
│ (schema migrations) (via client) │
└─────────────────────────────────────────────────────────────┘
```
**PostgreSQL (Primary Database):**
- Accessed via Prisma ORM
- Transactional data (users, projects, datasets, etc.)
- ACID guarantees
- Schema managed via `prisma migrate`
- Located in `packages/shared/prisma/`
**ClickHouse (Analytics Database):**
- Accessed via direct SQL queries
- High-volume trace/observation data
- Columnar storage for analytics
- Optimized for aggregations
- Schema in `packages/shared/src/server/clickhouse/`
- Schema managed via `golang-migrate`
**Redis (Cache & Queues):**
- BullMQ job queues
- Caching layer
- Session storage
- Rate limiting
### Data Access Pattern
**Services access databases directly:**
```typescript
// PostgreSQL via Prisma
import { prisma } from "@langfuse/shared/src/db";
const dataset = await prisma.dataset.create({ data });
// ClickHouse via helper functions
import { getTracesTable } from "@langfuse/shared/src/server";
const traces = await getTracesTable({
projectId,
filter: [...],
limit: 1000,
});
// Redis via queue/cache utilities
import { redis } from "@langfuse/shared/src/server";
await redis.set(`cache:${key}`, value, "EX", 3600);
```
**Repository Pattern:**
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns. Repositories provide:
- Abstraction over complex queries (traces, observations, scores, events)
- Data converters for transforming database models to application models
- ClickHouse query builders and stream processing
- Reusable query logic across services
Services can use repositories for complex operations OR Prisma directly for simple CRUD operations.
---
## Best Practices
### 1. Keep Procedures Thin
tRPC procedures should only handle protocol concerns:
```typescript
// ❌ BAD: Business logic in procedure
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createSchema)
.mutation(async ({ input, ctx }) => {
// 200 lines of business logic here
const existing = await prisma.dataset.findFirst(...);
if (existing) throw new Error(...);
const dataset = await prisma.dataset.create(...);
await sendNotification(...);
return dataset;
}),
});
// ✅ GOOD: Delegate to service
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createSchema)
.mutation(async ({ input, ctx }) => {
return await createDataset(input, ctx.session);
}),
});
```
### 2. Services Should Be Protocol-Agnostic
Services should work regardless of entry point:
```typescript
// ✅ GOOD: No HTTP/tRPC knowledge
export async function createDataset(data: CreateDatasetInput) {
// Pure business logic
return await prisma.dataset.create({ data });
}
// ❌ BAD: tRPC-specific
export async function createDataset(ctx: TRPCContext) {
// Coupled to tRPC
}
```
### 3. Observability with OpenTelemetry + DataDog
**Langfuse uses OpenTelemetry for backend observability, with traces and logs sent to DataDog.**
Use structured logging and instrumentation:
```typescript
import {
logger,
traceException,
instrumentAsync,
} from "@langfuse/shared/src/server";
export async function processEvaluation(evalId: string) {
return await instrumentAsync(
{ name: "evaluation.process", attributes: { evalId } },
async (span) => {
// Structured logging (includes trace_id, span_id, dd.trace_id)
logger.info("Starting evaluation", { evalId });
try {
// Operation here
const result = await runEvaluation(evalId);
span.setAttributes({
score: result.score,
status: "success",
});
return result;
} catch (error) {
// Record exception to OpenTelemetry span (sent to DataDog)
traceException(error, span);
logger.error("Evaluation failed", { evalId, error: error.message });
throw error;
}
},
);
}
```
**Note**: Frontend uses Sentry for error tracking, but backend (tRPC, API routes, services, worker) uses OpenTelemetry + DataDog.
### 4. Use Proper Error Handling
Transform errors at entry points:
```typescript
// tRPC procedure
try {
return await service();
} catch (error) {
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "User-friendly message",
cause: error,
});
}
// Public API
try {
return await service();
} catch (error) {
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
return res.status(500).json({
error: "User-friendly message",
});
}
```
### 5. Validate at Entry Points
Use Zod v4 for all input validation:
```typescript
import { z } from "zod/v4";
// tRPC
.input(z.object({
name: z.string().min(1).max(255),
projectId: z.string(),
}))
// Public API
const bodySchema = z.object({
name: z.string().min(1).max(255),
});
const validated = bodySchema.parse(req.body);
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main guide
- [routing-and-controllers.md](routing-and-controllers.md) - tRPC and Public API details
- [services-and-repositories.md](services-and-repositories.md) - Service patterns
- [testing-guide.md](testing-guide.md) - Testing strategies
@@ -1,563 +0,0 @@
# Configuration Management - Environment Variables
Complete guide to managing configuration across Langfuse's monorepo packages.
## Table of Contents
- [Environment Variable Pattern](#environment-variable-pattern)
- [Package-Specific Configuration](#package-specific-configuration)
- [Special Environment Variables](#special-environment-variables)
- [Best Practices](#best-practices)
---
## Environment Variable Pattern
### Why Zod-Validated Environment Variables?
**Problems with raw process.env:**
- ❌ No type safety
- ❌ No validation
- ❌ Hard to test
- ❌ Runtime errors for typos
- ❌ No default values
**Benefits of Zod validation:**
- ✅ Type-safe configuration
- ✅ Validated at startup
- ✅ Clear error messages
- ✅ Default values
- ✅ Environment-specific transformation
---
## Package-Specific Configuration
Each package has its own `env.ts` or `env.mjs` file that validates and exports environment variables:
```
langfuse/
├── web/src/env.mjs # Next.js app (t3-env pattern)
├── worker/src/env.ts # Worker service (Zod schema)
├── packages/shared/src/env.ts # Shared config (Zod schema)
└── ee/src/env.ts # Enterprise Edition (Zod schema)
```
### Web Package (`web/src/env.mjs`)
Uses **t3-oss/env-nextjs** for Next.js-specific validation with server/client separation.
**Key Features:**
- Separates server-side and client-side environment variables
- Client variables must be prefixed with `NEXT_PUBLIC_`
- Validates at build time (unless `DOCKER_BUILD=1`)
- `runtimeEnv` section manually maps all variables
**Structure:**
```typescript
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
// Server-side only variables (never exposed to client)
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(1),
SALT: z.string(),
CLICKHOUSE_URL: z.string().url(),
// ... 100+ server variables
},
// Client-side variables (exposed to browser)
client: {
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z
.enum(["US", "EU", "STAGING", "DEV", "HIPAA", "JP"])
.optional(),
NEXT_PUBLIC_SIGN_UP_DISABLED: z.enum(["true", "false"]).default("false"),
// ... client variables
},
// Runtime mapping (required for Next.js edge runtime)
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION:
process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
// ... must map ALL variables
},
// Skip validation in Docker builds
skipValidation: process.env.DOCKER_BUILD === "1",
emptyStringAsUndefined: true,
});
```
**Usage:**
```typescript
// In server-side code (tRPC, API routes)
import { env } from "@/src/env.mjs";
const dbUrl = env.DATABASE_URL;
const salt = env.SALT;
// In client-side code (React components)
import { env } from "@/src/env.mjs";
const region = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
```
### Worker Package (`worker/src/env.ts`)
Uses **plain Zod schema** for Express.js worker service.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
BUILD_ID: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string(),
PORT: z.coerce.number().positive().max(65536).default(3030),
// ClickHouse
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
// S3 Event Upload (required)
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
error: "Langfuse requires a bucket name for S3 Event Uploads.",
}),
// Queue concurrency settings
LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY: z.coerce
.number()
.positive()
.default(20),
LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY: z.coerce
.number()
.positive()
.default(5),
// Queue consumer toggles
QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
// ... 150+ worker-specific variables
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "./env";
const concurrency = env.LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY;
const s3Bucket = env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET;
```
### Shared Package (`packages/shared/src/env.ts`)
Uses **plain Zod schema** for configuration shared between web and worker.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "./utils/environment";
const EnvSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
// Redis configuration
REDIS_HOST: z.string().nullish(),
REDIS_PORT: z.coerce.number().positive().max(65536).default(6379).nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
// ClickHouse
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(25),
// S3 Event Upload
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string(),
LANGFUSE_S3_EVENT_UPLOAD_REGION: z.string().optional(),
// Logging
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.optional(),
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
// Encryption
ENCRYPTION_KEY: z
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
// ... 80+ shared variables
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "@langfuse/shared/src/env";
const redisHost = env.REDIS_HOST;
const clickhouseUrl = env.CLICKHOUSE_URL;
```
### Enterprise Edition Package (`ee/src/env.ts`)
Minimal Zod schema for EE-specific variables.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
});
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "@langfuse/ee/src/env";
const licenseKey = env.LANGFUSE_EE_LICENSE_KEY;
```
---
## Special Environment Variables
### NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
**Purpose:** Identifies the cloud deployment region for Langfuse Cloud.
**Type:** `"US" | "EU" | "STAGING" | "DEV" | "HIPAA" | "JP" | undefined`
**Where Used:**
- **web/src/env.mjs** - Client-side accessible (prefixed with `NEXT_PUBLIC_`)
- **ee/src/env.ts** - Enterprise features
- **packages/shared/src/env.ts** - Shared logic
- **worker/src/env.ts** - Worker processing
**When Set:**
| Environment | Value | Purpose |
|--------------------------|------------------------|------------------------------------------------|
| **Developer Laptop** | `"DEV"` or `"STAGING"` | Local development against cloud infrastructure |
| **Langfuse Cloud US** | `"US"` | Production US region |
| **Langfuse Cloud EU** | `"EU"` | Production EU region |
| **Langfuse Cloud HIPAA** | `"HIPAA"` | HIPAA-compliant region |
| **Langfuse Cloud JP** | `"JP"` | Production JP region |
| **OSS Self-Hosted** | `undefined` (not set) | Self-hosted deployments don't have region |
**Use Cases:**
```typescript
// Check if running in cloud
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
// Enable cloud-specific features
- Usage metering and billing
- Cloud spend alerts
- Free tier enforcement
- Stripe integration
- PostHog analytics
}
// Region-specific behavior
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "HIPAA") {
// HIPAA compliance features
}
// Development/staging checks
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV") {
// Enable debug features
}
```
**Example Configuration:**
```bash
# .env file on developer laptop
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=DEV
# Cloud US deployment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
# Self-hosted OSS deployment
# (variable not set)
```
### LANGFUSE_EE_LICENSE_KEY
**Purpose:** Enables Enterprise Edition features in self-hosted deployments.
**Type:** `string | undefined`
**Where Used:**
- **web/src/env.mjs** - Web app EE features
- **ee/src/env.ts** - EE package
**When Set:**
| Deployment | Value | Features Enabled |
| ------------------- | ------------------ | ---------------------------------------------------------------- |
| **Langfuse Cloud** | Not set | Cloud features controlled by `NEXT_PUBLIC_LANGFUSE_CLOUD_REGION` |
| **OSS Self-Hosted** | Not set | Core open-source features only |
| **EE Self-Hosted** | License key string | Enterprise features enabled |
**Enterprise Features Controlled:**
When `LANGFUSE_EE_LICENSE_KEY` is set and valid:
- SSO integrations (custom OIDC, SAML)
- Advanced RBAC
- Audit logging
- Custom branding
- SLA support
- Advanced security features
**Usage Pattern:**
```typescript
import { env } from "@/src/env.mjs";
// Check if EE license is present
if (env.LANGFUSE_EE_LICENSE_KEY) {
// Validate license
const isValidLicense = await validateEELicense(env.LANGFUSE_EE_LICENSE_KEY);
if (isValidLicense) {
// Enable EE features
enableCustomSSO();
enableAdvancedRBAC();
}
}
```
**Example Configuration:**
```bash
# OSS self-hosted (no license)
# LANGFUSE_EE_LICENSE_KEY not set
# EE self-hosted
LANGFUSE_EE_LICENSE_KEY=ee_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Langfuse Cloud (uses region instead)
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
# LANGFUSE_EE_LICENSE_KEY not used
```
### Other Important Variables
**DOCKER_BUILD**
```typescript
// Skip validation during Docker builds
skipValidation: process.env.DOCKER_BUILD === "1";
```
**Purpose:** Docker builds happen before runtime env vars are available, so validation must be skipped.
**SALT**
```typescript
SALT: z.string({
required_error: "A strong Salt is required to encrypt API keys securely.",
});
```
**Purpose:** Required for encrypting API keys in database. Must be set in production.
**ENCRYPTION_KEY**
```typescript
ENCRYPTION_KEY: z.string().length(64, "Must be 256 bits, 64 hex characters");
```
**Purpose:** Optional 256-bit key for encrypting sensitive database fields.
**Generate:** `openssl rand -hex 32`
---
## Best Practices
### 1. Always Import from env.mjs/env.ts
```typescript
// ❌ NEVER DO THIS
const dbUrl = process.env.DATABASE_URL;
// ✅ ALWAYS DO THIS
import { env } from "@/src/env.mjs";
const dbUrl = env.DATABASE_URL; // Type-safe, validated
```
### 2. Use Appropriate Import Path
```typescript
// In web package
import { env } from "@/src/env.mjs";
// In worker package
import { env } from "./env";
// In shared package
import { env } from "@langfuse/shared/src/env";
```
### 3. Client Variables Must Start with NEXT*PUBLIC*
```typescript
// ❌ Won't work in browser
API_KEY: z.string(); // in server config
// ✅ Accessible in browser
NEXT_PUBLIC_API_KEY: z.string(); // in client config
```
### 4. Provide Sensible Defaults for Development
```typescript
PORT: z.coerce.number().positive().default(3030),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
REDIS_PORT: z.coerce.number().positive().default(6379),
```
### 5. Use Coercion for Numbers
```typescript
// .env files are always strings
PORT: z.coerce.number(); // Converts "3000" to 3000
```
### 6. Transform Complex Values
```typescript
// Split comma-separated values
LANGFUSE_LOG_PROPAGATED_HEADERS: z.string().optional().transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : []
),
// Parse project:rate pairs
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z.string().optional().transform((val) => {
const map = new Map<string, number>();
val?.split(",").forEach(part => {
const [projectId, rate] = part.split(":");
map.set(projectId, parseFloat(rate));
});
return map;
}),
```
### 7. Validation at Startup
All environment variables are validated when the application starts. Invalid configuration will cause immediate failure with clear error messages:
```bash
❌ Validation error:
- SALT: Required
- CLICKHOUSE_URL: Invalid url
- PORT: Number must be less than or equal to 65536
```
### 8. Skip Validation in Docker Builds
Always include the Docker build escape hatch:
```typescript
export const env =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
### 9. Use removeEmptyEnvVariables Helper
Treats empty strings as undefined:
```typescript
import { removeEmptyEnvVariables } from "@langfuse/shared";
EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
This prevents errors from `.env` files with empty values:
```bash
# .env
OPTIONAL_VAR= # Treated as undefined, not empty string
```
---
## Configuration File Locations
```
langfuse/
├── .env # Local development overrides
├── .env.dev.example # Example dev configuration
├── web/src/env.mjs # Web app env validation
├── worker/src/env.ts # Worker env validation
├── packages/shared/src/env.ts # Shared env validation
└── ee/src/env.ts # EE env validation
```
**DO NOT commit:**
- `.env`
- `.env.local`
- `.env.production`
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main guide
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
@@ -1,660 +0,0 @@
# Database Patterns - PostgreSQL & ClickHouse
Complete guide to database access patterns in Langfuse using PostgreSQL (Prisma ORM) and ClickHouse (direct client).
## Table of Contents
- [Database Architecture Overview](#database-architecture-overview)
- [PostgreSQL with Prisma](#postgresql-with-prisma)
- [ClickHouse with Direct Client](#clickhouse-with-direct-client)
- [Repository Pattern](#repository-pattern)
- [When to Use Which Database](#when-to-use-which-database)
- [Error Handling](#error-handling)
---
## Database Architecture Overview
Langfuse uses a **dual database architecture**:
| Database | Technology | Purpose | Access Pattern |
| -------------- | ----------------- | ------------------------------------------------------------- | -------------------------------------- |
| **PostgreSQL** | Prisma ORM | Transactional data, relational data, CRUD operations | Type-safe ORM with migrations |
| **ClickHouse** | Direct SQL client | Analytics data, high-volume traces/observations, aggregations | Raw SQL queries with streaming support |
| **Redis** | ioredis | Queues (BullMQ), caching, rate limiting | Direct client access |
**Key Principle**: Use PostgreSQL for transactional data and relationships. Use ClickHouse for high-volume analytics and time-series data.
**⚠️ Important**: All queries must filter by `project_id` (or `projectId`) to ensure proper data isolation between tenants. This is essential for the multi-tenant architecture.
---
## PostgreSQL with Prisma
### Import Pattern
```typescript
import { prisma } from "@langfuse/shared/src/db";
// Direct access to Prisma client
const user = await prisma.user.findUnique({ where: { id } });
```
**Important**: Always import from `@langfuse/shared/src/db`, not `@prisma/client` directly.
### Common CRUD Operations
**⚠️ ALWAYS include `projectId` in WHERE clauses** for project-scoped data:
```typescript
// Create
const project = await prisma.project.create({
data: {
name: "My Project",
orgId: organizationId,
},
});
// ✅ GOOD: Read with projectId filter
const trace = await prisma.trace.findUnique({
where: { id: traceId, projectId }, // ← Always include projectId for tenant isolation
include: {
scores: true,
project: { select: { id: true, name: true } },
},
});
// ❌ BAD: Missing projectId filter
// const trace = await prisma.trace.findUnique({
// where: { id: traceId }, // ← Missing projectId!
// });
// Update
await prisma.user.update({
where: { id: userId },
data: { lastLogin: new Date() },
});
// ✅ GOOD: Delete with projectId
await prisma.apiKey.delete({
where: { id: apiKeyId, projectId }, // ← Always include projectId
});
// ✅ GOOD: Count with projectId
const traceCount = await prisma.trace.count({
where: { projectId, userId }, // ← Always include projectId
});
```
### Transactions
Use Prisma interactive transactions for operations that must be atomic:
```typescript
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
const project = await tx.project.create({
data: {
name: "Default Project",
orgId: user.id,
},
});
await tx.projectMembership.create({
data: {
userId: user.id,
projectId: project.id,
role: "OWNER",
},
});
return { user, project };
});
```
**Transaction options:**
```typescript
await prisma.$transaction(
async (tx) => {
// Transaction logic
},
{
maxWait: 5000, // Max time to wait for transaction to start (ms)
timeout: 10000, // Max time transaction can run (ms)
},
);
```
### Query Optimization
**Use `select` to limit fields:**
```typescript
// ❌ Fetches all fields (including large JSON columns)
const traces = await prisma.trace.findMany({ where: { projectId } });
// ✅ Only fetch needed fields
const traces = await prisma.trace.findMany({
where: { projectId },
select: {
id: true,
name: true,
timestamp: true,
userId: true,
},
});
```
**Prevent N+1 queries with `include`:**
```typescript
// ❌ N+1 Query Problem
const projects = await prisma.project.findMany();
for (const project of projects) {
// N additional queries
const memberCount = await prisma.projectMembership.count({
where: { projectId: project.id },
});
}
// ✅ Use include or aggregation
const projects = await prisma.project.findMany({
include: {
members: { select: { userId: true, role: true } },
},
});
```
**Pagination:**
```typescript
const PAGE_SIZE = 50;
const traces = await prisma.trace.findMany({
where: { projectId },
orderBy: { timestamp: "desc" },
take: PAGE_SIZE,
skip: page * PAGE_SIZE,
});
```
## ClickHouse with Direct Client
### Import Pattern
```typescript
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
```
### ClickHouse Client Singleton
ClickHouse uses a singleton client manager that reuses connections:
```typescript
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
// Get client (automatically reuses existing connection)
const client = clickhouseClient();
// For read-only queries (uses read replica if configured)
const client = clickhouseClient(undefined, "ReadOnly");
```
### Query Patterns
ClickHouse queries use **raw SQL** with parameterized queries. Parameters use `{paramName: Type}` syntax:
**⚠️ Important**: All ClickHouse queries must include `project_id` filter to ensure proper tenant isolation.
**Simple query:**
```typescript
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
// ✅ GOOD: Always filter by project_id
const rows = await queryClickhouse<{ id: string; name: string }>({
query: `
SELECT id, name, timestamp
FROM traces
WHERE project_id = {projectId: String} -- ← REQUIRED: Always filter by project_id
AND timestamp >= {startTime: DateTime64(3)}
ORDER BY timestamp DESC
LIMIT {limit: UInt32}
`,
params: {
projectId, // ← Required for tenant isolation
startTime: convertDateToClickhouseDateTime(startDate),
limit: 100,
},
tags: { feature: "tracing", type: "trace" },
});
// ❌ BAD: Missing project_id filter
// const rows = await queryClickhouse({
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
// params: { startTime },
// });
```
**Streaming query (for large result sets):**
```typescript
import { queryClickhouseStream } from "@langfuse/shared/src/server/repositories/clickhouse";
// Stream results to avoid loading all rows in memory
for await (const row of queryClickhouseStream<ObservationRecordReadType>({
query: `
SELECT *
FROM observations
WHERE project_id = {projectId: String}
AND start_time >= {startTime: DateTime64(3)}
`,
params: { projectId, startTime },
})) {
// Process row by row
await processObservation(row);
}
```
**Upsert (insert) operation:**
```typescript
import { upsertClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
await upsertClickhouse({
table: "traces",
records: [
{
id: traceId,
project_id: projectId,
timestamp: new Date(),
name: "API Call",
user_id: userId,
// ... other fields
},
],
eventBodyMapper: (record) => ({
// Transform record for event log
id: record.id,
name: record.name,
// ... other fields
}),
tags: { feature: "ingestion", type: "trace" },
});
```
**DDL/Administrative commands:**
```typescript
import { commandClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
// Create table, alter schema, etc.
await commandClickhouse({
query: `
ALTER TABLE traces
ADD COLUMN IF NOT EXISTS new_field String
`,
tags: { feature: "migration" },
});
```
### ClickHouse Type Mapping
| JavaScript Type | ClickHouse Param Type |
| --------------- | --------------------------------------------------------- |
| `string` | `String` |
| `number` | `UInt32`, `Int64`, `Float64` |
| `Date` | `DateTime64(3)` (use `convertDateToClickhouseDateTime()`) |
| `boolean` | `UInt8` (0 or 1) |
| `string[]` | `Array(String)` |
**Date handling:**
```typescript
import { convertDateToClickhouseDateTime } from "@langfuse/shared/src/server/clickhouse/client";
const params = {
startTime: convertDateToClickhouseDateTime(new Date()),
};
```
### ClickHouse Query Best Practices
**1. Always filter by `project_id` for tenant isolation:**
```typescript
// ✅ CORRECT: project_id filter is required
const query = `
SELECT *
FROM traces
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
AND timestamp >= {startTime: DateTime64(3)}
`;
// ❌ WRONG: Missing project_id filter
// const query = `
// SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}
// `;
```
**Why this is important:**
- Langfuse is multi-tenant - each project's data must be isolated
- The `project_id` filter ensures queries only access data from the intended tenant
- All queries on project-scoped tables (traces, observations, scores, sessions, etc.) must filter by `project_id`
**2. Use LIMIT BY for deduplication:**
```typescript
// Get latest version of each trace
const query = `
SELECT *
FROM traces
WHERE project_id = {projectId: String} -- ← Always include project_id
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`;
```
**3. Use time-based filtering for performance:**
```typescript
// Combine project_id filter with timestamp for optimal performance
const query = `
SELECT *
FROM observations
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
AND start_time >= {startTime: DateTime64(3)} -- ← Improves performance
AND start_time < {endTime: DateTime64(3)}
`;
```
**4. Use CTEs for complex queries (still require `project_id`):**
```typescript
const query = `
WITH observations_agg AS (
SELECT
trace_id,
count() as observation_count,
sum(total_cost) as total_cost
FROM observations
WHERE project_id = {projectId: String} -- ← Filter in CTE
GROUP BY trace_id
)
SELECT
t.id,
t.name,
o.observation_count,
o.total_cost
FROM traces t
LEFT JOIN observations_agg o ON t.id = o.trace_id
WHERE t.project_id = {projectId: String} -- ← Filter in main query
`;
```
**Note**: When using CTEs or subqueries, ensure `project_id` filter is applied at each level.
**Error handling with retries:**
ClickHouse queries automatically retry on network errors (socket hang up). Custom error handling for resource limits:
```typescript
import {
queryClickhouse,
ClickHouseResourceError,
} from "@langfuse/shared/src/server/repositories/clickhouse";
try {
const rows = await queryClickhouse({ query, params });
} catch (error) {
if (error instanceof ClickHouseResourceError) {
// Memory limit, timeout, or overcommit error
throw new Error(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
}
throw error;
}
```
---
## Repository Pattern
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns.
### When to Use Repositories
**Use repositories when:**
- Complex ClickHouse queries with CTEs, aggregations, or joins
- Query used in multiple places (DRY principle)
- Need data transformation/converters (DB → domain models)
- Building reusable query logic with filters
**Use direct Prisma/ClickHouse for:**
- Simple CRUD operations
- One-off queries
- Prototyping (refactor to repository later)
### Repository Examples
**Trace repository (ClickHouse):**
```typescript
// packages/shared/src/server/repositories/traces.ts
export const getTracesByIds = async (
projectId: string,
traceIds: string[],
): Promise<TraceRecordReadType[]> => {
const rows = await queryClickhouse<TraceRecordReadType>({
query: `
SELECT *
FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`,
params: { projectId, traceIds },
tags: { feature: "tracing", type: "trace" },
});
return rows.map(convertClickhouseToDomain);
};
```
**Score repository (PostgreSQL + ClickHouse):**
```typescript
// Repositories can query both databases
export const getScoresByTraceId = async (
projectId: string,
traceId: string,
) => {
// Use ClickHouse for analytics
const clickhouseScores = await queryClickhouse<ScoreRecordReadType>({
query: `
SELECT *
FROM scores
WHERE project_id = {projectId: String}
AND trace_id = {traceId: String}
`,
params: { projectId, traceId },
});
// Use Prisma for config data
const scoreConfigs = await prisma.scoreConfig.findMany({
where: { projectId },
});
return enrichScoresWithConfigs(clickhouseScores, scoreConfigs);
};
```
---
## When to Use Which Database
| Use Case | Database | Reasoning |
| -------------------------------------- | ---------- | ------------------------------------------ |
| User accounts, projects, API keys | PostgreSQL | Transactional data with strong consistency |
| Prompt management, dataset definitions | PostgreSQL | Configuration data with relations |
| Project settings, RBAC permissions | PostgreSQL | Small, frequently updated data |
| Traces, observations, events | ClickHouse | High-volume time-series data |
| Score aggregations, analytics queries | ClickHouse | Fast aggregations over millions of rows |
| Usage metrics, cost calculations | ClickHouse | Analytical queries with GROUP BY |
| Exports, large dataset queries | ClickHouse | Streaming support for large result sets |
**Decision flow:**
1. Is it high-volume time-series data? → **ClickHouse**
2. Does it need aggregation over millions of rows? → **ClickHouse**
3. Is it transactional data with relationships? → **PostgreSQL**
4. Is it configuration or user data? → **PostgreSQL**
5. Is it frequently updated? → **PostgreSQL**
6. Is it append-only analytics data? → **ClickHouse**
### Project-Scoped vs Global Tables
**Project-scoped tables (MUST filter by `project_id`):**
- `traces` - All trace queries require `project_id`
- `observations` - All observation queries require `project_id`
- `scores` - All score queries require `project_id`
- `events` - All event queries require `project_id`
- `dataset_run_items_rmt` - All dataset run queries require `project_id`
**Global tables (no `project_id` filter needed):**
- `users` - User management (use `id` for filtering)
- `organizations` - Organization data (use `id` for filtering)
- System configuration tables
**Example of correct filtering:**
```typescript
// ✅ CORRECT: Project-scoped query
const traces = await queryClickhouse({
query: `
SELECT * FROM traces
WHERE project_id = {projectId: String}
AND timestamp >= {startTime: DateTime64(3)}
`,
params: { projectId, startTime },
});
// ✅ CORRECT: Global table query (no project_id needed)
const user = await prisma.user.findUnique({
where: { id: userId },
});
// ❌ WRONG: Project-scoped query without project_id filter
// const traces = await queryClickhouse({
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
// });
```
---
## Error Handling
### PostgreSQL (Prisma) Errors
```typescript
import { Prisma } from "@prisma/client";
import { prisma } from "@langfuse/shared/src/db";
try {
await prisma.user.create({ data: userData });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Unique constraint violation
if (error.code === "P2002") {
const target = error.meta?.target as string[];
throw new ConflictError(`${target?.join(", ")} already exists`);
}
// Foreign key constraint
if (error.code === "P2003") {
throw new ValidationError("Invalid reference");
}
// Record not found
if (error.code === "P2025") {
throw new NotFoundError("Record not found");
}
// Record required to connect not found
if (error.code === "P2018") {
throw new ValidationError("Related record not found");
}
}
// Unknown error
logger.error("Prisma error", { error });
throw error;
}
```
**Common Prisma error codes:**
| Code | Meaning | Typical Cause |
| -------- | ---------------------------- | ------------------------------------- |
| `P2002` | Unique constraint violation | Duplicate email, API key, etc. |
| `P2003` | Foreign key constraint | Referenced record doesn't exist |
| `P2025` | Record not found | Update/delete of non-existent record |
| `P2018` | Required relation not found | Connect to non-existent related record |
### ClickHouse Errors
```typescript
import {
queryClickhouse,
ClickHouseResourceError,
} from "@langfuse/shared/src/server/repositories/clickhouse";
try {
const rows = await queryClickhouse({ query, params });
} catch (error) {
// ClickHouse resource errors (memory limit, timeout, overcommit)
if (error instanceof ClickHouseResourceError) {
logger.warn("ClickHouse resource error", {
errorType: error.errorType, // "MEMORY_LIMIT" | "OVERCOMMIT" | "TIMEOUT"
message: error.message,
});
// User-friendly error message
throw new BadRequestError(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
}
// Network/connection errors are automatically retried
logger.error("ClickHouse error", { error });
throw error;
}
```
**ClickHouse error types:**
| Error Type | Discriminator | Meaning | Solution |
| --------------- | ----------------------- | ---------------------------- | -------------------------------------------------- |
| `MEMORY_LIMIT` | "memory limit exceeded" | Query used too much memory | Use more specific filters or shorter time range |
| `OVERCOMMIT` | "OvercommitTracker" | Memory overcommit limit hit | Reduce query complexity or result set size |
| `TIMEOUT` | "Timeout", "timed out" | Query took too long | Add filters, reduce time range, or optimize query |
**ClickHouse retries:**
ClickHouse queries automatically retry network errors (socket hang up) with exponential backoff. Configure retry behavior:
```typescript
// In packages/shared/src/env.ts
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().positive().default(3)
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
- [configuration.md](configuration.md) - Environment variable configuration
@@ -1,765 +0,0 @@
# Middleware Guide - tRPC & Public API Patterns
Complete guide to middleware patterns in Langfuse's Next.js + tRPC architecture.
## Table of Contents
- [tRPC Middleware](#trpc-middleware)
- [Public API Middleware](#public-api-middleware)
- [Authentication Patterns](#authentication-patterns)
- [Error Handling Middleware](#error-handling-middleware)
- [OpenTelemetry Instrumentation](#opentelemetry-instrumentation)
- [Composable Procedures](#composable-procedures)
---
## tRPC Middleware
**File:** `web/src/server/api/trpc.ts`
tRPC middleware in Langfuse is composable and type-safe. Each middleware enriches the context and provides guarantees to subsequent middleware.
### Core tRPC Middlewares
**1. Error Handling Middleware (`withErrorHandling`)**
Intercepts all errors and transforms them into user-friendly tRPC errors:
```typescript
const withErrorHandling = t.middleware(async ({ ctx, next }) => {
const res = await next({ ctx });
if (!res.ok) {
if (res.error.cause instanceof ClickHouseResourceError) {
// Surface ClickHouse resource errors with advice message
res.error = new TRPCError({
code: "SERVICE_UNAVAILABLE",
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
});
} else {
// Transform 5xx errors to not expose internals
const { code, httpStatus } = resolveError(res.error);
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
res.error = new TRPCError({
code,
cause: null, // do not expose stack traces
message: isSafeToExpose
? res.error.message
: "Internal error. We have been notified and are working on it.",
});
}
}
return res;
});
```
**2. OpenTelemetry Instrumentation (`withOtelInstrumentation`)**
Propagates OpenTelemetry context with Langfuse-specific baggage:
```typescript
const withOtelInstrumentation = t.middleware(async (opts) => {
const actualInput = await opts.getRawInput();
const baggageCtx = contextWithLangfuseProps({
headers: opts.ctx.headers,
userId: opts.ctx.session?.user?.id,
projectId: (actualInput as Record<string, string>)?.projectId,
});
return opentelemetry.context.with(baggageCtx, () => opts.next());
});
```
**3. Authentication Middleware (`enforceUserIsAuthed`)**
Ensures user is logged in via NextAuth session:
```typescript
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
```
**4. Project Membership Middleware (`enforceUserIsAuthedAndProjectMember`)**
Validates that the user is a member of the project specified in input:
```typescript
const enforceUserIsAuthedAndProjectMember = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const actualInput = await opts.getRawInput();
const parsedInput = inputProjectSchema.safeParse(actualInput);
if (!parsedInput.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input, projectId is required",
});
}
const projectId = parsedInput.data.projectId;
const sessionProject = ctx.session.user.organizations
.flatMap((org) => org.projects.map((project) => ({ ...project, organization: org })))
.find((project) => project.id === projectId);
if (!sessionProject) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User is not a member of this project",
});
}
return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
orgId: sessionProject.organization.id,
orgRole: sessionProject.organization.role,
projectId: projectId,
projectRole: sessionProject.role,
},
},
});
});
```
**5. Organization Membership Middleware (`enforceIsAuthedAndOrgMember`)**
Validates organization membership:
```typescript
const enforceIsAuthedAndOrgMember = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const actualInput = await opts.getRawInput();
const result = inputOrganizationSchema.safeParse(actualInput);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input, orgId is required",
});
}
const orgId = result.data.orgId;
const sessionOrg = ctx.session.user.organizations.find((org) => org.id === orgId);
if (!sessionOrg && ctx.session.user.admin !== true) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User is not a member of this organization",
});
}
return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
orgId: orgId,
orgRole: ctx.session.user.admin === true ? Role.OWNER : sessionOrg!.role,
},
},
});
});
```
**6. Trace Access Middleware (`enforceTraceAccess`)**
Special middleware for trace-level routes that supports public traces:
```typescript
const enforceTraceAccess = t.middleware(async (opts) => {
const { ctx, next } = opts;
const actualInput = await opts.getRawInput();
const result = inputTraceSchema.safeParse(actualInput);
if (!result.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
}
const trace = await getTraceById({
traceId: result.data.traceId,
projectId: result.data.projectId,
timestamp: result.data.timestamp ?? undefined,
});
if (!trace) {
throw new TRPCError({ code: "NOT_FOUND", message: "Trace not found" });
}
const sessionProject = ctx.session?.user?.organizations
.flatMap((org) => org.projects)
.find(({ id }) => id === result.data.projectId);
// Allow access if:
// 1. User is a project member
// 2. Trace is public
// 3. User is admin
if (!trace.public && !sessionProject && ctx.session?.user?.admin !== true) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User is not a member of this project and this trace is not public",
});
}
return next({
ctx: {
session: { ...ctx.session, projectRole: sessionProject?.role },
trace: trace, // pass the trace to avoid refetching
},
});
});
```
### tRPC Procedure Types
Langfuse exports composed procedures with middleware chains:
```typescript
// 1. Public procedure (no auth required)
export const publicProcedure = withOtelTracingProcedure.use(withErrorHandling);
// 2. Authenticated procedure (NextAuth session required)
export const authenticatedProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceUserIsAuthed);
// 3. Project-scoped procedure (project membership required)
export const protectedProjectProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceUserIsAuthedAndProjectMember);
// 4. Organization-scoped procedure
export const protectedOrganizationProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceIsAuthedAndOrgMember);
// 5. Trace access procedure (public traces supported)
export const protectedGetTraceProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceTraceAccess);
// 6. Session access procedure
export const protectedGetSessionProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceSessionAccess);
// 7. Admin API key procedure (for admin operations)
export const adminProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceAdminAuth);
```
---
## Public API Middleware
**Files:** `web/src/features/public-api/server/`
### withMiddlewares Pattern
Wraps all public API routes with CORS, error handling, and OpenTelemetry:
```typescript
export function withMiddlewares(handlers: Handlers) {
return async (req: NextApiRequest, res: NextApiResponse) => {
const ctx = contextWithLangfuseProps({ headers: req.headers });
return opentelemetry.context.with(ctx, async () => {
try {
// 1. CORS middleware
await runMiddleware(req, res, cors);
// 2. HTTP method routing
const method = req.method as HttpMethod;
if (!handlers[method]) throw new MethodNotAllowedError();
// 3. Execute handler
return await handlers[method](req, res);
} catch (error) {
// 4. Error handling
if (error instanceof BaseError) {
if (error.httpCode >= 500) traceException(error);
return res.status(error.httpCode).json({
message: error.message,
error: error.name,
});
}
if (error instanceof ClickHouseResourceError) {
return res.status(524).json({
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
error: "Request is taking too long to process.",
});
}
if (isZodError(error)) {
return res.status(400).json({
message: "Invalid request data",
error: error.issues,
});
}
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
});
};
}
```
**Usage:**
```typescript
// web/src/pages/api/public/datasets/[datasetName]/items.ts
export default withMiddlewares({
GET: getDatasetItemsHandler,
POST: createDatasetItemHandler,
});
```
### createAuthedProjectAPIRoute Pattern
Factory function for authenticated public API routes with:
- Authentication (Basic auth or Admin API key)
- Rate limiting
- Input/output validation (Zod)
- OpenTelemetry context
```typescript
export const createAuthedProjectAPIRoute = <TQuery, TBody, TResponse>(
routeConfig: RouteConfig<TQuery, TBody, TResponse>
) => {
return async (req: NextApiRequest, res: NextApiResponse) => {
// 1. Authentication (verifyAuth)
const auth = await verifyAuth(req, routeConfig.isAdminApiKeyAuthAllowed || false);
// 2. Rate limiting
const rateLimitResponse = await RateLimitService.getInstance().rateLimitRequest(
auth.scope,
routeConfig.rateLimitResource || "public-api"
);
if (rateLimitResponse?.isRateLimited()) {
return rateLimitResponse.sendRestResponseIfLimited(res);
}
// 3. Input validation
const query = routeConfig.querySchema
? routeConfig.querySchema.parse(req.query)
: {};
const body = routeConfig.bodySchema
? routeConfig.bodySchema.parse(req.body)
: {};
// 4. Execute with OpenTelemetry context
const ctx = contextWithLangfuseProps({
headers: req.headers,
projectId: auth.scope.projectId,
});
return opentelemetry.context.with(ctx, async () => {
const response = await routeConfig.fn({ query, body, req, res, auth });
// 5. Response validation (dev only)
if (env.NODE_ENV === "development" && routeConfig.responseSchema) {
const parsingResult = routeConfig.responseSchema.safeParse(response);
if (!parsingResult.success) {
logger.error("Response validation failed:", parsingResult.error);
}
}
res.status(routeConfig.successStatusCode || 200).json(response);
});
};
};
```
**Usage:**
```typescript
// web/src/pages/api/public/traces/[traceId].ts
export default createAuthedProjectAPIRoute({
name: "Get Trace",
querySchema: GetTraceV1Query,
responseSchema: GetTraceV1Response,
fn: async ({ query, auth }) => {
const trace = await getTraceById({
traceId: query.traceId,
projectId: auth.scope.projectId,
});
return transformTraceToApiResponse(trace);
},
});
```
---
## Authentication Patterns
### tRPC Authentication (NextAuth)
tRPC uses NextAuth sessions stored in JWT cookies:
```typescript
// Context creation with session
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const session = await getServerAuthSession({ req, res });
addUserToSpan({
userId: session?.user?.id,
email: session?.user?.email ?? undefined,
});
return {
session,
headers: req.headers,
prisma,
DB,
};
};
```
**Session types:**
```typescript
// Base authenticated context
export type AuthedContext = {
session: { user: NonNullable<Session["user"]> };
};
// Project-scoped context
export type ProjectAuthedContext = {
session: AuthedContext["session"] & {
orgId: string;
orgRole: Role;
projectId: string;
projectRole: Role;
};
};
```
### Public API Authentication
Public APIs use **Basic Auth** with API keys:
```typescript
async function verifyBasicAuth(authHeader: string | undefined) {
const regularAuth = await new ApiAuthService(prisma, redis)
.verifyAuthHeaderAndReturnScope(authHeader);
if (!regularAuth.validKey) {
throw { status: 401, message: regularAuth.error };
}
if (regularAuth.scope.accessLevel !== "project") {
throw { status: 401, message: "Access denied - need basic auth with secret key" };
}
return regularAuth;
}
```
**Admin API Key Authentication** (self-hosted only):
```typescript
async function verifyAdminApiKeyAuth(req: NextApiRequest) {
// Requires:
// 1. Authorization: Bearer <ADMIN_API_KEY>
// 2. x-langfuse-admin-api-key: <ADMIN_API_KEY>
// 3. x-langfuse-project-id: <project-id>
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
throw { status: 403, message: "Admin API key auth not available on Langfuse Cloud" };
}
const adminApiKey = env.ADMIN_API_KEY;
const bearerToken = req.headers.authorization?.replace("Bearer ", "");
const adminApiKeyHeader = req.headers["x-langfuse-admin-api-key"];
// Timing-safe comparison
const isValid =
crypto.timingSafeEqual(Buffer.from(bearerToken), Buffer.from(adminApiKey)) &&
crypto.timingSafeEqual(Buffer.from(adminApiKeyHeader), Buffer.from(adminApiKey));
if (!isValid) throw { status: 401, message: "Invalid admin API key" };
const projectId = req.headers["x-langfuse-project-id"];
const project = await prisma.project.findUnique({ where: { id: projectId } });
if (!project) throw { status: 404, message: "Project not found" };
return { validKey: true, scope: { projectId, accessLevel: "project" } };
}
```
---
## Error Handling Middleware
### tRPC Error Transformation
All tRPC errors go through `withErrorHandling` middleware:
**Error types handled:**
1. **ClickHouseResourceError**`SERVICE_UNAVAILABLE` (524)
2. **BaseError** → Preserves httpCode and message
3. **5xx errors** → Sanitized as "Internal error" (hides stack traces)
4. **4xx errors** → Original error message preserved
**Example:**
```typescript
if (!res.ok) {
if (res.error.cause instanceof ClickHouseResourceError) {
res.error = new TRPCError({
code: "SERVICE_UNAVAILABLE",
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
});
} else {
const { code, httpStatus } = resolveError(res.error);
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
res.error = new TRPCError({
code,
cause: null,
message: isSafeToExpose ? res.error.message : "Internal error.",
});
}
}
```
### Public API Error Handling
Public API uses `withMiddlewares` for error handling:
```typescript
catch (error) {
// 1. BaseError (custom application errors)
if (error instanceof BaseError) {
if (error.httpCode >= 500) traceException(error);
return res.status(error.httpCode).json({
message: error.message,
error: error.name,
});
}
// 2. ClickHouseResourceError (query timeouts, memory limits)
if (error instanceof ClickHouseResourceError) {
return res.status(524).json({
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
error: "Request is taking too long to process.",
});
}
// 3. Zod validation errors
if (isZodError(error)) {
return res.status(400).json({
message: "Invalid request data",
error: error.issues,
});
}
// 4. Prisma errors
if (isPrismaException(error)) {
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: "An unknown error occurred",
});
}
// 5. Unknown errors
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
```
---
## OpenTelemetry Instrumentation
All requests (tRPC and public API) propagate OpenTelemetry context with Langfuse-specific baggage.
### Context Propagation Pattern
```typescript
import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
import * as opentelemetry from "@opentelemetry/api";
// Create context with Langfuse baggage
const ctx = contextWithLangfuseProps({
headers: req.headers,
userId: session?.user?.id,
projectId: input?.projectId,
});
// Execute with context
return opentelemetry.context.with(ctx, async () => {
// All instrumented code inside here will have access to baggage
return await handler();
});
```
**Baggage includes:**
- `userId` - User ID from session
- `projectId` - Project ID from input
- `headers` - Request headers for trace propagation
### tRPC Instrumentation
```typescript
const withOtelInstrumentation = t.middleware(async (opts) => {
const actualInput = await opts.getRawInput();
const baggageCtx = contextWithLangfuseProps({
headers: opts.ctx.headers,
userId: opts.ctx.session?.user?.id,
projectId: (actualInput as Record<string, string>)?.projectId,
});
return opentelemetry.context.with(baggageCtx, () => opts.next());
});
// Used with Baselime tracing
const withOtelTracingProcedure = t.procedure
.use(withOtelInstrumentation)
.use(tracing({ collectInput: true, collectResult: true }));
```
---
## Composable Procedures
tRPC procedures are composed by chaining middleware:
### Composition Pattern
```typescript
// Base procedure with tracing + error handling
const baseProcedure = withOtelTracingProcedure.use(withErrorHandling);
// Add authentication
const authedProcedure = baseProcedure.use(enforceUserIsAuthed);
// Add project scoping
const projectProcedure = authedProcedure.use(enforceUserIsAuthedAndProjectMember);
```
### Using Procedures in Routers
```typescript
import { protectedProjectProcedure } from "@/src/server/api/trpc";
export const tracesRouter = createTRPCRouter({
// Input automatically validated against Zod schema
all: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
page: z.number().optional(),
limit: z.number().optional(),
})
)
.query(async ({ input, ctx }) => {
// ctx.session.projectId is guaranteed to exist
// ctx.session.projectRole contains user's role
const traces = await getTraces({
projectId: input.projectId,
page: input.page ?? 0,
limit: input.limit ?? 50,
});
return traces;
}),
byId: protectedGetTraceProcedure
.input(
z.object({
traceId: z.string(),
projectId: z.string(),
timestamp: z.date().nullish(),
})
)
.query(async ({ input, ctx }) => {
// ctx.trace is guaranteed to exist (fetched by middleware)
// No need to refetch
return ctx.trace;
}),
});
```
### Middleware Execution Order
Middleware executes in the order it's chained:
```typescript
protectedProjectProcedure
.use(withErrorHandling) // 1. Wraps entire execution
.use(enforceUserIsAuthed) // 2. Validates session exists
.use(enforceUserIsAuthedAndProjectMember) // 3. Validates project membership
.input(schema) // 4. Validates input
.query(async ({ input, ctx }) => { // 5. Executes query
// ...
});
```
**Context enrichment:**
Each middleware can enrich the context:
```typescript
// After enforceUserIsAuthed:
ctx.session.user // NonNullable<User>
// After enforceUserIsAuthedAndProjectMember:
ctx.session.projectId // string
ctx.session.projectRole // Role (OWNER | ADMIN | MEMBER | VIEWER)
ctx.session.orgId // string
ctx.session.orgRole // Role
// After enforceTraceAccess:
ctx.trace // TraceRecord (pre-fetched)
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
- [../AGENTS.md](../AGENTS.md) - Error handling patterns and traceException guidance
@@ -1,844 +0,0 @@
# Routing Patterns - Next.js & tRPC
Complete guide to routing and separation of concerns in Langfuse's Next.js + tRPC architecture.
## Table of Contents
- [Architecture Overview](#architecture-overview)
- [tRPC Routers](#trpc-routers)
- [Public REST API Routes](#public-rest-api-routes)
- [Service Layer](#service-layer)
- [Repository Layer](#repository-layer)
- [Separation of Concerns](#separation-of-concerns)
- [Anti-Patterns](#anti-patterns)
---
## Architecture Overview
Langfuse uses a **layered architecture** with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────┐
│ ENTRY POINTS │
│ ┌──────────────────────┐ ┌─────────────────────────┐ │
│ │ tRPC Procedures │ │ Public REST API Routes │ │
│ │ (Internal UI API) │ │ (SDK/External API) │ │
│ └──────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
│ Business logic, orchestration, validation │
│ web/src/features/*/server/ or packages/shared/services/ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ REPOSITORY LAYER │
│ Complex queries, data transformation │
│ packages/shared/src/server/repositories/ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ DATABASE LAYER │
│ PostgreSQL (Prisma) + ClickHouse (Direct Client) │
└─────────────────────────────────────────────────────────────┘
```
### Key Principles
**Entry Points (Routes/Procedures):**
- ✅ Define routing and procedure signatures
- ✅ Handle authentication/authorization (via middleware)
- ✅ Validate input (Zod schemas)
- ✅ Delegate to services
- ✅ Return responses
**Entry Points should NEVER:**
- ❌ Contain business logic
- ❌ Access database directly
- ❌ Perform complex data transformations
- ❌ Make direct repository calls (use services)
**Services:**
- ✅ Contain business logic
- ✅ Orchestrate multiple operations
- ✅ Call repositories or Prisma/ClickHouse
- ✅ Handle complex workflows
- ❌ Should NOT know about HTTP, tRPC, or request/response objects
**Repositories:**
- ✅ Complex database queries
- ✅ Data transformation (DB → domain models)
- ✅ ClickHouse query builders
- ✅ Reusable query logic
- ❌ Should NOT contain business logic
---
## tRPC Routers
**Location:** `web/src/server/api/routers/`
tRPC routers define type-safe procedures for the internal UI. Each router groups related operations.
### Router Structure
**File:** `web/src/server/api/routers/scores.ts`
```typescript
import { z } from "zod/v4";
import { createTRPCRouter, protectedProjectProcedure } from "@/src/server/api/trpc";
import { paginationZod, singleFilter, orderBy } from "@langfuse/shared";
import {
getScoresUiTable,
getScoresUiCount,
upsertScore,
} from "@langfuse/shared/src/server";
const ScoreAllOptions = z.object({
projectId: z.string(),
filter: z.array(singleFilter),
orderBy: orderBy,
...paginationZod,
});
export const scoresRouter = createTRPCRouter({
/**
* Get all scores for a project
*/
all: protectedProjectProcedure
.input(ScoreAllOptions)
.query(async ({ input, ctx }) => {
// Delegate to repository for data fetching
const clickhouseScoreData = await getScoresUiTable({
projectId: input.projectId,
filter: input.filter ?? [],
orderBy: input.orderBy,
limit: input.limit,
offset: input.page * input.limit,
});
// Delegate to Prisma for related data
const [jobExecutions, users] = await Promise.all([
ctx.prisma.jobExecution.findMany({
where: {
jobOutputScoreId: {
in: clickhouseScoreData.map((score) => score.id),
},
},
}),
ctx.prisma.user.findMany({
where: {
id: {
in: clickhouseScoreData
.map((s) => s.authorUserId)
.filter((id): id is string => id !== null),
},
},
}),
]);
// Transform and combine data
return clickhouseScoreData.map((score) => ({
...score,
jobConfigurationId:
jobExecutions.find((j) => j.jobOutputScoreId === score.id)
?.jobConfigurationId ?? null,
authorUserImage:
users.find((u) => u.id === score.authorUserId)?.image ?? null,
authorUserName:
users.find((u) => u.id === score.authorUserId)?.name ?? null,
}));
}),
/**
* Create or update score
*/
createAnnotationScore: protectedProjectProcedure
.input(CreateAnnotationScoreData)
.mutation(async ({ input, ctx }) => {
// Validation
validateConfigAgainstBody(input);
// Delegate to repository
await upsertScore({
id: input.id ?? randomUUID(),
traceId: input.traceId,
projectId: input.projectId,
name: input.name,
value: input.value,
source: ScoreSource.ANNOTATION,
authorUserId: ctx.session.user.id,
comment: input.comment,
});
// Audit log
await auditLog({
session: ctx.session,
resourceType: "score",
resourceId: input.id,
action: "create",
});
return { success: true };
}),
});
```
**Key Points:**
- Use appropriate procedure type (`protectedProjectProcedure`, `authenticatedProcedure`, etc.)
- Define input schema with Zod (`.input()`)
- Use `.query()` for reads, `.mutation()` for writes
- Delegate to services/repositories for data access
- Keep procedures thin - no business logic
- Type-safe throughout (TypeScript infers types from Zod schemas)
### Registering Routers
**File:** `web/src/server/api/root.ts`
```typescript
import { createTRPCRouter } from "@/src/server/api/trpc";
import { scoresRouter } from "./routers/scores";
import { tracesRouter } from "./routers/traces";
import { dashboardRouter } from "@/src/features/dashboard/server/dashboard-router";
export const appRouter = createTRPCRouter({
scores: scoresRouter,
traces: tracesRouter,
dashboard: dashboardRouter,
// ... other routers
});
export type AppRouter = typeof appRouter;
```
**Calling from frontend:**
```typescript
// Type-safe client call
const { data, isLoading } = api.scores.all.useQuery({
projectId: "proj_123",
page: 0,
limit: 50,
filter: [],
orderBy: null,
});
```
---
## Public REST API Routes
**Location:** `web/src/pages/api/public/`
Public API routes use **Next.js file-based routing** and provide REST endpoints for SDKs and external integrations.
### File-based Routing
Next.js uses file system for routing:
```
web/src/pages/api/public/
├── scores/
│ ├── index.ts → GET/POST /api/public/scores
│ └── [scoreId].ts → GET/PATCH/DELETE /api/public/scores/:scoreId
├── traces/
│ ├── index.ts → GET /api/public/traces
│ └── [traceId].ts → GET /api/public/traces/:traceId
└── datasets/
└── [name]/
├── index.ts → GET/POST /api/public/datasets/:name
└── items/
└── index.ts → GET /api/public/datasets/:name/items
```
**Dynamic routes:**
- `[param].ts` → Single dynamic segment (e.g., `/api/public/scores/[scoreId].ts`)
- `[...param].ts` → Catch-all route (e.g., `/api/public/[...path].ts`)
### REST API Pattern
**File:** `web/src/pages/api/public/scores/index.ts`
```typescript
import { v4 } from "uuid";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import {
GetScoresQueryV1,
GetScoresResponseV1,
PostScoresBodyV1,
PostScoresResponseV1,
} from "@langfuse/shared";
import { eventTypes, processEventBatch } from "@langfuse/shared/src/server";
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
export default withMiddlewares({
// POST /api/public/scores
POST: createAuthedProjectAPIRoute({
name: "Create Score",
bodySchema: PostScoresBodyV1,
responseSchema: PostScoresResponseV1,
fn: async ({ body, auth, res }) => {
const event = {
id: v4(),
type: eventTypes.SCORE_CREATE,
timestamp: new Date().toISOString(),
body,
};
if (!event.body.id) {
event.body.id = v4();
}
const result = await processEventBatch([event], auth);
if (result.errors.length > 0) {
const error = result.errors[0];
res.status(error.status).json({
message: error.error ?? error.message,
});
return { id: "" };
}
return { id: event.body.id };
},
}),
// GET /api/public/scores
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
querySchema: GetScoresQueryV1,
responseSchema: GetScoresResponseV1,
fn: async ({ query, auth }) => {
const scoresApiService = new ScoresApiService("v1");
const [items, count] = await Promise.all([
scoresApiService.generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page,
limit: query.limit,
userId: query.userId,
name: query.name,
}),
scoresApiService.getScoresCountForPublicApi({
projectId: auth.scope.projectId,
userId: query.userId,
name: query.name,
}),
]);
return {
data: items,
meta: {
page: query.page,
limit: query.limit,
totalItems: count,
totalPages: Math.ceil(count / query.limit),
},
};
},
}),
});
```
**Key Points:**
- Use `withMiddlewares` for all public API routes (provides CORS, error handling, OpenTelemetry)
- Use `createAuthedProjectAPIRoute` for authenticated endpoints (handles auth, rate limiting, validation)
- Define separate handlers for each HTTP method
- Input/output validated with Zod schemas
- Delegate to services for business logic
### Simple Public Routes
For routes that don't need authentication:
```typescript
// web/src/pages/api/public/health.ts
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
export default withMiddlewares({
GET: async (req, res) => {
res.status(200).json({ status: "ok" });
},
});
```
---
## Service Layer
**Location:** `web/src/features/*/server/` or `packages/shared/src/server/services/`
Services contain business logic and orchestrate operations. They're called by tRPC procedures and API routes.
### Service Pattern
**File:** `web/src/features/public-api/server/scores-api-service.ts`
```typescript
import {
_handleGenerateScoresForPublicApi,
_handleGetScoresCountForPublicApi,
type ScoreQueryType,
} from "@/src/features/public-api/server/scores";
import { _handleGetScoreById } from "@langfuse/shared/src/server";
export class ScoresApiService {
constructor(private readonly apiVersion: "v1" | "v2") {}
/**
* Get a specific score by ID
*/
async getScoreById({
projectId,
scoreId,
source,
}: {
projectId: string;
scoreId: string;
source?: ScoreSourceType;
}) {
return _handleGetScoreById({
projectId,
scoreId,
source,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
preferredClickhouseService: "ReadOnly",
});
}
/**
* Get list of scores with version-aware filtering
*/
async generateScoresForPublicApi(props: ScoreQueryType) {
return _handleGenerateScoresForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
});
}
/**
* Get count of scores with version-aware filtering
*/
async getScoresCountForPublicApi(props: ScoreQueryType) {
return _handleGetScoresCountForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
});
}
}
```
**Key Points:**
- Services contain business logic, not routing logic
- Services should NOT import tRPC or Next.js types
- Services can call repositories, Prisma, ClickHouse directly
- Services orchestrate multiple operations
- Services are reusable across tRPC and public API
### Where to Put Services
**Feature-specific services:**
```
web/src/features/
├── datasets/
│ └── server/
│ └── dataset-service.ts
├── evals/
│ └── server/
│ └── eval-service.ts
└── public-api/
└── server/
└── scores-api-service.ts
```
**Shared services:**
```
packages/shared/src/server/services/
├── SlackService.ts
├── DashboardService/
├── StorageService.ts
└── DefaultEvaluationModelService/
```
---
## Repository Layer
**Location:** `packages/shared/src/server/repositories/`
Repositories handle complex database queries, data transformation, and provide reusable query logic.
### Repository Structure
```
packages/shared/src/server/repositories/
├── traces.ts # Trace queries (ClickHouse)
├── observations.ts # Observation queries (ClickHouse)
├── scores.ts # Score queries (ClickHouse)
├── clickhouse.ts # Core ClickHouse helpers
└── definitions.ts # Type definitions
```
### Repository Pattern
**File:** `packages/shared/src/server/repositories/traces.ts`
```typescript
import { queryClickhouse, upsertClickhouse } from "./clickhouse";
import { TraceRecordReadType } from "./definitions";
import { convertClickhouseToDomain } from "./traces_converters";
/**
* Get traces by IDs
*/
export const getTracesByIds = async (
projectId: string,
traceIds: string[]
): Promise<TraceRecordReadType[]> => {
const rows = await queryClickhouse<TraceRecordReadType>({
query: `
SELECT *
FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`,
params: { projectId, traceIds },
tags: { feature: "tracing", type: "trace" },
});
return rows.map(convertClickhouseToDomain);
};
/**
* Upsert trace to ClickHouse
*/
export const upsertTrace = async (
trace: TraceRecordInsertType
): Promise<void> => {
await upsertClickhouse({
table: "traces",
records: [trace],
eventBodyMapper: (body) => ({
id: body.id,
name: body.name,
user_id: body.user_id,
// ... map fields
}),
tags: { feature: "ingestion", type: "trace" },
});
};
```
**Key Points:**
- Use `queryClickhouse` for SELECT queries
- Use `upsertClickhouse` for INSERT/UPDATE
- Use `commandClickhouse` for DDL (ALTER TABLE, etc.)
- Include data converters (`convertClickhouseToDomain`)
- Add OpenTelemetry tags for observability
- Repositories should NOT contain business logic
### When to Use Repositories
**Use repositories for:**
- Complex ClickHouse queries with CTEs, joins, aggregations
- Queries used in multiple places (DRY principle)
- Data transformation from DB types to domain models
- Streaming large result sets
**Use direct Prisma/ClickHouse for:**
- Simple CRUD operations
- One-off queries
- Prototyping (can refactor to repository later)
---
## Separation of Concerns
### ✅ Good Example: Proper Layering
**tRPC Procedure (Entry Point):**
```typescript
// web/src/server/api/routers/scores.ts
export const scoresRouter = createTRPCRouter({
all: protectedProjectProcedure
.input(ScoreFilterOptions)
.query(async ({ input }) => {
// ✅ Thin procedure - delegates to repository
return await getScoresUiTable({
projectId: input.projectId,
filter: input.filter,
orderBy: input.orderBy,
});
}),
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
// ✅ Delegates to service for orchestration
return await createScoreWithValidation({
scoreData: input,
userId: ctx.session.user.id,
projectId: ctx.session.projectId,
});
}),
});
```
**Service (Business Logic):**
```typescript
// web/src/features/scores/server/score-service.ts
export async function createScoreWithValidation({
scoreData,
userId,
projectId,
}: {
scoreData: CreateScoreInput;
userId: string;
projectId: string;
}) {
// ✅ Business logic: validation
const config = await prisma.scoreConfig.findUnique({
where: { id: scoreData.configId },
});
if (!config) {
throw new LangfuseNotFoundError("Score config not found");
}
validateConfigAgainstBody(config, scoreData);
// ✅ Business logic: orchestration
const scoreId = randomUUID();
await Promise.all([
// Create score in ClickHouse
upsertScore({
id: scoreId,
projectId,
traceId: scoreData.traceId,
name: scoreData.name,
value: scoreData.value,
authorUserId: userId,
}),
// Audit log in PostgreSQL
auditLog({
userId,
resourceType: "score",
resourceId: scoreId,
action: "create",
}),
]);
return { id: scoreId };
}
```
**Repository (Data Access):**
```typescript
// packages/shared/src/server/repositories/scores.ts
export const upsertScore = async (
score: ScoreInsertType
): Promise<void> => {
// ✅ Pure data access - no business logic
await upsertClickhouse({
table: "scores",
records: [score],
eventBodyMapper: (body) => ({
id: body.id,
trace_id: body.traceId,
name: body.name,
value: body.value,
author_user_id: body.authorUserId,
}),
tags: { feature: "scoring" },
});
};
```
### Why This Works
1. **tRPC Procedure**: Thin, delegates to service
2. **Service**: Contains all business logic (validation, orchestration)
3. **Repository**: Pure data access, reusable
4. **Service is protocol-agnostic**: Can be called from tRPC, public API, or worker
5. **Clear separation**: Easy to test, maintain, extend
---
## Anti-Patterns
### ❌ Anti-Pattern 1: Business Logic in Routes
**Bad:**
```typescript
// ❌ BAD: Business logic in tRPC procedure
export const scoresRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
// ❌ Validation logic in route
const config = await ctx.prisma.scoreConfig.findUnique({
where: { id: input.configId },
});
if (!config) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (config.dataType === "NUMERIC" && typeof input.value !== "number") {
throw new TRPCError({ code: "BAD_REQUEST" });
}
// ❌ Direct database access
await ctx.prisma.score.create({
data: {
id: randomUUID(),
projectId: ctx.session.projectId,
traceId: input.traceId,
name: input.name,
value: input.value,
},
});
// ❌ More business logic
await auditLog({ ... });
return { success: true };
}),
});
```
**Why it's bad:**
- Business logic tied to tRPC (can't reuse in public API)
- Hard to test (need to mock tRPC context)
- No separation of concerns
- Difficult to maintain
**Good:**
```typescript
// ✅ GOOD: Thin procedure, delegates to service
export const scoresRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
return await createScoreWithValidation({
scoreData: input,
userId: ctx.session.user.id,
projectId: ctx.session.projectId,
});
}),
});
```
### ❌ Anti-Pattern 2: Database Calls in Routes
**Bad:**
```typescript
// ❌ BAD: Direct database access in route
export default withMiddlewares({
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
fn: async ({ auth }) => {
// ❌ Direct ClickHouse query in route
const scores = await queryClickhouse({
query: "SELECT * FROM scores WHERE project_id = {projectId: String}",
params: { projectId: auth.scope.projectId },
});
return { data: scores };
},
}),
});
```
**Good:**
```typescript
// ✅ GOOD: Delegates to service or repository
export default withMiddlewares({
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
fn: async ({ auth, query }) => {
const scoresService = new ScoresApiService("v1");
return await scoresService.generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page,
limit: query.limit,
});
},
}),
});
```
### ❌ Anti-Pattern 3: Business Logic in Repositories
**Bad:**
```typescript
// ❌ BAD: Business logic in repository
export const upsertScore = async (
score: ScoreInsertType
): Promise<void> => {
// ❌ Validation in repository
if (!score.name) {
throw new Error("Score name is required");
}
// ❌ Authorization check in repository
const project = await prisma.project.findUnique({
where: { id: score.projectId },
});
if (!project) {
throw new Error("Project not found");
}
// ❌ Side effects in repository
await auditLog({ ... });
await upsertClickhouse({ ... });
};
```
**Good:**
```typescript
// ✅ GOOD: Pure data access, no business logic
export const upsertScore = async (
score: ScoreInsertType
): Promise<void> => {
await upsertClickhouse({
table: "scores",
records: [score],
eventBodyMapper: (body) => ({
id: body.id,
trace_id: body.traceId,
name: body.name,
value: body.value,
}),
tags: { feature: "scoring" },
});
};
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
- [middleware-guide.md](middleware-guide.md) - Middleware patterns
- [database-patterns.md](database-patterns.md) - Database access patterns
@@ -1,877 +0,0 @@
# Services and Repositories - Business Logic Layer
Complete guide to organizing business logic with services and data access with repositories.
## Table of Contents
- [Service Layer Overview](#service-layer-overview)
- [Dependency Injection Pattern](#dependency-injection-pattern)
- [Singleton Pattern](#singleton-pattern)
- [Repository Pattern](#repository-pattern)
- [Service Design Principles](#service-design-principles)
- [Caching Strategies](#caching-strategies)
- [Testing Services](#testing-services)
---
## Service Layer Overview
### Purpose of Services
**Services contain business logic** - the 'what' and 'why' of your application:
```
Controller asks: "Should I do this?"
Service answers: "Yes/No, here's why, and here's what happens"
Repository executes: "Here's the data you requested"
```
**Services are responsible for:**
- ✅ Business rules enforcement
- ✅ Orchestrating multiple repositories
- ✅ Transaction management
- ✅ Complex calculations
- ✅ External service integration
- ✅ Business validations
**Services should NOT:**
- ❌ Know about HTTP (Request/Response)
- ❌ Direct Prisma access (use repositories)
- ❌ Handle route-specific logic
- ❌ Format HTTP responses
---
## Dependency Injection Pattern
### Why Dependency Injection?
**Benefits:**
- Easy to test (inject mocks)
- Clear dependencies
- Flexible configuration
- Promotes loose coupling
### Excellent Example: NotificationService
**File:** `/blog-api/src/services/NotificationService.ts`
```typescript
// Define dependencies interface for clarity
export interface NotificationServiceDependencies {
prisma: PrismaClient;
batchingService: BatchingService;
emailComposer: EmailComposer;
}
// Service with dependency injection
export class NotificationService {
private prisma: PrismaClient;
private batchingService: BatchingService;
private emailComposer: EmailComposer;
private preferencesCache: Map<
string,
{ preferences: UserPreference; timestamp: number }
> = new Map();
private CACHE_TTL =
(notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;
// Dependencies injected via constructor
constructor(dependencies: NotificationServiceDependencies) {
this.prisma = dependencies.prisma;
this.batchingService = dependencies.batchingService;
this.emailComposer = dependencies.emailComposer;
}
/**
* Create a notification and route it appropriately
*/
async createNotification(params: CreateNotificationParams) {
const {
recipientID,
type,
title,
message,
link,
context = {},
channel = "both",
priority = NotificationPriority.NORMAL,
} = params;
try {
// Get template and render content
const template = getNotificationTemplate(type);
const rendered = renderNotificationContent(template, context);
// Create in-app notification record
const notificationId = await createNotificationRecord({
instanceId: parseInt(context.instanceId || "0", 10),
template: type,
recipientUserId: recipientID,
channel: channel === "email" ? "email" : "inApp",
contextData: context,
title: finalTitle,
message: finalMessage,
link: finalLink,
});
// Route notification based on channel
if (channel === "email" || channel === "both") {
await this.routeNotification({
notificationId,
userId: recipientID,
type,
priority,
title: finalTitle,
message: finalMessage,
link: finalLink,
context,
});
}
return notification;
} catch (error) {
ErrorLogger.log(error, {
context: {
"[NotificationService] createNotification": {
type: params.type,
recipientID: params.recipientID,
},
},
});
throw error;
}
}
/**
* Route notification based on user preferences
*/
private async routeNotification(params: {
notificationId: number;
userId: string;
type: string;
priority: NotificationPriority;
title: string;
message: string;
link?: string;
context?: Record<string, any>;
}) {
// Get user preferences with caching
const preferences = await this.getUserPreferences(params.userId);
// Check if we should batch or send immediately
if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
await this.batchingService.queueNotificationForBatch({
notificationId: params.notificationId,
userId: params.userId,
userPreference: preferences,
priority: params.priority,
});
} else {
// Send immediately via EmailComposer
await this.sendImmediateEmail({
userId: params.userId,
title: params.title,
message: params.message,
link: params.link,
context: params.context,
type: params.type,
});
}
}
/**
* Determine if email should be batched
*/
shouldBatchEmail(
preferences: UserPreference,
notificationType: string,
priority: NotificationPriority,
): boolean {
// HIGH priority always immediate
if (priority === NotificationPriority.HIGH) {
return false;
}
// Check batch mode
const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
return batchMode !== BatchMode.IMMEDIATE;
}
/**
* Get user preferences with caching
*/
async getUserPreferences(userId: string): Promise<UserPreference> {
// Check cache first
const cached = this.preferencesCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.preferences;
}
const preference = await this.prisma.userPreference.findUnique({
where: { userID: userId },
});
const finalPreferences = preference || DEFAULT_PREFERENCES;
// Update cache
this.preferencesCache.set(userId, {
preferences: finalPreferences,
timestamp: Date.now(),
});
return finalPreferences;
}
}
```
**Usage in Controller:**
```typescript
// Instantiate with dependencies
const notificationService = new NotificationService({
prisma: PrismaService.main,
batchingService: new BatchingService(PrismaService.main),
emailComposer: new EmailComposer(),
});
// Use in controller
const notification = await notificationService.createNotification({
recipientID: "user-123",
type: "AFRLWorkflowNotification",
context: { workflowName: "AFRL Monthly Report" },
});
```
**Key Takeaways:**
- Dependencies passed via constructor
- Clear interface defines required dependencies
- Easy to test (inject mocks)
- Encapsulated caching logic
- Business rules isolated from HTTP
---
## Singleton Pattern
### When to Use Singletons
**Use for:**
- Services with expensive initialization
- Services with shared state (caching)
- Services accessed from many places
- Permission services
- Configuration services
### Example: PermissionService (Singleton)
**File:** `/blog-api/src/services/permissionService.ts`
```typescript
import { PrismaClient } from "@prisma/client";
class PermissionService {
private static instance: PermissionService;
private prisma: PrismaClient;
private permissionCache: Map<
string,
{ canAccess: boolean; timestamp: number }
> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Private constructor prevents direct instantiation
private constructor() {
this.prisma = PrismaService.main;
}
// Get singleton instance
public static getInstance(): PermissionService {
if (!PermissionService.instance) {
PermissionService.instance = new PermissionService();
}
return PermissionService.instance;
}
/**
* Check if user can complete a workflow step
*/
async canCompleteStep(
userId: string,
stepInstanceId: number,
): Promise<boolean> {
const cacheKey = `${userId}:${stepInstanceId}`;
// Check cache
const cached = this.permissionCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.canAccess;
}
try {
const post = await this.prisma.post.findUnique({
where: { id: postId },
include: {
author: true,
comments: {
include: {
user: true,
},
},
},
});
if (!post) {
return false;
}
// Check if user has permission
const canEdit =
post.authorId === userId || (await this.isUserAdmin(userId));
// Cache result
this.permissionCache.set(cacheKey, {
canAccess: isAssigned,
timestamp: Date.now(),
});
return isAssigned;
} catch (error) {
console.error(
"[PermissionService] Error checking step permission:",
error,
);
return false;
}
}
/**
* Clear cache for user
*/
clearUserCache(userId: string): void {
for (const [key] of this.permissionCache) {
if (key.startsWith(`${userId}:`)) {
this.permissionCache.delete(key);
}
}
}
/**
* Clear all cache
*/
clearCache(): void {
this.permissionCache.clear();
}
}
// Export singleton instance
export const permissionService = PermissionService.getInstance();
```
**Usage:**
```typescript
import { permissionService } from "../services/permissionService";
// Use anywhere in the codebase
const canComplete = await permissionService.canCompleteStep(userId, stepId);
if (!canComplete) {
throw new ForbiddenError("You do not have permission to complete this step");
}
```
---
## Repository Pattern
### Purpose of Repositories
**Repositories abstract data access** - the 'how' of data operations:
```
Service: "Get me all active users sorted by name"
Repository: "Here's the Prisma query that does that"
```
**Repositories are responsible for:**
- ✅ All Prisma operations
- ✅ Query construction
- ✅ Query optimization (select, include)
- ✅ Database error handling
- ✅ Caching database results
**Repositories should NOT:**
- ❌ Contain business logic
- ❌ Know about HTTP
- ❌ Make decisions (that's service layer)
### Repository Template
```typescript
// repositories/UserRepository.ts
import { PrismaService } from "@project-lifecycle-portal/database";
import type { User, Prisma } from "@project-lifecycle-portal/database";
export class UserRepository {
/**
* Find user by ID with optimized query
*/
async findById(userId: string): Promise<User | null> {
try {
return await PrismaService.main.user.findUnique({
where: { userID: userId },
select: {
userID: true,
email: true,
name: true,
isActive: true,
roles: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
console.error("[UserRepository] Error finding user by ID:", error);
throw new Error(`Failed to find user: ${userId}`);
}
}
/**
* Find all active users
*/
async findActive(options?: {
orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> {
try {
return await PrismaService.main.user.findMany({
where: { isActive: true },
orderBy: options?.orderBy || { name: "asc" },
select: {
userID: true,
email: true,
name: true,
roles: true,
},
});
} catch (error) {
console.error("[UserRepository] Error finding active users:", error);
throw new Error("Failed to find active users");
}
}
/**
* Find user by email
*/
async findByEmail(email: string): Promise<User | null> {
try {
return await PrismaService.main.user.findUnique({
where: { email },
});
} catch (error) {
console.error("[UserRepository] Error finding user by email:", error);
throw new Error(`Failed to find user with email: ${email}`);
}
}
/**
* Create new user
*/
async create(data: Prisma.UserCreateInput): Promise<User> {
try {
return await PrismaService.main.user.create({ data });
} catch (error) {
console.error("[UserRepository] Error creating user:", error);
throw new Error("Failed to create user");
}
}
/**
* Update user
*/
async update(userId: string, data: Prisma.UserUpdateInput): Promise<User> {
try {
return await PrismaService.main.user.update({
where: { userID: userId },
data,
});
} catch (error) {
console.error("[UserRepository] Error updating user:", error);
throw new Error(`Failed to update user: ${userId}`);
}
}
/**
* Delete user (soft delete by setting isActive = false)
*/
async delete(userId: string): Promise<User> {
try {
return await PrismaService.main.user.update({
where: { userID: userId },
data: { isActive: false },
});
} catch (error) {
console.error("[UserRepository] Error deleting user:", error);
throw new Error(`Failed to delete user: ${userId}`);
}
}
/**
* Check if email exists
*/
async emailExists(email: string): Promise<boolean> {
try {
const count = await PrismaService.main.user.count({
where: { email },
});
return count > 0;
} catch (error) {
console.error("[UserRepository] Error checking email exists:", error);
throw new Error("Failed to check if email exists");
}
}
}
// Export singleton instance
export const userRepository = new UserRepository();
```
**Using Repository in Service:**
```typescript
// services/userService.ts
import { userRepository } from "../repositories/UserRepository";
import { ConflictError, NotFoundError } from "../utils/errors";
export class UserService {
/**
* Create new user with business rules
*/
async createUser(data: {
email: string;
name: string;
roles: string[];
}): Promise<User> {
// Business rule: Check if email already exists
const emailExists = await userRepository.emailExists(data.email);
if (emailExists) {
throw new ConflictError("Email already exists");
}
// Business rule: Validate roles
const validRoles = ["admin", "operations", "user"];
const invalidRoles = data.roles.filter(
(role) => !validRoles.includes(role),
);
if (invalidRoles.length > 0) {
throw new ValidationError(`Invalid roles: ${invalidRoles.join(", ")}`);
}
// Create user via repository
return await userRepository.create({
email: data.email,
name: data.name,
roles: data.roles,
isActive: true,
});
}
/**
* Get user by ID
*/
async getUser(userId: string): Promise<User> {
const user = await userRepository.findById(userId);
if (!user) {
throw new NotFoundError(`User not found: ${userId}`);
}
return user;
}
}
```
---
## Service Design Principles
### 1. Single Responsibility
Each service should have ONE clear purpose:
```typescript
// ✅ GOOD - Single responsibility
class UserService {
async createUser() {}
async updateUser() {}
async deleteUser() {}
}
class EmailService {
async sendEmail() {}
async sendBulkEmails() {}
}
// ❌ BAD - Too many responsibilities
class UserService {
async createUser() {}
async sendWelcomeEmail() {} // Should be EmailService
async logUserActivity() {} // Should be AuditService
async processPayment() {} // Should be PaymentService
}
```
### 2. Clear Method Names
Method names should describe WHAT they do:
```typescript
// ✅ GOOD - Clear intent
async createNotification()
async getUserPreferences()
async shouldBatchEmail()
async routeNotification()
// ❌ BAD - Vague or misleading
async process()
async handle()
async doIt()
async execute()
```
### 3. Use Params Objects for Multiple Arguments
When a function receives multiple arguments, use a single params object instead of positional arguments:
```typescript
// ❌ BAD - Positional arguments are unclear and can be swapped
async function createTrace(
projectId: string,
userId: string,
sessionId: string,
name: string,
) {}
// Call site - which string is which?
await createTrace(projectId, userId, sessionId, name);
// ✅ GOOD - Params object makes intent clear
async function createTrace(params: {
projectId: string;
userId: string;
sessionId: string;
name: string;
}) {}
// Call site - clear and prevents argument swapping bugs
await createTrace({ projectId, userId, sessionId, name });
```
**Benefits:**
- More readable at call sites
- Prevents bugs when positional arguments of the same type are accidentally swapped
- Easier to add optional parameters later
- Self-documenting code
### 4. Return Types
Always use explicit return types:
```typescript
// ✅ GOOD - Explicit types
async createUser(data: CreateUserDTO): Promise<User> {}
async findUsers(): Promise<User[]> {}
async deleteUser(id: string): Promise<void> {}
// ❌ BAD - Implicit any
async createUser(data) {} // No types!
```
### 5. Error Handling
Services should throw meaningful errors:
```typescript
// ✅ GOOD - Meaningful errors
if (!user) {
throw new NotFoundError(`User not found: ${userId}`);
}
if (emailExists) {
throw new ConflictError("Email already exists");
}
// ❌ BAD - Generic errors
if (!user) {
throw new Error("Error"); // What error?
}
```
### 6. Avoid God Services
Don't create services that do everything:
```typescript
// ❌ BAD - God service
class WorkflowService {
async startWorkflow() {}
async completeStep() {}
async assignRoles() {}
async sendNotifications() {} // Should be NotificationService
async validatePermissions() {} // Should be PermissionService
async logAuditTrail() {} // Should be AuditService
// ... 50 more methods
}
// ✅ GOOD - Focused services
class WorkflowService {
constructor(
private notificationService: NotificationService,
private permissionService: PermissionService,
private auditService: AuditService,
) {}
async startWorkflow() {
// Orchestrate other services
await this.permissionService.checkPermission();
await this.workflowRepository.create();
await this.notificationService.notify();
await this.auditService.log();
}
}
```
---
## Caching Strategies
### 1. In-Memory Caching
```typescript
class UserService {
private cache: Map<string, { user: User; timestamp: number }> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async getUser(userId: string): Promise<User> {
// Check cache
const cached = this.cache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.user;
}
// Fetch from database
const user = await userRepository.findById(userId);
// Update cache
if (user) {
this.cache.set(userId, { user, timestamp: Date.now() });
}
return user;
}
clearUserCache(userId: string): void {
this.cache.delete(userId);
}
}
```
### 2. Cache Invalidation
```typescript
class UserService {
async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
// Update in database
const user = await userRepository.update(userId, data);
// Invalidate cache
this.clearUserCache(userId);
return user;
}
}
```
---
## Testing Services
### Unit Tests
```typescript
// tests/userService.test.ts
import { UserService } from "../services/userService";
import { userRepository } from "../repositories/UserRepository";
import { ConflictError } from "../utils/errors";
// Mock repository
jest.mock("../repositories/UserRepository");
describe("UserService", () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
jest.clearAllMocks();
});
describe("createUser", () => {
it("should create user when email does not exist", async () => {
// Arrange
const userData = {
email: "test@example.com",
name: "Test User",
roles: ["user"],
};
(userRepository.emailExists as jest.Mock).mockResolvedValue(false);
(userRepository.create as jest.Mock).mockResolvedValue({
userID: "123",
...userData,
});
// Act
const user = await userService.createUser(userData);
// Assert
expect(user).toBeDefined();
expect(user.email).toBe(userData.email);
expect(userRepository.emailExists).toHaveBeenCalledWith(userData.email);
expect(userRepository.create).toHaveBeenCalled();
});
it("should throw ConflictError when email exists", async () => {
// Arrange
const userData = {
email: "existing@example.com",
name: "Test User",
roles: ["user"],
};
(userRepository.emailExists as jest.Mock).mockResolvedValue(true);
// Act & Assert
await expect(userService.createUser(userData)).rejects.toThrow(
ConflictError,
);
expect(userRepository.create).not.toHaveBeenCalled();
});
});
});
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main guide
- [routing-and-controllers.md](routing-and-controllers.md) - Controllers that use services
- [database-patterns.md](database-patterns.md) - Prisma and repository patterns
- [testing-guide.md](testing-guide.md) - Testing service and repository code
@@ -1,555 +0,0 @@
# Testing Guide - Backend Testing Strategies
Complete guide to testing Langfuse backend services across web, worker, and shared packages.
## Table of Contents
- [Test Types Overview](#test-types-overview)
- [Integration Tests (Public API)](#integration-tests-public-api)
- [Service-Level Tests (Repository/Service)](#service-level-tests-repositoryservice)
- [tRPC Tests (Procedure Testing)](#trpc-tests-procedure-testing)
- [Worker Tests (Queue Processing)](#worker-tests-queue-processing)
- [Key Testing Principles](#key-testing-principles)
- [Running Tests](#running-tests)
---
## Test Types Overview
Langfuse uses multiple testing strategies for different layers:
| Test Type | Framework | Location | Purpose |
|-----------|-----------|----------|---------|
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedure testing with auth |
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service function testing |
| Worker | Vitest | `worker/src/__tests__/` | Queue processors and streams |
---
## Integration Tests (Public API)
Test full REST API endpoints end-to-end using HTTP requests.
**File location:** `web/src/__tests__/async/datasets-api.servertest.ts`
```typescript
import { makeZodVerifiedAPICall } from "../helpers";
import { PostDatasetsV1Response } from "@/src/features/public-api/types/datasets";
describe("Dataset API", () => {
it("should create dataset", async () => {
const res = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: "test-dataset" },
auth,
);
expect(res.status).toBe(200);
});
it("should validate input", async () => {
const res = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: "" }, // Invalid empty name
auth,
);
expect(res.status).toBe(400);
});
});
```
**Key Points:**
- Uses `makeZodVerifiedAPICall` for type-safe API testing
- Tests HTTP status codes and response validation
- Tests both success and error cases
---
## Service-Level Tests (Repository/Service)
Test individual repository/service functions with isolated data.
**File location:** `web/src/__tests__/async/repositories/event-repository.servertest.ts`
```typescript
import {
createEvent,
createEventsCh,
getObservationsWithModelDataFromEventsTable,
} from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import { randomUUID } from "crypto";
describe("Event Repository Tests", () => {
it("should return observations with model data", async () => {
const traceId = randomUUID();
const generationId = randomUUID();
const modelId = randomUUID();
// Create test data
await prisma.model.create({
data: {
id: modelId,
projectId,
modelName: `gpt-4-${modelId}`,
matchPattern: `(?i)^(gpt-?4-${modelId})$`,
startDate: new Date("2023-01-01"),
unit: "TOKENS",
Price: {
create: [
{ usageType: "input", price: 0.03 },
{ usageType: "output", price: 0.06 },
],
},
},
});
const event = createEvent({
id: generationId,
span_id: generationId,
project_id: projectId,
trace_id: traceId,
type: "GENERATION",
name: `test-generation-${generationId}`,
model_id: modelId,
});
await createEventsCh([event]);
// Test the service function
const result = await getObservationsWithModelDataFromEventsTable({
projectId,
filter: [{ type: "string", column: "id", operator: "=", value: generationId }],
limit: 1000,
offset: 0,
});
expect(result.length).toBeGreaterThan(0);
const observation = result.find((o) => o.id === generationId);
expect(observation?.internalModelId).toBe(modelId);
expect(Number(observation?.inputPrice)).toBeCloseTo(0.03, 5);
// Cleanup
await prisma.model.delete({ where: { id: modelId } });
});
it("should handle filters correctly", async () => {
const projectId = randomUUID();
const traceId = randomUUID();
const observations = [
createEvent({
id: randomUUID(),
project_id: projectId,
trace_id: traceId,
type: "GENERATION",
name: "test1",
}),
createEvent({
id: randomUUID(),
project_id: projectId,
trace_id: traceId,
type: "SPAN",
name: "test2",
}),
];
await createEventsCh(observations);
const result = await getObservationsWithModelDataFromEventsTable({
projectId,
filter: [
{ type: "stringOptions", column: "type", operator: "any of", value: ["GENERATION"] }
],
limit: 1000,
offset: 0,
});
expect(result.every(o => o.type === "GENERATION")).toBe(true);
});
});
```
**Key Points:**
- Tests service/repository functions directly
- Uses ClickHouse and Prisma test data
- Always cleanup test data after tests
- Use unique IDs to avoid test interference
---
## tRPC Tests (Procedure Testing)
Test tRPC procedures with caller pattern and auth context.
**File location:** `web/src/__tests__/async/automations-trpc.servertest.ts`
```typescript
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { prisma } from "@langfuse/shared/src/db";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import type { Session } from "next-auth";
import { v4 } from "uuid";
import { JobConfigState } from "@langfuse/shared";
async function prepare() {
const { project, org } = await createOrgProjectAndApiKey();
const session: Session = {
expires: "1",
user: {
id: "user-1",
name: "Demo User",
organizations: [{
id: org.id,
name: org.name,
role: "OWNER",
projects: [{
id: project.id,
role: "ADMIN",
name: project.name,
}],
}],
},
};
const ctx = createInnerTRPCContext({ session, headers: {} });
const caller = appRouter.createCaller({ ...ctx, prisma });
return { project, org, session, ctx, caller };
}
describe("automations trpc", () => {
it("should retrieve all automations for a project", async () => {
const { project, caller } = await prepare();
// Create test trigger
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
},
});
// Create test action
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
headers: { "Content-Type": "application/json" },
},
},
});
// Link trigger to action
await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Call tRPC procedure
const response = await caller.automations.getAutomations({
projectId: project.id,
});
expect(response).toHaveLength(1);
expect(response[0]).toMatchObject({
name: "Test Automation",
trigger: expect.objectContaining({
id: trigger.id,
eventSource: "prompt",
}),
});
});
it("should throw error when user lacks permissions", async () => {
const { project, session } = await prepare();
// Create limited session
const limitedSession: Session = {
...session,
user: {
...session.user!,
organizations: [{
...session.user!.organizations[0],
projects: [{
...session.user!.organizations[0].projects[0],
role: "VIEWER", // VIEWER can't create automations
}],
}],
},
};
const limitedCtx = createInnerTRPCContext({
session: limitedSession,
headers: {},
});
const limitedCaller = appRouter.createCaller({ ...limitedCtx, prisma });
await expect(
limitedCaller.automations.createAutomation({
projectId: project.id,
name: "Unauthorized",
eventSource: "prompt",
eventAction: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
actionType: "WEBHOOK",
actionConfig: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {},
apiVersion: { prompt: "v1" },
},
}),
).rejects.toThrow("User does not have access");
});
});
```
**Key Points:**
- Uses `prepare()` helper to set up test context
- Creates authenticated caller with `appRouter.createCaller`
- Tests both success and permission error cases
- Can test different user roles and permissions
---
## Worker Tests (Queue Processing)
Test queue processors and stream functions using vitest.
**File location:** `worker/src/__tests__/batchExport.test.ts`
```typescript
import { randomUUID } from "crypto";
import { expect, describe, it } from "vitest";
import {
createObservation,
createObservationsCh,
createOrgProjectAndApiKey,
createTraceScore,
createScoresCh,
createTrace,
createTracesCh,
} from "@langfuse/shared/src/server";
import { getObservationStream } from "../features/database-read-stream/observation-stream";
describe("batch export test suite", () => {
it("should export observations", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const traceId = randomUUID();
const trace = createTrace({
project_id: projectId,
id: traceId,
});
await createTracesCh([trace]);
const observations = [
createObservation({
project_id: projectId,
trace_id: traceId,
type: "SPAN",
}),
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "GENERATION",
}),
];
const score = createTraceScore({
project_id: projectId,
trace_id: traceId,
observation_id: observations[0].id,
name: "test",
value: 123,
});
await createScoresCh([score]);
await createObservationsCh(observations);
// Test the stream function
const stream = await getObservationStream({
projectId: projectId,
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
filter: [],
});
const rows: any[] = [];
for await (const chunk of stream) {
rows.push(chunk);
}
expect(rows).toHaveLength(2);
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: observations[0].id,
type: observations[0].type,
test: [score.value],
}),
]),
);
});
it("should export with filters", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const observations = [
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "GENERATION",
name: "test1",
}),
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "SPAN",
name: "test2",
}),
];
await createObservationsCh(observations);
const stream = await getObservationStream({
projectId: projectId,
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
filter: [
{
type: "stringOptions",
operator: "any of",
column: "name",
value: ["test1"],
},
],
});
const rows: any[] = [];
for await (const chunk of stream) {
rows.push(chunk);
}
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe("test1");
});
});
```
**Key Points:**
- Uses vitest (not Jest) for worker tests
- Tests stream functions with async iteration
- Creates isolated test data per test
- Use unique project IDs to avoid interference
---
## Key Testing Principles
### General Principles
1. **Test Isolation**: Each test should be independent and runnable in any order
2. **Unique IDs**: Use `randomUUID()` or unique project IDs to avoid test interference
3. **Cleanup**: Always clean up test data in service tests (or use unique project IDs)
4. **Avoid Global Resets**: Prefer scoped cleanup or unique project IDs over global reset helpers
### By Test Type
| Test Type | Key Principles |
|-----------|----------------|
| **Integration** | Test HTTP endpoints, validate status codes and response shapes |
| **tRPC** | Use `createInnerTRPCContext` and `appRouter.createCaller`, test auth/permissions |
| **Service** | Test individual functions with isolated data, always cleanup |
| **Worker** | Use vitest, test streams with async iteration, test filtering logic |
### Test Data Management
```typescript
// ✅ GOOD: Use unique IDs
const projectId = randomUUID();
const traceId = randomUUID();
// ✅ GOOD: Cleanup in service tests
afterAll(async () => {
await prisma.model.delete({ where: { id: modelId } });
});
// ✅ GOOD: Use unique projects (no cleanup needed)
const { projectId } = await createOrgProjectAndApiKey();
// ❌ BAD: Shared test data between tests
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
```
---
## Running Tests
### Web Tests (Jest)
```bash
# Run all tests
pnpm test
# Run sync tests
pnpm test-sync
# Run async tests
pnpm test -- --testPathPattern="async"
# Run specific test file
pnpm test -- --testPathPattern="datasets-api"
# Run specific test
pnpm test -- --testPathPattern="datasets-api" --testNamePattern="should create dataset"
```
### Worker Tests (Vitest)
```bash
# Run all worker tests
pnpm run test --filter=worker
# Run specific test file
pnpm run test --filter=worker -- batchExport
# Run specific test
pnpm run test --filter=worker -- batchExport -t "should export observations"
```
### Coverage
```bash
# Web coverage
pnpm test -- --coverage
# Worker coverage
pnpm run test --filter=worker -- --coverage
```
---
**Related Files:**
- [../AGENTS.md](../AGENTS.md) - Main backend guidelines
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
- [services-and-repositories.md](services-and-repositories.md) - Service and repository examples
-48
View File
@@ -1,48 +0,0 @@
---
name: changelog-writing
description: |
Shared workflow for writing Langfuse changelog entries after a feature is complete.
Use when a branch is ready for merge and a changelog entry or changelog draft is needed.
---
# Changelog Writing
Use this skill when a completed feature branch needs a changelog entry.
## Workflow
1. Understand the change set.
2. Study recent changelog patterns in `../langfuse-docs/pages/changelog`.
3. Find related documentation links in `../langfuse-docs/pages`.
4. Draft a user-focused changelog entry.
5. Recommend whether an image or screenshot should be added.
## What To Gather
- The branch diff relative to `main`
- The Linear issue, if the branch name includes an `lfe-XXXX` identifier
- The affected product areas
- Relevant docs pages to link or create
## Writing Rules
- Write for users, not internal implementation detail
- Prefer second person: "you can now..."
- Focus on what changed, why it matters, and how to use it
- Match the structure and tone of recent changelog posts
- Keep technical detail only where it improves user understanding
## Output Format
Provide:
1. A short summary of what changed
2. The complete changelog post content
3. Whether an image should be added and what it should show
4. Any docs pages that should be linked or created
## Reference Files
- Changelog destination: `../langfuse-docs/pages/changelog`
- Recent changelog examples: inspect 3-5 recent files in that directory
- Existing docs: `../langfuse-docs/pages`
File diff suppressed because it is too large Load Diff
@@ -1,50 +0,0 @@
# ClickHouse Best Practices
Agent skill providing comprehensive ClickHouse guidance for schema design, query optimization, and data ingestion.
## Installation
```bash
npx skills add ClickHouse/clickhouse-agent-skills
```
## What's Included
**28 atomic rules** organized by prefix:
| Prefix | Count | Coverage |
|--------|-------|----------|
| `schema-pk-*` | 4 | PRIMARY KEY selection, cardinality ordering |
| `schema-types-*` | 5 | Data types, LowCardinality, Nullable |
| `schema-partition-*` | 4 | Partitioning strategy, lifecycle management |
| `schema-json-*` | 1 | JSON type usage |
| `query-join-*` | 5 | JOIN algorithms, filtering, alternatives |
| `query-index-*` | 1 | Data skipping indices |
| `query-mv-*` | 2 | Incremental and refreshable MVs |
| `insert-batch-*` | 1 | Batch sizing (10K-100K rows) |
| `insert-async-*` | 2 | Async inserts, data formats |
| `insert-mutation-*` | 2 | Mutation avoidance |
| `insert-optimize-*` | 1 | OPTIMIZE FINAL avoidance |
## Trigger Phrases
This skill activates when you:
- "Create a table for..."
- "Optimize this query..."
- "Design a schema for..."
- "Why is this query slow?"
- "How should I insert data into..."
- "Should I use UPDATE or..."
## Files
| File | Purpose |
|------|---------|
| `SKILL.md` | Quick reference and decision frameworks |
| `AGENTS.md` | Complete rule reference (auto-generated) |
| `rules/*.md` | Individual rule definitions |
## Related Documentation
All rules link to official ClickHouse documentation:
- [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
@@ -1,234 +0,0 @@
---
name: clickhouse-best-practices
description: MUST USE when reviewing ClickHouse schemas, queries, or configurations. Contains 28 rules that MUST be checked before providing recommendations. Always read relevant rule files and cite specific rules in responses.
license: Apache-2.0
metadata:
author: ClickHouse Inc
version: "0.3.0"
---
# ClickHouse Best Practices
Comprehensive guidance for ClickHouse covering schema design, query optimization, and data ingestion. Contains 28 rules across 3 main categories (schema, query, insert), prioritized by impact.
> **Official docs:** [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
## IMPORTANT: How to Apply This Skill
**Before answering ClickHouse questions, follow this priority order:**
1. **Check for applicable rules** in the `rules/` directory
2. **If rules exist:** Apply them and cite them in your response using "Per `rule-name`..."
3. **If no rule exists:** Use the LLM's ClickHouse knowledge or search documentation
4. **If uncertain:** Use web search for current best practices
5. **Always cite your source:** rule name, "general ClickHouse guidance", or URL
**Why rules take priority:** ClickHouse has specific behaviors (columnar storage, sparse indexes, merge tree mechanics) where general database intuition can be misleading. The rules encode validated, ClickHouse-specific guidance.
### For Formal Reviews
When performing a formal review of schemas, queries, or data ingestion:
---
## Review Procedures
### For Schema Reviews (CREATE TABLE, ALTER TABLE)
**Read these rule files in order:**
1. `rules/schema-pk-plan-before-creation.md` - ORDER BY is immutable
2. `rules/schema-pk-cardinality-order.md` - Column ordering in keys
3. `rules/schema-pk-prioritize-filters.md` - Filter column inclusion
4. `rules/schema-types-native-types.md` - Proper type selection
5. `rules/schema-types-minimize-bitwidth.md` - Numeric type sizing
6. `rules/schema-types-lowcardinality.md` - LowCardinality usage
7. `rules/schema-types-avoid-nullable.md` - Nullable vs DEFAULT
8. `rules/schema-partition-low-cardinality.md` - Partition count limits
9. `rules/schema-partition-lifecycle.md` - Partitioning purpose
**Check for:**
- [ ] PRIMARY KEY / ORDER BY column order (low-to-high cardinality)
- [ ] Data types match actual data ranges
- [ ] LowCardinality applied to appropriate string columns
- [ ] Partition key cardinality bounded (100-1,000 values)
- [ ] ReplacingMergeTree has version column if used
### For Query Reviews (SELECT, JOIN, aggregations)
**Read these rule files:**
1. `rules/query-join-choose-algorithm.md` - Algorithm selection
2. `rules/query-join-filter-before.md` - Pre-join filtering
3. `rules/query-join-use-any.md` - ANY vs regular JOIN
4. `rules/query-index-skipping-indices.md` - Secondary index usage
5. `rules/schema-pk-filter-on-orderby.md` - Filter alignment with ORDER BY
**Check for:**
- [ ] Filters use ORDER BY prefix columns
- [ ] JOINs filter tables before joining (not after)
- [ ] Correct JOIN algorithm for table sizes
- [ ] Skipping indices for non-ORDER BY filter columns
### For Insert Strategy Reviews (data ingestion, updates, deletes)
**Read these rule files:**
1. `rules/insert-batch-size.md` - Batch sizing requirements
2. `rules/insert-mutation-avoid-update.md` - UPDATE alternatives
3. `rules/insert-mutation-avoid-delete.md` - DELETE alternatives
4. `rules/insert-async-small-batches.md` - Async insert usage
5. `rules/insert-optimize-avoid-final.md` - OPTIMIZE TABLE risks
**Check for:**
- [ ] Batch size 10K-100K rows per INSERT
- [ ] No ALTER TABLE UPDATE for frequent changes
- [ ] ReplacingMergeTree or CollapsingMergeTree for update patterns
- [ ] Async inserts enabled for high-frequency small batches
---
## Output Format
Structure your response as follows:
```
## Rules Checked
- `rule-name-1` - Compliant / Violation found
- `rule-name-2` - Compliant / Violation found
...
## Findings
### Violations
- **`rule-name`**: Description of the issue
- Current: [what the code does]
- Required: [what it should do]
- Fix: [specific correction]
### Compliant
- `rule-name`: Brief note on why it's correct
## Recommendations
[Prioritized list of changes, citing rules]
```
---
## Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rule Count |
|----------|----------|--------|--------|------------|
| 1 | Primary Key Selection | CRITICAL | `schema-pk-` | 4 |
| 2 | Data Type Selection | CRITICAL | `schema-types-` | 5 |
| 3 | JOIN Optimization | CRITICAL | `query-join-` | 5 |
| 4 | Insert Batching | CRITICAL | `insert-batch-` | 1 |
| 5 | Mutation Avoidance | CRITICAL | `insert-mutation-` | 2 |
| 6 | Partitioning Strategy | HIGH | `schema-partition-` | 4 |
| 7 | Skipping Indices | HIGH | `query-index-` | 1 |
| 8 | Materialized Views | HIGH | `query-mv-` | 2 |
| 9 | Async Inserts | HIGH | `insert-async-` | 2 |
| 10 | OPTIMIZE Avoidance | HIGH | `insert-optimize-` | 1 |
| 11 | JSON Usage | MEDIUM | `schema-json-` | 1 |
---
## Quick Reference
### Schema Design - Primary Key (CRITICAL)
- `schema-pk-plan-before-creation` - Plan ORDER BY before table creation (immutable)
- `schema-pk-cardinality-order` - Order columns low-to-high cardinality
- `schema-pk-prioritize-filters` - Include frequently filtered columns
- `schema-pk-filter-on-orderby` - Query filters must use ORDER BY prefix
### Schema Design - Data Types (CRITICAL)
- `schema-types-native-types` - Use native types, not String for everything
- `schema-types-minimize-bitwidth` - Use smallest numeric type that fits
- `schema-types-lowcardinality` - LowCardinality for <10K unique strings
- `schema-types-enum` - Enum for finite value sets with validation
- `schema-types-avoid-nullable` - Avoid Nullable; use DEFAULT instead
### Schema Design - Partitioning (HIGH)
- `schema-partition-low-cardinality` - Keep partition count 100-1,000
- `schema-partition-lifecycle` - Use partitioning for data lifecycle, not queries
- `schema-partition-query-tradeoffs` - Understand partition pruning trade-offs
- `schema-partition-start-without` - Consider starting without partitioning
### Schema Design - JSON (MEDIUM)
- `schema-json-when-to-use` - JSON for dynamic schemas; typed columns for known
### Query Optimization - JOINs (CRITICAL)
- `query-join-choose-algorithm` - Select algorithm based on table sizes
- `query-join-use-any` - ANY JOIN when only one match needed
- `query-join-filter-before` - Filter tables before joining
- `query-join-consider-alternatives` - Dictionaries/denormalization vs JOIN
- `query-join-null-handling` - join_use_nulls=0 for default values
### Query Optimization - Indices (HIGH)
- `query-index-skipping-indices` - Skipping indices for non-ORDER BY filters
### Query Optimization - Materialized Views (HIGH)
- `query-mv-incremental` - Incremental MVs for real-time aggregations
- `query-mv-refreshable` - Refreshable MVs for complex joins
### Insert Strategy - Batching (CRITICAL)
- `insert-batch-size` - Batch 10K-100K rows per INSERT
### Insert Strategy - Async (HIGH)
- `insert-async-small-batches` - Async inserts for high-frequency small batches
- `insert-format-native` - Native format for best performance
### Insert Strategy - Mutations (CRITICAL)
- `insert-mutation-avoid-update` - ReplacingMergeTree instead of ALTER UPDATE
- `insert-mutation-avoid-delete` - Lightweight DELETE or DROP PARTITION
### Insert Strategy - Optimization (HIGH)
- `insert-optimize-avoid-final` - Let background merges work
---
## When to Apply
This skill activates when you encounter:
- `CREATE TABLE` statements
- `ALTER TABLE` modifications
- `ORDER BY` or `PRIMARY KEY` discussions
- Data type selection questions
- Slow query troubleshooting
- JOIN optimization requests
- Data ingestion pipeline design
- Update/delete strategy questions
- ReplacingMergeTree or other specialized engine usage
- Partitioning strategy decisions
---
## Rule File Structure
Each rule file in `rules/` contains:
- **YAML frontmatter**: title, impact level, tags
- **Brief explanation**: Why this rule matters
- **Incorrect example**: Anti-pattern with explanation
- **Correct example**: Best practice with explanation
- **Additional context**: Trade-offs, when to apply, references
---
## Full Compiled Document
For the complete guide with all rules expanded inline: `AGENTS.md`
Use `AGENTS.md` when you need to check multiple rules quickly without reading individual files.
@@ -1,24 +0,0 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Schema Design (schema)
**Impact:** CRITICAL
**Description:** Proper schema design is foundational to ClickHouse performance. ORDER BY is immutable after table creation; wrong choices require full data migration. Includes primary key selection, data types, partitioning strategy, and JSON usage. Column types and ordering can impact query speed by orders of magnitude.
## 2. Query Optimization (query)
**Impact:** CRITICAL
**Description:** Query patterns dramatically affect performance. JOIN algorithms, filtering strategies, skipping indices, and materialized views can reduce query time from minutes to milliseconds. Pre-computed aggregations read thousands of rows instead of billions.
## 3. Insert Strategy (insert)
**Impact:** CRITICAL
**Description:** Each INSERT creates a data part. Single-row inserts overwhelm the merge process. Proper batching (10K-100K rows), async inserts for high-frequency writes, mutation avoidance, and letting background merges work are essential for stable cluster performance.
@@ -1,28 +0,0 @@
---
title: Rule Title Here
impact: CRITICAL | HIGH | MEDIUM | LOW
impactDescription: "Quantified improvement (e.g., 10x faster queries)"
tags: [tag1, tag2]
---
## Rule Title Here
**Impact: CRITICAL** (optional description)
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```sql
-- Bad: description
SELECT * FROM table;
```
**Correct (description of what's right):**
```sql
-- Good: description
SELECT * FROM table;
```
Reference: [Official Docs](https://clickhouse.com/docs/best-practices/...)
@@ -1,55 +0,0 @@
---
title: Use Async Inserts for High-Frequency Small Batches
impact: HIGH
impactDescription: "Server-side buffering when client batching isn't practical"
tags: [insert, async, buffering, small-batches]
---
## Use Async Inserts for High-Frequency Small Batches
**Impact: HIGH**
When client-side batching isn't practical, async inserts buffer server-side and create larger parts automatically.
**Incorrect (small batches without async):**
```python
# Small batches without async_insert - creates too many parts
for batch in chunks(events, 100):
client.execute("INSERT INTO events VALUES", batch)
```
**Correct (enable async inserts):**
```python
# Enable async_insert with safe defaults
client.execute("SET async_insert = 1")
client.execute("SET wait_for_async_insert = 1") # Confirms durability
for batch in chunks(events, 100):
client.execute("INSERT INTO events VALUES", batch)
# Server buffers and creates larger parts automatically
```
```sql
-- Configure server-side for specific users
ALTER USER my_app_user SETTINGS
async_insert = 1,
wait_for_async_insert = 1,
async_insert_max_data_size = 10000000, -- Flush at 10MB
async_insert_busy_timeout_ms = 1000; -- Flush after 1s
```
**Flush conditions (whichever occurs first):**
- Buffer reaches `async_insert_max_data_size`
- Time threshold `async_insert_busy_timeout_ms` elapses
- Maximum insert queries accumulate
**Return modes:**
| Setting | Behavior | Use Case |
|---------|----------|----------|
| `wait_for_async_insert=1` | Waits for flush, confirms durability | **Recommended** |
| `wait_for_async_insert=0` | Fire-and-forget, unaware of errors | **Risky** - only if you accept data loss |
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,54 +0,0 @@
---
title: Batch Inserts Appropriately (10K-100K rows)
impact: CRITICAL
impactDescription: "Each INSERT creates a part; single-row inserts overwhelm merge process"
tags: [insert, batching, parts, performance]
---
## Batch Inserts Appropriately (10K-100K rows)
**Impact: CRITICAL**
Each INSERT creates a new data part. Single-row or small-batch inserts create thousands of tiny parts, overwhelming the merge process and causing cluster instability.
**Incorrect (single-row or tiny batches):**
```python
# Single-row inserts - creates 10,000 parts!
for event in events:
client.execute("INSERT INTO events VALUES", [event])
# Tiny batches - still too many parts
for batch in chunks(events, 100): # 100 rows per INSERT
client.execute("INSERT INTO events VALUES", batch)
```
**Correct (proper batch size):**
```python
# Ideal batch size: 10,000-100,000 rows
BATCH_SIZE = 10_000
for batch in chunks(events, BATCH_SIZE):
client.execute("INSERT INTO events VALUES", batch)
```
**Recommended batch sizes:**
| Threshold | Value |
|-----------|-------|
| Minimum | 1,000 rows |
| Ideal range | 10,000-100,000 rows |
| Insert rate (sync) | ~1 insert per second |
**Validation:**
```sql
-- Monitor part count (>3000 per partition blocks inserts)
SELECT table, count() as parts, sum(rows) as total_rows
FROM system.parts
WHERE active AND database = 'default'
GROUP BY table
ORDER BY parts DESC;
```
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,29 +0,0 @@
---
title: Use Native Format for Best Insert Performance
impact: MEDIUM
impactDescription: "Native format is most efficient; JSONEachRow is expensive to parse"
tags: [insert, format, Native, performance]
---
## Use Native Format for Best Insert Performance
**Impact: MEDIUM**
Data format affects insert performance. Native format is column-oriented with minimal parsing overhead.
**Performance Ranking (fastest to slowest):**
| Format | Notes |
|--------|-------|
| **Native** | Most efficient. Column-oriented, minimal parsing. Recommended. |
| **RowBinary** | Efficient row-based alternative |
| **JSONEachRow** | Easier to use but expensive to parse |
**Example:**
```python
# Use Native format for best performance
client.execute("INSERT INTO events VALUES", data, settings={'input_format': 'Native'})
```
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,74 +0,0 @@
---
title: Avoid ALTER TABLE DELETE
impact: CRITICAL
impactDescription: "Use lightweight DELETE, CollapsingMergeTree, or DROP PARTITION instead"
tags: [insert, mutation, DELETE, CollapsingMergeTree]
---
## Avoid ALTER TABLE DELETE
**Impact: CRITICAL**
`ALTER TABLE DELETE` is a mutation that rewrites entire data parts. Use alternatives like lightweight DELETE, CollapsingMergeTree, or DROP PARTITION.
**Incorrect (mutation delete):**
```sql
-- Mutation delete for cleanup
ALTER TABLE orders DELETE WHERE status = 'cancelled';
-- Time-based cleanup via mutation (very expensive)
ALTER TABLE sessions DELETE WHERE created_at < now() - INTERVAL 7 DAY;
```
**Correct - CollapsingMergeTree:**
```sql
CREATE TABLE orders (
order_id UInt64,
customer_id UInt64,
total Decimal(10,2),
sign Int8 -- 1 = active, -1 = deleted
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY order_id;
-- Insert order
INSERT INTO orders VALUES (123, 456, 99.99, 1);
-- "Delete" by inserting with sign = -1
INSERT INTO orders VALUES (123, 456, 99.99, -1);
-- Query collapses +1 and -1 pairs
SELECT order_id, sum(total * sign) as total
FROM orders GROUP BY order_id HAVING sum(sign) > 0;
```
**Correct - Lightweight Deletes (23.3+):**
```sql
-- Marks rows, doesn't rewrite immediately
DELETE FROM orders WHERE status = 'cancelled';
-- Physical deletion happens during normal merges
```
**Correct - DROP PARTITION for Bulk Deletion:**
```sql
-- Instant deletion of old data
ALTER TABLE events DROP PARTITION '202301';
-- Much faster than:
ALTER TABLE events DELETE WHERE toYYYYMM(timestamp) = 202301;
```
**Delete strategy comparison:**
| Method | Speed | When to Use |
|--------|-------|-------------|
| ALTER DELETE | Slow | Rare corrections only |
| CollapsingMergeTree | Fast | Frequent soft deletes |
| Lightweight DELETE | Medium | Occasional deletes |
| DROP PARTITION | Instant | Bulk deletion by partition |
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
@@ -1,58 +0,0 @@
---
title: Avoid ALTER TABLE UPDATE
impact: CRITICAL
impactDescription: "Mutations rewrite entire parts; use ReplacingMergeTree instead"
tags: [insert, mutation, UPDATE, ReplacingMergeTree]
---
## Avoid ALTER TABLE UPDATE
**Impact: CRITICAL**
`ALTER TABLE UPDATE` is a mutation - an asynchronous background process that rewrites entire data parts affected by the change. This is extremely expensive for frequent or large-scale operations.
**Why mutations are problematic:**
- **Write amplification:** Rewrite complete parts even for minor changes
- **Disk I/O spike:** Degrades overall cluster performance
- **No rollback:** Cannot be rolled back after submission
- **Inconsistent reads:** SELECT may read mix of mutated and unmutated parts
**Incorrect (mutation for updates):**
```sql
-- Rewrites potentially huge amounts of data
ALTER TABLE users UPDATE status = 'inactive'
WHERE last_login < now() - INTERVAL 90 DAY;
-- Frequent row updates via mutation
ALTER TABLE inventory UPDATE quantity = quantity - 1
WHERE product_id = 123;
-- If product exists across 100 parts, rewrites ALL 100 parts
```
**Correct (ReplacingMergeTree):**
```sql
-- Table design for updates
CREATE TABLE users (
user_id UInt64,
name String,
status LowCardinality(String),
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;
-- "Update" by inserting new version
INSERT INTO users (user_id, name, status)
VALUES (123, 'John', 'inactive');
-- Query with FINAL to get latest version
SELECT * FROM users FINAL WHERE user_id = 123;
-- Or use aggregation
SELECT user_id, argMax(status, updated_at) as status
FROM users GROUP BY user_id;
```
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
@@ -1,57 +0,0 @@
---
title: Avoid OPTIMIZE TABLE FINAL
impact: HIGH
impactDescription: "Forces expensive merge of all parts; let background merges work"
tags: [insert, OPTIMIZE, merge, performance]
---
## Avoid OPTIMIZE TABLE FINAL
**Impact: HIGH**
`OPTIMIZE TABLE ... FINAL` forces immediate merge of all parts into one part per partition. This is resource-intensive and rarely necessary. ClickHouse already performs smart background merges.
**Note:** `OPTIMIZE FINAL` is not the same as `FINAL`. The `FINAL` modifier in SELECT queries may be necessary for deduplicated results in ReplacingMergeTree and is generally fine to use.
**Incorrect (OPTIMIZE FINAL after inserts):**
```sql
-- Running OPTIMIZE FINAL after every batch insert
INSERT INTO events SELECT * FROM staging_events;
OPTIMIZE TABLE events FINAL; -- Expensive and unnecessary!
-- Scheduled OPTIMIZE FINAL jobs
-- Cron: 0 * * * * clickhouse-client -q "OPTIMIZE TABLE events FINAL"
```
**Correct (let background merges work):**
```sql
-- Let background merges handle optimization
INSERT INTO events SELECT * FROM staging_events;
-- Done! ClickHouse merges automatically
-- For ReplacingMergeTree deduplication, use FINAL in queries
SELECT * FROM events FINAL WHERE user_id = 123;
-- Instead of running OPTIMIZE FINAL to deduplicate
```
**Problems with OPTIMIZE FINAL:**
- Rewrites entire partition regardless of need
- Ignores the ~150 GB part size safeguard
- Can cause memory pressure or OOM errors
- Lengthy execution time for large datasets
**When OPTIMIZE FINAL may be acceptable:**
- Finalizing data before table freezing
- Preparing data for export operations
- One-time operations, not regular workflows
**Better alternatives:**
| Need | Alternative |
|------|-------------|
| Deduplicate ReplacingMergeTree | Use `FINAL` modifier in SELECT |
| Reduce part count | Rely on background merges |
Reference: [Avoid OPTIMIZE FINAL](https://clickhouse.com/docs/best-practices/avoid-optimize-final)
@@ -1,77 +0,0 @@
---
title: Use Data Skipping Indices for Non-ORDER BY Filters
impact: HIGH
impactDescription: "Up to 60x faster queries by skipping irrelevant granules"
tags: [query, index, skipping, bloom_filter]
---
## Use Data Skipping Indices for Non-ORDER BY Filters
**Impact: HIGH**
Queries filtering on columns not in ORDER BY cannot use the primary index and result in full scans. Data skipping indices store metadata about blocks and skip granules that definitely don't match.
**Important:** Skip indices should be considered **after** optimizing data types, primary key selection, and materialized views.
**When to use:**
- High overall cardinality but low cardinality within blocks
- Rare values critical for search (error codes, specific IDs)
- Column correlates with primary key
**When NOT to use:**
- As a first optimization step
- Matching values scattered across many blocks
- Without testing on real data
**Incorrect (filtering on non-ORDER BY column):**
```sql
CREATE TABLE events (
event_type LowCardinality(String),
timestamp DateTime,
user_id UInt64 -- Not in ORDER BY
)
ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp));
-- Query filters on user_id - scans all matching event_type
SELECT * FROM events
WHERE event_type = 'click' AND user_id = 12345;
```
**Correct (add skipping index):**
```sql
CREATE TABLE events (
event_type LowCardinality(String),
timestamp DateTime,
user_id UInt64,
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4
)
ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp));
-- Or add to existing table
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_user_id;
```
**Index types:**
| Type | Best For | Example Filter |
|------|----------|----------------|
| `bloom_filter` | Equality on high-cardinality | `WHERE user_id = 123` |
| `set(N)` | Low cardinality (N unique values) | `WHERE status IN ('a','b')` |
| `minmax` | Range queries | `WHERE amount > 1000` |
| `ngrambf_v1` | Text search | `WHERE text LIKE '%term%'` |
| `tokenbf_v1` | Token search | `WHERE hasToken(text, 'word')` |
**Validation:**
```sql
EXPLAIN indexes = 1
SELECT * FROM events WHERE user_id = 12345;
-- Look for "Skip" in output showing granules skipped
```
Reference: [Use Data Skipping Indices Where Appropriate](https://clickhouse.com/docs/best-practices/use-data-skipping-indices-where-appropriate)
@@ -1,43 +0,0 @@
---
title: Choose the Right JOIN Algorithm
impact: CRITICAL
impactDescription: "Wrong algorithm causes OOM; right algorithm handles large tables efficiently"
tags: [query, JOIN, algorithm, memory]
---
## Choose the Right JOIN Algorithm
**Impact: CRITICAL**
ClickHouse's default hash join loads the RIGHT table entirely into memory. Choose the right algorithm based on table sizes and constraints.
**Algorithm selection:**
| Algorithm | Best For | Trade-off |
|-----------|----------|-----------|
| `parallel_hash` | Small-to-medium in-memory tables | Default since 24.11; fast, concurrent |
| `hash` | General purpose, all join types | Single-threaded hash table build |
| `direct` | Dictionary lookups (INNER/LEFT only) | Fastest; no hash table construction |
| `full_sorting_merge` | Tables already sorted on join key | Skips sort if pre-ordered; low memory |
| `partial_merge` | Large tables, memory-constrained | Minimized memory; slower execution |
| `grace_hash` | Large datasets, tunable memory | Flexible; disk-spilling capability |
| `auto` | Adaptive algorithm selection | Tries hash first, falls back on memory pressure |
**Example usage:**
```sql
-- Let ClickHouse choose automatically
SET join_algorithm = 'auto';
-- For large-to-large joins where memory is constrained
SET join_algorithm = 'partial_merge';
SELECT * FROM large_a JOIN large_b ON large_b.id = large_a.id;
-- When joining by primary key columns, sort-merge skips sorting step
SET join_algorithm = 'full_sorting_merge';
SELECT * FROM table_a a JOIN table_b b ON b.pk_col = a.pk_col;
```
**Note:** ClickHouse 24.12+ automatically positions smaller tables on the right side. For earlier versions, manually ensure the smaller table is on the RIGHT.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,72 +0,0 @@
---
title: Consider Alternatives to JOINs
impact: CRITICAL
impactDescription: "Dictionaries and denormalization shift work from query time to insert time"
tags: [query, JOIN, dictionary, denormalization]
---
## Consider Alternatives to JOINs
**Impact: CRITICAL**
Repeated JOINs to dimension tables add overhead. Dictionaries or denormalization shift computational work from query time to insert/pre-processing time.
**Incorrect (JOIN on every query):**
```sql
-- JOIN on every query
SELECT o.order_id, c.name, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2024-01-01';
```
**Correct - Dictionary Lookup:**
```sql
-- Create dictionary
CREATE DICTIONARY customer_dict (
id UInt64,
name String,
email String
)
PRIMARY KEY id
SOURCE(CLICKHOUSE(TABLE 'customers'))
LAYOUT(HASHED())
LIFETIME(MIN 300 MAX 360);
-- Use dictGet instead of JOIN (uses direct join algorithm - fastest)
SELECT
order_id,
dictGet('customer_dict', 'name', customer_id) as customer_name,
dictGet('customer_dict', 'email', customer_id) as customer_email
FROM orders
WHERE created_at > '2024-01-01';
```
**Correct - Denormalization:**
```sql
-- Denormalized table with materialized view
CREATE MATERIALIZED VIEW orders_enriched_mv TO orders_enriched AS
SELECT
o.order_id, o.customer_id,
c.name as customer_name,
c.email as customer_email,
o.total, o.created_at
FROM orders o
JOIN customers c ON c.id = o.customer_id;
```
**Approach comparison:**
| Approach | Use Case | Performance |
|----------|----------|-------------|
| Dictionary | Frequent lookups to small dimension | Fastest (in-memory) |
| Denormalization | Analytics always need enriched data | Fast (no join at query) |
| IN subquery | Existence filtering | Often faster than JOIN |
| JOIN | Infrequent or complex joins | Acceptable |
**Critical dictionary caveat:** Dictionaries silently deduplicate duplicate keys, retaining only the final value. Only use when source has unique keys.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,54 +0,0 @@
---
title: Filter Tables Before Joining
impact: CRITICAL
impactDescription: "Joining full tables then filtering wastes resources"
tags: [query, JOIN, filtering, subquery]
---
## Filter Tables Before Joining
**Impact: CRITICAL**
Joining full tables then filtering wastes resources. Add filtering in `WHERE` or `JOIN ON` clauses. If automatic pushdown fails, restructure as a subquery.
**Incorrect (join then filter):**
```sql
-- Joins entire tables, then filters
SELECT o.order_id, c.name, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2024-01-01' AND c.country = 'US';
```
**Correct (filter in subqueries before joining):**
```sql
-- Filter in subqueries before joining
SELECT o.order_id, c.name, o.total
FROM (
SELECT order_id, customer_id, total
FROM orders
WHERE created_at > '2024-01-01'
) o
JOIN (
SELECT id, name
FROM customers
WHERE country = 'US'
) c ON c.id = o.customer_id;
```
**Even better - aggregate before joining:**
```sql
SELECT c.country, o.total_revenue
FROM (
SELECT customer_id, sum(total) as total_revenue
FROM orders
WHERE created_at > '2024-01-01'
GROUP BY customer_id
) o
JOIN customers c ON c.id = o.customer_id;
```
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,33 +0,0 @@
---
title: Optimize NULL Handling in Outer JOINs
impact: MEDIUM
impactDescription: "Default values instead of NULL reduces memory overhead"
tags: [query, JOIN, NULL, memory]
---
## Optimize NULL Handling in Outer JOINs
**Impact: MEDIUM**
Set `join_use_nulls = 0` to use default column values instead of NULL markers, reducing memory overhead compared to Nullable wrappers.
**Example:**
```sql
-- Use default values instead of NULLs for non-matching rows
SET join_use_nulls = 0;
SELECT o.order_id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
-- Non-matching rows get '' for name instead of NULL
```
**When to use:**
| Setting | Behavior | Use Case |
|---------|----------|----------|
| `join_use_nulls = 0` | Default values (empty string, 0) for non-matches | When you can handle default values |
| `join_use_nulls = 1` (default) | NULL for non-matches | When you need to distinguish "no match" from "matched with default" |
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,40 +0,0 @@
---
title: Use ANY JOIN When Only One Match Needed
impact: HIGH
impactDescription: "Returns first match only; less memory and faster execution"
tags: [query, JOIN, ANY, performance]
---
## Use ANY JOIN When Only One Match Needed
**Impact: HIGH**
Use `ANY` JOINs when you only need a single match rather than all matches. They consume less memory and execute faster.
**Incorrect (returns all matches):**
```sql
-- Returns all matching rows, uses more memory
SELECT o.order_id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
```
**Correct (returns first match only):**
```sql
-- Returns only first match per row, faster and less memory
SELECT o.order_id, c.name
FROM orders o
LEFT ANY JOIN customers c ON c.id = o.customer_id;
```
**ANY JOIN types:**
| Type | Behavior |
|------|----------|
| `LEFT ANY JOIN` | At most one match from right table |
| `INNER ANY JOIN` | At most one match, only matching rows |
| `RIGHT ANY JOIN` | At most one match from left table |
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,68 +0,0 @@
---
title: Use Incremental MVs for Real-Time Aggregations
impact: HIGH
impactDescription: "Read thousands of rows instead of billions; minimal cluster overhead"
tags: [query, materialized-view, aggregation, real-time]
---
## Use Incremental MVs for Real-Time Aggregations
**Impact: HIGH**
Incremental MVs automatically apply the view's query to new data blocks at insert time. Results are written to a target table and partial results merge over time.
**Incorrect (full aggregation on every query):**
```sql
-- Full aggregation on every dashboard load
SELECT
event_type,
toStartOfHour(timestamp) as hour,
count() as events,
uniq(user_id) as unique_users
FROM events
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY event_type, hour;
-- Scans 7 days of data every time (billions of rows)
```
**Correct (incremental MV with pre-aggregation):**
```sql
-- Create target table for aggregated data
CREATE TABLE events_hourly (
event_type LowCardinality(String),
hour DateTime,
events AggregateFunction(count),
unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, hour);
-- Create materialized view to populate incrementally
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
event_type,
toStartOfHour(timestamp) as hour,
countState() as events,
uniqState(user_id) as unique_users
FROM events
GROUP BY event_type, hour;
-- Query the pre-aggregated data
SELECT
event_type, hour,
countMerge(events) as events,
uniqMerge(unique_users) as unique_users
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY event_type, hour;
-- Reads thousands of rows instead of billions
```
**Key points:**
- Use `-State` functions in MV, `-Merge` functions in query
- Incremental - existing data not automatically included (backfill separately)
- Minimal cluster overhead at insert time
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
@@ -1,64 +0,0 @@
---
title: Use Refreshable MVs for Complex Joins and Batch Workflows
impact: HIGH
impactDescription: "Sub-millisecond queries with periodic refresh; ideal for complex joins"
tags: [query, materialized-view, refresh, batch]
---
## Use Refreshable MVs for Complex Joins and Batch Workflows
**Impact: HIGH**
Refreshable MVs execute queries periodically on a schedule. The full query re-executes and overwrites (or appends to) the target table.
**Best for:**
- Sub-millisecond latency where minor staleness is acceptable
- Caching "top N" results or lookup tables
- Complex multi-table joins requiring denormalization
- Batch workflows and DAG dependencies
**Incorrect (expensive join on every request):**
```sql
-- Complex join executed on every request
SELECT
o.order_id, o.total,
c.name as customer_name,
p.name as product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.created_at >= now() - INTERVAL 1 DAY;
```
**Correct (refreshable MV):**
```sql
-- Create refreshable MV that runs every 5 minutes
CREATE MATERIALIZED VIEW orders_denormalized
REFRESH EVERY 5 MINUTE
ENGINE = MergeTree()
ORDER BY (created_at, order_id)
AS SELECT
o.order_id, o.created_at, o.total,
c.name as customer_name, c.segment,
p.name as product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.created_at >= now() - INTERVAL 1 DAY;
-- Query the pre-joined data (sub-millisecond)
SELECT * FROM orders_denormalized WHERE segment = 'enterprise';
```
**APPEND vs REPLACE modes:**
| Mode | Behavior | Use Case |
|------|----------|----------|
| `REPLACE` (default) | Overwrites previous contents | Current state, lookup tables |
| `APPEND` | Adds new rows to existing data | Periodic snapshots, historical accumulation |
**Critical warning:** Query should run quickly compared to refresh interval. Don't schedule every 10 seconds if the query takes 10+ seconds.
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
@@ -1,76 +0,0 @@
---
title: Use JSON Type for Dynamic Schemas
impact: MEDIUM
impactDescription: "Field-level querying for semi-structured data; use typed columns for known schemas"
tags: [schema, JSON, semi-structured, flexibility]
---
## Use JSON Type for Dynamic Schemas
**Impact: MEDIUM**
ClickHouse's JSON type splits JSON objects into separate sub-columns, enabling field-level query optimization. Use it for truly dynamic data, not everything.
**Incorrect (schema bloat or opaque String):**
```sql
-- BAD: Hundreds of nullable columns for event properties
CREATE TABLE events (
event_id UUID,
prop_page_url Nullable(String),
prop_button_id Nullable(String),
-- ... 100 more nullable columns
)
-- BAD: JSON as String when you need field queries
CREATE TABLE events (
event_id UUID,
properties String -- No field-level optimization
)
```
**Correct (JSON for dynamic, typed for known):**
```sql
-- Use JSON type for dynamic properties
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(),
event_type LowCardinality(String),
timestamp DateTime DEFAULT now(),
properties JSON -- Flexible schema with type inference
)
ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
-- Query JSON paths directly
SELECT
event_type,
properties.url as page_url,
properties.amount as purchase_amount
FROM events
WHERE event_type = 'page_view' AND properties.url = '/home';
```
**When to use JSON:**
| Scenario | Use JSON? |
|----------|-----------|
| Data structure varies unpredictably | Yes |
| Field types/schemas change over time | Yes |
| Need field-level querying | Yes |
| Fixed, known schema | No (use typed columns) |
| JSON as opaque blob (no field queries) | No (use String) |
**Optimization: specify types for known paths:**
```sql
CREATE TABLE events (
properties JSON(
url String,
amount Float64,
product_id UInt64
)
)
```
Reference: [Use JSON Where Appropriate](https://clickhouse.com/docs/best-practices/use-json-where-appropriate)
@@ -1,50 +0,0 @@
---
title: Use Partitioning for Data Lifecycle Management
impact: HIGH
impactDescription: "DROP PARTITION is instant; DELETE is expensive row-by-row scan"
tags: [schema, partitioning, TTL, data-management]
---
## Use Partitioning for Data Lifecycle Management
**Impact: HIGH**
Partitioning is **primarily a data management technique, not a query optimization tool**. It excels at:
- **Dropping data**: Remove entire partitions as single metadata operations
- **TTL retention**: Implement time-based retention policies efficiently
- **Tiered storage**: Move old partitions to cold storage
- **Archiving**: Move partitions between tables
**Incorrect (no time alignment for lifecycle):**
```sql
-- Cannot efficiently drop old data by time
CREATE TABLE events (...)
ENGINE = MergeTree()
PARTITION BY event_type -- No time alignment
ORDER BY (timestamp);
-- Slow: must scan and delete row by row
DELETE FROM events WHERE timestamp < '2023-01-01';
```
**Correct (time-based for lifecycle):**
```sql
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, timestamp)
TTL timestamp + INTERVAL 1 YEAR DELETE; -- Drops whole partitions
-- Fast: metadata-only operation
ALTER TABLE events DROP PARTITION '202301';
-- Archive to cold storage
ALTER TABLE events_archive ATTACH PARTITION '202301' FROM events;
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,61 +0,0 @@
---
title: Keep Partition Cardinality Low (100-1,000 Values)
impact: HIGH
impactDescription: "Too many partitions cause part explosion and 'too many parts' errors"
tags: [schema, partitioning, parts]
---
## Keep Partition Cardinality Low (100-1,000 Values)
**Impact: HIGH**
Too many distinct partition values create excessive data parts, eventually triggering "too many parts" errors. ClickHouse enforces limits via `max_parts_in_total` and `parts_to_throw_insert` settings.
**Incorrect (high cardinality partitioning):**
```sql
-- High cardinality = too many partitions
CREATE TABLE events (...)
ENGINE = MergeTree()
PARTITION BY user_id -- Millions of partitions!
ORDER BY (timestamp);
-- Daily partitions can grow unbounded over years
CREATE TABLE logs (...)
ENGINE = MergeTree()
PARTITION BY toDate(timestamp) -- 3650 partitions over 10 years
ORDER BY (service, timestamp);
```
**Correct (bounded cardinality):**
```sql
-- Monthly partitions = 12 per year, bounded cardinality
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
)
ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, timestamp);
```
**Validation:**
```sql
-- Check partition count and health
SELECT
partition,
count() as parts,
sum(rows) as rows,
formatReadableSize(sum(bytes_on_disk)) as size
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition
ORDER BY partition;
-- Warning signs: hundreds or thousands of partitions
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,35 +0,0 @@
---
title: Understand Partition Query Performance Trade-offs
impact: MEDIUM
impactDescription: "Partition pruning helps some queries; spanning many partitions hurts others"
tags: [schema, partitioning, query, performance]
---
## Understand Partition Query Performance Trade-offs
**Impact: MEDIUM**
Partitioning can help or hurt query performance:
- **Potential improvement**: Queries filtering by partition key may benefit from partition pruning
- **Potential degradation**: Queries spanning many partitions increase total parts scanned
ClickHouse automatically builds **MinMax indexes** on partition columns. Data merges occur **within partitions only**, not across them.
**Incorrect (query scans all partitions):**
```sql
-- Query must scan all partitions
SELECT count(*) FROM events
WHERE event_type = 'click'; -- No partition pruning
```
**Correct (query prunes to single partition):**
```sql
-- Query prunes to single partition
SELECT count(*) FROM events
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
AND event_type = 'click';
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,42 +0,0 @@
---
title: Consider Starting Without Partitioning
impact: MEDIUM
impactDescription: "Add partitioning later when you have clear lifecycle requirements"
tags: [schema, partitioning, simplicity]
---
## Consider Starting Without Partitioning
**Impact: MEDIUM**
Start without partitioning and add it later only if:
- You have clear data lifecycle requirements (retention, archiving)
- Your access patterns clearly benefit from partition pruning
- You understand the cardinality implications
**Example (start simple):**
```sql
-- Start simple, no partitioning
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
)
ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
-- Add partitioning later if needed for lifecycle management
-- (requires table recreation or materialized view migration)
```
**When to add partitioning:**
| Need | Add Partitioning? |
|------|-------------------|
| Time-based data retention | Yes |
| Archive old data to cold storage | Yes |
| Query performance on time ranges | Maybe (test first) |
| No specific lifecycle needs | No |
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,45 +0,0 @@
---
title: Order Columns by Cardinality (Low to High)
impact: CRITICAL
impactDescription: "Enables granule skipping; high-cardinality first prevents index pruning"
tags: [schema, primary-key, cardinality, ORDER BY]
---
## Order Columns by Cardinality (Low to High)
**Impact: CRITICAL**
Since the sparse primary index operates on data blocks (granules) rather than individual rows, low-cardinality leading columns create more useful index entries that can skip entire blocks. Place lower-cardinality columns before higher-cardinality ones in the ordering key.
**Incorrect (high cardinality first):**
```sql
-- UUID first means no pruning benefit
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_id, event_type, timestamp);
-- Every granule has different event_id values, index can't skip anything
```
**Correct (low cardinality first):**
```sql
-- Low cardinality first enables pruning
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_type, event_date, event_id);
-- Index can skip entire event_type groups
```
**Column Order Guidelines:**
| Position | Cardinality | Examples |
|----------|-------------|----------|
| 1st | Low (few distinct values) | event_type, status, country |
| 2nd | Date (coarse granularity) | toDate(timestamp) |
| 3rd+ | Medium-High | user_id, session_id |
| Last | High (if needed) | event_id, uuid |
**Tip:** Use `toDate(timestamp)` instead of raw `DateTime` columns when day-level filtering suffices - this reduces index size from 32-bit to 16-bit representations.
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,52 +0,0 @@
---
title: Filter on ORDER BY Columns in Queries
impact: CRITICAL
impactDescription: "Skipping prefix columns prevents index usage"
tags: [schema, primary-key, WHERE, query]
---
## Filter on ORDER BY Columns in Queries
**Impact: CRITICAL**
Even with good schema design, queries must use ORDER BY columns to benefit. Skipping prefix columns or filtering on non-ORDER BY columns prevents index usage.
**Incorrect (skips prefix or uses non-ORDER BY columns):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Skips prefix columns - can't use index effectively
SELECT * FROM events WHERE event_type = 'click';
-- Filter on column not in ORDER BY - full table scan
SELECT * FROM events WHERE user_agent LIKE '%Chrome%';
```
**Correct (uses ORDER BY prefix):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Full prefix match - best performance
SELECT * FROM events
WHERE tenant_id = 123 AND event_type = 'click';
-- Partial prefix - still uses index
SELECT * FROM events WHERE tenant_id = 123;
-- Range on later column after equality on earlier
SELECT * FROM events
WHERE tenant_id = 123 AND event_type = 'click' AND timestamp >= '2024-01-01';
```
**Index usage reference:**
| Filter | Index Used? |
|--------|-------------|
| `WHERE tenant_id = 123` | Full |
| `WHERE tenant_id = 123 AND event_type = 'click'` | Full |
| `WHERE event_type = 'click'` | None (skipped prefix) |
| `WHERE timestamp > '2024-01-01'` | None (skipped both) |
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,64 +0,0 @@
---
title: Plan PRIMARY KEY Before Table Creation
impact: CRITICAL
impactDescription: "ORDER BY is immutable; wrong choice requires full data migration"
tags: [schema, primary-key, ORDER BY]
---
## Plan PRIMARY KEY Before Table Creation
**Impact: CRITICAL** (immutable after creation)
ClickHouse's ORDER BY clause defines physical data ordering and the sparse index. Unlike other databases, **ORDER BY cannot be modified after table creation**. A wrong choice requires creating a new table and migrating all data.
**Incorrect (arbitrary ORDER BY without query analysis):**
```sql
-- Creating table without analyzing query patterns
CREATE TABLE events (
event_id UUID,
user_id UInt64,
timestamp DateTime
)
ENGINE = MergeTree()
ORDER BY (event_id); -- Chosen arbitrarily
-- Later: "Most queries filter by user_id!"
-- Cannot fix with: ALTER TABLE events MODIFY ORDER BY (user_id, timestamp)
-- ERROR: Cannot modify ORDER BY
```
**Correct (query-driven ORDER BY selection):**
```sql
-- Step 1: Document query patterns BEFORE creating table
/*
Query Analysis:
- 60% of queries: WHERE user_id = ? AND timestamp BETWEEN ? AND ?
- 25% of queries: WHERE event_type = ? AND timestamp > ?
- 15% of queries: WHERE event_id = ?
Conclusion: user_id and event_type are primary filters
*/
-- Step 2: Create table with correct ORDER BY
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_type LowCardinality(String),
timestamp DateTime,
event_date Date DEFAULT toDate(timestamp)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date, event_id);
```
**Pre-creation checklist:**
- [ ] Listed top 5-10 query patterns
- [ ] Identified columns in WHERE clauses with frequency
- [ ] Prioritized columns that exclude large numbers of rows
- [ ] Ordered columns by cardinality (low first, high last)
- [ ] Limited to 4-5 key columns (typically sufficient)
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,44 +0,0 @@
---
title: Prioritize Filter Columns in ORDER BY
impact: CRITICAL
impactDescription: "Columns not in ORDER BY cause full table scans"
tags: [schema, primary-key, WHERE, filtering]
---
## Prioritize Filter Columns in ORDER BY
**Impact: CRITICAL**
Prioritize columns frequently used in query filters (WHERE clause), especially those that exclude large numbers of rows. Queries filtering on columns not in ORDER BY result in full table scans.
**Incorrect (ORDER BY doesn't match query patterns):**
```sql
-- If most queries filter by tenant_id:
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_id); -- Queries by tenant_id will full-scan!
```
**Correct (ORDER BY matches filter patterns):**
```sql
-- ORDER BY matches query filter patterns
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (tenant_id, event_date, event_id);
-- Query now uses primary index:
SELECT * FROM events WHERE tenant_id = 123 AND event_date >= '2024-01-01';
```
**Validation:**
```sql
-- Verify index usage
EXPLAIN indexes = 1
SELECT * FROM events WHERE tenant_id = 123;
-- Look for "PrimaryKey" with Key Condition
```
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,55 +0,0 @@
---
title: Avoid Nullable Unless Semantically Required
impact: HIGH
impactDescription: "Nullable adds storage overhead; use DEFAULT values instead"
tags: [schema, data-types, Nullable, DEFAULT]
---
## Avoid Nullable Unless Semantically Required
**Impact: HIGH**
Nullable columns maintain a separate UInt8 column for tracking null values, increasing storage and degrading performance. Use DEFAULT values instead when feasible.
**Incorrect (Nullable everywhere):**
```sql
CREATE TABLE users (
id Nullable(UInt64), -- IDs should never be null
name Nullable(String), -- Empty string is fine
age Nullable(UInt8), -- 0 is a valid default
login_count Nullable(UInt32) -- 0 is a valid default
)
```
**Correct (DEFAULT values, Nullable only when semantic):**
```sql
CREATE TABLE users (
id UInt64, -- Never null
name String DEFAULT '', -- Empty = unknown
age UInt8 DEFAULT 0, -- 0 = unknown
login_count UInt32 DEFAULT 0, -- 0 = never logged in
deleted_at Nullable(DateTime), -- NULL = not deleted (semantic!)
parent_id Nullable(UInt64) -- NULL = no parent (semantic!)
)
```
**When Nullable IS appropriate:**
| Use Case | Why |
|----------|-----|
| `deleted_at` | NULL = "not deleted", timestamp = "deleted at X" |
| `parent_id` | NULL = "no parent", value = "has parent" |
| `discount_percent` | NULL = "no discount", 0 = "0% discount" |
**Defaults instead of Nullable:**
| Type | Default |
|------|---------|
| String | `''` (empty string) |
| UInt*/Int* | `0` |
| DateTime | `now()` or `toDateTime(0)` |
| UUID | `generateUUIDv4()` |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,58 +0,0 @@
---
title: Use Enum for Finite Value Sets
impact: MEDIUM
impactDescription: "Insert-time validation and natural ordering; 1-2 bytes storage"
tags: [schema, data-types, Enum, validation]
---
## Use Enum for Finite Value Sets
**Impact: MEDIUM**
Enum types provide validation at insert time and enable queries that exploit natural ordering. Use Enum8 (up to 256 values) or Enum16 (up to 65,536 values).
**Incorrect (String without validation):**
```sql
CREATE TABLE orders (
status String -- No validation, typos like "shiped" allowed
)
-- Ordering requires CASE statements
SELECT * FROM orders ORDER BY
CASE status
WHEN 'pending' THEN 1
WHEN 'processing' THEN 2
WHEN 'shipped' THEN 3
END;
```
**Correct (Enum with validation and ordering):**
```sql
CREATE TABLE orders (
status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4)
)
-- Insert validation: invalid values rejected
INSERT INTO orders VALUES ('shiped'); -- ERROR: Unknown element 'shiped'
-- Natural ordering works automatically
SELECT * FROM orders ORDER BY status; -- Orders by enum value (1, 2, 3, 4)
-- Comparisons use natural order
SELECT * FROM orders WHERE status > 'processing'; -- shipped and delivered
```
**Enum Guidelines:**
| Scenario | Use |
|----------|-----|
| Fixed set of values known at schema time | Enum8/Enum16 |
| Values may change frequently | LowCardinality(String) |
| Need insert-time validation | Enum |
| Need natural ordering in queries | Enum |
| < 256 distinct values | Enum8 (1 byte) |
| 256-65,536 distinct values | Enum16 (2 bytes) |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,58 +0,0 @@
---
title: Use LowCardinality for Repeated Strings
impact: HIGH
impactDescription: "Dictionary encoding for <10K unique values; significant storage reduction"
tags: [schema, data-types, LowCardinality, storage]
---
## Use LowCardinality for Repeated Strings
**Impact: HIGH**
String columns with repeated values store each value repeatedly. LowCardinality uses dictionary encoding for significant storage reduction.
**Incorrect (plain String for repeated values):**
```sql
CREATE TABLE events (
country String, -- "United States" stored 500M times
browser String, -- "Chrome" stored 300M times
event_type String -- "page_view" stored 800M times
)
```
**Correct (LowCardinality for low unique counts):**
```sql
CREATE TABLE events (
country LowCardinality(String), -- ~200 unique values
browser LowCardinality(String), -- ~50 unique values
event_type LowCardinality(String) -- ~100 unique values
)
```
**When to use LowCardinality:**
| Unique Values | Recommendation |
|---------------|----------------|
| < 10,000 | Use LowCardinality |
| > 10,000 | Use regular String |
```sql
-- Check cardinality before deciding
SELECT uniq(column_name) FROM table_name;
```
**LowCardinality vs FixedString:**
Reserve `FixedString` for strictly fixed-length data (e.g., 2-char country codes). For most low-cardinality text, `LowCardinality(String)` outperforms `FixedString`.
```sql
-- FixedString: Only for truly fixed-length data
country_code FixedString(2), -- "US", "DE", "JP" - always 2 chars
-- LowCardinality: For variable-length low-cardinality strings
country_name LowCardinality(String), -- "United States", "Germany"
```
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,49 +0,0 @@
---
title: Minimize Bit-Width for Numeric Types
impact: HIGH
impactDescription: "Smaller types reduce storage and improve cache efficiency"
tags: [schema, data-types, numeric, storage]
---
## Minimize Bit-Width for Numeric Types
**Impact: HIGH**
Select the smallest numeric type that accommodates your data range. Prefer unsigned types when negative values aren't needed.
**Incorrect (oversized types):**
```sql
CREATE TABLE metrics (
status_code Int64, -- HTTP codes are 100-599
age Int64, -- Human age fits in UInt8
year Int64, -- Years fit in UInt16
item_count Int64 -- Often small numbers
)
```
**Correct (right-sized types):**
```sql
CREATE TABLE metrics (
status_code UInt16, -- 0-65,535 (HTTP codes fit easily)
age UInt8, -- 0-255 (sufficient for age)
year UInt16, -- 0-65,535 (sufficient for years)
item_count UInt32 -- 0-4 billion (adjust based on actual max)
)
```
**Numeric Type Reference:**
| Type | Range | Bytes |
|------|-------|-------|
| UInt8 | 0 to 255 | 1 |
| UInt16 | 0 to 65,535 | 2 |
| UInt32 | 0 to 4.3 billion | 4 |
| UInt64 | 0 to 18 quintillion | 8 |
| Int8 | -128 to 127 | 1 |
| Int16 | -32,768 to 32,767 | 2 |
| Int32 | -2.1 billion to 2.1 billion | 4 |
| Int64 | -9 quintillion to 9 quintillion | 8 |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,51 +0,0 @@
---
title: Use Native Types Instead of String
impact: CRITICAL
impactDescription: "2-10x storage reduction; enables compression and correct semantics"
tags: [schema, data-types, storage]
---
## Use Native Types Instead of String
**Impact: CRITICAL**
Using String for all data wastes storage, prevents compression optimization, and makes comparisons slower. ClickHouse's column-oriented architecture benefits directly from optimal type selection.
**Incorrect (String for everything):**
```sql
CREATE TABLE events (
event_id String, -- "550e8400-e29b-41d4-a716-446655440000" = 36 bytes
user_id String, -- "12345" = 5 bytes (no numeric operations)
created_at String, -- "2024-01-15 10:30:00" = 19 bytes
count String, -- "42" - can't do math!
is_active String -- "true" = 4 bytes
)
```
**Correct (native types):**
```sql
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(), -- 16 bytes (vs 36)
user_id UInt64, -- 8 bytes, numeric ops
created_at DateTime DEFAULT now(), -- 4 bytes (vs 19)
count UInt32 DEFAULT 0, -- 4 bytes, math works
is_active Bool DEFAULT true -- 1 byte (vs 4)
)
```
**Type Selection Quick Reference:**
| Data | Use | Avoid |
|------|-----|-------|
| Sequential IDs | UInt32/UInt64 | String |
| UUIDs | UUID | String |
| Status/Category | Enum8 or LowCardinality(String) | String |
| Timestamps | DateTime | DateTime64, String |
| Dates only | Date or Date32 | DateTime, String |
| Counts | UInt8/16/32 (smallest that fits) | Int64, String |
| Money | Decimal(P,S) or Int64 (cents) | Float64, String |
| Booleans | Bool or UInt8 | String |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
-54
View File
@@ -1,54 +0,0 @@
---
name: code-review
description: |
Shared code review workflow for Langfuse. Use when reviewing a PR, branch, diff,
or local changes for correctness, regressions, risk, and missing tests.
Start with references/review-checklist.md for repo-specific review rules and
use package AGENTS.md files plus any matching shared skills when the change
touches those areas.
---
# Code Review
Use this skill when the task is to review code changes rather than implement a
feature.
## Start Here
- Read [`references/review-checklist.md`](references/review-checklist.md) for
the repo's canonical review rules.
- Read root [`AGENTS.md`](../../../AGENTS.md) and the nearest package
`AGENTS.md` for the files under review.
- If the review touches ClickHouse, also use the shared
`clickhouse-best-practices` skill.
- If the review touches backend code, also use the shared
`backend-dev-guidelines` skill where relevant.
## Review Priorities
Focus on:
- correctness bugs
- behavioral regressions
- security and tenant-isolation risks
- performance issues with real impact
- missing or weak tests for risky changes
## Output Expectations
- Findings first, ordered by severity
- File and line references for each finding
- Short summary only after findings
- If no findings, say so explicitly and mention any residual risk or coverage gaps
## Scope Guidance
Use `references/review-checklist.md` for Langfuse-specific checks such as:
- ClickHouse and Postgres migration expectations
- project-scoped tenant isolation checks
- API/Fern consistency
- banner-offset UI positioning
- environment variable access patterns
Do not duplicate those rules in ad hoc prompts or tool-specific command files.
@@ -1,57 +0,0 @@
# Langfuse Review Checklist
This is the canonical shared review checklist for Langfuse.
## Database Migrations
### ClickHouse
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/clustered` directory should include `ON CLUSTER default` and should use `Replicated` merge tree table types.
- E.g. `ReplacingMergeTree` is likely an error while `ReplicatedReplacingMergeTree` would be correct in most cases.
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/unclustered` directory must not include `ON CLUSTER` statements and must not use `Replicated` merge tree table types.
- Migrations in `packages/shared/clickhouse/migrations/clustered` should match their counterparts in `packages/shared/clickhouse/migrations/unclustered` aside from the restrictions listed above.
- When adding new indexes on ClickHouse, ensure that there is a corresponding `MATERIALIZE INDEX` statement in the same migration. The materialization can use `SETTINGS mutations_sync = 2` if they operate on smaller tables, but may timeout otherwise.
- All ClickHouse queries on project-scoped tables (traces, observations, scores, events, sessions, etc.) must include `WHERE project_id = {projectId: String}` filter to ensure proper tenant isolation and that queries only access data from the intended project.
- For operations on the `events` table, you must never use the `FINAL` keyword as it kills performance. `events` is built so that `FINAL` is never required.
### Postgres
- Most `schema.prisma` changes should produce a change in `packages/shared/prisma/migrations`.
- All Prisma queries on project-scoped tables must include `projectId` in the WHERE clause (e.g., `where: { id: traceId, projectId }`) to ensure proper tenant isolation and that queries only access data from the intended project.
### Environment Variables
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
## Redis Invocations
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
## Langfuse Cloud
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
## Banner Height System
- Use `top-banner-offset` instead of `top-0` for any elements that are positioned `sticky`, `fixed`, or `absolute` with a global reference point (e.g., `top-0`). This ensures proper spacing when system banners (payment, maintenance, etc.) are displayed.
- The banner height is managed through CSS variables (`--banner-height` and `--banner-offset`) defined in `web/src/styles/globals.css`.
- Banner components (like PaymentBanner) dynamically update `--banner-height` using ResizeObserver to track their actual height, ensuring accurate positioning even when banners resize (e.g., on mobile wrapping).
- Available Tailwind utilities:
- `top-banner-offset` / `pt-banner-offset` - For sticky/fixed/absolute positioning and padding
- `h-screen-with-banner` / `min-h-screen-with-banner` - For full-height containers accounting for banners
## JavaScript / TypeScript Style
- use concat instead of spread to avoid stack overflow with large arrays
## Seeder
- make sure that for new features with data model changes, the database seeder is adjusted.
## API Documentation
- Whenever a file in `web/src/features/public-api/types` changes, the `fern/apis` definition probably needs to be adjusted, too.
- `nullish` types should map to `optional<nullable<T>>` in fern.
- `nullable` types should map to `nullable<T>` in fern.
- `optional` types should map to `optional<T>` in fern.
@@ -1,60 +0,0 @@
---
name: frontend-browser-review
description: |
Shared workflow for browser-based review of user-visible frontend changes in Langfuse.
Use when a change affects UI behavior, layout, styling, navigation, or browser-visible
regressions and should be checked with the Playwright MCP server before signoff.
---
# Frontend Browser Review
Use this skill when a change affects what users see or do in the browser.
## Start Here
- Read [`../../../web/AGENTS.md`](../../../web/AGENTS.md) for web-specific
entry points and test commands.
- Use the workspace `playwright` MCP server configured from the repo-owned
shared agent setup.
## When To Use It
- UI changes in `web/**`
- Layout, styling, or responsive behavior changes
- Changes to navigation or page flows
- Bug fixes where the failure mode is visible in the browser
- Final signoff for user-visible frontend work
## Review Loop
1. Start the app with `pnpm run dev:web` unless an existing local server is
already running.
2. Install Chromium with `pnpm run playwright:install` if Playwright has not
been set up on the machine yet.
3. Open the primary changed flow with the Playwright MCP server.
4. Exercise the main happy path affected by the change.
5. Check for obvious visual regressions:
- broken layout or spacing
- banner overlap or viewport anchoring issues
- missing loading, empty, or error states
- broken responsive behavior on narrow widths
6. If the page changed materially, inspect the resulting UI state and compare
it against the intended behavior from the task or existing patterns.
7. If the browser session fails, inspect traces and artifacts under
`.playwright-mcp/`.
## Output Expectations
Report:
1. What flow you reviewed
2. Whether the primary flow worked
3. Any visible regressions or follow-up risks
4. If review was blocked, exactly what prevented browser verification
## Scope Notes
- This skill complements, not replaces, targeted tests and linting.
- For implementation details, stay in `web/AGENTS.md` and package-local skills.
- Use this as the browser-signoff workflow, not as a generic frontend coding
guide.
-951
View File
@@ -1,951 +0,0 @@
---
name: turborepo
description: |
Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,
dependsOn, caching, remote cache, the "turbo" CLI, --filter, --affected, CI optimization, environment
variables, internal packages, monorepo structure/best practices, and boundaries.
Use when user: configures tasks/workflows/pipelines, creates packages, sets up
monorepo, shares code between apps, runs changed/affected packages, debugs cache,
or has apps/packages directories.
metadata:
version: 2.8.21-canary.9
---
# Turborepo Skill
Build system for JavaScript/TypeScript monorepos. Turborepo caches task outputs and runs tasks in parallel based on dependency graph.
## IMPORTANT: Package Tasks, Not Root Tasks
**DO NOT create Root Tasks. ALWAYS create package tasks.**
When creating tasks/scripts/pipelines, you MUST:
1. Add the script to each relevant package's `package.json`
2. Register the task in root `turbo.json`
3. Root `package.json` only delegates via `turbo run <task>`
**DO NOT** put task logic in root `package.json`. This defeats Turborepo's parallelization.
```json
// DO THIS: Scripts in each package
// apps/web/package.json
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
// apps/api/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// packages/ui/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
```
```json
// turbo.json - register tasks
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": { "dependsOn": ["build"] }
}
}
```
```json
// Root package.json - ONLY delegates, no task logic
{
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test"
}
}
```
```json
// DO NOT DO THIS - defeats parallelization
// Root package.json
{
"scripts": {
"build": "cd apps/web && next build && cd ../api && tsc",
"lint": "eslint apps/ packages/",
"test": "vitest"
}
}
```
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
## Secondary Rule: `turbo run` vs `turbo`
**Always use `turbo run` when the command is written into code:**
```json
// package.json - ALWAYS "turbo run"
{
"scripts": {
"build": "turbo run build"
}
}
```
```yaml
# CI workflows - ALWAYS "turbo run"
- run: turbo run build --affected
```
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
## Quick Decision Trees
### "I need to configure a task"
```
Configure a task?
├─ Define task dependencies → references/configuration/tasks.md
├─ Lint/check-types (parallel + caching) → Use Transit Nodes pattern (see below)
├─ Specify build outputs → references/configuration/tasks.md#outputs
├─ Handle environment variables → references/environment/RULE.md
├─ Set up dev/watch tasks → references/configuration/tasks.md#persistent
├─ Package-specific config → references/configuration/RULE.md#package-configurations
└─ Global settings (cacheDir, daemon) → references/configuration/global-options.md
```
### "My cache isn't working"
```
Cache problems?
├─ Tasks run but outputs not restored → Missing `outputs` key
├─ Cache misses unexpectedly → references/caching/gotchas.md
├─ Need to debug hash inputs → Use --summarize or --dry
├─ Want to skip cache entirely → Use --force or cache: false
├─ Remote cache not working → references/caching/remote-cache.md
└─ Environment causing misses → references/environment/gotchas.md
```
### "I want to run only changed packages"
```
Run only what changed?
├─ Changed packages + dependents (RECOMMENDED) → turbo run build --affected
├─ Custom base branch → --affected --affected-base=origin/develop
├─ Manual git comparison → --filter=...[origin/main]
└─ See all filter options → references/filtering/RULE.md
```
**`--affected` is the primary way to run only changed packages.** It automatically compares against the default branch and includes dependents.
### "I want to filter packages"
```
Filter packages?
├─ Only changed packages → --affected (see above)
├─ By package name → --filter=web
├─ By directory → --filter=./apps/*
├─ Package + dependencies → --filter=web...
├─ Package + dependents → --filter=...web
└─ Complex combinations → references/filtering/patterns.md
```
### "Environment variables aren't working"
```
Environment issues?
├─ Vars not available at runtime → Strict mode filtering (default)
├─ Cache hits with wrong env → Var not in `env` key
├─ .env changes not causing rebuilds → .env not in `inputs`
├─ CI variables missing → references/environment/gotchas.md
└─ Framework vars (NEXT_PUBLIC_*) → Auto-included via inference
```
### "I need to set up CI"
```
CI setup?
├─ GitHub Actions → references/ci/github-actions.md
├─ Vercel deployment → references/ci/vercel.md
├─ Remote cache in CI → references/caching/remote-cache.md
├─ Only build changed packages → --affected flag
├─ Skip unnecessary builds → turbo-ignore (references/cli/commands.md)
└─ Skip container setup when no changes → turbo-ignore
```
### "I want to watch for changes during development"
```
Watch mode?
├─ Re-run tasks on change → turbo watch (references/watch/RULE.md)
├─ Dev servers with dependencies → Use `with` key (references/configuration/tasks.md#with)
├─ Restart dev server on dep change → Use `interruptible: true`
└─ Persistent dev tasks → Use `persistent: true`
```
### "I need to create/structure a package"
```
Package creation/structure?
├─ Create an internal package → references/best-practices/packages.md
├─ Repository structure → references/best-practices/structure.md
├─ Dependency management → references/best-practices/dependencies.md
├─ Best practices overview → references/best-practices/RULE.md
├─ JIT vs Compiled packages → references/best-practices/packages.md#compilation-strategies
└─ Sharing code between apps → references/best-practices/RULE.md#package-types
```
### "How should I structure my monorepo?"
```
Monorepo structure?
├─ Standard layout (apps/, packages/) → references/best-practices/RULE.md
├─ Package types (apps vs libraries) → references/best-practices/RULE.md#package-types
├─ Creating internal packages → references/best-practices/packages.md
├─ TypeScript configuration → references/best-practices/structure.md#typescript-configuration
├─ ESLint configuration → references/best-practices/structure.md#eslint-configuration
├─ Dependency management → references/best-practices/dependencies.md
└─ Enforce package boundaries → references/boundaries/RULE.md
```
### "I want to enforce architectural boundaries"
```
Enforce boundaries?
├─ Check for violations → turbo boundaries
├─ Tag packages → references/boundaries/RULE.md#tags
├─ Restrict which packages can import others → references/boundaries/RULE.md#rule-types
└─ Prevent cross-package file imports → references/boundaries/RULE.md
```
## Critical Anti-Patterns
### Using `turbo` Shorthand in Code
**`turbo run` is recommended in package.json scripts and CI pipelines.** The shorthand `turbo <task>` is intended for interactive terminal use.
```json
// WRONG - using shorthand in package.json
{
"scripts": {
"build": "turbo build",
"dev": "turbo dev"
}
}
// CORRECT
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
```yaml
# WRONG - using shorthand in CI
- run: turbo build --affected
# CORRECT
- run: turbo run build --affected
```
### Root Scripts Bypassing Turbo
Root `package.json` scripts MUST delegate to `turbo run`, not run tasks directly.
```json
// WRONG - bypasses turbo entirely
{
"scripts": {
"build": "bun build",
"dev": "bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
### Using `&&` to Chain Turbo Tasks
Don't chain turbo tasks with `&&`. Let turbo orchestrate.
```json
// WRONG - turbo task not using turbo run
{
"scripts": {
"changeset:publish": "bun build && changeset publish"
}
}
// CORRECT
{
"scripts": {
"changeset:publish": "turbo run build && changeset publish"
}
}
```
### `prebuild` Scripts That Manually Build Dependencies
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
```json
// WRONG - manually building dependencies
{
"scripts": {
"prebuild": "cd ../../packages/types && bun run build && cd ../utils && bun run build",
"build": "next build"
}
}
```
**However, the fix depends on whether workspace dependencies are declared:**
1. **If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
2. **If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
- Then remove the `prebuild` script
```json
// CORRECT - declare dependency, let turbo handle build order
// package.json
{
"dependencies": {
"@repo/types": "workspace:*",
"@repo/utils": "workspace:*"
},
"scripts": {
"build": "next build"
}
}
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
```
**Key insight:** `^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
### Overly Broad `globalDependencies`
`globalDependencies` affects ALL tasks in ALL packages via the **global hash** — tasks cannot opt out of specific files, even with negation globs in `inputs`. Be specific.
```json
// WRONG - heavy hammer, affects all hashes
{
"globalDependencies": ["**/.env.*local"]
}
// BETTER - move to task-level inputs
{
"globalDependencies": [".env"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
```
With `futureFlags.globalConfiguration`, this problem is reduced because `global.inputs` files are folded into each task's inputs (not the global hash). Tasks can exclude specific files:
```json
// BEST - global.inputs with per-task exclusion
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": [".env"]
},
"tasks": {
"build": { "outputs": ["dist/**"] },
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env"]
}
}
}
```
### Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks": {
"build": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"test": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"dev": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"cache": false,
"persistent": true
}
}
}
// BETTER - use globalEnv and globalDependencies for shared config
{
"globalEnv": ["API_URL", "DATABASE_URL"],
"globalDependencies": [".env*"],
"tasks": {
"build": {},
"test": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
**When to use global vs task-level:**
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
### NOT an Anti-Pattern: Large `env` Arrays
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
### Using `--parallel` Flag
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
```bash
# WRONG - bypasses dependency graph
turbo run lint --parallel
# CORRECT - configure tasks to allow parallel execution
# In turbo.json, set dependsOn appropriately (or use transit nodes)
turbo run lint
```
### Package-Specific Task Overrides in Root turbo.json
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
```json
// WRONG - root turbo.json with many package-specific overrides
{
"tasks": {
"test": { "dependsOn": ["build"] },
"@repo/web#test": { "outputs": ["coverage/**"] },
"@repo/api#test": { "outputs": ["coverage/**"] },
"@repo/utils#test": { "outputs": [] },
"@repo/cli#test": { "outputs": [] },
"@repo/core#test": { "outputs": [] }
}
}
// CORRECT - use Package Configurations
// Root turbo.json - base config only
{
"tasks": {
"test": { "dependsOn": ["build"] }
}
}
// packages/web/turbo.json - package-specific override
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
// packages/api/turbo.json
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
```
**Benefits of Package Configurations:**
- Keeps configuration close to the code it affects
- Root turbo.json stays clean and focused on base patterns
- Easier to understand what's special about each package
- Works with `$TURBO_EXTENDS$` to inherit + extend arrays
**When to use `package#task` in root:**
- Single package needs a unique dependency (e.g., `"deploy": { "dependsOn": ["web#build"] }`)
- Temporary override while migrating
See `references/configuration/RULE.md#package-configurations` for full details.
### Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
```json
// WRONG - traversing out of package
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
}
}
}
// CORRECT - use $TURBO_ROOT$ for repo root
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
}
}
}
```
### Missing `outputs` for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG: build produces files but they're not cached
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
// CORRECT: build outputs are cached
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
```
Common outputs by framework:
- Next.js: `[".next/**", "!.next/cache/**"]`
- Vite/Rollup: `["dist/**"]`
- tsc: `["dist/**"]` or custom `outDir`
**TypeScript `--noEmit` can still produce cache files:**
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
```json
// If tsconfig has incremental: true, tsc --noEmit produces cache files
{
"tasks": {
"typecheck": {
"outputs": ["node_modules/.cache/tsbuildinfo.json"] // or wherever tsBuildInfoFile points
}
}
}
```
To determine correct outputs for TypeScript tasks:
1. Check if `incremental` or `composite` is enabled in tsconfig
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
3. If no incremental mode, `tsc --noEmit` produces no files
### `^build` vs `build` Confusion
```json
{
"tasks": {
// ^build = run build in DEPENDENCIES first (other packages this one imports)
"build": {
"dependsOn": ["^build"]
},
// build (no ^) = run build in SAME PACKAGE first
"test": {
"dependsOn": ["build"]
},
// pkg#task = specific package's task
"deploy": {
"dependsOn": ["web#build"]
}
}
}
```
### Environment Variables Not Hashed
```json
// WRONG: API_URL changes won't cause rebuilds
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}
// CORRECT: API_URL changes invalidate cache
{
"tasks": {
"build": {
"outputs": ["dist/**"],
"env": ["API_URL", "API_KEY"]
}
}
}
```
### `.env` Files Not in Inputs
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
```json
// WRONG: .env changes don't invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
// CORRECT: .env file changes invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.*"]
}
}
}
```
### Root `.env` File in Monorepo
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
```
// WRONG - root .env affects all packages implicitly
my-monorepo/
├── .env # Which packages use this?
├── apps/
│ ├── web/
│ └── api/
└── packages/
// CORRECT - .env files in packages that need them
my-monorepo/
├── apps/
│ ├── web/
│ │ └── .env # Clear: web needs DATABASE_URL
│ └── api/
│ └── .env # Clear: api needs API_KEY
└── packages/
```
**Problems with root `.env`:**
- Unclear which packages consume which variables
- All packages get all variables (even ones they don't need)
- Cache invalidation is coarse-grained (root .env change invalidates everything)
- Security risk: packages may accidentally access sensitive vars meant for others
- Bad habits start small — starter templates should model correct patterns
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
### Strict Mode Filtering CI Variables
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
```json
// If CI scripts need GITHUB_TOKEN but it's not in env:
{
"globalPassThroughEnv": ["GITHUB_TOKEN", "CI"],
"tasks": { ... }
}
```
Or use `--env-mode=loose` (not recommended for production).
### Shared Code in Apps (Should Be a Package)
```
// WRONG: Shared code inside an app
apps/
web/
shared/ # This breaks monorepo principles!
utils.ts
// CORRECT: Extract to a package
packages/
utils/
src/utils.ts
```
### Accessing Files Across Package Boundaries
```typescript
// WRONG: Reaching into another package's internals
import { Button } from "../../packages/ui/src/button";
// CORRECT: Install and import properly
import { Button } from "@repo/ui/button";
```
### Too Many Root Dependencies
```json
// WRONG: App dependencies in root
{
"dependencies": {
"react": "^18",
"next": "^14"
}
}
// CORRECT: Only repo tools in root
{
"devDependencies": {
"turbo": "latest"
}
}
```
## Common Task Configurations
### Standard Build Pipeline
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
### Dev Task with `^dev` Pattern (for `turbo watch`)
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
```json
// Root turbo.json
{
"tasks": {
"dev": {
"dependsOn": ["^dev"],
"cache": false,
"persistent": false // Packages have one-shot dev scripts
}
}
}
// Package turbo.json (apps/web/turbo.json)
{
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true // Apps run long-running dev servers
}
}
}
```
**Why this works:**
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
```json
{
"tasks": {
"prepare": {
"dependsOn": ["^prepare"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["prepare"],
"cache": false,
"persistent": true
}
}
}
```
### Transit Nodes for Parallel Tasks with Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
**The problem with `dependsOn: ["^taskname"]`:**
- Forces sequential execution (slow)
**The problem with `dependsOn: []` (no dependencies):**
- Allows parallel execution (fast)
- But cache is INCORRECT - changing dependency source won't invalidate cache
**Transit Nodes solve both:**
```json
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"my-task": { "dependsOn": ["transit"] }
}
}
```
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
### With Environment Variables
```json
{
"globalEnv": ["NODE_ENV"],
"globalDependencies": [".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "DATABASE_URL"]
}
}
}
```
With `futureFlags.globalConfiguration`, the same config moves global settings under `global` — and `.env` becomes a per-task input instead of a global hash input:
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"env": ["NODE_ENV"],
"inputs": [".env"]
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "DATABASE_URL"]
}
}
}
```
## Reference Index
### Configuration
| File | Purpose |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [configuration/RULE.md](./references/configuration/RULE.md) | turbo.json overview, Package Configurations |
| [configuration/tasks.md](./references/configuration/tasks.md) | dependsOn, outputs, inputs, env, cache, persistent |
| [configuration/global-options.md](./references/configuration/global-options.md) | globalEnv, globalDependencies, global key, futureFlags, cacheDir, envMode |
| [configuration/gotchas.md](./references/configuration/gotchas.md) | Common configuration mistakes |
### Caching
| File | Purpose |
| --------------------------------------------------------------- | -------------------------------------------- |
| [caching/RULE.md](./references/caching/RULE.md) | How caching works, hash inputs |
| [caching/remote-cache.md](./references/caching/remote-cache.md) | Vercel Remote Cache, self-hosted, login/link |
| [caching/gotchas.md](./references/caching/gotchas.md) | Debugging cache misses, --summarize, --dry |
### Environment Variables
| File | Purpose |
| ------------------------------------------------------------- | ----------------------------------------- |
| [environment/RULE.md](./references/environment/RULE.md) | env, globalEnv, passThroughEnv |
| [environment/modes.md](./references/environment/modes.md) | Strict vs Loose mode, framework inference |
| [environment/gotchas.md](./references/environment/gotchas.md) | .env files, CI issues |
### Filtering
| File | Purpose |
| ----------------------------------------------------------- | ------------------------ |
| [filtering/RULE.md](./references/filtering/RULE.md) | --filter syntax overview |
| [filtering/patterns.md](./references/filtering/patterns.md) | Common filter patterns |
### CI/CD
| File | Purpose |
| --------------------------------------------------------- | ------------------------------- |
| [ci/RULE.md](./references/ci/RULE.md) | General CI principles |
| [ci/github-actions.md](./references/ci/github-actions.md) | Complete GitHub Actions setup |
| [ci/vercel.md](./references/ci/vercel.md) | Vercel deployment, turbo-ignore |
| [ci/patterns.md](./references/ci/patterns.md) | --affected, caching strategies |
### CLI
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------- |
| [cli/RULE.md](./references/cli/RULE.md) | turbo run basics |
| [cli/commands.md](./references/cli/commands.md) | turbo run flags, turbo-ignore, other commands |
### Best Practices
| File | Purpose |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
| [best-practices/RULE.md](./references/best-practices/RULE.md) | Monorepo best practices overview |
| [best-practices/structure.md](./references/best-practices/structure.md) | Repository structure, workspace config, TypeScript/ESLint setup |
| [best-practices/packages.md](./references/best-practices/packages.md) | Creating internal packages, JIT vs Compiled, exports |
| [best-practices/dependencies.md](./references/best-practices/dependencies.md) | Dependency management, installing, version sync |
### Watch Mode
| File | Purpose |
| ------------------------------------------- | ----------------------------------------------- |
| [watch/RULE.md](./references/watch/RULE.md) | turbo watch, interruptible tasks, dev workflows |
### Boundaries (Experimental)
| File | Purpose |
| ----------------------------------------------------- | ----------------------------------------------------- |
| [boundaries/RULE.md](./references/boundaries/RULE.md) | Enforce package isolation, tag-based dependency rules |
## Source Documentation
This skill is based on the official Turborepo documentation at:
- Source: `apps/docs/content/docs/` in the Turborepo repository
- Live: https://turborepo.dev/docs
@@ -1,70 +0,0 @@
---
description: Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
---
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
## Workflow
### Step 1: Load turborepo skill
```
skill({ name: 'turborepo' })
```
### Step 2: Identify task type from user request
Analyze $ARGUMENTS to determine:
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
- **Task type**: new setup, debugging, optimization, or implementation
Use decision trees in SKILL.md to select the relevant reference files.
### Step 3: Read relevant reference files
Based on task type, read from `references/<topic>/`:
| Task | Files to Read |
| -------------------- | ------------------------------------------------------- |
| Configure turbo.json | `configuration/RULE.md` + `configuration/tasks.md` |
| Debug cache issues | `caching/gotchas.md` |
| Set up remote cache | `caching/remote-cache.md` |
| Filter packages | `filtering/RULE.md` + `filtering/patterns.md` |
| Environment problems | `environment/gotchas.md` + `environment/modes.md` |
| Set up CI | `ci/RULE.md` + `ci/github-actions.md` or `ci/vercel.md` |
| CLI usage | `cli/commands.md` |
### Step 4: Execute task
Apply Turborepo-specific patterns from references to complete the user's request.
**CRITICAL - When creating tasks/scripts/pipelines:**
1. **DO NOT create Root Tasks** - Always create package tasks
2. Add scripts to each relevant package's `package.json` (e.g., `apps/web/package.json`, `packages/ui/package.json`)
3. Register the task in root `turbo.json`
4. Root `package.json` only contains `turbo run <task>` - never actual task logic
**Other things to verify:**
- `outputs` defined for cacheable tasks
- `dependsOn` uses correct syntax (`^task` vs `task`)
- Environment variables in `env` key
- `.env` files in `inputs` if used
- Use `turbo run` (not `turbo`) in package.json and CI
### Step 5: Summarize
```
=== Turborepo Task Complete ===
Topic: <configuration|caching|filtering|environment|ci|cli>
Files referenced: <reference files consulted>
<brief summary of what was done>
```
<user-request>
$ARGUMENTS
</user-request>
@@ -1,241 +0,0 @@
# Monorepo Best Practices
Essential patterns for structuring and maintaining a healthy Turborepo monorepo.
## Repository Structure
### Standard Layout
```
my-monorepo/
├── apps/ # Application packages (deployable)
│ ├── web/
│ ├── docs/
│ └── api/
├── packages/ # Library packages (shared code)
│ ├── ui/
│ ├── utils/
│ └── config-*/ # Shared configs (eslint, typescript, etc.)
├── package.json # Root package.json (minimal deps)
├── turbo.json # Turborepo configuration
├── pnpm-workspace.yaml # (pnpm) or workspaces in package.json
└── pnpm-lock.yaml # Lockfile (required)
```
### Key Principles
1. **`apps/` for deployables**: Next.js sites, APIs, CLIs - things that get deployed
2. **`packages/` for libraries**: Shared code consumed by apps or other packages
3. **One purpose per package**: Each package should do one thing well
4. **No nested packages**: Don't put packages inside packages
## Package Types
### Application Packages (`apps/`)
- **Deployable**: These are the "endpoints" of your package graph
- **Not installed by other packages**: Apps shouldn't be dependencies of other packages
- **No shared code**: If code needs sharing, extract to `packages/`
```json
// apps/web/package.json
{
"name": "web",
"private": true,
"dependencies": {
"@repo/ui": "workspace:*",
"next": "latest"
}
}
```
### Library Packages (`packages/`)
- **Shared code**: Utilities, components, configs
- **Namespaced names**: Use `@repo/` or `@yourorg/` prefix
- **Clear exports**: Define what the package exposes
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
}
}
```
## Package Compilation Strategies
### Just-in-Time (Simplest)
Export TypeScript directly; let the app's bundler compile it.
```json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx"
}
}
```
**Pros**: Zero build config, instant changes
**Cons**: Can't cache builds, requires app bundler support
### Compiled (Recommended for Libraries)
Package compiles itself with `tsc` or bundler.
```json
{
"name": "@repo/ui",
"exports": {
"./button": {
"types": "./src/button.tsx",
"default": "./dist/button.js"
}
},
"scripts": {
"build": "tsc"
}
}
```
**Pros**: Cacheable by Turborepo, works everywhere
**Cons**: More configuration
## Dependency Management
### Install Where Used
Install dependencies in the package that uses them, not the root.
```bash
# Good: Install in the package that needs it
pnpm add lodash --filter=@repo/utils
# Avoid: Installing everything at root
pnpm add lodash -w # Only for repo-level tools
```
### Root Dependencies
Only these belong in root `package.json`:
- `turbo` - The build system
- `husky`, `lint-staged` - Git hooks
- Repository-level tooling
### Internal Dependencies
Use workspace protocol for internal packages:
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" }
// npm/yarn
{ "@repo/ui": "*" }
```
## Exports Best Practices
### Use `exports` Field (Not `main`)
```json
{
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx",
"./utils": "./src/utils.ts"
}
}
```
### Avoid Barrel Files
Don't create `index.ts` files that re-export everything:
```typescript
// BAD: packages/ui/src/index.ts
export * from './button';
export * from './card';
export * from './modal';
// ... imports everything even if you need one thing
// GOOD: Direct exports in package.json
{
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
}
}
```
### Namespace Your Packages
```json
// Good
{ "name": "@repo/ui" }
{ "name": "@acme/utils" }
// Avoid (conflicts with npm registry)
{ "name": "ui" }
{ "name": "utils" }
```
## Common Anti-Patterns
### Accessing Files Across Package Boundaries
```typescript
// BAD: Reaching into another package
import { Button } from "../../packages/ui/src/button";
// GOOD: Install and import properly
import { Button } from "@repo/ui/button";
```
### Shared Code in Apps
```
// BAD
apps/
web/
shared/ # This should be a package!
utils.ts
// GOOD
packages/
utils/ # Proper shared package
src/utils.ts
```
### Too Many Root Dependencies
```json
// BAD: Root has app dependencies
{
"dependencies": {
"react": "^18",
"next": "^14",
"lodash": "^4"
}
}
// GOOD: Root only has repo tools
{
"devDependencies": {
"turbo": "latest",
"husky": "latest"
}
}
```
## See Also
- [structure.md](./structure.md) - Detailed repository structure patterns
- [packages.md](./packages.md) - Creating and managing internal packages
- [dependencies.md](./dependencies.md) - Dependency management strategies
@@ -1,246 +0,0 @@
# Dependency Management
Best practices for managing dependencies in a Turborepo monorepo.
## Core Principle: Install Where Used
Dependencies belong in the package that uses them, not the root.
```bash
# Good: Install in specific package
pnpm add react --filter=@repo/ui
pnpm add next --filter=web
# Avoid: Installing in root
pnpm add react -w # Only for repo-level tools!
```
## Benefits of Local Installation
### 1. Clarity
Each package's `package.json` lists exactly what it needs:
```json
// packages/ui/package.json
{
"dependencies": {
"react": "^18.0.0",
"class-variance-authority": "^0.7.0"
}
}
```
### 2. Flexibility
Different packages can use different versions when needed:
```json
// packages/legacy-ui/package.json
{ "dependencies": { "react": "^17.0.0" } }
// packages/ui/package.json
{ "dependencies": { "react": "^18.0.0" } }
```
### 3. Better Caching
Installing in root changes workspace lockfile, invalidating all caches.
### 4. Pruning Support
`turbo prune` can remove unused dependencies for Docker images.
## What Belongs in Root
Only repository-level tools:
```json
// Root package.json
{
"devDependencies": {
"turbo": "latest",
"husky": "^8.0.0",
"lint-staged": "^15.0.0"
}
}
```
**NOT** application dependencies:
- react, next, express
- lodash, axios, zod
- Testing libraries (unless truly repo-wide)
## Installing Dependencies
### Single Package
```bash
# pnpm
pnpm add lodash --filter=@repo/utils
# npm
npm install lodash --workspace=@repo/utils
# yarn
yarn workspace @repo/utils add lodash
# bun
cd packages/utils && bun add lodash
```
### Multiple Packages
```bash
# pnpm
pnpm add jest --save-dev --filter=web --filter=@repo/ui
# npm
npm install jest --save-dev --workspace=web --workspace=@repo/ui
# yarn (v2+)
yarn workspaces foreach -R --from '{web,@repo/ui}' add jest --dev
```
### Internal Packages
```bash
# pnpm
pnpm add @repo/ui --filter=web
# This updates package.json:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
```
## Keeping Versions in Sync
### Option 1: Tooling
```bash
# syncpack - Check and fix version mismatches
npx syncpack list-mismatches
npx syncpack fix-mismatches
# manypkg - Similar functionality
npx @manypkg/cli check
npx @manypkg/cli fix
# sherif - Rust-based, very fast
npx sherif
```
### Option 2: Package Manager Commands
```bash
# pnpm - Update everywhere
pnpm up --recursive typescript@latest
# npm - Update in all workspaces
npm install typescript@latest --workspaces
```
### Option 3: pnpm Catalogs (pnpm 9.5+)
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^18.2.0
typescript: ^5.3.0
```
```json
// Any package.json
{
"dependencies": {
"react": "catalog:" // Uses version from catalog
}
}
```
## Internal vs External Dependencies
### Internal (Workspace)
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" }
// npm/yarn
{ "@repo/ui": "*" }
```
Turborepo understands these relationships and orders builds accordingly.
### External (npm Registry)
```json
{ "lodash": "^4.17.21" }
```
Standard semver versioning from npm.
## Peer Dependencies
For library packages that expect the consumer to provide dependencies:
```json
// packages/ui/package.json
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"react": "^18.0.0", // For development/testing
"react-dom": "^18.0.0"
}
}
```
## Common Issues
### "Module not found"
1. Check the dependency is installed in the right package
2. Run `pnpm install` / `npm install` to update lockfile
3. Check exports are defined in the package
### Version Conflicts
Packages can use different versions - this is a feature, not a bug. But if you need consistency:
1. Use tooling (syncpack, manypkg)
2. Use pnpm catalogs
3. Create a lint rule
### Hoisting Issues
Some tools expect dependencies in specific locations. Use package manager config:
```yaml
# .npmrc (pnpm)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
```
## Lockfile
**Required** for:
- Reproducible builds
- Turborepo dependency analysis
- Cache correctness
```bash
# Commit your lockfile!
git add pnpm-lock.yaml # or package-lock.json, yarn.lock
```
@@ -1,335 +0,0 @@
# Creating Internal Packages
How to create and structure internal packages in your monorepo.
## Package Creation Checklist
1. Create directory in `packages/`
2. Add `package.json` with name and exports
3. Add source code in `src/`
4. Add `tsconfig.json` if using TypeScript
5. Install as dependency in consuming packages
6. Run package manager install to update lockfile
## Package Compilation Strategies
### Just-in-Time (JIT)
Export TypeScript directly. The consuming app's bundler compiles it.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
},
"scripts": {
"lint": "eslint .",
"check-types": "tsc --noEmit"
}
}
```
**When to use:**
- Apps use modern bundlers (Turbopack, webpack, Vite)
- You want minimal configuration
- Build times are acceptable without caching
**Limitations:**
- No Turborepo cache for the package itself
- Consumer must support TypeScript compilation
- Can't use TypeScript `paths` (use Node.js subpath imports instead)
### Compiled
Package handles its own compilation.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": {
"types": "./src/button.tsx",
"default": "./dist/button.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
```
```json
// packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
**When to use:**
- You want Turborepo to cache builds
- Package will be used by non-bundler tools
- You need maximum compatibility
**Remember:** Add `dist/**` to turbo.json outputs!
## Defining Exports
### Multiple Entrypoints
```json
{
"exports": {
".": "./src/index.ts", // @repo/ui
"./button": "./src/button.tsx", // @repo/ui/button
"./card": "./src/card.tsx", // @repo/ui/card
"./hooks": "./src/hooks/index.ts" // @repo/ui/hooks
}
}
```
### Conditional Exports (Compiled)
```json
{
"exports": {
"./button": {
"types": "./src/button.tsx",
"import": "./dist/button.mjs",
"require": "./dist/button.cjs",
"default": "./dist/button.js"
}
}
}
```
## Installing Internal Packages
### Add to Consuming Package
```json
// apps/web/package.json
{
"dependencies": {
"@repo/ui": "workspace:*" // pnpm/bun
// "@repo/ui": "*" // npm/yarn
}
}
```
### Run Install
```bash
pnpm install # Updates lockfile with new dependency
```
### Import and Use
```typescript
// apps/web/src/page.tsx
import { Button } from '@repo/ui/button';
export default function Page() {
return <Button>Click me</Button>;
}
```
## One Purpose Per Package
### Good Examples
```
packages/
├── ui/ # Shared UI components
├── utils/ # General utilities
├── auth/ # Authentication logic
├── database/ # Database client/schemas
├── eslint-config/ # ESLint configuration
├── typescript-config/ # TypeScript configuration
└── api-client/ # Generated API client
```
### Avoid Mega-Packages
```
// BAD: One package for everything
packages/
└── shared/
├── components/
├── utils/
├── hooks/
├── types/
└── api/
// GOOD: Separate by purpose
packages/
├── ui/ # Components
├── utils/ # Utilities
├── hooks/ # React hooks
├── types/ # Shared TypeScript types
└── api-client/ # API utilities
```
## Config Packages
### TypeScript Config
```json
// packages/typescript-config/package.json
{
"name": "@repo/typescript-config",
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./library.json": "./library.json"
}
}
```
### ESLint Config
```json
// packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"exports": {
"./base": "./base.js",
"./next": "./next.js"
},
"dependencies": {
"eslint": "^8.0.0",
"eslint-config-next": "latest"
}
}
```
## Common Mistakes
### Forgetting to Export
```json
// BAD: No exports defined
{
"name": "@repo/ui"
}
// GOOD: Clear exports
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx"
}
}
```
### Wrong Workspace Syntax
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" } // Correct
// npm/yarn
{ "@repo/ui": "*" } // Correct
{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn!
```
### Missing from turbo.json Outputs
```json
// Package builds to dist/, but turbo.json doesn't know
{
"tasks": {
"build": {
"outputs": [".next/**"] // Missing dist/**!
}
}
}
// Correct
{
"tasks": {
"build": {
"outputs": [".next/**", "dist/**"]
}
}
}
```
## TypeScript Best Practices
### Use Node.js Subpath Imports (Not `paths`)
TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+).
**JIT Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./src/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.ts"; // Uses .ts extension
```
**Compiled Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./dist/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.js"; // Uses .js extension
```
### Use `tsc` for Internal Packages
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
### Enable Go-to-Definition
For Compiled Packages, enable declaration maps:
```json
// tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true
}
}
```
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
### No Root tsconfig.json Needed
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
### Avoid TypeScript Project References
They add complexity and another caching layer. Turborepo handles dependencies better.
@@ -1,297 +0,0 @@
# Repository Structure
Detailed guidance on structuring a Turborepo monorepo.
## Workspace Configuration
### pnpm (Recommended)
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
```
### npm/yarn/bun
```json
// package.json
{
"workspaces": ["apps/*", "packages/*"]
}
```
## Root package.json
```json
{
"name": "my-monorepo",
"private": true,
"packageManager": "pnpm@9.0.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "latest"
}
}
```
Key points:
- `private: true` - Prevents accidental publishing
- `packageManager` - Enforces consistent package manager version
- **Scripts only delegate to `turbo run`** - No actual build logic here!
- Minimal devDependencies (just turbo and repo tools)
## Always Prefer Package Tasks
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
```json
// packages/web/package.json
{
"scripts": {
"build": "next build",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
// packages/api/package.json
{
"scripts": {
"build": "tsc",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
```
Package tasks enable Turborepo to:
1. **Parallelize** - Run `web#lint` and `api#lint` simultaneously
2. **Cache individually** - Each package's task output is cached separately
3. **Filter precisely** - Run `turbo run test --filter=web` for just one package
**Root Tasks are a fallback** for tasks that truly cannot run per-package:
```json
// AVOID unless necessary - sequential, not parallelized, can't filter
{
"scripts": {
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
}
}
```
## Root turbo.json
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
With `futureFlags.globalConfiguration`, global settings move under a `global` key:
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": ["tsconfig.json"],
"env": ["CI"]
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
## Directory Organization
### Grouping Packages
You can group packages by adding more workspace paths:
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "packages/config/*" # Grouped configs
- "packages/features/*" # Feature packages
```
This allows:
```
packages/
├── ui/
├── utils/
├── config/
│ ├── eslint/
│ ├── typescript/
│ └── tailwind/
└── features/
├── auth/
└── payments/
```
### What NOT to Do
```yaml
# BAD: Nested wildcards cause ambiguous behavior
packages:
- "packages/**" # Don't do this!
```
## Package Anatomy
### Minimum Required Files
```
packages/ui/
├── package.json # Required: Makes it a package
├── src/ # Source code
│ └── button.tsx
└── tsconfig.json # TypeScript config (if using TS)
```
### package.json Requirements
```json
{
"name": "@repo/ui", // Unique, namespaced name
"version": "0.0.0", // Version (can be 0.0.0 for internal)
"private": true, // Prevents accidental publishing
"exports": {
// Entry points
"./button": "./src/button.tsx"
}
}
```
## TypeScript Configuration
### Shared Base Config
Create a shared TypeScript config package:
```
packages/
└── typescript-config/
├── package.json
├── base.json
├── nextjs.json
└── library.json
```
```json
// packages/typescript-config/base.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022"
}
}
```
### Extending in Packages
```json
// packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
### No Root tsconfig.json
You likely don't need a `tsconfig.json` in the workspace root. Each package should have its own config extending from the shared config package.
## ESLint Configuration
### Shared Config Package
```
packages/
└── eslint-config/
├── package.json
├── base.js
├── next.js
└── library.js
```
```json
// packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"exports": {
"./base": "./base.js",
"./next": "./next.js",
"./library": "./library.js"
}
}
```
### Using in Packages
```js
// apps/web/.eslintrc.js
module.exports = {
extends: ["@repo/eslint-config/next"]
};
```
## Lockfile
A lockfile is **required** for:
- Reproducible builds
- Turborepo to understand package dependencies
- Cache correctness
Without a lockfile, you'll see unpredictable behavior.
@@ -1,126 +0,0 @@
# Boundaries
**Experimental feature** - See [RFC](https://github.com/vercel/turborepo/discussions/9435)
Full docs: https://turborepo.dev/docs/reference/boundaries
Boundaries enforce package isolation by detecting:
1. Imports of files outside the package's directory
2. Imports of packages not declared in `package.json` dependencies
## Usage
```bash
turbo boundaries
```
Run this to check for workspace violations across your monorepo.
## Tags
Tags allow you to create rules for which packages can depend on each other.
### Adding Tags to a Package
```json
// packages/ui/turbo.json
{
"tags": ["internal"]
}
```
### Configuring Tag Rules
Rules go in root `turbo.json`:
```json
// turbo.json
{
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
}
}
}
}
```
This prevents `public`-tagged packages from importing `internal`-tagged packages.
### Rule Types
**Allow-list approach** (only allow specific tags):
```json
{
"boundaries": {
"tags": {
"public": {
"dependencies": {
"allow": ["public"]
}
}
}
}
}
```
**Deny-list approach** (block specific tags):
```json
{
"boundaries": {
"tags": {
"public": {
"dependencies": {
"deny": ["internal"]
}
}
}
}
}
```
**Restrict dependents** (who can import this package):
```json
{
"boundaries": {
"tags": {
"private": {
"dependents": {
"deny": ["public"]
}
}
}
}
}
```
### Using Package Names
Package names work in place of tags:
```json
{
"boundaries": {
"tags": {
"private": {
"dependents": {
"deny": ["@repo/my-pkg"]
}
}
}
}
}
```
## Key Points
- Rules apply transitively (dependencies of dependencies)
- Helps enforce architectural boundaries at scale
- Catches violations before runtime/build errors
@@ -1,153 +0,0 @@
# How Turborepo Caching Works
Turborepo's core principle: **never do the same work twice**.
## The Cache Equation
```
fingerprint(inputs) → stored outputs
```
If inputs haven't changed, restore outputs from cache instead of re-running the task.
## What Determines the Cache Key
### Global Hash Inputs
These affect ALL tasks in the repo:
- `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`
- Files listed in `globalDependencies` (or `global.env` when using `globalConfiguration`)
- Environment variables in `globalEnv` (or `global.env`)
- `turbo.json` configuration
```json
{
"globalDependencies": [".env", "tsconfig.base.json"],
"globalEnv": ["CI", "NODE_ENV"]
}
```
### Task Hash Inputs
These affect specific tasks:
- All files in the package (unless filtered by `inputs`)
- `package.json` contents
- Environment variables in task's `env` key
- Task configuration (command, outputs, dependencies)
- Hashes of dependent tasks (`dependsOn`)
- Files from `global.inputs` (when using `futureFlags.globalConfiguration` — see below)
```json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"env": ["API_URL"]
}
}
}
```
### How `global.inputs` Changes the Hash Equation
When `futureFlags.globalConfiguration` is enabled, `global.inputs` files are **not** part of the global hash. Instead, they are prepended to every task's `inputs` and folded into the **task hash**. This is a fundamental change from `globalDependencies`.
**With `globalDependencies` (default):**
```
task cache key = hash(global hash, task hash)
↑ includes globalDependencies file hashes
```
Changing a `globalDependencies` file invalidates **every** task, regardless of task-level `inputs`. There is no way for a task to opt out.
**With `global.inputs` (`futureFlags.globalConfiguration`):**
```
task cache key = hash(global hash, task hash)
↑ includes global.inputs file hashes (merged with task inputs)
```
`global.inputs` files are merged into each task's input globs. This means:
- Tasks can **exclude** specific global files with negation globs: `"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]`
- The global hash is smaller (it still includes lockfile, engines, `global.env`, etc. — but not file hashes from `global.inputs`)
- The task hash correctly includes the global input file hashes alongside the task's own inputs
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": ["tsconfig.json", ".env"]
},
"tasks": {
"build": {
"outputs": ["dist/**"]
},
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]
}
}
}
```
In this example, changing `tsconfig.json` invalidates `build` (it's in the task's inputs) but **not** `lint` (which explicitly excludes it). With `globalDependencies`, both would have been invalidated.
## What Gets Cached
1. **File outputs** - files/directories specified in `outputs`
2. **Task logs** - stdout/stderr for replay on cache hit
```json
{
"tasks": {
"build": {
"outputs": ["dist/**", ".next/**"]
}
}
}
```
## Local Cache Location
```
.turbo/cache/
├── <hash1>.tar.zst # compressed outputs
├── <hash2>.tar.zst
└── ...
```
Add `.turbo` to `.gitignore`.
## Cache Restoration
On cache hit, Turborepo:
1. Extracts archived outputs to their original locations
2. Replays the logged stdout/stderr
3. Reports the task as cached (shows `FULL TURBO` in output)
## Example Flow
```bash
# First run - executes build, caches result
turbo build
# → packages/ui: cache miss, executing...
# → packages/web: cache miss, executing...
# Second run - same inputs, restores from cache
turbo build
# → packages/ui: cache hit, replaying output
# → packages/web: cache hit, replaying output
# → FULL TURBO
```
## Key Points
- Cache is content-addressed (based on input hash, not timestamps)
- Empty `outputs` array means task runs but nothing is cached
- Tasks without `outputs` key cache nothing (use `"outputs": []` to be explicit)
- Cache is invalidated when ANY input changes
@@ -1,190 +0,0 @@
# Debugging Cache Issues
## Diagnostic Tools
### `--summarize`
Generates a JSON file with all hash inputs. Compare two runs to find differences.
```bash
turbo build --summarize
# Creates .turbo/runs/<run-id>.json
```
The summary includes:
- Global hash and its inputs
- Per-task hashes and their inputs
- Environment variables that affected the hash
**Comparing runs:**
```bash
# Run twice, compare the summaries
diff .turbo/runs/<first-run>.json .turbo/runs/<second-run>.json
```
### `--dry` / `--dry=json`
See what would run without executing anything:
```bash
turbo build --dry
turbo build --dry=json # machine-readable output
```
Shows cache status for each task without running them.
### `--force`
Skip reading cache, re-execute all tasks:
```bash
turbo build --force
```
Useful to verify tasks actually work (not just cached results).
## Unexpected Cache Misses
**Symptom:** Task runs when you expected a cache hit.
### Environment Variable Changed
Check if an env var in the `env` key changed:
```json
{
"tasks": {
"build": {
"env": ["API_URL", "NODE_ENV"]
}
}
}
```
Different `API_URL` between runs = cache miss.
### .env File Changed
`.env` files aren't tracked by default. Add to `inputs`:
```json
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"]
}
}
}
```
Or use `globalDependencies` for repo-wide env files:
```json
{
"globalDependencies": [".env"]
}
```
With `futureFlags.globalConfiguration`, use `global.inputs` instead. The key difference: `global.inputs` files are folded into each task's hash individually (not the global hash), so tasks can exclude specific files with negation globs.
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": [".env"]
}
}
```
### Lockfile Changed
Installing/updating packages changes the global hash.
### Source Files Changed
Any file in the package (or in `inputs`) triggers a miss.
### turbo.json Changed
Config changes invalidate the global hash.
## Incorrect Cache Hits
**Symptom:** Cached output is stale/wrong.
### Missing Environment Variable
Task uses an env var not listed in `env`:
```javascript
// build.js
const apiUrl = process.env.API_URL; // not tracked!
```
Fix: add to task config:
```json
{
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
```
### Missing File in Inputs
Task reads a file outside default inputs:
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
"../../shared-config.json" // file outside package
]
}
}
}
```
## Useful Flags
```bash
# Only show output for cache misses
turbo build --output-logs=new-only
# Show output for everything (debugging)
turbo build --output-logs=full
# See why tasks are running
turbo build --verbosity=2
```
## Debugging with `globalConfiguration` Enabled
When `futureFlags.globalConfiguration` is on, `global.inputs` files appear in per-task hash inputs (not the global hash). If you're getting unexpected cache misses:
1. Check `--summarize` output — global input files will show up in the **task inputs** section, not the global hash section
2. Verify tasks aren't accidentally excluding global inputs via negation globs in `inputs`
3. Remember that toggling the `globalConfiguration` flag itself invalidates all caches (the flag value is part of the global hash)
If you're getting unexpected cache **hits** after changing a global input file, the task may be excluding that file with a negation glob. Check the task's `inputs` for `!$TURBO_ROOT$/...` patterns.
## Quick Checklist
Cache miss when expected hit:
1. Run with `--summarize`, compare with previous run
2. Check env vars with `--dry=json`
3. Look for lockfile/config changes in git
Cache hit when expected miss:
1. Verify env var is in `env` array
2. Verify file is in `inputs` array
3. Check if file is outside package directory
@@ -1,127 +0,0 @@
# Remote Caching
Share cache artifacts across your team and CI pipelines.
## Benefits
- Team members get cache hits from each other's work
- CI gets cache hits from local development (and vice versa)
- Dramatically faster CI runs after first build
- No more "works on my machine" rebuilds
## Vercel Remote Cache
Free, zero-config when deploying on Vercel. For local dev and other CI:
### Local Development Setup
```bash
# Authenticate with Vercel
npx turbo login
# Link repo to your Vercel team
npx turbo link
```
This creates `.turbo/config.json` with your team info (gitignored by default).
### CI Setup
Set these environment variables:
```bash
TURBO_TOKEN=<your-token>
TURBO_TEAM=<your-team-slug>
```
Get your token from Vercel dashboard → Settings → Tokens.
**GitHub Actions example:**
```yaml
- name: Build
run: npx turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
## Configuration in turbo.json
```json
{
"remoteCache": {
"enabled": true,
"signature": false
}
}
```
Options:
- `enabled`: toggle remote cache (default: true when authenticated)
- `signature`: require artifact signing (default: false)
## Artifact Signing
Verify cache artifacts haven't been tampered with:
```bash
# Set a secret key (use same key across all environments)
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="your-secret-key"
```
Enable in config:
```json
{
"remoteCache": {
"signature": true
}
}
```
Signed artifacts can only be restored if the signature matches.
## Self-Hosted Options
Community implementations for running your own cache server:
- **turbo-remote-cache** (Node.js) - supports S3, GCS, Azure
- **turborepo-remote-cache** (Go) - lightweight, S3-compatible
- **ducktape** (Rust) - high-performance option
Configure with environment variables:
```bash
TURBO_API=https://your-cache-server.com
TURBO_TOKEN=your-auth-token
TURBO_TEAM=your-team
```
## Cache Behavior Control
```bash
# Disable remote cache for a run
turbo build --remote-cache-read-only # read but don't write
turbo build --no-cache # skip cache entirely
# Environment variable alternative
TURBO_REMOTE_ONLY=true # only use remote, skip local
```
## Debugging Remote Cache
```bash
# Verbose output shows cache operations
turbo build --verbosity=2
# Check if remote cache is configured
turbo config
```
Look for:
- "Remote caching enabled" in output
- Upload/download messages during runs
- "cache hit, replaying output" with remote cache indicator
@@ -1,79 +0,0 @@
# CI/CD with Turborepo
General principles for running Turborepo in continuous integration environments.
## Core Principles
### Always Use `turbo run` in CI
**Never use the `turbo <tasks>` shorthand in CI or scripts.** Always use `turbo run`:
```bash
# CORRECT - Always use in CI, package.json, scripts
turbo run build test lint
# WRONG - Shorthand is only for one-off terminal commands
turbo build test lint
```
The shorthand `turbo <tasks>` is only for one-off invocations typed directly in terminal by humans or agents. Anywhere the command is written into code (CI, package.json, scripts), use `turbo run`.
### Enable Remote Caching
Remote caching dramatically speeds up CI by sharing cached artifacts across runs.
Required environment variables:
```bash
TURBO_TOKEN=your_vercel_token
TURBO_TEAM=your_team_slug
```
### Use --affected for PR Builds
The `--affected` flag only runs tasks for packages changed since the base branch:
```bash
turbo run build test --affected
```
This requires Git history to compute what changed.
## Git History Requirements
### Fetch Depth
`--affected` needs access to the merge base. Shallow clones break this.
```yaml
# GitHub Actions
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Minimum for --affected
# Use 0 for full history if merge base is far
```
### Why Shallow Clones Break --affected
Turborepo compares the current HEAD to the merge base with `main`. If that commit isn't fetched, `--affected` falls back to running everything.
For PRs with many commits, consider:
```yaml
fetch-depth: 0 # Full history
```
## Environment Variables Reference
| Variable | Purpose |
| ------------------- | ------------------------------------ |
| `TURBO_TOKEN` | Vercel access token for remote cache |
| `TURBO_TEAM` | Your Vercel team slug |
| `TURBO_REMOTE_ONLY` | Skip local cache, use remote only |
| `TURBO_LOG_ORDER` | Set to `grouped` for cleaner CI logs |
## See Also
- [github-actions.md](./github-actions.md) - GitHub Actions setup
- [vercel.md](./vercel.md) - Vercel deployment
- [patterns.md](./patterns.md) - CI optimization patterns
@@ -1,162 +0,0 @@
# GitHub Actions
Complete setup guide for Turborepo with GitHub Actions.
## Basic Workflow Structure
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build and Test
run: turbo run build test lint
```
## Package Manager Setup
### pnpm
```yaml
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
```
### Yarn
```yaml
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
- run: yarn install --frozen-lockfile
```
### Bun
```yaml
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- run: bun install --frozen-lockfile
```
## Remote Cache Setup
### 1. Create Vercel Access Token
1. Go to [Vercel Dashboard](https://vercel.com/account/tokens)
2. Create a new token with appropriate scope
3. Copy the token value
### 2. Add Secrets and Variables
In your GitHub repository settings:
**Secrets** (Settings > Secrets and variables > Actions > Secrets):
- `TURBO_TOKEN`: Your Vercel access token
**Variables** (Settings > Secrets and variables > Actions > Variables):
- `TURBO_TEAM`: Your Vercel team slug
### 3. Add to Workflow
```yaml
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
## Alternative: actions/cache
If you can't use remote cache, cache Turborepo's local cache directory:
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}
restore-keys: |
turbo-${{ runner.os }}-
```
Note: This is less effective than remote cache since it's per-branch.
## Complete Example
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: turbo run build --affected
- name: Test
run: turbo run test --affected
- name: Lint
run: turbo run lint --affected
```
@@ -1,145 +0,0 @@
# CI Optimization Patterns
Strategies for efficient CI/CD with Turborepo.
## PR vs Main Branch Builds
### PR Builds: Only Affected
Test only what changed in the PR:
```yaml
- name: Test (PR)
if: github.event_name == 'pull_request'
run: turbo run build test --affected
```
### Main Branch: Full Build
Ensure complete validation on merge:
```yaml
- name: Test (Main)
if: github.ref == 'refs/heads/main'
run: turbo run build test
```
## Custom Git Ranges with --filter
For advanced scenarios, use `--filter` with git refs:
```bash
# Changes since specific commit
turbo run test --filter="...[abc123]"
# Changes between refs
turbo run test --filter="...[main...HEAD]"
# Changes in last 3 commits
turbo run test --filter="...[HEAD~3]"
```
## Caching Strategies
### Remote Cache (Recommended)
Best performance - shared across all CI runs and developers:
```yaml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
### actions/cache Fallback
When remote cache isn't available:
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ github.ref }}-
turbo-${{ runner.os }}-
```
Limitations:
- Cache is branch-scoped
- PRs restore from base branch cache
- Less efficient than remote cache
## Matrix Builds
Test across Node versions:
```yaml
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: turbo run test
```
## Parallelizing Across Jobs
Split tasks into separate jobs:
```yaml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: turbo run lint --affected
test:
runs-on: ubuntu-latest
steps:
- run: turbo run test --affected
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- run: turbo run build
```
### Cache Considerations
When parallelizing:
- Each job has separate cache writes
- Remote cache handles this automatically
- With actions/cache, use unique keys per job to avoid conflicts
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
```
## Conditional Tasks
Skip expensive tasks on draft PRs:
```yaml
- name: E2E Tests
if: github.event.pull_request.draft == false
run: turbo run test:e2e --affected
```
Or require label for full test:
```yaml
- name: Full Test Suite
if: contains(github.event.pull_request.labels.*.name, 'full-test')
run: turbo run test
```
@@ -1,103 +0,0 @@
# Vercel Deployment
Turborepo integrates seamlessly with Vercel for monorepo deployments.
## Remote Cache
Remote caching is **automatically enabled** when deploying to Vercel. No configuration needed - Vercel detects Turborepo and enables caching.
This means:
- No `TURBO_TOKEN` or `TURBO_TEAM` setup required on Vercel
- Cache is shared across all deployments
- Preview and production builds benefit from cache
## turbo-ignore
Skip unnecessary builds when a package hasn't changed using `turbo-ignore`.
### Installation
```bash
npx turbo-ignore
```
Or install globally in your project:
```bash
pnpm add -D turbo-ignore
```
### Setup in Vercel
1. Go to your project in Vercel Dashboard
2. Navigate to Settings > Git > Ignored Build Step
3. Select "Custom" and enter:
```bash
npx turbo-ignore
```
### How It Works
`turbo-ignore` checks if the current package (or its dependencies) changed since the last successful deployment:
1. Compares current commit to last deployed commit
2. Uses Turborepo's dependency graph
3. Returns exit code 0 (skip) if no changes
4. Returns exit code 1 (build) if changes detected
### Options
```bash
# Check specific package
npx turbo-ignore web
# Use specific comparison ref
npx turbo-ignore --fallback=HEAD~1
# Verbose output
npx turbo-ignore --verbose
```
## Environment Variables
Set environment variables in Vercel Dashboard:
1. Go to Project Settings > Environment Variables
2. Add variables for each environment (Production, Preview, Development)
Common variables:
- `DATABASE_URL`
- `API_KEY`
- Package-specific config
## Monorepo Root Directory
For monorepos, set the root directory in Vercel:
1. Project Settings > General > Root Directory
2. Set to the package path (e.g., `apps/web`)
Vercel automatically:
- Installs dependencies from monorepo root
- Runs build from the package directory
- Detects framework settings
## Build Command
Vercel auto-detects `turbo run build` when `turbo.json` exists at root.
Override if needed:
```bash
turbo run build --filter=web
```
Or for production-only optimizations:
```bash
turbo run build --filter=web --env-mode=strict
```
@@ -1,100 +0,0 @@
# turbo run
The primary command for executing tasks across your monorepo.
## Basic Usage
```bash
# Full form (use in CI, package.json, scripts)
turbo run <tasks>
# Shorthand (only for one-off terminal invocations)
turbo <tasks>
```
## When to Use `turbo run` vs `turbo`
**Always use `turbo run` when the command is written into code:**
- `package.json` scripts
- CI/CD workflows (GitHub Actions, etc.)
- Shell scripts
- Documentation
- Any static/committed configuration
**Only use `turbo` (shorthand) for:**
- One-off commands typed directly in terminal
- Ad-hoc invocations by humans or agents
```json
// package.json - ALWAYS use "turbo run"
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
}
}
```
```yaml
# CI workflow - ALWAYS use "turbo run"
- run: turbo run build --affected
- run: turbo run test --affected
```
```bash
# Terminal one-off - shorthand OK
turbo build --filter=web
```
## Running Tasks
Tasks must be defined in `turbo.json` before running.
```bash
# Single task
turbo build
# Multiple tasks
turbo run build lint test
# See available tasks (run without arguments)
turbo run
```
## Passing Arguments to Scripts
Use `--` to pass arguments through to the underlying package scripts:
```bash
turbo run build -- --sourcemap
turbo test -- --watch
turbo lint -- --fix
```
Everything after `--` goes directly to the task's script.
## Package Selection
By default, turbo runs tasks in all packages. Use `--filter` to narrow scope:
```bash
turbo build --filter=web
turbo test --filter=./apps/*
```
See `filtering/` for complete filter syntax.
## Quick Reference
| Goal | Command |
| ------------------- | -------------------------- |
| Build everything | `turbo build` |
| Build one package | `turbo build --filter=web` |
| Multiple tasks | `turbo build lint test` |
| Pass args to script | `turbo build -- --arg` |
| Preview run | `turbo build --dry` |
| Force rebuild | `turbo build --force` |
@@ -1,297 +0,0 @@
# turbo run Flags Reference
Full docs: https://turborepo.dev/docs/reference/run
## Package Selection
### `--filter` / `-F`
Select specific packages to run tasks in.
```bash
turbo build --filter=web
turbo build -F=@repo/ui -F=@repo/utils
turbo test --filter=./apps/*
```
See `filtering/` for complete syntax (globs, dependencies, git ranges).
### Task Identifier Syntax (v2.2.4+)
Run specific package tasks directly:
```bash
turbo run web#build # Build web package
turbo run web#build docs#lint # Multiple specific tasks
```
### `--affected`
Run only in packages changed since the base branch.
```bash
turbo build --affected
turbo test --affected --filter=./apps/* # combine with filter
```
**How it works:**
- Default: compares `main...HEAD`
- In GitHub Actions: auto-detects `GITHUB_BASE_REF`
- Override base: `TURBO_SCM_BASE=development turbo build --affected`
- Override head: `TURBO_SCM_HEAD=your-branch turbo build --affected`
**Requires git history** - shallow clones may fall back to running all tasks.
## Execution Control
### `--dry` / `--dry=json`
Preview what would run without executing.
```bash
turbo build --dry # human-readable
turbo build --dry=json # machine-readable
```
### `--force`
Ignore all cached artifacts, re-run everything.
```bash
turbo build --force
```
### `--concurrency`
Limit parallel task execution.
```bash
turbo build --concurrency=4 # max 4 tasks
turbo build --concurrency=50% # 50% of CPU cores
```
### `--continue`
Keep running other tasks when one fails.
```bash
turbo build test --continue
```
### `--only`
Run only the specified task, skip its dependencies.
```bash
turbo build --only # skip running dependsOn tasks
```
### `--parallel` (Discouraged)
Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds.
## Cache Control
### `--cache`
Fine-grained cache behavior control.
```bash
# Default: read/write both local and remote
turbo build --cache=local:rw,remote:rw
# Read-only local, no remote
turbo build --cache=local:r,remote:
# Disable local, read-only remote
turbo build --cache=local:,remote:r
# Disable all caching
turbo build --cache=local:,remote:
```
## Output & Debugging
### `--graph`
Generate task graph visualization.
```bash
turbo build --graph # opens in browser
turbo build --graph=graph.svg # SVG file
turbo build --graph=graph.png # PNG file
turbo build --graph=graph.json # JSON data
turbo build --graph=graph.mermaid # Mermaid diagram
```
### `--summarize`
Generate JSON run summary for debugging.
```bash
turbo build --summarize
# creates .turbo/runs/<run-id>.json
```
### `--output-logs`
Control log output verbosity.
```bash
turbo build --output-logs=full # all logs (default)
turbo build --output-logs=new-only # only cache misses
turbo build --output-logs=errors-only # only failures
turbo build --output-logs=none # silent
```
### `--profile`
Generate Chrome tracing profile for performance analysis.
```bash
turbo build --profile=profile.json
# open chrome://tracing and load the file
```
### `--verbosity` / `-v`
Control turbo's own log level.
```bash
turbo build -v # verbose
turbo build -vv # more verbose
turbo build -vvv # maximum verbosity
```
## Environment
### `--env-mode`
Control environment variable handling.
```bash
turbo build --env-mode=strict # only declared env vars (default)
turbo build --env-mode=loose # include all env vars in hash
```
## UI
### `--ui`
Select output interface.
```bash
turbo build --ui=tui # interactive terminal UI (default in TTY)
turbo build --ui=stream # streaming logs (default in CI)
```
---
# turbo-ignore
Full docs: https://turborepo.dev/docs/reference/turbo-ignore
Skip CI work when nothing relevant changed. Useful for skipping container setup.
## Basic Usage
```bash
# Check if build is needed for current package (uses Automatic Package Scoping)
npx turbo-ignore
# Check specific package
npx turbo-ignore web
# Check specific task
npx turbo-ignore --task=test
```
## Exit Codes
- `0`: No changes detected - skip CI work
- `1`: Changes detected - proceed with CI
## CI Integration Example
```yaml
# GitHub Actions
- name: Check for changes
id: turbo-ignore
run: npx turbo-ignore web
continue-on-error: true
- name: Build
if: steps.turbo-ignore.outcome == 'failure' # changes detected
run: pnpm build
```
## Comparison Depth
Default: compares to parent commit (`HEAD^1`).
```bash
# Compare to specific commit
npx turbo-ignore --fallback=abc123
# Compare to branch
npx turbo-ignore --fallback=main
```
---
# Other Commands
## turbo boundaries
Check workspace violations (experimental).
```bash
turbo boundaries
```
See `references/boundaries/` for configuration.
## turbo watch
Re-run tasks on file changes.
```bash
turbo watch build test
```
See `references/watch/` for details.
## turbo prune
Create sparse checkout for Docker.
```bash
turbo prune web --docker
```
## turbo link / unlink
Connect/disconnect Remote Cache.
```bash
turbo link # connect to Vercel Remote Cache
turbo unlink # disconnect
```
## turbo login / logout
Authenticate with Remote Cache provider.
```bash
turbo login # authenticate
turbo logout # log out
```
## turbo generate
Scaffold new packages.
```bash
turbo generate
```
@@ -1,235 +0,0 @@
# turbo.json Configuration Overview
Configuration reference for Turborepo. Full docs: https://turborepo.dev/docs/reference/configuration
## File Location
Root `turbo.json` lives at repo root, sibling to root `package.json`:
```
my-monorepo/
├── turbo.json # Root configuration
├── package.json
└── packages/
└── web/
├── turbo.json # Package Configuration (optional)
└── package.json
```
## Always Prefer Package Tasks Over Root Tasks
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
Package tasks enable parallelization, individual caching, and filtering. Define scripts in each package's `package.json`:
```json
// packages/web/package.json
{
"scripts": {
"build": "next build",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
// packages/api/package.json
{
"scripts": {
"build": "tsc",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
```
```json
// Root package.json - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
}
}
```
When you run `turbo run lint`, Turborepo finds all packages with a `lint` script and runs them **in parallel**.
**Root Tasks are a fallback**, not the default. Only use them for tasks that truly cannot run per-package (e.g., repo-level CI scripts, workspace-wide config generation).
```json
// AVOID: Task logic in root defeats parallelization
{
"scripts": {
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
}
}
```
## Basic Structure
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"globalEnv": ["CI"],
"globalDependencies": ["tsconfig.json"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
The `$schema` key enables IDE autocompletion and validation.
### With `futureFlags.globalConfiguration`
When the `globalConfiguration` future flag is enabled, global options move under a `global` key with cleaner names:
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": ["tsconfig.json"],
"env": ["CI"],
"ui": "tui"
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
```
See the [global options reference](./global-options.md) for the full rename mapping and behavior changes.
## Configuration Sections
**Global options** - Settings affecting all tasks:
- Without flag: `globalEnv`, `globalDependencies`, `globalPassThroughEnv`, `cacheDir`, `daemon`, `envMode`, `ui`, `remoteCache`
- With `globalConfiguration` flag: all of the above move under the `global` key (see [global options](./global-options.md))
**Task definitions** - Per-task settings in `tasks` object:
- `dependsOn`, `outputs`, `inputs`, `env`
- `cache`, `persistent`, `interactive`, `outputLogs`
## Package Configurations
Use `turbo.json` in individual packages to override root settings:
```json
// packages/web/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
```
The `"extends": ["//"]` is required - it references the root configuration.
**When to use Package Configurations:**
- Framework-specific outputs (Next.js, Vite, etc.)
- Package-specific env vars
- Different caching rules for specific packages
- Keeping framework config close to the framework code
### Extending from Other Packages
You can extend from config packages instead of just root:
```json
// packages/web/turbo.json
{
"extends": ["//", "@repo/turbo-config"]
}
```
### Adding to Inherited Arrays with `$TURBO_EXTENDS$`
By default, array fields in Package Configurations **replace** root values. Use `$TURBO_EXTENDS$` to **append** instead:
```json
// Root turbo.json
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}
```
```json
// packages/web/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
// Inherits "dist/**" from root, adds ".next/**"
"outputs": ["$TURBO_EXTENDS$", ".next/**", "!.next/cache/**"]
}
}
}
```
Without `$TURBO_EXTENDS$`, outputs would only be `[".next/**", "!.next/cache/**"]`.
**Works with:**
- `dependsOn`
- `env`
- `inputs`
- `outputs`
- `passThroughEnv`
- `with`
### Excluding Tasks from Packages
Use `extends: false` to exclude a task from a package:
```json
// packages/ui/turbo.json
{
"extends": ["//"],
"tasks": {
"e2e": {
"extends": false // UI package doesn't have e2e tests
}
}
}
```
## `turbo.jsonc` for Comments
Use `turbo.jsonc` extension to add comments with IDE support:
```jsonc
// turbo.jsonc
{
"tasks": {
"build": {
// Next.js outputs
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
```
@@ -1,239 +0,0 @@
# Global Options Reference
Options that affect all tasks. Full docs: https://turborepo.dev/docs/reference/configuration
## globalEnv
Environment variables affecting all task hashes.
```json
{
"globalEnv": ["CI", "NODE_ENV", "VERCEL_*"]
}
```
Use for variables that should invalidate all caches when changed.
## globalDependencies
Files that affect all task hashes.
```json
{
"globalDependencies": ["tsconfig.json", ".env", "pnpm-lock.yaml"]
}
```
Lockfile is included by default. Add shared configs here.
## globalPassThroughEnv
Variables available to tasks but not included in hash.
```json
{
"globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
}
```
Use for credentials that shouldn't affect cache keys.
## cacheDir
Custom cache location. Default: `node_modules/.cache/turbo`.
```json
{
"cacheDir": ".turbo/cache"
}
```
## daemon
**Deprecated**: The daemon is no longer used for `turbo run` and this option will be removed in version 3.0. The daemon is still used by `turbo watch` and the Turborepo LSP.
## envMode
How unspecified env vars are handled. Default: `"strict"`.
```json
{
"envMode": "strict" // Only specified vars available
// or
"envMode": "loose" // All vars pass through
}
```
Strict mode catches missing env declarations.
## ui
Terminal UI mode. Default: `"stream"`.
```json
{
"ui": "tui" // Interactive terminal UI
// or
"ui": "stream" // Traditional streaming logs
}
```
TUI provides better UX for parallel tasks.
## remoteCache
Configure remote caching.
```json
{
"remoteCache": {
"enabled": true,
"signature": true,
"timeout": 30,
"uploadTimeout": 60
}
}
```
| Option | Default | Description |
| --------------- | ---------------------- | ------------------------------------------------------ |
| `enabled` | `true` | Enable/disable remote caching |
| `signature` | `false` | Sign artifacts with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` |
| `preflight` | `false` | Send OPTIONS request before cache requests |
| `timeout` | `30` | Timeout in seconds for cache operations |
| `uploadTimeout` | `60` | Timeout in seconds for uploads |
| `apiUrl` | `"https://vercel.com"` | Remote cache API endpoint |
| `loginUrl` | `"https://vercel.com"` | Login endpoint |
| `teamId` | - | Team ID (must start with `team_`) |
| `teamSlug` | - | Team slug for querystring |
See https://turborepo.dev/docs/core-concepts/remote-caching for setup.
## concurrency
Default: `"10"`
Limit parallel task execution.
```json
{
"concurrency": "4" // Max 4 tasks at once
// or
"concurrency": "50%" // 50% of available CPUs
}
```
## futureFlags
Enable experimental features that will become default in future versions.
```json
{
"futureFlags": {
"errorsOnlyShowHash": true
}
}
```
### `errorsOnlyShowHash`
When using `outputLogs: "errors-only"`, show task hashes on start/completion:
- Cache miss: `cache miss, executing <hash> (only logging errors)`
- Cache hit: `cache hit, replaying logs (no errors) <hash>`
### `longerSignatureKey`
Enforce a minimum key length of 32 bytes for `TURBO_REMOTE_CACHE_SIGNATURE_KEY` when `remoteCache.signature` is enabled. Short keys weaken HMAC-SHA256 signatures. Fails the run immediately if the key is too short.
### `globalConfiguration`
Moves global configuration keys under a top-level `global` key for clarity and changes how `global.inputs` (formerly `globalDependencies`) affects task hashing.
When enabled:
- Global config keys move under `global` with cleaner names
- `global.inputs` files are **prepended to every task's inputs** instead of being folded into the global hash — tasks can opt out of specific global inputs using negation globs
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": ["tsconfig.json", ".env"],
"env": ["CI", "NODE_ENV"],
"passThroughEnv": ["AWS_SECRET_KEY"],
"ui": "tui",
"envMode": "strict",
"cacheDir": ".turbo/cache",
"remoteCache": { "enabled": true },
"concurrency": "50%"
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
```
**Key rename mapping:**
| Old (top-level) | New (`global.`) |
| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| `globalDependencies` | `inputs` |
| `globalEnv` | `env` |
| `globalPassThroughEnv` | `passThroughEnv` |
| `ui`, `envMode`, `cacheDir`, `daemon`, `concurrency`, `noUpdateNotifier`, `dangerouslyDisablePackageManagerCheck`, `remoteCache` | Same names under `global` |
**Behavior change for `global.inputs`:**
With `globalDependencies` (old): files are hashed into the **global hash**, which is embedded in every task's cache key. Changing any of these files invalidates all tasks — there is no opt-out.
With `global.inputs` (new): files are treated as **implicit task inputs** prepended to each task's `inputs` globs. This means:
- Tasks can exclude specific global files: `"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]`
- The global hash no longer includes these file hashes (it still includes lockfile, engines, global env, etc.)
- Tasks with no explicit `inputs` still hash all package files plus the global inputs
See the [gotchas doc](./gotchas.md) for guidance on using `$TURBO_DEFAULT$` with `global.inputs`.
## noUpdateNotifier
Disable update notifications when new turbo versions are available.
```json
{
"noUpdateNotifier": true
}
```
## dangerouslyDisablePackageManagerCheck
Bypass the `packageManager` field requirement. Use for incremental migration.
```json
{
"dangerouslyDisablePackageManagerCheck": true
}
```
**Warning**: Unstable lockfiles can cause unpredictable behavior.
## Git Worktree Cache Sharing
When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees.
**How it works:**
- Detects worktree configuration
- Redirects cache to main worktree's `.turbo/cache`
- Works alongside Remote Cache
**Benefits:**
- Cache hits across branches
- Reduced disk usage
- Faster branch switching
**Disabled by**: Setting explicit `cacheDir` in turbo.json.
@@ -1,368 +0,0 @@
# Configuration Gotchas
Common mistakes and how to fix them.
## #1 Root Scripts Not Using `turbo run`
Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands.
```json
// WRONG - bypasses turbo, no parallelization or caching
{
"scripts": {
"build": "bun build",
"dev": "bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness.
## #2 Using `&&` to Chain Turbo Tasks
Don't use `&&` to chain tasks that turbo should orchestrate.
```json
// WRONG - changeset:publish chains turbo task with non-turbo command
{
"scripts": {
"changeset:publish": "bun build && changeset publish"
}
}
// CORRECT - use turbo run, let turbo handle dependencies
{
"scripts": {
"changeset:publish": "turbo run build && changeset publish"
}
}
```
If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits.
## #3 Overly Broad globalDependencies
`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific.
```json
// WRONG - affects all hashes
{
"globalDependencies": ["**/.env.*local"]
}
// CORRECT - move to specific tasks that need it
{
"globalDependencies": [".env"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
```
**Why this matters:** `**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead:
- Use `globalDependencies` only for truly global files (root `.env`)
- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior
With `futureFlags.globalConfiguration`, this is less of a concern because `global.inputs` acts as implicit task inputs — tasks can opt out of specific files with negation globs. But keeping the list focused is still good practice.
## #4 Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks": {
"build": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"test": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
}
}
}
// BETTER - use globalEnv and globalDependencies
{
"globalEnv": ["API_URL", "DATABASE_URL"],
"globalDependencies": [".env*"],
"tasks": {
"build": {},
"test": {}
}
}
```
**When to use global vs task-level:**
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
## #5 Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
```json
// WRONG - traversing out of package
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
}
}
}
// CORRECT - use $TURBO_ROOT$ for repo root
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
}
}
}
```
## #6 MOST COMMON MISTAKE: Creating Root Tasks
**DO NOT create Root Tasks. ALWAYS create package tasks.**
When you need to create a task (build, lint, test, typecheck, etc.):
1. Add the script to **each relevant package's** `package.json`
2. Register the task in root `turbo.json`
3. Root `package.json` only contains `turbo run <task>`
```json
// WRONG - DO NOT DO THIS
// Root package.json with task logic
{
"scripts": {
"build": "cd apps/web && next build && cd ../api && tsc",
"lint": "eslint apps/ packages/",
"test": "vitest"
}
}
// CORRECT - DO THIS
// apps/web/package.json
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
// apps/api/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// packages/ui/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// Root package.json - ONLY delegates
{ "scripts": { "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" } }
// turbo.json - register tasks
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": {}
}
}
```
**Why this matters:**
- Package tasks run in **parallel** across all packages
- Each package's output is cached **individually**
- You can **filter** to specific packages: `turbo run test --filter=web`
Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare).
## #7 Tasks That Need Parallel Execution + Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation.
**Use Transit Nodes for these tasks:**
```json
// WRONG - forces sequential execution (SLOW)
"my-task": {
"dependsOn": ["^my-task"]
}
// ALSO WRONG - no dependency awareness (INCORRECT CACHING)
"my-task": {}
// CORRECT - use Transit Nodes for parallel + correct caching
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"my-task": { "dependsOn": ["transit"] }
}
}
```
**Why Transit Nodes work:**
- `transit` creates dependency relationships without matching any actual script
- Tasks that depend on `transit` gain dependency awareness
- Since `transit` completes instantly (no script), tasks run in parallel
- Cache correctly invalidates when dependency source code changes
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
## Missing outputs for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG - build produces files but they're not cached
"build": {
"dependsOn": ["^build"]
}
// CORRECT - outputs are cached
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
```
No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache.
## Forgetting ^ in dependsOn
```json
// WRONG - looks for "build" in SAME package (infinite loop or missing)
"build": {
"dependsOn": ["build"]
}
// CORRECT - runs dependencies' build first
"build": {
"dependsOn": ["^build"]
}
```
The `^` means "in dependency packages", not "in this package".
## Missing persistent on Dev Tasks
```json
// WRONG - dependent tasks hang waiting for dev to "finish"
"dev": {
"cache": false
}
// CORRECT
"dev": {
"cache": false,
"persistent": true
}
```
## Package Config Missing extends
```json
// WRONG - packages/web/turbo.json
{
"tasks": {
"build": { "outputs": [".next/**"] }
}
}
// CORRECT
{
"extends": ["//"],
"tasks": {
"build": { "outputs": [".next/**"] }
}
}
```
Without `"extends": ["//"]`, Package Configurations are invalid.
## Root Tasks Need Special Syntax
To run a task defined only in root `package.json`:
```bash
# WRONG
turbo run format
# CORRECT
turbo run //#format
```
And in dependsOn:
```json
"build": {
"dependsOn": ["//#codegen"] // Root package's codegen
}
```
## Overwriting Default Inputs
```json
// WRONG - only watches test files, ignores source changes
"test": {
"inputs": ["tests/**"]
}
// CORRECT - extends defaults, adds test files
"test": {
"inputs": ["$TURBO_DEFAULT$", "tests/**"]
}
```
Without `$TURBO_DEFAULT$`, you replace all default file watching.
## Excluding `global.inputs` Without `$TURBO_DEFAULT$`
When using `futureFlags.globalConfiguration`, `global.inputs` values are prepended to every task's inputs. If you want to exclude a global input from a specific task, you **must** include `$TURBO_DEFAULT$` to preserve default file hashing.
```json
// WRONG - task hashes NO files at all (global input cancelled, no defaults)
"build": {
"inputs": ["!$TURBO_ROOT$/config.txt"]
}
// CORRECT - task hashes all package files, minus config.txt
"build": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/config.txt"]
}
```
Without `$TURBO_DEFAULT$`, the only inclusion glob comes from `global.inputs`, which the negation cancels out. The task ends up with no inclusions and no default file hashing, so it hashes nothing. Changes to source files won't cause cache misses.
## Caching Tasks with Side Effects
```json
// WRONG - deploy might be skipped on cache hit
"deploy": {
"dependsOn": ["build"]
}
// CORRECT
"deploy": {
"dependsOn": ["build"],
"cache": false
}
```
Always disable cache for deploy, publish, or mutation tasks.
@@ -1,325 +0,0 @@
# Task Configuration Reference
Full docs: https://turborepo.dev/docs/reference/configuration#tasks
## dependsOn
Controls task execution order.
```json
{
"tasks": {
"build": {
"dependsOn": [
"^build", // Dependencies' build tasks first
"codegen", // Same package's codegen task first
"shared#build" // Specific package's build task
]
}
}
}
```
| Syntax | Meaning |
| ---------- | ------------------------------------ |
| `^task` | Run `task` in all dependencies first |
| `task` | Run `task` in same package first |
| `pkg#task` | Run specific package's task first |
The `^` prefix is crucial - without it, you're referencing the same package.
### Transit Nodes for Parallel Tasks
For tasks like `lint` and `check-types` that can run in parallel but need dependency-aware caching:
```json
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"lint": { "dependsOn": ["transit"] },
"check-types": { "dependsOn": ["transit"] }
}
}
```
**DO NOT use `dependsOn: ["^lint"]`** - this forces sequential execution.
**DO NOT use `dependsOn: []`** - this breaks cache invalidation.
The `transit` task creates dependency relationships without running anything (no matching script), so tasks run in parallel with correct caching.
## outputs
Glob patterns for files to cache. **If omitted, nothing is cached.**
```json
{
"tasks": {
"build": {
"outputs": ["dist/**", "build/**"]
}
}
}
```
**Framework examples:**
```json
// Next.js
"outputs": [".next/**", "!.next/cache/**"]
// Vite
"outputs": ["dist/**"]
// TypeScript (tsc)
"outputs": ["dist/**", "*.tsbuildinfo"]
// No file outputs (lint, typecheck)
"outputs": []
```
Use `!` prefix to exclude patterns from caching.
## inputs
Files considered when calculating task hash. Defaults to all tracked files in package.
```json
{
"tasks": {
"test": {
"inputs": ["src/**", "tests/**", "vitest.config.ts"]
}
}
}
```
**Special values:**
| Value | Meaning |
| --------------------- | --------------------------------------- |
| `$TURBO_DEFAULT$` | Include default inputs, then add/remove |
| `$TURBO_ROOT$/<path>` | Reference files from repo root |
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
"!README.md",
"$TURBO_ROOT$/tsconfig.base.json"
]
}
}
}
```
### Interaction with `global.inputs`
When `futureFlags.globalConfiguration` is enabled, files listed in `global.inputs` are prepended to every task's `inputs`. The combined list is then used to compute the task hash.
This is different from `globalDependencies`, where files were hashed into the **global** hash and could not be influenced by task-level `inputs`.
**With `globalDependencies` (old behavior):**
- `globalDependencies` files contribute to the global hash
- Task `inputs` only control which **package** files are hashed
- There is no way for a task to "opt out" of a `globalDependencies` file
**With `global.inputs` (new behavior):**
- `global.inputs` files are merged into each task's `inputs` globs
- Task `inputs` and `global.inputs` are combined, then the full list is hashed into the **task** hash
- Tasks can exclude specific global files with negation globs
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": ["tsconfig.json", ".env"]
},
"tasks": {
"build": {},
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env"]
}
}
}
```
In this example:
- `build` hashes all package files + `tsconfig.json` + `.env` (from `global.inputs`)
- `lint` hashes all package files + `tsconfig.json`, but **excludes** `.env` because of the negation glob
Tasks with no explicit `inputs` key still hash all package files (the default behavior) plus the `global.inputs` files.
## env
Environment variables to include in task hash.
```json
{
"tasks": {
"build": {
"env": [
"API_URL",
"NEXT_PUBLIC_*", // Wildcard matching
"!DEBUG" // Exclude from hash
]
}
}
}
```
Variables listed here affect cache hits - changing the value invalidates cache.
## cache
Enable/disable caching for a task. Default: `true`.
```json
{
"tasks": {
"dev": { "cache": false },
"deploy": { "cache": false }
}
}
```
Disable for: dev servers, deploy commands, tasks with side effects.
## persistent
Mark long-running tasks that don't exit. Default: `false`.
```json
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
```
Required for dev servers - without it, dependent tasks wait forever.
## interactive
Allow task to receive stdin input. Default: `false`.
```json
{
"tasks": {
"login": {
"cache": false,
"interactive": true
}
}
}
```
## outputLogs
Control when logs are shown. Options: `full`, `hash-only`, `new-only`, `errors-only`, `none`.
```json
{
"tasks": {
"build": {
"outputLogs": "new-only" // Only show logs on cache miss
}
}
}
```
## with
Run tasks alongside this task. For long-running tasks that need runtime dependencies.
```json
{
"tasks": {
"dev": {
"with": ["api#dev"],
"persistent": true,
"cache": false
}
}
}
```
Unlike `dependsOn`, `with` runs tasks concurrently (not sequentially). Use for dev servers that need other services running.
## interruptible
Allow `turbo watch` to restart the task on changes. Default: `false`.
```json
{
"tasks": {
"dev": {
"persistent": true,
"interruptible": true,
"cache": false
}
}
}
```
Use for dev servers that don't automatically detect dependency changes.
## description
Human-readable description of the task.
```json
{
"tasks": {
"build": {
"description": "Compiles the application for production deployment"
}
}
}
```
For documentation only - doesn't affect execution or caching.
## passThroughEnv
Environment variables available at runtime but NOT included in cache hash.
```json
{
"tasks": {
"build": {
"passThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
}
}
}
```
**Warning**: Changes to these vars won't cause cache misses. Use `env` if changes should invalidate cache.
## extends (Package Configuration only)
Control task inheritance in Package Configurations.
```json
// packages/ui/turbo.json
{
"extends": ["//"],
"tasks": {
"lint": {
"extends": false // Exclude from this package
}
}
}
```
| Value | Behavior |
| ---------------- | -------------------------------------------------------------- |
| `true` (default) | Inherit from root turbo.json |
| `false` | Exclude task from package, or define fresh without inheritance |
@@ -1,123 +0,0 @@
# Environment Variables in Turborepo
Turborepo provides fine-grained control over which environment variables affect task hashing and runtime availability.
## Configuration Keys
### `env` - Task-Specific Variables
Variables that affect a specific task's hash. When these change, only that task rebuilds.
```json
{
"tasks": {
"build": {
"env": ["DATABASE_URL", "API_KEY"]
}
}
}
```
### `globalEnv` - Variables Affecting All Tasks
Variables that affect EVERY task's hash. When these change, all tasks rebuild.
```json
{
"globalEnv": ["CI", "NODE_ENV"]
}
```
### `passThroughEnv` - Runtime-Only Variables (Not Hashed)
Variables available at runtime but NOT included in hash. **Use with caution** - changes won't trigger rebuilds.
```json
{
"tasks": {
"deploy": {
"passThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
}
}
}
```
### `globalPassThroughEnv` - Global Runtime Variables
Same as `passThroughEnv` but for all tasks.
```json
{
"globalPassThroughEnv": ["GITHUB_TOKEN"]
}
```
## Wildcards and Negation
### Wildcards
Match multiple variables with `*`:
```json
{
"env": ["MY_API_*", "FEATURE_FLAG_*"]
}
```
This matches `MY_API_URL`, `MY_API_KEY`, `FEATURE_FLAG_DARK_MODE`, etc.
### Negation
Exclude variables (useful with framework inference):
```json
{
"env": ["!NEXT_PUBLIC_ANALYTICS_ID"]
}
```
## With `futureFlags.globalConfiguration`
When the `globalConfiguration` future flag is enabled, global environment keys move under the `global` key with cleaner names:
| Old (top-level) | New (`global.`) |
| ---------------------- | ---------------- |
| `globalEnv` | `env` |
| `globalPassThroughEnv` | `passThroughEnv` |
`global.env` and `global.passThroughEnv` behave identically to their top-level counterparts — they affect the global hash and all tasks, respectively. The rename is purely organizational.
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"env": ["CI", "NODE_ENV"],
"passThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"]
},
"tasks": {
"build": {
"env": ["DATABASE_URL", "API_*"],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
}
}
}
```
## Complete Example
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"globalEnv": ["CI", "NODE_ENV"],
"globalPassThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"],
"tasks": {
"build": {
"env": ["DATABASE_URL", "API_*"],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
},
"test": {
"env": ["TEST_DATABASE_URL"]
}
}
}
```
@@ -1,175 +0,0 @@
# Environment Variable Gotchas
Common mistakes and how to fix them.
## .env Files Must Be in `inputs`
Turbo does NOT read `.env` files. Your framework (Next.js, Vite, etc.) or `dotenv` loads them. But Turbo needs to know when they change.
**Wrong:**
```json
{
"tasks": {
"build": {
"env": ["DATABASE_URL"]
}
}
}
```
**Right:**
```json
{
"tasks": {
"build": {
"env": ["DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local", ".env.production"]
}
}
}
```
## Strict Mode Filters CI Variables
In strict mode, CI provider variables (GITHUB_TOKEN, GITLAB_CI, etc.) are filtered unless explicitly listed.
**Symptom:** Task fails with "authentication required" or "permission denied" in CI.
**Solution:**
```json
{
"globalPassThroughEnv": ["GITHUB_TOKEN", "GITLAB_CI", "CI"]
}
```
## passThroughEnv Doesn't Affect Hash
Variables in `passThroughEnv` are available at runtime but changes WON'T trigger rebuilds.
**Dangerous example:**
```json
{
"tasks": {
"build": {
"passThroughEnv": ["API_URL"]
}
}
}
```
If `API_URL` changes from staging to production, Turbo may serve a cached build pointing to the wrong API.
**Use passThroughEnv only for:**
- Auth tokens that don't affect output (SENTRY_AUTH_TOKEN)
- CI metadata (GITHUB_RUN_ID)
- Variables consumed after build (deploy credentials)
## Runtime-Created Variables Are Invisible
Turbo captures env vars at startup. Variables created during execution aren't seen.
**Won't work:**
```bash
# In package.json scripts
"build": "export API_URL=$COMPUTED_VALUE && next build"
```
**Solution:** Set vars before invoking turbo:
```bash
API_URL=$COMPUTED_VALUE turbo run build
```
## Different .env Files for Different Environments
If you use `.env.development` and `.env.production`, both should be in inputs.
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
".env",
".env.local",
".env.development",
".env.development.local",
".env.production",
".env.production.local"
]
}
}
}
```
## Complete Next.js Example
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"globalEnv": ["CI", "NODE_ENV", "VERCEL"],
"globalPassThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
"inputs": [
"$TURBO_DEFAULT$",
".env",
".env.local",
".env.production",
".env.production.local"
],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
```
This config:
- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics)
- Passes through SENTRY_AUTH_TOKEN without hashing
- Includes all .env file variants in the hash
- Makes CI tokens available globally
### With `futureFlags.globalConfiguration`
The same config using the `global` key. The `.env` files move to `global.inputs`, which means they get folded into each task's hash individually rather than the global hash. This lets tasks exclude specific `.env` files if needed.
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"futureFlags": { "globalConfiguration": true },
"global": {
"env": ["CI", "NODE_ENV", "VERCEL"],
"passThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
"inputs": [".env", ".env.local", ".env.production", ".env.production.local"]
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
```
With this approach, a task that doesn't care about `.env.production` can exclude it:
```json
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env.production"]
}
```
This wouldn't have been possible with `globalDependencies`, where `.env.production` would be baked into the global hash and affect every task unconditionally.
@@ -1,101 +0,0 @@
# Environment Modes
Turborepo supports different modes for handling environment variables during task execution.
## Strict Mode (Default)
Only explicitly configured variables are available to tasks.
**Behavior:**
- Tasks only see vars listed in `env`, `globalEnv`, `passThroughEnv`, or `globalPassThroughEnv`
- Unlisted vars are filtered out
- Tasks fail if they require unlisted variables
**Benefits:**
- Guarantees cache correctness
- Prevents accidental dependencies on system vars
- Reproducible builds across machines
```bash
# Explicit (though it's the default)
turbo run build --env-mode=strict
```
## Loose Mode
All system environment variables are available to tasks.
```bash
turbo run build --env-mode=loose
```
**Behavior:**
- Every system env var is passed through
- Only vars in `env`/`globalEnv` affect the hash
- Other vars are available but NOT hashed
**Risks:**
- Cache may restore incorrect results if unhashed vars changed
- "Works on my machine" bugs
- CI vs local environment mismatches
**Use case:** Migrating legacy projects or debugging strict mode issues.
## Framework Inference (Automatic)
Turborepo automatically detects frameworks and includes their conventional env vars.
### Inferred Variables by Framework
| Framework | Pattern |
| ---------------- | ------------------- |
| Next.js | `NEXT_PUBLIC_*` |
| Vite | `VITE_*` |
| Create React App | `REACT_APP_*` |
| Gatsby | `GATSBY_*` |
| Nuxt | `NUXT_*`, `NITRO_*` |
| Expo | `EXPO_PUBLIC_*` |
| Astro | `PUBLIC_*` |
| SvelteKit | `PUBLIC_*` |
| Remix | `REMIX_*` |
| Redwood | `REDWOOD_ENV_*` |
| Sanity | `SANITY_STUDIO_*` |
| Solid | `VITE_*` |
### Disabling Framework Inference
Globally via CLI:
```bash
turbo run build --framework-inference=false
```
Or exclude specific patterns in config:
```json
{
"tasks": {
"build": {
"env": ["!NEXT_PUBLIC_*"]
}
}
}
```
### Why Disable?
- You want explicit control over all env vars
- Framework vars shouldn't bust the cache (e.g., analytics IDs)
- Debugging unexpected cache misses
## Checking Environment Mode
Use `--dry` to see which vars affect each task:
```bash
turbo run build --dry=json | jq '.tasks[].environmentVariables'
```
@@ -1,148 +0,0 @@
# Turborepo Filter Syntax Reference
## Running Only Changed Packages: `--affected`
**The primary way to run only changed packages is `--affected`:**
```bash
# Run build/test/lint only in changed packages and their dependents
turbo run build test lint --affected
```
This compares your current branch to the default branch (usually `main` or `master`) and runs tasks in:
1. Packages with file changes
2. Packages that depend on changed packages (dependents)
### Why Include Dependents?
If you change `@repo/ui`, packages that import `@repo/ui` (like `apps/web`) need to re-run their tasks to verify they still work with the changes.
### Customizing --affected
```bash
# Use a different base branch
turbo run build --affected --affected-base=origin/develop
# Use a different head (current state)
turbo run build --affected --affected-head=HEAD~5
```
### Common CI Pattern
```yaml
# .github/workflows/ci.yml
- run: turbo run build test lint --affected
```
This is the most efficient CI setup - only run tasks for what actually changed.
---
## Manual Git Comparison with --filter
For more control, use `--filter` with git comparison syntax:
```bash
# Changed packages + dependents (same as --affected)
turbo run build --filter=...[origin/main]
# Only changed packages (no dependents)
turbo run build --filter=[origin/main]
# Changed packages + dependencies (packages they import)
turbo run build --filter=[origin/main]...
# Changed since last commit
turbo run build --filter=...[HEAD^1]
# Changed between two commits
turbo run build --filter=[a1b2c3d...e4f5g6h]
```
### Comparison Syntax
| Syntax | Meaning |
| ------------- | ------------------------------------- |
| `[ref]` | Packages changed since `ref` |
| `...[ref]` | Changed packages + their dependents |
| `[ref]...` | Changed packages + their dependencies |
| `...[ref]...` | Dependencies, changed, AND dependents |
---
## Other Filter Types
Filters select which packages to include in a `turbo run` invocation.
### Basic Syntax
```bash
turbo run build --filter=<package-name>
turbo run build -F <package-name>
```
Multiple filters combine as a union (packages matching ANY filter run).
### By Package Name
```bash
--filter=web # exact match
--filter=@acme/* # scope glob
--filter=*-app # name glob
```
### By Directory
```bash
--filter=./apps/* # all packages in apps/
--filter=./packages/ui # specific directory
```
### By Dependencies/Dependents
| Syntax | Meaning |
| ----------- | -------------------------------------- |
| `pkg...` | Package AND all its dependencies |
| `...pkg` | Package AND all its dependents |
| `...pkg...` | Dependencies, package, AND dependents |
| `^pkg...` | Only dependencies (exclude pkg itself) |
| `...^pkg` | Only dependents (exclude pkg itself) |
### Negation
Exclude packages with `!`:
```bash
--filter=!web # exclude web
--filter=./apps/* --filter=!admin # apps except admin
```
### Task Identifiers
Run a specific task in a specific package:
```bash
turbo run web#build # only web's build task
turbo run web#build api#test # web build + api test
```
### Combining Filters
Multiple `--filter` flags create a union:
```bash
turbo run build --filter=web --filter=api # runs in both
```
---
## Quick Reference: Changed Packages
| Goal | Command |
| ---------------------------------- | ----------------------------------------------------------- |
| Changed + dependents (recommended) | `turbo run build --affected` |
| Custom base branch | `turbo run build --affected --affected-base=origin/develop` |
| Only changed (no dependents) | `turbo run build --filter=[origin/main]` |
| Changed + dependencies | `turbo run build --filter=[origin/main]...` |
| Since last commit | `turbo run build --filter=...[HEAD^1]` |
@@ -1,152 +0,0 @@
# Common Filter Patterns
Practical examples for typical monorepo scenarios.
## Single Package
Run task in one package:
```bash
turbo run build --filter=web
turbo run test --filter=@acme/api
```
## Package with Dependencies
Build a package and everything it depends on:
```bash
turbo run build --filter=web...
```
Useful for: ensuring all dependencies are built before the target.
## Package Dependents
Run in all packages that depend on a library:
```bash
turbo run test --filter=...ui
```
Useful for: testing consumers after changing a shared package.
## Dependents Only (Exclude Target)
Test packages that depend on ui, but not ui itself:
```bash
turbo run test --filter=...^ui
```
## Changed Packages
Run only in packages with file changes since last commit:
```bash
turbo run lint --filter=[HEAD^1]
```
Since a specific branch point:
```bash
turbo run lint --filter=[main...HEAD]
```
## Changed + Dependents (PR Builds)
Run in changed packages AND packages that depend on them:
```bash
turbo run build test --filter=...[HEAD^1]
```
Or use the shortcut:
```bash
turbo run build test --affected
```
## Directory-Based
Run in all apps:
```bash
turbo run build --filter=./apps/*
```
Run in specific directories:
```bash
turbo run build --filter=./apps/web --filter=./apps/api
```
## Scope-Based
Run in all packages under a scope:
```bash
turbo run build --filter=@acme/*
```
## Exclusions
Run in all apps except admin:
```bash
turbo run build --filter=./apps/* --filter=!admin
```
Run everywhere except specific packages:
```bash
turbo run lint --filter=!legacy-app --filter=!deprecated-pkg
```
## Complex Combinations
Apps that changed, plus their dependents:
```bash
turbo run build --filter=...[HEAD^1] --filter=./apps/*
```
All packages except docs, but only if changed:
```bash
turbo run build --filter=[main...HEAD] --filter=!docs
```
## Debugging Filters
Use `--dry` to see what would run without executing:
```bash
turbo run build --filter=web... --dry
```
Use `--dry=json` for machine-readable output:
```bash
turbo run build --filter=...[HEAD^1] --dry=json
```
## CI/CD Patterns
PR validation (most common):
```bash
turbo run build test lint --affected
```
Deploy only changed apps:
```bash
turbo run deploy --filter=./apps/* --filter=[main...HEAD]
```
Full rebuild of specific app and deps:
```bash
turbo run build --filter=production-app...
```
@@ -1,99 +0,0 @@
# turbo watch
Full docs: https://turborepo.dev/docs/reference/watch
Re-run tasks automatically when code changes. Dependency-aware.
```bash
turbo watch [tasks]
```
## Basic Usage
```bash
# Watch and re-run build task when code changes
turbo watch build
# Watch multiple tasks
turbo watch build test lint
```
Tasks re-run in order configured in `turbo.json` when source files change.
## With Persistent Tasks
Persistent tasks (`"persistent": true`) won't exit, so they can't be depended on. They work the same in `turbo watch` as `turbo run`.
### Dependency-Aware Persistent Tasks
If your tool has built-in watching (like `next dev`), use its watcher:
```json
{
"tasks": {
"dev": {
"persistent": true,
"cache": false
}
}
}
```
### Non-Dependency-Aware Tools
For tools that don't detect dependency changes, use `interruptible`:
```json
{
"tasks": {
"dev": {
"persistent": true,
"interruptible": true,
"cache": false
}
}
}
```
`turbo watch` will restart interruptible tasks when dependencies change.
## Limitations
### Caching
Caching is experimental with watch mode:
```bash
turbo watch your-tasks --experimental-write-cache
```
### Task Outputs in Source Control
If tasks write files tracked by git, watch mode may loop infinitely. Watch mode uses file hashes to prevent this but it's not foolproof.
**Recommendation**: Remove task outputs from git.
## vs turbo run
| Feature | `turbo run` | `turbo watch` |
| ----------------- | ----------- | ------------- |
| Runs once | Yes | No |
| Re-runs on change | No | Yes |
| Caching | Full | Experimental |
| Use case | CI, one-off | Development |
## Common Patterns
### Development Workflow
```bash
# Run dev servers and watch for build changes
turbo watch dev build
```
### Type Checking During Development
```bash
# Watch and re-run type checks
turbo watch check-types
```
-4
View File
@@ -1,4 +0,0 @@
[codespell]
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma,pnpm-lock.yaml,./worker/src/__tests__/chatml/framework-traces
ignore-words-list = afterall,vertx,notIn,alue,allTime
-26
View File
@@ -1,26 +0,0 @@
# Dev container Dockerfile
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 \
GOBIN=/out \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
# Build only the ClickHouse migrate CLI used in this repo.
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
FROM mcr.microsoft.com/devcontainers/universal:2
# Install golang-migrate for database migrations
COPY --from=migrate-builder /out/migrate /usr/local/bin/migrate
# Activate the repo's pinned pnpm via Corepack
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
# Install Clickhouse
RUN curl https://clickhouse.com/ | sh && \
sudo ./clickhouse install
# Install agent CLIs used in this repo
RUN npm install -g @anthropic-ai/claude-code @openai/codex
-8
View File
@@ -1,8 +0,0 @@
{
"name": "langfuse-development",
"build": {
"dockerfile": "Dockerfile"
},
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"postCreateCommand": "cp .env.dev.example .env && pnpm i"
}
-15
View File
@@ -1,15 +0,0 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.pnpm-store
**/.pnpm-store
.turbo
**/.turbo
**/.next
**/.next-check
**/dist
**/*.tsbuildinfo
.git
**/node_modules
-85
View File
@@ -1,85 +0,0 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# DON'T PANIC: The Azurite Secrets are well-known and meant to be hard-coded
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_BATCH_EXPORT_REGION=auto
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
LANGFUSE_USE_AZURE_BLOB=true
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK=false
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
-93
View File
@@ -1,93 +0,0 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="bitnami"
# REDIS_KEY_PREFIX="" # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
# BullMQ queues will use this via BullMQ's native prefix option
# Cache operations will use this via ioredis keyPrefix
REDIS_CLUSTER_ENABLED="true"
REDIS_CLUSTER_NODES="127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375"
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=8
LANGFUSE_INGESTION_SECONDARY_QUEUE_SHARD_COUNT=8
LANGFUSE_OTEL_INGESTION_QUEUE_SHARD_COUNT=4
LANGFUSE_EVAL_EXECUTION_QUEUE_SHARD_COUNT=4
LANGFUSE_EVAL_EXECUTION_SECONDARY_QUEUE_SHARD_COUNT=4
LANGFUSE_LLM_AS_JUDGE_EXECUTION_QUEUE_SHARD_COUNT=4
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT=4
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
-165
View File
@@ -1,165 +0,0 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# ============================================================================
# DOCKER CONFIGURATION
# ============================================================================
# These variables configure docker-compose port mappings and container names
# To run multiple instances, copy this file to .env and customize these values
# Host Ports
# POSTGRES_HOST_PORT=5432
# REDIS_HOST_PORT=6379
# CLICKHOUSE_HTTP_PORT=8123
# CLICKHOUSE_NATIVE_PORT=9000
# MINIO_API_PORT=9090
# MINIO_CONSOLE_PORT=9091
# WEB_HOST_PORT=3000
# WORKER_HOST_PORT=3030
# Container Names
# POSTGRES_CONTAINER_NAME=langfuse-postgres
# CLICKHOUSE_CONTAINER_NAME=langfuse-clickhouse
# REDIS_CONTAINER_NAME=langfuse-redis
# MINIO_CONTAINER_NAME=langfuse-minio
# WEB_CONTAINER_NAME=langfuse-web
# WORKER_CONTAINER_NAME=langfuse-worker
# Volumes
# POSTGRES_VOLUME_NAME=langfuse_postgres_data
# CLICKHOUSE_DATA_VOLUME_NAME=langfuse_clickhouse_data
# CLICKHOUSE_LOGS_VOLUME_NAME=langfuse_clickhouse_logs
# MINIO_VOLUME_NAME=langfuse_minio_data
# Network
# DOCKER_NETWORK_NAME=langfuse-network
# ============================================================================
# APPLICATION CONFIGURATION
# ============================================================================
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
# CLICKHOUSE_READ_ONLY_URL="http://localhost:8123" # Optional: read replica for legacy tables
# CLICKHOUSE_EVENTS_READ_ONLY_URL="http://localhost:8123" # Optional: read replica for events table queries
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
CLOUD_CRM_EMAIL="" # Optional BCC address for usage threshold emails (e.g., for CRM integration like HubSpot)
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
# REDIS_KEY_PREFIX="" # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
# BullMQ queues will use this via BullMQ's native prefix option
# Cache operations will use this via ioredis keyPrefix
# REDIS_SENTINEL_ENABLED="false"
# REDIS_SENTINEL_NODES="sentinel1:26379,sentinel2:26379"
# REDIS_SENTINEL_MASTER_NAME="mymaster"
# REDIS_SENTINEL_USERNAME=""
# REDIS_SENTINEL_PASSWORD=""
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
# For SDK integration tests to pass, decrease the ingestion queue delay by uncommenting the env vars:
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=10
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=10
# LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY=5
# LANGFUSE_LLM_AS_JUDGE_EXECUTION_WORKER_CONCURRENCY=5
# Slack credentials for development
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
SLACK_STATE_SECRET=your_slack_state_secret
# Langfuse AI instance for tracing, prompts
LANGFUSE_AI_FEATURES_PUBLIC_KEY="pk-lf-1234567890"
LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-1234567890"
LANGFUSE_AI_FEATURES_HOST="http://localhost:3000"
LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=localhost
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=127.0.0.1,::1
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=127.0.0.0/8
# Langfuse AI Bedrock credentials
AWS_ACCESS_KEY_ID="A123456789"
AWS_SECRET_ACCESS_KEY="SAK123456789"
LANGFUSE_AWS_BEDROCK_REGION="eu-west-1"
LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-haiku-20240307-v1:0"
# Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS=true
LANGFUSE_ENABLE_EVENTS_TABLE_FLAGS=true
LANGFUSE_ENABLE_EVENTS_TABLE_V2_APIS=true
LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true
CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE="true"
+28
View File
@@ -0,0 +1,28 @@
# Hanzo Cloud Console — environment.
# Copy to .env.local and adjust. All values are public (NEXT_PUBLIC_*) since the
# console is a browser app that talks to the unified /v1 backend with cookies.
# Unified Hanzo Cloud backend (the casibase /v1 API). Default: production.
# Local backend: http://localhost:14000
NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
# (fork a starter → customize by prompt in the builder). Default: production.
NEXT_PUBLIC_APP_URL=https://hanzo.app
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
# by the browser, or sign-in drops on iam.hanzo.ai with an issuer mismatch.
NEXT_PUBLIC_IAM_URL=https://hanzo.id
# IAM application console2 authenticates as. console2 is a front-end of the
# shared Hanzo Cloud /v1 backend, which exchanges the OIDC code and validates the
# token as app `hanzo-cloud` (aud=hanzo-cloud) — so the front-end presents the
# same app/client_id. Must match the cloud-api binding, not a console-only app.
NEXT_PUBLIC_IAM_APP_NAME=hanzo-cloud
NEXT_PUBLIC_IAM_ORG_NAME=hanzo
NEXT_PUBLIC_IAM_CLIENT_ID=hanzo-cloud
-335
View File
@@ -1,335 +0,0 @@
# More information: https://langfuse.com/docs/deployment/self-host
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
# DATABASE_URL supports pooled connections, but then you need to set DIRECT_URL
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
# SHADOW_DATABASE_URL=
# optional, set to true to disable automated database migrations on Docker start
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
# Next Auth
# NEXTAUTH_URL does not need to be set when deploying on Vercel
NEXTAUTH_URL="http://localhost:3000"
# For each of these, you can generate a new secret on the command line with:
# openssl rand -base64 32
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
SALT="salt" # salt used to hash api keys
# API level encryption for sensitive data
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
# Use CSP headers to enforce HTTPS, optional
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Configure base path for self-hosting, optional
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
# NEXT_PUBLIC_BASE_PATH="/app"
# Docker only, optional
# PORT=3000
# HOSTNAME=localhost
# Opentelemetry, optional
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
OTEL_SERVICE_NAME="langfuse"
# Default role for users who sign up, optional, can be org or org+project
# Supports comma-separated IDs for multiple orgs (e.g., "org1,org2,org3")
# LANGFUSE_DEFAULT_ORG_ID=
# LANGFUSE_DEFAULT_ORG_ROLE=
# Supports comma-separated IDs for multiple projects (e.g., "proj1,proj2,proj3")
# LANGFUSE_DEFAULT_PROJECT_ID=
# LANGFUSE_DEFAULT_PROJECT_ROLE=
# Logging, optional
# LANGFUSE_LOG_LEVEL=info
# LANGFUSE_LOG_FORMAT=text
# Enable experimental features, optional
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=false
# Auth, optional configuration
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
# AUTH_IGNORE_ACCOUNT_FIELDS=foo,bar
# AUTH_DISABLE_USERNAME_PASSWORD=true
# AUTH_DISABLE_SIGNUP=true
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
# SSO, each group is optional
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
# AUTH_GOOGLE_CLIENT_AUTH_METHOD=
# AUTH_GOOGLE_CHECKS=
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
# AUTH_GITHUB_CLIENT_AUTH_METHOD=
# AUTH_GITHUB_CHECKS=
# AUTH_GITHUB_ENTERPRISE_CLIENT_ID=
# AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET=
# AUTH_GITHUB_ENTERPRISE_BASE_URL=
# AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING=false
# AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD=
# AUTH_GITHUB_ENTERPRISE_CHECKS=
# AUTH_GITLAB_CLIENT_ID=
# AUTH_GITLAB_CLIENT_SECRET=
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
# AUTH_GITLAB_ISSUER=
# AUTH_GITLAB_CLIENT_AUTH_METHOD=
# AUTH_GITLAB_CHECKS=
# AUTH_GITLAB_URL=
# AUTH_AZURE_AD_CLIENT_ID=
# AUTH_AZURE_AD_CLIENT_SECRET=
# AUTH_AZURE_AD_TENANT_ID=
# AUTH_AZURE_AD_ALLOW_ACCOUNT_LINKING=false
# AUTH_AZURE_AD_CLIENT_AUTH_METHOD=
# AUTH_AZURE_AD_CHECKS=
# AUTH_OKTA_CLIENT_ID=
# AUTH_OKTA_CLIENT_SECRET=
# AUTH_OKTA_ISSUER=
# AUTH_OKTA_ALLOW_ACCOUNT_LINKING=false
# AUTH_OKTA_CLIENT_AUTH_METHOD=
# AUTH_OKTA_CHECKS=
# AUTH_AUTH0_CLIENT_ID=
# AUTH_AUTH0_CLIENT_SECRET=
# AUTH_AUTH0_ISSUER=
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
# AUTH_AUTH0_CLIENT_AUTH_METHOD=
# AUTH_AUTH0_CHECKS=
# AUTH_COGNITO_CLIENT_ID=
# AUTH_COGNITO_CLIENT_SECRET=
# AUTH_COGNITO_ISSUER=
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
# AUTH_COGNITO_CLIENT_AUTH_METHOD=
# AUTH_COGNITO_CHECKS=
# AUTH_KEYCLOAK_CLIENT_ID=
# AUTH_KEYCLOAK_CLIENT_SECRET=
# AUTH_KEYCLOAK_ISSUER=
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
# AUTH_KEYCLOAK_CLIENT_AUTH_METHOD=
# AUTH_KEYCLOAK_CHECKS=
# AUTH_KEYCLOAK_NAME=
# AUTH_WORKOS_CLIENT_ID=
# AUTH_WORKOS_CLIENT_SECRET=
# AUTH_WORKOS_ALLOW_ACCOUNT_LINKING=false
# AUTH_WORKOS_ORGANIZATION_ID=
# AUTH_WORKOS_CONNECTION_ID=
# AUTH_CUSTOM_CLIENT_ID=
# AUTH_CUSTOM_CLIENT_SECRET=
# AUTH_CUSTOM_ISSUER=
# AUTH_CUSTOM_NAME=
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
# AUTH_CUSTOM_CLIENT_AUTH_METHOD=
# AUTH_CUSTOM_CHECKS=
# AUTH_JUMPCLOUD_CLIENT_ID=
# AUTH_JUMPCLOUD_CLIENT_SECRET=
# AUTH_JUMPCLOUD_ISSUER=
# AUTH_JUMPCLOUD_ALLOW_ACCOUNT_LINKING=
# AUTH_JUMPCLOUD_CLIENT_AUTH_METHOD=
# AUTH_JUMPCLOUD_CHECKS=
# AUTH_JUMPCLOUD_SCOPE=
# Transactional email, optional
# Defines the email address to use as the from address.
# EMAIL_FROM_ADDRESS=
# Defines the connection url for smtp server.
# SMTP_CONNECTION_URL=
# S3 Batch Exports
# LANGFUSE_S3_BATCH_EXPORT_ENABLED=
# LANGFUSE_S3_BATCH_EXPORT_BUCKET=
# LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=
# LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=
# LANGFUSE_S3_BATCH_EXPORT_REGION=
# LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=
# LANGFUSE_S3_BATCH_EXPORT_PREFIX=
# S3 storage for events, optional, used to persist all incoming events
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
# Optional prefix to be used within the bucket. Must end with `/` if set
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
# The following four options are optional and fallback to the normal SDK credential provider chain if omitted
# See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
# LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=
# LANGFUSE_S3_EVENT_UPLOAD_REGION=
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
# Whether to use blob_storage_file_log table to manage blob storage events
# Can be set to `false` if `event` entities are managed using lifecycle policies in the blob storage bucket.
LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
# Automated provisioning of default resources
# LANGFUSE_INIT_ORG_ID=org-id
# LANGFUSE_INIT_ORG_NAME=org-name
# LANGFUSE_INIT_PROJECT_ID=project-id
# LANGFUSE_INIT_PROJECT_NAME=project-name
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
# LANGFUSE_INIT_USER_EMAIL=user@example.com
# LANGFUSE_INIT_USER_NAME=User Name
# LANGFUSE_INIT_USER_PASSWORD=password
# Redis configuration
# REDIS_HOST=
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_USERNAME=default
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# REDIS_KEY_PREFIX= # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
# BullMQ queues will use this via BullMQ's native prefix option
# Cache operations will use this via ioredis keyPrefix
# Redis Cluster configuration (optional)
# REDIS_CLUSTER_ENABLED=false
# REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379
# Redis Sentinel configuration (optional, cannot be enabled with cluster mode simultaneously)
# REDIS_SENTINEL_ENABLED=false
# REDIS_SENTINEL_NODES=sentinel1:26379,sentinel2:26379,sentinel3:26379
# REDIS_SENTINEL_MASTER_NAME=mymaster
# REDIS_SENTINEL_USERNAME=
# REDIS_SENTINEL_PASSWORD=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
# Clickhouse configuration
# CLICKHOUSE_URL=
# CLICKHOUSE_CLUSTER_NAME=default
# CLICKHOUSE_DB=default
# CLICKHOUSE_USER=
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_CLUSTER_ENABLED=true
# Ingestion configuration
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# Evaluation worker concurrency
# LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY=5
# LANGFUSE_LLM_AS_JUDGE_EXECUTION_WORKER_CONCURRENCY=5
# API Traces endpoint controls (may induce breaking changes on API when changed!)
# Reject GET /api/public/traces requests that do not include a fromTimestamp parameter (returns 400)
# LANGFUSE_API_TRACES_REJECT_NO_DATE_RANGE=false
# Apply a default date range (in days) to GET /api/public/traces when no fromTimestamp is provided
# LANGFUSE_API_TRACES_DEFAULT_DATE_RANGE_DAYS=
# Comma-separated default field groups for GET /api/public/traces when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACES_DEFAULT_FIELDS=
# Comma-separated default field groups for GET /api/public/traces/{traceId} when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS=
### START Enterprise Edition Configuration
# Allowlisted users that can create new organizations, by default all users can create organizations
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
# UI Customization Options
# LANGFUSE_UI_API_HOST=https://api.example.com
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
# LANGFUSE_UI_LOGO_LIGHT_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-light-mode.png
# LANGFUSE_UI_LOGO_DARK_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-dark-mode.png
# LANGFUSE_UI_DEFAULT_MODEL_ADAPTER=Anthropic # OpenAI, Anthropic, Azure
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
# LANGFUSE_UI_VISIBLE_PRODUCT_MODULES=
# LANGFUSE_UI_HIDDEN_PRODUCT_MODULES=
### END Enterprise Edition Configuration
### START Langfuse Cloud Config
# Used for Langfuse Cloud deployments
# Not recommended for self-hosted deployments as these are NOT COVERED BY SEMANTIC VERSIONING
# NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="US"
# NEXTAUTH_COOKIE_DOMAIN=".langfuse.com"
# LANGFUSE_TEAM_SLACK_WEBHOOK=
# LANGFUSE_NEW_USER_SIGNUP_WEBHOOK=
# Posthog (optional for analytics of web ui)
# NEXT_PUBLIC_POSTHOG_HOST=
# NEXT_PUBLIC_POSTHOG_KEY=
# Sentry
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
# NEXT_PUBLIC_SENTRY_DSN=
# NEXT_SENTRY_ORG=
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# SENTRY_CSP_REPORT_URI=
# Demo project that users can use to try the platform
# NEXT_PUBLIC_DEMO_ORG_ID=
# NEXT_PUBLIC_DEMO_PROJECT_ID=
# Plain Chat
# NEXT_PUBLIC_PLAIN_APP_ID=
# PLAIN_AUTHENTICATION_SECRET=
# PLAIN_API_KEY=
# PLAIN_CARDS_API_TOKEN=
# Pylon Support
# PYLON_API_KEY=
# Admin API
# ADMIN_API_KEY=
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
# Rate limiting
# LANGFUSE_RATE_LIMITS_ENABLED=
# Free tier usage thresholds (Cloud deployments only)
# Enable the queue consumer that monitors free tier usage (default: true, but requires cloud region)
# QUEUE_CONSUMER_FREE_TIER_USAGE_THRESHOLD_QUEUE_IS_ENABLED=true
# Enable enforcement: send emails and block orgs that exceed free tier limits (default: false)
# LANGFUSE_FREE_TIER_USAGE_THRESHOLD_ENFORCEMENT_ENABLED=false
# Optional BCC address for usage threshold emails (e.g., for CRM integration like HubSpot)
# CLOUD_CRM_EMAIL=
# Stripe
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SIGNING_SECRET=
# Betterstack Status Page
# BETTERSTACK_UPTIME_API_KEY=
# BETTERSTACK_UPTIME_STATUS_PAGE_ID=
### END Langfuse Cloud Config
### START Langfuse CI Config
# LANGFUSE_INIT_ORG_CLOUD_PLAN=
### END Langfuse CI Config
-12
View File
@@ -1,12 +0,0 @@
# Test Environment Configuration
# Copy this file to .env.test for test database isolation
# Only overrides specific test variables - other values inherited from .env
# PostgreSQL - Test Database
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
# ClickHouse - Use Default Database for now, nothing set
# Redis - Test Database (database 1 for isolation)
REDIS_CONNECTION_STRING="redis://:myredissecret@127.0.0.1:6379/1"
-2
View File
@@ -1,2 +0,0 @@
# Currently inactive
# * @langfuse/maintainers
-11
View File
@@ -1,11 +0,0 @@
body:
- type: textarea
attributes:
label: Describe the feature or potential improvement
description: Please describe the change as clear and concise as possible. Remember to add context as to why you believe this is needed.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the change here. If your idea is related to any issues or discussions, link them here.
-36
View File
@@ -1,36 +0,0 @@
body:
- type: textarea
attributes:
label: Describe your question
description: Please describe the question you have as clear and concise as possible. Include error messages, unexpected behavior, or steps to reproduce the problem.
validations:
required: true
- type: dropdown
attributes:
label: Langfuse Cloud or Self-Hosted?
options:
- "Langfuse Cloud"
- "Self-Hosted"
validations:
required: true
- type: input
attributes:
label: If Self-Hosted
description: What version are you running? We may ask you to upgrade to the latest version, as many issues are continuously being fixed.
- type: input
attributes:
label: If Langfuse Cloud
description: Please share the link to your Langfuse project or the specific view you have a question about. This helps us resolve requests faster.
- type: textarea
attributes:
label: SDK and integration versions
description: If you're experiencing an issue with an integration or SDK, please share all package versions you're using. If you are not on the latest version, try upgrading, as this will often resolve the issue.
- type: checkboxes
attributes:
label: Pre-Submission Checklist
description: Please check for existing [issues](https://github.com/langfuse/langfuse/issues) and [discussions](https://github.com/orgs/langfuse/discussions) and ask the [Langfuse AI chatbot](https://langfuse.com/docs/ask-ai).
options:
- label: I have checked for existing issues/discussions and consulted Langfuse AI.
required: true
validations:
required: true
-47
View File
@@ -1,47 +0,0 @@
name: 🐞 Bug Report
description: Report a bug to help us improve
title: "bug: <short description>"
labels: ["🐞❔ unconfirmed bug"]
body:
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug, and what you expected to happen when you encountered it.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Describe how to reproduce the bug. Please provide detailed steps, code snippets, a minimal reproduction repository, etc.
validations:
required: true
- type: dropdown
attributes:
label: Langfuse Cloud or self-hosted?
options:
- "Langfuse Cloud"
- "Self-hosted"
validations:
required: true
- type: input
attributes:
label: If self-hosted, what version are you running?
description: We may ask you to upgrade to the latest version, as many issues are continuously being fixed.
- type: textarea
attributes:
label: SDK and integration versions
description: If you're experiencing an issue with an integration or SDK, please share all package versions you're using. If you are not on the latest version, try upgrading, as this will often resolve the issue.
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the bug here, including screenshots if applicable.
- type: dropdown
id: contribute
attributes:
label: Are you interested in contributing a fix for this bug?
description: If this is a confirmed bug, the maintainers are happy to provide guidance and review.
options:
- "No"
- "Yes"
validations:
required: true
-7
View File
@@ -1,7 +0,0 @@
contact_links:
- name: 💡 Feature Request
url: https://github.com/orgs/langfuse/discussions/new?category=ideas
about: Suggest any ideas you have using our discussion forums.
- name: 🤗 Get Help
url: https://github.com/orgs/langfuse/discussions/new?category=support
about: If you cant get something to work the way you expect, open a question in our discussion forums.

Some files were not shown because too many files have changed in this diff Show More