Hard-won lessons, edge cases, and "watch out for" patterns. Organized by domain.
Read the relevant section before working in that area. See also ARCHITECTURE.md and CONTRIBUTING.md.
docs/plans/is gitignored (along with.tessl/,.plan/): plan files written by/ce-planand friends are local artifacts, not tracked in git. Editing them never produces agit statusdiff — don't try to commit a plan's frontmatterstatusflip.docs/residual-review-findings/IS tracked and is the right place for review residuals that should persist across machines.
exactOptionalPropertyTypes: Use...(val ? { key: val } : {})spread, neverkey: val ?? undefinednoUncheckedIndexedAccess: Allarr[i]returnsT | undefined— guard with null check before usenoPropertyAccessFromIndexSignature: Useobj['key']bracket notation for index signatures- Bracket access for index signatures:
obj['key']is required undernoPropertyAccessFromIndexSignature— do not auto-rewrite toobj.key; oxlint does not flag the bracket form z.preprocess+ React Hook Form:z.preprocesswidens input type tounknown, breakingzodResolverunder strict mode. Define the form type as an explicit interface (notz.infer) and cast:zodResolver(schema) as unknown as Resolver<FormType>
-
Dashboard sub-resource routes need ownership checks:
requireProjectAccess()only verifies the user is a member of the project specified in theX-Project-Idheader -- it does NOT verify the requested resource (e.g., agent) belongs to that project. Always fetch the parent resource and checkresource.projectId === currentUser.projectIdbefore returning sub-resource data (benchmarks, errors, etc.). -
app.onError()must checkinstanceof HTTPExceptionbefore returning a generic 500 — without this, auth middleware 401 responses get swallowed into 500s:app.onError((err, c) => { if (err instanceof HTTPException) return err.getResponse(); // ... generic error handling });
-
Streaming upload routes must skip body-parsing middleware:
c.req.raw.bodyis aReadableStreamconsumed on first read. Any middleware that callsc.req.json(),c.req.parseBody(), orc.req.arrayBuffer()consumes it — downstream handlers get nothing. Separate streaming endpoints into their own route group withoutzValidator. -
Never leak internal errors to clients: The global
app.onError()handler must NOT senderr.messagein any environment (including dev) — Drizzle errors include full SQL queries with table names and column names. Always return a generic message and log the full error server-side. -
Use
Context<AppEnv>for handler/helper signatures, neverParameters<typeof Hono.prototype.get>[1]: TheParameters<...>form fails under TS strict mode (Property 'get' does not exist on type 'never'). ImportContextfromhonodirectly:function helper(c: Context<AppEnv>). -
Zod v3 vs v4 at the
@hono/zod-validatorhook boundary: v0.7 emits$ZodError(zod v4 core) on validation failure, but local helpers likemapZodErrorare typed againstz.ZodErrorfrom zod v3. Runtimeissues[]shape matches; cast throughunknown(mapZodError(result.error as z.ZodError)) at the hook boundary, not at every call site.
db.execute(sql...)returns array-like result — access rows asresult[0], notresult.rows[0]- No native
FOR UPDATE SKIP LOCKED— use rawdb.execute(sql...)with a CTE for atomic claim patterns - Never use
sql.raw()for agent/user-supplied values — use Drizzle's parameterized${value}in tagged templates. Arrays like${[1,2,3]}::int[]are bound safely.sql.raw()is only for static SQL fragments (table/column names). onConflictDoUpdateusesexcludedwith snake_case: Inset:clauses, reference the PostgreSQLexcludedpseudo-table using snake_case DB column names (e.g.,sql`excluded.speed_hs`), not Drizzle's camelCase field names (speedHs).onConflictDoUpdate+ duplicate rows in VALUES: PostgreSQL rejects a single INSERT when the VALUES list contains multiple rows targeting the same conflict key (e.g., two entries with the same(agentId, hashcatMode)). Deduplicate input arrays before calling.insert().values().onConflictDoUpdate(), or validate uniqueness at the schema level.- Migration drift bundling:
drizzle-kit generatediffs currentschema.tsagainst the last migration snapshot — if prior schema changes were never migrated, they silently bundle into the next migration. Review generated.sqlfiles for unexpected ALTER statements before committing. - Scoping a polluted migration: To isolate only intended changes: (1) backup
schema.ts, (2) temporarily revert unrelated schema changes, (3) delete the migration SQL + snapshot + journal entry, (4) rundrizzle-kit generate, (5) restoreschema.tsfrom backup. - Atomic status guards: Never read-then-write agent/task status in separate queries -- fold the guard into the
UPDATE WHEREclause (e.g.,sql`${agents.status} != 'busy'`) to prevent race conditions. - Campaign progress uses SQL aggregation: Use
COUNT(*) FILTER (WHERE status IN (...))andSUM(...) FILTER (WHERE status = 'running')instead of loading all tasks into memory. Clamp keyspace progress withGREATEST(0, LEAST(..., 1)). - Attack keyspaces round-trip with a number-or-string boundary, never as raw JS Number:
attacks.keyspace varchar(255)is sized for bigint values - mask attacks routinely exceedNumber.MAX_SAFE_INTEGER(e.g.?a^12~ 5.4e23).Number.parseInt(keyspace)silently loses precision above 2^53. UseBigInt(keyspace)for arithmetic; the canonical bigint-string boundary lives inpackages/backend/src/services/keyspace.ts. Fortasks.workRangestart/end/total, usejsonSafeBigintinpackages/backend/src/services/tasks.ts- it stores values as JS Number when they fit in safe-integer range and as decimal strings when they don't, so most attacks stay number-shaped and only mask-overflow chunks pay the string tax. Do NOT blindly stringify every coord; the union typenumber | stringis intentional, and consumers know to coerce viaBigInt(...)either way.
JWT custom claims may return as strings: RESOLVED -- migrated from jose JWTs to BetterAuth database-backed sessions (#126). The JWT claim type coercion bug no longer applies.- BetterAuth returns
user.idas string: Even when theuserstable usesserial(integer) IDs, BetterAuth'sgetSession()returnsuser.idas a string. Always useNumber(session.user.id)when bridging to thecurrentUsercontext. - Project scope differs by surface (dashboard = session, control = header): On the dashboard surface project scope is server-managed --
requireSessionreads it exclusively fromsession.session.projectId(set viaPOST /api/v1/dashboard/projects/select); theX-Project-Idheader is not read there (#159 U4). Only the control API (stateless per-user API keys) derives scope from theX-Project-Idheader viarequireApiKey, since it has no session row to read from. - Cookie name is
hh.session_token: BetterAuth usescookiePrefix: 'hh'which produceshh.session_tokenas the cookie name. Oldsessioncookies from the JWT era are cleaned up by therequireSessionmiddleware. user.additionalFieldsmust declare every non-defaultuserscolumn you read off the session: BetterAuth only surfaces columns it has been told about.users.roles(a Postgrestext[]) was absent from the session until declared asuser.additionalFields.roles: { type: 'string[]', required: false, input: false }inlib/auth.ts. Without itsession.user.rolesisundefined,coerceRolesyields[], and every globalrequireRole(...)check 403s for a legitimate admin (#228). Useinput: falseso roles stay admin/seed-managed -- better-auth'sparseInputDataactively rejects a client-suppliedinput:falsefield with400 FIELD_NOT_ALLOWED, closing the self-escalation path. The session-namespace analog issession.additionalFields.projectId.- Auth-config-shape tests must run in their own isolated phase: A test that reads
auth.options.*(e.g. asserting theadditionalFields.rolesdeclaration survives) cannot share abun testprocess with the dashboard route tests -- those callmock.module('.../lib/auth.js', ...), which replaces theauthsingleton's live binding process-wide and strips.options. Gate the config test behind its own env var (AUTH_CONFIG_ROLES_TEST_ISOLATED) and dynamicawait import('.../lib/auth.js'), wired as a separate phase inpackage.json. Note the victim direction: this file does not mock anything; it is polluted by others' mocks, so isolation protects it rather than containing it.
Bun.serve()idle timeout defaults to 10s — large uploads on slow connections will timeout. SetidleTimeout: 120in the server config for upload-heavy services.
- Queue names cannot contain
:(BullMQ 5.67+) — colons conflict with the Redis key separator. Use hyphens:tasks-high,jobs-hash-list-parsing.
- Circular import:
campaigns.ts↔tasks.ts— resolved via dynamicawait import('./tasks.js')and a_depsinjection object incampaigns.ts. Maintain this pattern when adding cross-service calls. _depsinjection pattern:campaigns.tsexports a mutable_depsobject for dynamic imports. Production code calls_deps.getTasksModule()instead ofimport('./tasks.js')directly. Tests override_depsproperties to inject spies — this bypasses bun:test's shared module cache.- Masklist keyspace: any uncomputable line nulls the WHOLE list (#231) —
sumMasklistKeyspace(and its streaming twinsumMasklistKeyspaceFromStream) sumcalculateMaskKeyspaceper.hcmaskline, but if any non-skipped line is uncomputable (custom-charset def via unescaped comma,?1-?4ref, unknown?-token, over-length) the whole list returnsnulland the caller falls back to the single-task path. Never skip-and-sum the computable lines — that under-counts and mis-chunks ("rather under-chunk than mis-chunk"). The unescaped-comma guard must use backslash-run parity (\\,is a real separator → null;\,and\\\,are escaped → literal): a single-preceding-char check mis-classifies\\,and silently mis-chunks. - Masklist keyspace fans out unconditionally on (re)upload/worker —
mask_lists.keyspaceis always rewritten (includingnull), so the recompute to dependent mode-3 attacks must fire even when the keyspace is null; otherwise a re-upload to an uncomputable file leaves dependents on a stale non-null value. Wordlists/rulelists only fan out once a line count is known. The sharedservices/resources/masklist-keyspace.ts#computeAndPersistMasklistKeyspace(used by the line-count worker and the backfill) streams rather than buffers so a large.hcmaskcannot OOM the worker.
Mock Module Fundamentals:
Named two-pattern taxonomy + decision rule:
docs/solutions/conventions/bun-test-mock-module-import-order.md(Pattern A: mutable-impl variables +beforeEachreset; Pattern B: isolated-phase env gate +await import()). The entries below are the underlying mechanics that motivate that taxonomy.
mock.module()beforeawait import(): Mock dependencies before dynamically importing the module under test — used for service tests that need DB/queue mocks- Shared module cache gotcha:
mock.modulemerges mock exports into the real module's ESM namespace — non-mocked exports pass through, but mocked ones (e.g.,resolveGenerationStrategy: mock()) silently replace the real function for ALL test files in the same run. Never mock individual exports of a module unless every consumer in every test file can tolerate the mock. - Flaky module cache: Tests relying on
mock.modulecan pass in isolation but fail in the full suite non-deterministically. If a test fails inbun --filter @hashhive/backend testbut passes alone, re-run the full suite once before debugging — bun's module evaluation order across files is not guaranteed. - Separate test files for conflicting mocks: If a module is already imported at top level in one test file (e.g.,
resolveGenerationStrategyincampaigns.test.ts), tests needing full module mocks for the same source must go in a separate test file to avoid import-order conflicts. - Isolated-phase pattern for files needing exclusive
mock.moduleownership:mock.moduleruns at module load (beforedescribe.skipcan suppress it) and persists process-wide. Wrap the entire file body inif (IS_ISOLATED) { ... }and userequire('../../src/...')inside to defer ESM resolution past the mocks. Gate via env var (e.g.CONTROL_RBAC_TEST_ISOLATED=1) wired throughpackage.json's test script as a separate phase. Existing examples:tasks.test.ts,queue-manager.test.ts,control-routes-rbac.test.ts,redis-degradation.test.ts,workers/metrics.test.ts. Adding a new gate is a coordinated edit: env-gate in the test file + mocks wrapped inif (IS_ISOLATED)+ a new<GATE>=1 bun test --preload ./tests/preload.ts <path> &&segment inpackages/backend/package.jsontestscript before the barebun test. - Isolated-phase imports use
await import(), notrequire(), for async modules: The gotcha above mentionsrequire('../../src/...')for late module resolution, but bun rejects this on modules with top-levelawait(e.g. anything that pulls insrc/index.ts's app graph) withrequire() async module ... is unsupported. use "await import()" instead. Useconst { app } = await import('../../src/index.js')inside the gated branch — the test file is a module so top-level await works. Also surface the skip stub with aconsole.warn+ anexpect(process.env['<GATE>']).toBeUndefined()assertion so a CI misconfig that drops the isolated phase cannot leave the suite silently green. Canonical example:dashboard-campaigns-routes.test.ts. - Re-export the real implementation when you must mock siblings but want to preserve one export: If a route test mocks a whole module but another test file in the same run exercises one of that module's exports for real behavior (e.g., a pure comparator),
importthe real export at the top of the route test and re-export it from themock.modulefactory rather than inlining a degraded stub. The static import resolves to the real binding beforemock.modulehoists, so the factory can re-publish the genuine function — the process-global leak still happens, but it now installs the real implementation everywhere instead of the stub. Diagnostic signature when this is missing: a test passes locally but fails on Linux CI with a value the real function cannot produce (test-file load order differs between platforms). Canonical example:crackers-routes.test.tsre-exportscompareCrackerVersionssocrackers.test.tsstill sees the real impl when both run in the samebun testprocess. Symmetric rule: when a test fails only on CI with a value disconnected from the implementation, search formock.modulecalls on the affected module before rewriting the implementation — three commits of regex-rewriting on the parser could have been zero if Attempt 1 had rungrep -rn 'mock\.module.*<file>' tests/first.
Mock Patterns:
- Use
mockReset()notmockClear()inbeforeEach:mockClear()only resets call history — queuedmockResolvedValueOncevalues can leak across tests, especially in CI where test execution order differs. Always followmockReset()withmockImplementation()to restore the default return value. - Drizzle mock chains must match production code — e.g.
insert().values()returning{ onConflictDoNothing: mock() } - BullMQ worker test mocks: if worker does
db.select(), mock must return chainable{ from: mock(() => chain), where: mock(() => Promise.resolve([])) } - Route-level contract tests: When mocking for
import { app }, mock ALL transitive service dependencies (e.g.,tasks.js,events.js). Avoid mocking modules that other test files import un-mocked (e.g., don't mockcampaigns.jsinagent-api-contract.test.ts— it leaksresolveGenerationStrategy: mock()intocampaign-transition.test.ts). Instead, mock the leaf dependency (tasks.js) to break the import chain.
Infrastructure:
- Backend contract tests validate auth (401), validation (400), and camelCase response shapes (200) without a running DB
- Test fixtures:
packages/backend/tests/fixtures.ts— factory functions + token helpers - oxlint overrides (
.oxlintrc.json):tests/**and**/tests/**relaxno-shadow,no-await-in-loop, and several typescript-* rules;scripts/**andpackages/*/scripts/**relaxno-await-in-loop;packages/frontend/e2e/**relaxesunicorn/consistent-function-scoping
Environment:
- Frontend tests use
happy-domwith manual global injection (not@happy-dom/global-registrator) - Always call
afterEach(cleanup)in Testing Library tests — DOM persists in happy-dom @testing-library/user-eventis NOT installed — usefireEventfrom@testing-library/react- Run tests per-package: Use
bun --filter @hashhive/frontend test/bun --filter @hashhive/backend test— rootbun testskips per-packagebunfig.toml(happy-dom), causingdocument is not defined - Tests are NOT in the type-check scope:
packages/{backend,frontend}/tsconfig.jsonincludes onlysrc/**/*.just check/tsc --noEmitdoes not catch type errors intests/. Test fixtures with missing required fields will compile and the bun runtime will accept them. When adding or refactoring shared types via@hashhive/shared, also update test factories/fixtures so the test data matches the wire shape — otherwise the drift only surfaces in PR review.
Test Utilities:
tests/mocks/fetch.ts—mockFetch()replaces global fetch with route-to-response mapping; callrestoreFetch()in afterEachtests/mocks/websocket.ts—installMockWebSocket()replaces global WebSocket; providessimulateOpen/Close/Messagetests/fixtures/api-responses.ts— factory functions:mockLoginResponse,mockMeResponse,mockDashboardStatstests/utils/store-reset.ts—resetAllStores()resets all Zustand stores; call in afterEachtests/test-utils.tsx—renderWithProviders()(single component),renderWithRouter()(navigation tests),cleanupAll()(DOM + stores)
Gotchas:
- 401 intercept:
api.tsglobally intercepts all 401 responses as "Session expired" -- tests for endpoints using theapiwrapper must use 400 for invalid credentials to avoid triggering the interceptor. Login is exempt: it calls BetterAuth via rawfetch(not theapiwrapper), so 401 from BetterAuth is correct and does not trigger the interceptor. - PermissionGuard hides elements: Tests asserting on guarded elements (New Campaign link, lifecycle buttons, Upload buttons) must seed the auth store with
roles: ['admin']orroles: ['contributor']viauseAuthStore.setState()— without this, PermissionGuard renders nothing - Testing pages that use
useEvents/EventsProvider: WebSocket only opens whenauthClient.useSession()returns a session. CallsetupAuthClientMock()thensetMockSession()before the page module loads. Easiest pattern: top-levelsetupAuthClientMock(); const { Page } = await import('../../src/pages/page')— mock registration is module-load-order-sensitive. Without this,wsMock.instances[0]is undefined andif (!ws) returnsilently skips the test. - Playwright e2e suite runs single-worker until each spec has its own seeded user: every e2e in
packages/frontend/e2e/signs in as the same seededtest@hashhive.local. Any spec that picks a project on/select-projectwritesusers.last_project_id, and BetterAuth'ssession.create.beforehook rehydratessession.projectIdfrom that column on the next sign-in — so a follow-up test expecting/select-projectwill land on/and time out. Bothsmoke.spec.tsandselect-project.spec.tsmutate that column.test.describe.serialinside a file is necessary but not sufficient because it doesn't serialize across files;playwright.config.tsenforcesworkers: 1so cross-file ordering matches CI. Real isolation (per-spec seeded user OR abeforeEachthat resetslast_project_id) is the durable answer; until that ships, do not relaxworkers: 1and do not droptest.describe.serialon shared-user blocks. The same root cause is why the config excludesdemo-capture.spec.tsfrom the default suite.
EventsProvideris the singleton WebSocket owner — mounted inAppLayout.useEvents()opens a fresh WS on every call; calling it from a page opens a duplicate connection AND duplicates the invalidation work the provider already does.- To refresh a query on an event, add the query key to the maps in
use-events.ts:invalidationKeys(project-scoped, invalidated as[key, projectId]),agentScopedKeysByEvent(per-agentId),campaignScopedKeysByEvent(per-campaignId). Do not calluseEvents({ onEvent })from page components. EventTypeunion +KNOWN_EVENT_TYPESset derive from a single const tupleEVENT_TYPESinuse-events.ts: adding a new variant is a one-line change. TheisKnownEventTypeguard drops unrecognized frames.
- Unicode escapes in JSX string attributes render literally:
message="Loading\u2026"displays asLoading\u2026, notLoading.... JSX attribute strings are NOT JS string literals — they don't process\uXXXXescapes. Use the actual character or a JS expression:message={"Loading\u2026"}. Prefer plain ASCII (...,-) over Unicode punctuation. - No fancy punctuation in UI text: Use
...not…,-not—/–. Plain ASCII only. - No arbitrary pixel font sizes: Use Tailwind's rem-based scale (
text-xs,text-sm, etc.), nevertext-[11px]or similar — these don't respect user zoom preferences. - Tailwind v4 custom colors in
border-l-*don't generate CSS: Classes likeborder-l-ctp-tealusing custom color tokens produce no output. Use inlinestyle={{ borderLeftColor: 'hsl(var(--ctp-teal))' }}withborder-l-2class for the width.
bun --filter @hashhive/<pkg> test <path>does NOT scope to one file. It runs the package's fulltestscript and ignores the path argument. To target one file,cd packages/<pkg> && bun test --preload ./tests/setup.ts <path>. The--preloadis required for frontend tests (they needwindow).- Backend scripts must run from
packages/backend/— env validation readspackages/backend/.envrelative to CWD.bun src/scripts/seed-admin.tsfrom repo root fails with "DATABASE_URL: Invalid input";cd packages/backend && bun src/scripts/seed-admin.tsworks. - Rebuild
@hashhive/sharedbefore backend type-check after schema or schema-types edits. Backend imports fromdist, notsrc. After anypackages/shared/src/schemas/*.tsortypes/*.tschange, runbun --filter @hashhive/shared buildbeforebun --filter @hashhive/backend type-checkor you'll get "no exported member" errors. - Drizzle migration SQL gets reformatted by the pre-commit hook on first commit attempt. Both the
.sqlfile andmeta/NNNN_snapshot.jsoncome back as "files were modified by this hook" — re-stage them and re-commit. (Path:packages/shared/src/db/migrations/.) - Tests that
mock.module('../../src/services/events.js')must mirror every export imported by upstream consumers. Adding a new export toservices/events.tsthat any route (e.g.,routes/dashboard/events.ts) imports at top-level breaks ~10 test files at import time with "Export named 'X' not found." Either update every mock, or keep new constants local to the consumer file.
- macOS Docker IPv6 trap. Node resolves
localhostto::1before127.0.0.1; Docker Desktop on macOS only forwards ports on IPv4 even thoughdocker psreports both families.postgres-jsretries on connect-refused and limps along, butioredis(BullMQ) fails fast withECONNREFUSED ::1:6379. Spell every host-side dev URL as127.0.0.1, neverlocalhost. Already applied topackages/backend/.env.example(DATABASE_URL, REDIS_URL) and the seaweedfs healthcheck — same trap if you add a new dev URL. - SeaweedFS 4.27 master HTTP UI binds to the container's docker-network IP, not
127.0.0.1. An in-container probe onlocalhost:9333always gets connection-refused even though the master is up and the host port-forward works. The compose healthcheck targets the S3 API on127.0.0.1:8333viacurl -sS(any HTTP response, including the anonymous-GET 403, is treated as healthy). docker-compose.ymlimage-tag drift. Docker Desktop periodically auto-bumps theimage:lines on disk (postgres:16→18, redis:7→8, seaweedfs:4.27→4.31 have all been seen). Ifgit statusshows image-tag changes you didn't make,git restore docker-compose.yml. Don't commit the bumps without checking that the volumes survive — see next.- Redis RDB volume incompatibility after image-tag drift. Once
redisdatahas been written by a newer Redis (e.g. 8), the pinnedredis:7-alpinerefuses withCan't handle RDB format version Nand the container crash-loops (exit 1, port refused). Dev redis is throwaway:docker compose stop redis && docker compose rm -f redis && docker volume rm hash_hive_redisdata && docker compose up -d redis. Do NOT do this for postgres — you'll lose the seed; re-runjust db-seedafter.