Files
3x-ui/CLAUDE.md
T
DIMFLIX da01b7637d feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration

Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up.

* feat(sub): add SubBalancer service

List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart.

* feat(sub): add SubBalancer API controller and routes

GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it.

* feat(sub): emit client-side balancers in JSON subscription

For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present.

* test(sub): cover SubBalancer service and JSON output

Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup.

* feat(sub): add sub-balancers i18n keys

pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales.

* feat(sub): add SubBalancer schema and API queries

Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key.

* feat(sub): add subscription balancers settings tab

SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled.

* test(sub): add SubBalancer form modal test

Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled).

* feat(sub): register sub-balancers in API docs and OpenAPI

Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it.

* docs: sync openapi.json with frontend

docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API.

* docs: add subscription balancers API reference

Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations.

* feat(sub): accept roundRobin balancer strategy

Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist.

* feat(sub): add roundRobin strategy label

pages.settings.subBalancers.strategyRoundRobin added to all 13 locales.

* feat(sub): expose roundRobin in balancer form

Zod strategy enum, form modal label key, and table strategy colour for roundRobin.

* docs(sub): list roundRobin in strategy description

The create/update strategy param description now mentions roundRobin alongside the other three.

* feat(sub): add subJsonObservatory setting

Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask.

* feat(sub): wire observatory config through sub controller

WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction.

* feat(sub): emit observatory conditionally with configurable probes

burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override.

* feat(sub): add subJsonObservatory to AllSetting model

Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string.

* feat(sub): add balancer observatory config card

New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults.

* fix(sub): hide save/restart header on sub-balancers tab

Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly.

* feat(sub): add observatory config i18n keys

pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales.

* feat(sub): regenerate openapi for subJsonObservatory

openapigen picks up the new AllSetting field; openapi.json synced into docs.

* feat(sub): add observatory tab to sub-balancers

Mirrors the Xray Balancers page: two tabs (Balancers + Observatory).
Wires allSetting/updateSetting into the tab and adds tabBalancers /
tabObservatory labels to all locales. The page Save header is shown
again on this tab so the observatory config can be saved.

* refactor(sub): drop observatory tab from sub-formats

Now that the observatory config lives under sub-balancers, remove the
duplicate tab plus its state and defaults from sub-formats.

* fix(sub): add missing inboundsCount i18n key

The sub-balancers table rendered the raw key path in the Inbounds
column because pages.settings.subBalancers.inboundsCount was not
defined. Added it to all 13 locales.

* test(sub): pin disabled-inbound exclusion from balancer

The balancer builds its members from the subscriber's already-filtered
entry set, so an inbound disabled for that user can never surface as a
member. Adds tests for both shapes (one of several disabled, and the
only selected one disabled).

* fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback

Three coupled defects on the balancer observatory surface, flagged in PR review:

- The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this.

- Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value.

- routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking.

Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it.

* fix(sub-balancer): localize controller toasts and reject malformed ids

Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success.

* fix(sub-balancer): enforce remark length cap server-side

The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap.

* fix(sub-balancer): exclude mtproto from balancer member picker

SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment.

* docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope

- Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only).

- Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX.

- Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror.

* style(model): trim SubBalancer comment to 2-line cap

CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3.

* fix(sub-balancer): parse enabled explicitly and preserve it on partial update

parseSubBalancerForm treated any non-"false" value as true (so "bogus"
silently enabled) and always overwrote Enabled on update, so a PATCH that
omitted the toggle reset a disabled balancer back to enabled. Parse the
field with strconv.ParseBool and return *bool: absent means "no change"
on update and "true" on create; a malformed value is rejected as 400.
Update keeps the stored Enabled when the pointer is nil.

* fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds

DelInbound cascaded hosts but left the deleted inbound id in every
sub_balancers.InboundIds, so the balancer kept emitting a member no
subscriber could resolve — a dangling outbound tag with no proxy behind it.
Strip the id inside the existing delete transaction (same shape as the hosts
cascade, #5648); with the last member gone the balancer stops emitting.

* fix(sub-balancer): return not-found when deleting a missing balancer

Delete returned the gorm result error only, which is nil when no row matched,
so the controller reported success:true for an id that never existed — a stale
UI row looked like a clean delete. Check RowsAffected and return a not-found
error on 0 so the toast reflects reality.

* style(sub): shorten leastPing/leastLoad observatory comments

The observatory-emission guard comment and its test comment ran a few
lines long; trim them to a couple of lines each without dropping the
invariant that leastPing/leastLoad require a burst observatory.

* fix(sub): validate observatory setting instead of silently dropping it

SetObservatoryConfig applied whatever survived json.Unmarshal with no checks,
so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even
unparseable JSON was either silently applied or silently ignored. Validate
each field: parse durations with time.ParseDuration, require http(s) URLs for
destination/connectivity, and log a warning naming the field and the bad value
on every fallback — including the unmarshal error, which was a quiet return.
Bad values now keep the built-in defaults instead of leaking into the emitted
burstObservatory.

* fix(sub): deduplicate burst-observatory defaults across Go and frontend

The burst-observatory ping defaults lived in three places that had drifted:
Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod
PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity
pre-check URL). Align them to one set: https probe destination, sampling 2,
and empty connectivity (skip the direct pre-check). The settings tab now
parses the stored JSON through PingConfigSchema and seeds its default from
DEFAULT_BURST_OBSERVATORY instead of carrying its own literal.

* refactor(sub): extract proxy outbounds once before the balancer loop

buildBalancerConfig unmarshalled every inbound document and re-extracted its
first outbound on each balancer, so with B balancers and N inbound docs the
same document was parsed B*N times. Pull each doc's proxy outbound in a single
pre-pass over the entries and cache it per entry; buildBalancerConfig now
clones the cached map before retagging, so one parse serves every balancer.
Output is byte-for-byte unchanged.

* fix(sub): form balancer member tags from the inbound protocol, not tcp→vless

balancerTransport derived the bal-N tag suffix from the outbound's transport
network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was
mislabelled "vless" in every client config — the tag lied about the proxy
type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless,
bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and
dedup suffix are unchanged. Update the existing tag assertions and add a vmess
case that fails under the old mapping.

* fix(sub-balancer): default strategy to random in the create form

The create-balancer form seeded strategy to 'leastLoad', but the service
validate() defaults an empty strategy to 'random' and the API docs say the
default is 'random' — so a freshly opened form showed leastLoad while saving
without touching the field silently stored random. Align the form default to
'random' so what the admin sees is what gets persisted.

* feat(api-docs): document the SubBalancer response schema

The five sub-balancer endpoints carried no responseSchema, so the API docs
page rendered them without a typed example. Add example: tags to every
SubBalancer field, allow the struct through openapigen, and point the list
(responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate
the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/.

* style(sub-balancer): drop whitespace-only separator lines, add final newline

subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as
separators between statements and had no trailing newline. Replace them with
clean empty blank lines and end each file with a newline.

* fix(i18n): translate sub-balancer toasts and observatory note

The sub-balancer toast messages (list/create/update/delete/invalidId) and
the observatory note were left in English across 11 non-English locales
(ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key
in the subBalancers block was already translated. Translate them to match the
meaning and terminology of the surrounding keys in each file; the JSON
structure and keys are unchanged.

* fix(sub-balancer): hide disabled inbounds from the member picker

The picker offered every protocol-eligible inbound regardless of its enable
flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled
member is therefore dropped from every subscriber's entries, and when it was
the balancer's only member the balancer document silently stops being emitted
— with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll
MembersDisabled already documents that backend behavior.

Filter the way the sibling client picker has since #5645: hide disabled
inbounds, but keep one that is already selected so editing an existing
balancer cannot silently drop a member.

Drop the `?? []` on the useWatch result so the new useMemo dependency stays
referentially stable.

* style(sub): trim the balancerMemberSuffix comment to the 2-line cap

Comment blocks in committed Go are capped at 2 lines; the name already carries
what the function picks, so keep only the why.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-08-23 22:34:20 +02:00

164 lines
10 KiB
Markdown

# CLAUDE.md
Operational guide for AI agents working in this repo. Long-form human docs:
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
Read those before large changes. This file is the short, must-follow version.
For a deep navigation map (request lifecycle, cron-job table, symptom → file
index, layering rules), read `docs/architecture.md` on demand — do not guess
file locations when it can answer in one hop.
## Stack
- Backend: Go 1.27 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
API. MTProto inbounds run a second managed child — the `mtg-multi` binary
(a multi-secret mtg fork — NOT a Go dependency; its prebuilt release binary is
fetched at image/release build time by `DockerInit.sh` + `release.yml`,
panel-side code in `internal/mtproto/`) — outside Xray, one process per inbound
serving each
client's FakeTLS secret via the fork's `[secrets]` section (plus per-client
ad-tags via `[secret-ad-tags]` and per-client data quota / expiry via
`[secret-limits]`, mapped from the client's `totalGB`/`expiryTime`). Client,
ad-tag and quota/expiry edits are hot-applied through the fork's management API
(`PUT /secrets`, bearer-token guarded) so connections survive; the manager
falls back to a process restart on older binaries. A client's panel-side
traffic reset also calls `POST /secrets/{name}/reset-quota` so a renewed client
is not re-blocked by the sidecar's quota counter.
- Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.
- Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in `frontend/`,
built into `internal/web/dist/` (gitignored) and embedded via `embed.FS`.
## Repo map
- `main.go` — entry point + `x-ui` CLI (run, migrate, migrate-db, setting, cert).
- `internal/config/` — env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER,
XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).
- `internal/database/` + `internal/database/model/` — GORM schema (~24 models;
Inbound, Client, Setting, User are the core), inbound Protocol enum,
AutoMigrate + hand-written migrations in `db.go`.
- `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
- `internal/pia/` — PIA WireGuard protocol client (auth, signed server list, `/addKey`).
- `internal/sub/` — subscription server (raw / JSON / Clash).
- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
cpu.high, memory.high, login.attempt).
- `internal/logger/`, `internal/util/` (link, crypto, sys, ldap, …),
`internal/tunnelmonitor/` — shared infrastructure.
- `internal/web/` — Gin server (embeds `dist/` + `translation/`).
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
- `service/` — business logic (InboundService, SettingService, XrayService,
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
- `job/` — 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
`runtime/` (master/sub-node over mTLS), `websocket/`.
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
(`frontend/public/openapi.json`) is assembled from those + `endpoints.ts` by
`frontend/scripts/build-openapi.mjs`.
- `docs/` — separate Next.js/Fumadocs site (pnpm, own CI in `docs-ci.yml`,
outside `make verify`). Holds a THIRD independent implementation of
link/subscription generation in `docs/lib/xray/` — check it whenever
share-link or install-command output changes.
## Hard rules (non-negotiable)
- Fix size must match bug size. Find the root cause, then make the SMALLEST
change that removes it — a one-line guard beats a new subsystem. A small bug
does not earn new columns, jobs, abstractions, config knobs or helper layers.
If a fix genuinely needs new architecture, say so and get agreement first;
never ship it unasked next to the fix.
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
carry the meaning first and rename rather than annotate; spend the 2 lines on
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
constraint. Exempt: `//go:build`, `//go:generate`, and other directives.
HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
`cd frontend && npm run gen`). Hand-maintained but pinned both ways by
`TestRouteRegistryContract` (`internal/web/routes_contract_test.go`): a missing
OR stale entry fails `make test-go`. Scope: `/panel/api/*` + a few session
routes; sub-server routes are exempt.
- Response examples come from Go struct `example:` tags via `tools/openapigen`
never hand-write them. A new struct must be added to openapigen's `StructAllow`
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
- A new or renamed endpoint has a FOURTH step nothing checks: copy
`frontend/public/openapi.json``docs/public/openapi.json`, then
`cd docs && pnpm gen:api` to refresh the MDX under
`docs/content/docs/en/reference/api/`. `docs-ci.yml` fires only on `docs/**`.
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
(13 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
- DB / model changes require a migration in `internal/database/db.go`.
- Every state-changing inbound/client op dispatches through `runtime.Runtime`
(`internal/web/runtime/`) — never straight to `internal/xray/api.go`, never from
a controller or cron job. A direct call passes every local test and silently
breaks every multi-node deployment. Other layering rules: `docs/architecture.md` §8.
- Conventional commits: `type(area): short imperative summary`, then a body
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
`docs`, `style`.
## Go conventions
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
`internal/sub`'s `initSubDB(t)` is the template.
- A test must fail without its fix. Write it, revert the fix, watch it go red,
restore. A test that passes either way is worse than no test: it certifies
nothing and then gets cited as proof the fix works.
- Test what can actually break. No test for a getter, a constant, a rename, a
pure map lookup, or inputs the function can never receive. One real test that
drives the bug through the actual code path beats five that restate the code.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
green `go test ./...` does not mean those paths ran.
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
`src/schemas/` are the source of truth; infer types with `z.infer`, never
hand-write. Do not edit `src/generated/`.
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
a headless-Chromium Storybook project, so run
`npx playwright install --with-deps chromium` once or `make verify` fails.
- Editing `frontend/src` does NOT change what users see until the Vite build is
regenerated into `internal/web/dist/`. In `XUI_DEBUG=true`, HTML is served from
the frozen embedded FS but JS/CSS off disk — after `npm run build` you MUST
restart `go run .` or you get a blank page with 404s.
- After touching share-link logic (`src/lib/xray/`), run `npm run test` (golden
fixtures); regenerate snapshots (`npx vitest run -u`) only for intentional
output changes, never to make a red test green.
## Build, test, verify
A fresh clone has no `internal/web/dist/`, so a bare `go build ./...` dies with
`pattern all:dist: no matching files found` while ~35 other packages pass — it
reads as a broken repo, not a missing step. Run `make dist-stub` once; every
`make` Go target already depends on it, which is why `make test-go` beats
`go test ./...`. Run `make help` for all targets. The local gate:
make verify # gen-check + lint + typecheck + test + build + build-storybook
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
you touch DB/dialect or parser code.
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
## Definition of done (before opening a PR)
1. `make verify` passes — its `gen-check` already runs `make gen` and fails on a
dirty `frontend/src/generated` / `frontend/public/openapi.json`.
2. Diff is focused; refactors are separate from feature work.