Fiber HTTP handlers: feature handler structs, route registration, auth surface, user job endpoints, error rendering.
API(handler.go) holds only the cross-cutting dependencies: the DB pool, the sqlc queries, the token issuer the auth middleware is built from, and the browser-tool hub (browserTools) that relays tool frames between a user's harness and their extension.Registerbuilds the shared services, constructs every feature handler, and calls each feature'sregisterin an order that keeps literal routes before param routes (e.g./jobs/searchbefore/jobs/:slug,/threads/countbefore/threads/:id, the static/me/tracking/*before/me/tracking/:slug).- Each feature area owns a
<feature>Handlersstruct with its own dependencies, anew<Feature>Handlersconstructor, and aregister(api fiber.Router, mw middleware)method that mounts its routes —grep 'Handlers struct' *.golists them all. Files are named after the routes they serve, so a feature spans several files (matchHandlers→match_analysis.go,match_analysis_stream.go,job_match.go, …). Handlers are thin; auth primitives, user job operations, API key management and error rendering live in their own files. - Two couplings worth knowing:
cvHandlersholds a*matchHandlers(it reuses the blocker/credits helpers for tailoring), andjobs_moderation.gocarries the moderator-authored writes behind themoderatorgate. - The
middlewarebundle (handler.go) carries the shared auth gates features mount behind:optional(attach caller, never reject),key(cookie or full-scope API key),cvKey(keywidened to admit the narrowcvkey — the CV surface and/meonly),cookie(cookie-only),optionalCookie(attach a cookie session, never reject — for provider callbacks, which are browser navigations),moderator(role gate, stacked afterkey/cookie). It also carries the two rate-limit pieces:outboundFetch(throttles endpoints that fetch a caller-supplied URL) andthrottler(backs the per-route limiters features build in their ownregister). - Services shared across features (résumé store, profile, credits, CV store/renderer,
conversation store, match analyzer, contribution, moderation, the LLM spend resolver)
are built once in
Registerand passed to each constructor; single-feature services are built inside the feature's constructor. A constructor that builds a shared service is the bug, not theRegisterline that digs it back out —cvH.cvStoreandassistantH.storewere both read out of a handler for a second consumer until the service moved up. When a second feature needs one, hoist it; do not reach into the first feature's struct. - No post-construction setter for a dependency that exists before construction. Three
of them (
withAssistantSessions,withFollowUps,withKeys) existed only because of the ownership above, and each made a handler's field nil for part of its life. What survives is the other kind — the dependency genuinely does not exist when the handler is constructed:accounts.WithCodes/report.WithNotifierattach to an SES client built inside anif,inboxH.withRecallwaits oncfg.LLM, andwithAccountDeletion,experienceH.withRequireContextandaccountDeletion.WithGatewayKeyswire servicesRegisterfinishes building further down. A few plain field assignments do the same (authH.*config fields,assistantH.realtime, theresumeH.llm/matchH.llmbindings). Two same-typed clients go in a named struct (assistantModels), not as adjacent parameters — a swap there compiles. - Catalogue scale is read, never counted (
stats.goCatalogScale,jobs.goopenJobTotal). Every figure describing how big the catalogue is —GET /stats/catalogand the jobs list'smeta.total— comes from the snapshotcmd/rollup-statspublishes (internal/ingest/catalogstats). Both callcatalogstats.Load, which takes no exact counter, so no request path can reach a catalogue-wide scan even by mistake. Neither read can fail: a cold cache, an unreachable Redis, a payload from an older build and nocfg.Cacheat all all degrade to the approximate estimate withexact: false. Add a new consumer by callingLoad, not by counting — and passexactthrough to whatever renders it, because a degraded snapshot zeroes the figures that exist only in the database and a zero must not reach a page as if it were a measurement. - Tests construct the feature struct directly with fakes/stubs (e.g.
&trackingHandlers{tracking: ...}) and mount routes on a barefiber.App. - Central
handler.RenderError(wired incmd/serverviafiber.Config{ErrorHandler: handler.RenderError}) renders JSON envelope: acodedError→its status with acodefield,*fiber.Error→its code,pgx.ErrNoRows→404, FK-violation (SQLSTATE 23503)→404,inbox.ErrNotFound→404,inbox.InvalidError/inbox.ErrSlugRequired/search.ErrBadQuery→400,inbox.ErrPendingSuggestion→409,context.Canceled→499 (client closed request), everything else→500. - Handlers signal failure by returning an error —
fiber.NewError(status, msg)for specific codes, bare error (e.g.pgx.ErrNoRows) for common cases. Don't hand-roll per-handler error JSON; don't re-mapErrNoRowsin read handlers (justreturn err).
register/loginset JWT cookie + return{"data": user}.logoutclears it.meis guarded byRequireAuthOrScopedKey(cookie or API key — the narrowcvkey included, so an agent can resolve which account its key belongs to).- Credential endpoints are rate-limited per route, keyed on client IP: register 5/min, login 10/min, verify-request 5/min, verify-confirm 10/15min, password/forgot 3/5min, password/reset 10/15min.
DELETE /api/v1/meerases the caller's account permanently — no soft-delete, no grace period, no restore. Cookie-only (RequireAuth), neverkeyAuth: a leaked API key must not destroy the account that issued it.- The body confirms the caller's own email (case-insensitive, matching the login lookup); a mismatch is 400 and erases nothing. Success is 204 plus an expired session cookie.
- The orchestration lives in
internal/identity/accountdelete, not here: object keys are collected, the Gmail grant revoked (best-effort, shared withGmailDisconnectviarevokeGmailGrant), objects deleted, and only then the row — so a storage failure leaves the account whole. That case surfaces as 503, meaning "nothing was deleted, retry", and is the one status worth knowing:ErrStorageUnavailablemust not fall through to a 500.
view/apply/save/trackinteraction endpoints. Addressed by job's public:slug(resolved to internal id before write). All writes are idempotent upserts behindRequireAuthOrKey.- Return
{"data": interaction}withuser_idomitted; public job reads stay unauthenticated. - The
/me/trackingsilence fields are null together or set together.last_activity_at,days_silentandsilence_statedescribe an application awaiting a reply; a row that is merely viewed or saved, or one in a settled stage, carries all three as null. Null means "nothing is owed here", which the board must be able to tell apart from "owed and answered promptly" — so never substitute a zero-dayactive. The derivation lives onjobtracking.TrackedJob.Silence; the handler takes onenow()for the whole page, so two rows cannot disagree about when the page was rendered.
Pipeline and cross-package invariants live in docs/agents/mail-stack.md; this section is the HTTP surface only. The use cases themselves live in internal/application/inbox — these handlers parse, call it and render, and the in-app assistant's mail tools call the same service without going through HTTP. A rule added to a handler here is a rule the assistant never meets.
It is the harness surface, not "the agent surface": there are two agents on this store now, and only this one speaks HTTP.
- Mounted on
mw.key, so a user's own agent harness drives the inbox with the full-scope key it already uses for the tracker. Exception: the Google OAuth pairs —/me/gmail/connect+/me/gmail/callback, and the calendar's own connect/callback beside them. Each connect staysmw.cookie— it redirects a browser to Google's consent screen and a keyed client cannot complete it. Each callback mountsmw.optionalCookieinstead: it is the browser returning from Google, and underRequireAutha session that did not survive the round-trip would render a JSON 401 into the address bar, so the callback answers that case itself with a redirect. TriageEmailisSetEmailClassification's sibling: status, link, provenance (link_source = 'agent') and the classified stamp in one update, thenmailclassify.AdvanceStage. Splitting it would manufacture states the worker never produces. An omitted slug means "not deciding the link", never "clear it". The stage advance is best-effort — the verdict is already durable.IngestEmailsvalidates the whole batch before writing any of it and commits in one transaction, so a bad message at the end cannot leave earlier ones stored under a 400.renderMutationis the shared tail of every mutation that returns the message it changed (link, unlink, confirm, reject, triage, create-application), so those cannot drift from one another or fromGetEmail. UnlikeGetEmailit does not mark the message read: the caller is acting on the message, not opening it.?link=partitions the mailbox intolinked/suggested/unlinked—suggestedis the confirmation queue,unlinkedthe mail with nowhere to go yet. The predicate must go intoListEmailsandCountEmailstogether or a filtered page reports an unfiltered total. A message that is both linked and carrying a stale suggestion reads aslinked: the resolved answer wins over the proposal it superseded.CreateApplicationFromEmailrecords an application from mail and links it in one call. It borrowsjobtrackingrather than writing its own apply, so theapplied_count-increments-once rule stays in one place; the application is dated by the email'sreceived_at, nevernow(). Mail carrying a pending suggestion is a 409 — the matcher already proposed an answer, and overwriting it silently would makelink_source = 'manual'a lie about a decision the caller never saw.
Routes (all on mw.key — the session cookie, the session JWT the extension's
connect flow minted, or a full-scope API key — and nothing else). Authentication
is the whole gate: every signed-in user reaches the assistant. The gate is key
rather than cookie because the extension's side panel holds conversations too
and cannot send an httpOnly cookie across origins. Nothing meters a turn —
the beta-tester gate that used to bound this spend is gone, and credit metering
has not replaced it yet:
| Route | Does |
|---|---|
POST /assistant/sessions |
start a conversation in the preset asked for — chat, profile, browse, interview or debrief; a tailoring one is minted by the CV bootstrap, which knows the CV and vacancy to bind |
GET /assistant/sessions |
the caller's conversations, most recently active first |
GET /assistant/sessions/:id |
one conversation with its stored transcript, for replay |
DELETE /assistant/sessions/:id |
remove a conversation and its transcript |
POST /assistant/sessions/:id/messages |
run one turn, streamed as named SSE events |
POST /assistant/sessions/:id/retry |
resume after a failed turn without appending another user message (same SSE stream) |
POST /assistant/sessions/:id/cancel |
stop a running turn (owner-scoped — see below) |
POST /assistant/sessions/:id/opening |
the assistant speaks first in a rehearsal or debrief, under a server-side brief; an already-answered opening is a 409 |
POST /assistant/sessions/:id/voice-token |
mint one voice call's credential (per-caller limited) |
POST /assistant/sessions/:id/voice-turns |
append a completed spoken turn to the transcript |
POST /assistant/sessions/:id/autopilot |
run the tailoring pass unattended — same stream, server-owned brief and ceiling (cookie-only) |
A session the caller does not own is a 404, never a 403, so ids stay unprobeable.
The autopilot route departs from the key gate above: it rewrites a CV without
being asked anything, and the browser is the one place the candidate can watch it
happen and undo it. It refuses anything but a tailoring session bound to a CV
(409). There is no pre-run snapshot any more: every edit the agent makes in
the turn is filed under one revision batch, so "undo the run" is undoing that
batch. Undoing it is POST /me/cvs/:id/revisions/batch/:bid/undo (also
cookie-only), which reverts the batch's standing edits newest-first and clears
the run's report with it.
Both SSE endpoints — the turn and the fit analysis — write through sseStream
(match_analysis_stream.go), the one owner of the stream protocol: it serializes the
heartbeat goroutine against the event callback and re-arms the write deadline before every
write. event reports whether the frame reached the client, and a marshal failure reports
true — an unencodable frame is our bug, not a dead reader.
A failed write does NOT stop an assistant turn. It used to: the failure cancelled the loop's context, which meant a phone freezing a backgrounded tab threw away live work — an unattended tailoring run once lost its report after twenty-five committed CV edits. A write that fails now means only that this reader is not listening; the turn runs to its own end under the step cap and the model timeout, and its transcript is stored either way. The fit analysis always ignored the same signal, for the neighbouring reason: its run was paid for with an AI credit.
Stopping a turn is therefore a request of its own — POST /assistant/sessions/:id/cancel,
owner-scoped — because a dropped connection cannot be told apart from a deliberate Stop.
turnRegistry (assistant_turns.go) holds each running turn's cancellation, which is the only
handle on a turn no request owns any more, and keeps a session to ONE turn at a time: a second
message waits for the first and is told so with a queued event, a third is refused with 409.
The registry is per-process, so a turn that outlives a blue/green flip cannot be cancelled and
ends at its step cap.
assistant_tools.go / assistant_tracking_tools.go / assistant_cv_tools.go /
assistant_profile_tool.go / assistant_present_tool.go / assistant_page_tools.go
build the agent's tools from the same services these handlers use, and
assistantRegistry picks the set for a session's preset. The loop itself lives in
internal/ai/assistant.
read_current_page (assistant_page_tools.go) is the only tool that leaves this
process for something we do not control: it drives the caller's browser through the
relay in internal/ai/browsertools. It is registered for the
browse preset alone, and it carries its own deadline, because every other tool is
bounded by a database or a search call and this one waits on a tab.
present_jobs is the odd one out: it is the only tool whose purpose is presentation
rather than retrieval or state change. It writes nothing and returns a receipt of
slugs, not vacancies — the client renders the deck and fetches each card's data by
slug, so a recommendation costs the model's context once, not twice.
get_profile is built on profileHandlers rather than the services under it, so the
tool and GET /me/profile share one assembly and cannot drift. It returns the CV as
resumeextract.Professional — contacts omitted, and not merely as good manners: a
tool result is persisted in the transcript and replayed into the model's context on
every later turn, so a name that lands there stays for the conversation's life. A
caller with no profile gets a result naming /my/profile, never an error and never an
empty profile the model would read as "no preferences".
GET /talent-network/:publicIDis the only unauthenticated route that serves candidate CV content — it takes nomwmiddleware at all, unlikeget_profile/GET /me/profile, which at least require a session or key.:publicIDisusers.talent_network_public_id(an opaque UUID, neverusers.id), minted for every user by migration0085regardless of opt-in state.- The 404-identity invariant:
talent_network_visibility = 'off'and "no such id" render the byte-identical{"error":"not found"}body. A malformed (non-UUID):publicIDalso 404s rather than 400 — seetalentNetworkPublicID— so a probe cannot distinguish "not a UUID" from "no such profile" from "a real profile that opted out". Do not add a distinct status/message for any of these three cases; that is the leak this route is built to not have. - The response body is built from
resumeextract.Structured.Anonymous()/.Public()(internal/candidate/resumeextract/visibility.go), neverStructuredorProfessionaldirectly — those two functions are the only place project-link stripping and current-employer masking happen, so this handler must not re-derive or bypass them (e.g. by reaching intorow.ResumeStructuredfor anything but thejson.UnmarshalintoStructured). full_nameis populated only forvisibility = 'public';GetProfileleaves it as the Go zero value for'anonymous', andomitemptydrops the key entirely rather than serializing it empty — there is no name field an anonymous response could accidentally carry.
GET /jobs/:slug/apply-formserves the questions a posting's application will ask, projected for reading byapplyform.Display— not the stored form, which keeps each platform's own field identifiers and option values so an autofiller can hand them back.- A 404 is the ordinary answer. Forms can be read from three ATS platforms, roughly a sixth of technical postings, so most of the catalogue has none and never will. Do not treat it as an error worth logging or alerting on.
- Two queries, not one join — mirroring
JobCopieson the same resource. An unknown slug fails atGetJobIDBySlug, a posting with no captured form atGetApplyFormByJobID. Both render 404, and keeping them distinguishable is the point: "this employer asks nothing" and "we cannot read this platform" are different statements.
- The bot mirrors Telegram's role but has one on/off switch, not two:
newDiscordHandlerswires the client only when all four env vars are set (DISCORD_BOT_TOKEN,DISCORD_APPLICATION_ID,DISCORD_PUBLIC_KEY,DISCORD_GUILD_ID— documented on theConfigfields inhandler.go, read from env ininternal/platform/config/config.go). Missing any one, the bot is fully inert:POST /me/discord/linkanswers 503,GET /me/discordreportsenabled: false, andPOST /api/v1/discord/interactions404s instead of attempting Ed25519 verification. - One-time setup once those four are set: point Discord's Interactions Endpoint URL at
POST /api/v1/discord/interactions, then rungo run ./cmd/discord-register-commandsto register/linkand/contributewith the guild. Discord caches guild command definitions server-side, so the running server never re-registers them itself — rerun the command only when a command's shape changes.
- Genuinely domain-specific status choices (e.g.
Mereturning 401 for a gone user token) stay in the handler. - Recovered panic is not double-reported (recover middleware marks it via
handler.LocalPanicReported). - Sentry reports only fall-through unexpected 500s — routine errors never reported.