Passive TLS client and server fingerprinting from packet captures. Two-crate
workspace: tlsfp-core (parsing and fingerprint logic, no I/O, unsafe forbidden)
and tlsfp (clap CLI). Edition 2024, MSRV 1.85.
Core (M0-M4): a hand-rolled bounds-checked TLS parser for ClientHello,
ServerHello, and the TLS 1.2 Certificate chain, plus the full fingerprint
family computed byte-exact against published vectors: JA3/JA3S (MD5), JA4/JA4_r,
JA4S, JA4H, JA4X, and JA4T. GREASE is stripped, wire order and unknown cipher
and extension values are preserved.
Pipeline (M5): a pcap and pcapng file reader behind a PacketSource trait,
etherparse L2-L4 decode (Ethernet, raw IP, BSD loopback, Linux SLL and SLL2,
VLAN and QinQ), and a hand-rolled per-flow TCP reassembler with wrapping serial
arithmetic, out-of-order parking, first-write-wins overlap resolution, and
bounded memory. The tlsfp pcap subcommand fingerprints every handshake in a
capture, as readable lines or NDJSON.
Verified three ways: 94 tests including unit KATs, nine real-pcap full-pipeline
KATs against the FoxIO sample corpus, and property tests that reassemble
arbitrarily segmented and reordered streams. Builds clean on stable and MSRV
1.85; clippy -D warnings and rustfmt clean.
Test captures under testdata/pcap are vendored unmodified from the FoxIO ja4
repository with per-file SHA-256 provenance.
build_module printed (ReleaseSafe) but ran plain 'zig build', which is Debug here (preferred_optimize_mode only ships ReleaseSafe with --release). It left a 20M Debug .so labeled as the shipped build, and 'zig build smoke' would reinstall Debug over any ReleaseSafe artifact via its install-step dependency.
Run build + test + smoke with --release=safe so the script produces and validates the real 5.9M shipped artifact end to end. Kept check-only (no dependency auto-install): missing deps still hard-fail/warn with the exact apt command.
Verified: ./install.sh exits 0 on a clean tree, final zig-out/lib/libhsm.so is 5.9M ReleaseSafe, tests + smoke pass, pkcs11-tool loads it.
learn/: add 00-OVERVIEW, 01-CONCEPTS, 02-ARCHITECTURE, 03-IMPLEMENTATION, 04-CHALLENGES, and MECHANICS (the byte-by-byte crypto deep-dive). CONFORMANCE shipped in M11. Real-breach grounding, function/file code refs (no line numbers), no AI voice.
Project README: flip M0-stale to real M0-M11 (21 mechanisms, std.Io.randomSecure, roadmap done through M12, learn/ link table).
Root README: graduate HSM to a completed project (Full Source Code 32->33, C badge -> Zig, title + footer now link to source and learn/).
Verified docs-only: zig build test and zig build smoke both exit 0, smoke mechanisms=21, objdump -T shows only C_GetFunctionList. No source changed.
go build ./cmd/healthcheck (run for verification, not via the bin/ recipe)
drops the binary in the working dir. Untracked previously; landed in the
last commit by accident. Gitignore now lists each cmd binary explicitly so
the same mistake can't recur for canary, healthcheck, or the two pdf/docx
template builders.
Three things that all blocked tunnel-start:
1. justfile: dropped set dotenv-load / set export — they pulled
.env.development into every recipe shell, where empty values
beat docker compose --env-file .env per shell-env precedence
(POSTGRES_PASSWORD was the visible casualty). Dev recipes now
pass --env-file .env.development explicitly so they're not
affected by the change.
2. canary image is distroless/static, so the wget-based healthcheck
had no binary to run and every check failed → nginx/tunnel never
started. Added cmd/healthcheck (tiny Go HTTP probe of /healthz),
built alongside canary, swapped compose healthcheck to /healthcheck.
3. Added tunnel-build and tunnel-rebuild recipes so the next person
doesn't have to remember which compose files the tunnel overlay
needs.
Operator request: give the webbug some personality. Trigger response is now
a small JPEG embedded into the binary via //go:embed, served from
backend/internal/token/generators/webbug/asset/pixel.jpg. Default is a
545-byte 32x32 yellow placeholder I generated with imagemagick — replace
the file with any image (same path, same filename, doesn't matter what
size or content) and the next backend rebuild picks it up. No other code
needs to change.
Scope: ONLY webbug. The docx, pdf, and envfile generators keep their own
Trigger functions that still call pixel.Clone() for the canonical
invisible 1x1 GIF — those types embed the trigger URL inside their
respective artifact bytes (a Word footer, a PDF /AA action, a fake .env
INTERNAL_METRICS_ENDPOINT line) and need the response to stay invisible
so the attacker doesn't notice they pinged us. Stealth preserved where
it matters.
The pixel package itself is untouched (still has the 43-byte GIF
constant); only webbug stops importing it.
Tradeoff baked in: a webbug whose response is a visible image is
*technically* less stealthy than the canonical 1x1 — the attacker can
see "huh there's a small image here." But the visible mechanism is real
too (think Mailchimp / SendGrid email-open tracking, which use sized
"social proof" images, not just 1x1s), so this is a legitimate variant.
For an operator who wants strict 1x1-invisible behavior in prod, the
revert is a 3-line change in webbug/generator.go (swap embed for
pixel.Clone() again).
Tests updated: webbug/generator_test.go no longer asserts gifByteLength
or pixel.Clone() equality; it now asserts the body starts with the JPEG
SOI marker (0xff 0xd8) + ContentType is image/jpeg. The
"independent-copy-per-call" invariant test still passes (bytes.Clone in
the Trigger ensures each Body slice is a fresh allocation).
Cache headers unchanged (no-store / no-cache / must-revalidate) so every
fetch hits the trigger handler.
Upstream 8d2599c0 added a mysqlEnabled parameter to token.NewHandler and
took a long line over the limit in handler_test. Pass the parameter from
the e2e stack (true: integration test exercises mysql token creation) and
reflow the long handler_test return so golines is happy.
Operator request: when MYSQL_FAKE_ENABLED=false (the dev default + the only
viable setting behind Cloudflare Tunnel since CF Tunnel doesn't carry raw
TCP), surface that in the UI so users can see the species but understand
they can't deploy it on this instance. Avoids the silent-broken case where
someone creates a mysql token, gets a connection_string artifact, points
their MySQL client at it, and just times out with no clue why.
Backend (3-line change):
- TypeDescriptor gains `Enabled bool` + optional `DisabledReason string`
- TypeDescriptors() now takes `mysqlEnabled bool`; mysql is the one whose
Enabled tracks the flag, all others are always true (they don't need
any deployment-level infra beyond HTTP)
- Handler stores mysqlEnabled, passes to descriptors call; main.go wires
cfg.MySQL.Enabled in. Handler tests updated to pass false explicitly.
Frontend (matching schema + visual treatment):
- typeDescriptorSchema picks up `enabled` (default true for backward compat)
and `disabled_reason` (optional string)
- TypeCard reads descriptor.enabled, sets `data-disabled` on the label,
disables the radio input, replaces the blurb with the disabled_reason
when present (so the user sees WHY rather than guessing), and renders
a small "disabled" pill in the top-right corner of the card
- SCSS: dashed border, muted palette, opacity 0.55, glyph faded, cursor
not-allowed, hover effect suppressed — reads as "shelf is here but the
specimen is checked out" rather than a broken button
In the operator UI: mysql card renders ghosted with the explanatory text
"Requires direct TCP exposure (port 3306). Not reachable via Cloudflare
Tunnel — only enable on a VPS with raw TCP access." Selecting it is
mechanically impossible (input is disabled), no form-level guard needed.
Flip MYSQL_FAKE_ENABLED=true in env when you stand up a VPS later and the
card re-enables with no other code changes.
Backend builds clean + token-package tests pass.
Frontend gates green: typecheck + biome + lint:scss + build (431ms).
Audit F23: integration tests covered repositories and individual handlers
but no test exercised the full chain. The PDF padding bug (F2) had passing
unit tests for years because the substring-only assertion masked the
embedded underscore padding.
New //go:build integration test in internal/server spins up Postgres via
testcontainers + miniredis, walks each of webbug, slowredirect, docx, pdf,
kubeconfig, envfile, mysql: POSTs /api/tokens, extracts the trigger URL
from the artifact bytes the way a victim would (regex for non-binary, ZIP
walk for docx, base64 decode for pdf), GETs the URL, then asserts the
manage view sees the event and the notifier fired. PDF subtest asserts the
byte after the embedded /c/<id> is not '_' — the structural check that
catches F2-style regressions.
Audit F3: validateURL checked scheme/host/userinfo but never resolved the
host, so an operator-supplied webhook_url could point at the canary's own
Redis (redis:6379), Postgres, link-local IMDS (169.254.169.254), or any
RFC1918 host on the docker network or VPS subnet — a classic confused-deputy
SSRF triggered by self-triggering a token after creation.
validateURL now resolves the hostname (or parses a literal IP) and rejects
loopback, RFC1918, CGNAT, link-local, multicast, unspecified, IMDS, and
IPv6 unique-local. The default HTTP client also installs a DialContext that
re-checks the dialed IP for defense-in-depth against DNS rebinding. The
Config gains an AllowPrivateHosts flag (default false) that test code opts
into when targeting httptest.NewServer.
Audit F2: the PDF placeholder was padded with underscores embedded directly
in the /URI path, so Acrobat fetched /c/<id>____________ and the chi route
extracted the padded id. Lookup returned ErrNotFound, the webbug fallback
served a 1x1 GIF, and event recording skipped because tok was nil. PDF
tokens silently never recorded events.
Part B (durable): pad inside a ?p= query string so the trigger path stays
canonical and chi extracts the real id. Part A (defense in depth): handler
TrimRight on _ so PDFs already in the field also resolve correctly. Added
two regression tests: byte-after-id assertion in the PDF artifact, and the
handler's trim path.
Audit F10: script-src 'self' blocked the Turnstile bootstrap script
(https://challenges.cloudflare.com/turnstile/v0/api.js) and its widget
iframe. Added challenges.cloudflare.com to script-src, connect-src, and a
new frame-src directive so the widget can render under prod CSP.
Audit F4: compose.yml already passed VITE_TURNSTILE_SITE_KEY as a build arg,
but vite.prod never declared an ARG/ENV for it, so Vite's import.meta.env
resolved to undefined. The Turnstile widget never rendered in prod, causing
the backend to reject every create-token request with TURNSTILE_FAILED.
Audit F1: prod.nginx had only the SPA fallback so all API requests, trigger
requests, and kubeconfig requests returned index.html, breaking the prod
deployment end-to-end. Added an upstream canary block to nginx.prod.conf and
three proxy_pass location blocks mirroring dev.nginx (plus CF-Connecting-IP
header since prod sits behind Cloudflare Tunnel).
Two tightly-coupled bugs visible after the PUBLIC_BASE_URL fix landed:
1. Trigger URLs now correctly show :22784 (the host-exposed nginx port),
but opening one in the browser rendered the SPA's NotFound page instead
of hitting the backend trigger handler.
Root cause: dev.nginx only had `location /` → frontend_dev. No location
block for `/c/`, `/k/`, or even `/api/`. Every request went to the SPA;
only `/api/*` happened to work because vite's internal proxy caught it
on its way through frontend_dev → canary. /c/* and /k/* (the trigger
paths) just fell through to the SPA fallback.
Fix: add `upstream canary { server canary:8080 }` to nginx.conf and
three new location blocks in dev.nginx for /api/, /c/, /k/. Each one
proxy_passes directly to the canary upstream with X-Real-IP +
X-Forwarded-For + X-Forwarded-Proto set so middleware.RealIP() resolves
the actual visitor IP rather than the docker bridge. Mirrors what
spec §12.3 prescribes for prod nginx.
2. Manage URL in responses still showed :8080 even after PUBLIC_BASE_URL
was set to :22784. Two separate config keys: canary.base_url
(PUBLIC_BASE_URL → trigger_url) and canary.manage_url (CANARY_MANAGE_URL
→ manage_url). The latter has no env-name overlap with the spec's
PUBLIC_BASE_URL, so it kept its default literal "http://localhost:8080".
Fix: in config.Load(), after unmarshal, fall manage_url back to base_url
when it's empty or still equals the bare default. Operators setting one
env var (PUBLIC_BASE_URL) now get both URLs computed off it. Explicit
CANARY_MANAGE_URL still wins if set — preserves the prod ability to
serve manage at a different domain than triggers. Extracted the literal
to a `defaultCanaryBaseURL` const so the default and the fallback
sentinel reference the same string.
After this + the prior PUBLIC_BASE_URL alias, a fresh token from the form
should produce trigger_url + manage_url both pointing at
http://localhost:${NGINX_HOST_PORT}/... — clickable, reachable, and triggers
hitting /c/ now route to canary and record events.
Operator action: just dev-down && just dev-up to rebuild nginx + canary
containers with the new config.
Spec §12.4, .env.example, .env.development, and dev.compose.yml all use the
name PUBLIC_BASE_URL for the externally-reachable URL stamped into trigger/
manage links. But the koanf env-key map in config.go only routed
CANARY_BASE_URL → canary.base_url. So setting PUBLIC_BASE_URL had no effect,
the default fallback "http://localhost:8080" silently won, and every issued
token came back with trigger_url/manage_url like
"http://localhost:8080/c/<id>" regardless of where canary was actually reachable.
Visible to the operator as: trigger/manage URLs in the SPECIMEN ISSUED dossier
pointing at :8080 (where canary doesn't even listen externally — host port is
nginx ${NGINX_HOST_PORT}, defaulting to randomised 22784).
Fix is one line: add PUBLIC_BASE_URL alongside CANARY_BASE_URL in the env-key
map. Both env names now route to canary.base_url. Backwards-compatible —
existing CANARY_BASE_URL deployments keep working.
Operator action: set `PUBLIC_BASE_URL=http://localhost:${NGINX_HOST_PORT}` in
.env.development (e.g. http://localhost:22784) and restart canary. URLs will
then reflect the actual reachable address.
Backend serializes `Events []Event` as JSON `null` when the slice is nil
(Go's zero value for slices). Spec §8.4 shows it as an array — stale spec,
verified actual behavior with `curl http://localhost:22784/api/m/<uuid>`
returning `"events": null` on a freshly-created token with no triggers.
Zod schema was `events: z.array(eventResponseSchema)` — failed to parse the
null, threw PARSE_ERROR, manage page rendered "CANNOT REACH ARCHIVE" instead
of the empty dossier.
Fixed: `.nullish().transform(v => v ?? [])` — accepts null/undefined and
normalizes to []. Manage page's `events.length === 0` empty-state check
("No events recorded yet") now reachable; consumers always see an array.
Caught by memory rule feedback_verify_data_shapes.md: trust reality > spec.
I trusted §8.4's example object during Phase 14 schema design instead of
hitting the live endpoint. Same kind of drift as the four Phase-14 spec-vs-code
divergences cleared at the time — this one slipped through because
TokenManagePage was never end-to-end exercised pre-UI.
Two latent template-leftover bugs in vite.config.ts proxy that 404'd every
useTokenTypes/useCreateToken/useManageToken/useDeleteToken call:
- Default target was http://localhost:8000. Backend listens on :8080
(backend/config.yaml `server.port: 8080`, default registered in
internal/config/config.go). Off-by-one-port silent for the whole
Phase 14 cycle because Phase 14 didn't end-to-end-test.
- `rewrite: (p) => p.replace(/^\/api/, '')` stripped /api before forwarding.
But backend mounts routes UNDER /api via `r.Route("/api", ...)`
(cmd/canary/main.go:322). So a request for /api/tokens/types got rewritten
to /tokens/types, which backend doesn't serve — 404 page not found.
Frontend axios uses baseURL `/api` and path `/tokens/types` → full URL
`/api/tokens/types`. With these fixes the proxy now passes that through
verbatim to http://localhost:8080/api/tokens/types, which is where chi
actually mounts the route.
dev.compose.yml: added VITE_API_TARGET=http://canary:8080 to the frontend
container's env so the dev-compose path also works (inside the container
"localhost" is the vite container, not the canary container — has to use
docker DNS).
Surfaced today when the operator manually eyeballed the landing page and
SpeciesSection rendered "Could not load species catalog. Try again in a
moment." (the descriptorsError branch in landing/index.tsx SpeciesSection).
System stack was the floor; this is the intended pairing. Three roles, three
voices:
- Archivo Black (display) — the big confident archive voice. CANARIES,
TRAP TRIPPED, DOSSIER NOT FOUND, SPECIMEN ISSUED, NO SPECIMEN HERE. Single
weight 900 black grotesque, wide letterforms, built for huge display. Pairs
visually with the PARADISE-style pixelated typography in the
vector-minimalism reference folder.
- Space Grotesk (sans) — speaking voice. Body, section titles, blurbs,
button labels, error text. Geometric joints + open apertures read as
"designed" where system extra-bold read as "just heavy." Weights 400-700
cover the range; section titles dropped 800→700 since Space Grotesk caps
at 700 (no perceptible difference in the design).
- Departure Mono (mono) — technical scribblings. SerialBar, timestamps,
field labels, strip pills, ID codes, all the spec-sheet micro-text. Its
pixelated character is the deliberate echo of image 1 (PARADISE) that
ui-monospace can't make.
Loaded via Google Fonts <link> in index.html (preconnect + display=swap),
no npm dep. ~50KB cold cache; cached-warm thereafter. System sans / system
mono remain in the fallback chain at the end of each stack so a CDN miss
degrades to the previous design without breaking.
$font-display token added to _fonts.scss for the giant headlines; $font-sans
and $font-mono updated to lead with the new families. Headlines previously at
font-weight: 900 dropped the weight (Archivo Black is single-weight), and
letter-spacing/line-height tightened slightly to match the new metrics.
Gates green: typecheck + biome + lint:scss + build (419ms; no chunk size
delta — fonts load from CDN, not the bundle).
Parallel audit (superpowers:code-reviewer + general-purpose spec adherence)
returned PASS-WITH-NOTES + PASS. All actionable SHOULD-FIX and NIT items
addressed in this commit per the fix-in-phase rule.
SHOULD-FIX (code review):
- Dead ternary at manage/index.tsx:253 (`cursor === undefined ? idx+1 : idx+1`)
collapsed to `idx + 1`. EventLogSection prop renamed `cursor` → `cursorIsSet`
(boolean) since the value itself was never consumed past the dead branch.
- URL.createObjectURL leak in artifact.tsx — both file (base64) and text
variants now run inside useEffect with cleanup that calls
URL.revokeObjectURL on dependency-change AND on unmount. Extracted into
two co-located hooks: useBase64ObjectUrl, useTextObjectUrl.
- Text inputs lacked <label htmlFor=…> association. Refactored the landing
form to use the existing Field primitives (TextField, TextareaField) which
wire htmlFor↔id via useId. This single change also closed the next finding.
- Dead infrastructure: Field primitives existed but no consumer used them.
Landing form's local FieldLabel removed; TextField/TextareaField now have
consumers. Net diff: −181 LOC.
- LeaderLabel: truly unused primitive, no foreseeable consumer in this scope.
Deleted (`frontend/src/components/LeaderLabel/`) and removed from barrel.
SHOULD-FIX (spec adherence):
- §8.6 row 410 → TOKEN_DISABLED: spec is stale. Backend Go ground truth
(verified via `grep StatusGone\|TOKEN_DISABLED backend/internal/`) emits
neither — disabled tokens come back as 200 with `token.enabled=false`,
which the DossierCard already renders with alarm-pill + HEADLINE_DISABLED.
Per feedback_verify_data_shapes.md: backend Go beats spec when they diverge.
No code change; documented here so the next AI doesn't re-open it.
- eventResponseSchema.extra: tightened from `z.unknown()` to
`z.record(z.string(), z.unknown())` to match backend's `json.RawMessage`
default of `{}` (event/repository.go:47). Added EventExtra type alias;
event-row.tsx now uses it instead of casting through unknown.
NIT:
- SerialBar: extracted `BAR_COUNT = 22` const; `FALLBACK_BARS` constant
string replaces the BAR_CHARS array that had length-9 / length-22
inconsistency between the empty-input and computed branches.
- Glyph: removed `default` branch — switch is now exhaustive over the
closed TokenType enum, so a future 8th type would be a compile error
rather than silently rendering WebbugGlyph.
- Dead `export { TOKEN_BLURB }` + `export { ArtifactDisplay }` from
landing/index.tsx — no observed consumer, dropped.
- App.tsx and main.tsx header banners normalized to the project-canonical
19 = signs (matches all other files in repo).
NOT addressed in this commit (intentional):
- CreateTokenRequest `metadata` as `z.unknown()`. Refactor to a discriminated
request schema keyed on `type` would tighten compile-time safety for any
future caller bypassing buildPayload — defer because (a) the form is the
only caller and validates via Zod on submit, (b) the change touches the
Phase 14 contract that was just stabilized two commits before this UI phase.
- NotFound (frontend route fallback) vs ManageNotFound (manage 404) live
separately. Both work; the two paths are intentional — off-path vs
off-dossier are semantically different events.
Gates green: typecheck + biome check + lint:scss + build (414ms; landing
chunk dropped 21KB → 19.93KB after dead style removal).
Landing route (/) is the token-creation page, framed as a "specimen
requisition" intake form per the vector-minimalism ethos. Mind: cataloging
a thing that resists cataloging — a trap baited for an unseen visitor.
Page composition:
- Top archive Strip (FIELD STATION / VOL / ISSUE pill)
- Hero block: large blocky "CANARIES" headline, Latin subtitle, purpose line
- Halftone separator
- Five sections (01 species / 02 annotate / 03 route / 04 species config / 05 verify)
- Bottom Strip with routing/license metadata
Form sub-sections (each a small component to keep complexity under biome's
maxAllowedComplexity=25 cap on FormView):
- SpeciesSection — 7-card grid via TypePicker (radio fieldset, sr-only inputs)
- AnnotateSection — memo textarea + conditional filename (when file-kind)
- RouteSection — Telegram | Webhook radio fieldset + conditional credentials
- ConfigSection — slowredirect destination_url or envfile include_keys
- VerifySection — Turnstile widget if VITE_TURNSTILE_SITE_KEY present
- SubmitFooter — release button + privacy note
Validation: client uses createTokenRequestSchema.safeParse, surfaces field
errors inline (mono caps in alarm color); server-side ApiError.fields are
mapped back into the same FieldErrors shape via buildSubmissionState (Phase 14
single-source envelope contract preserved).
Result view: SpecimenCard with full token dossier (DataRows), kind-discriminated
artifact display (url → CopyField; file/text → download Blob via object-URL;
connection_string → CopyField with note), Manage + Trigger CopyFields, link to
the dossier route /m/:manage_id (route wired in next chunk).
Turnstile: explicit-render widget, lazy script-load, registers token via
setTurnstileTokenProvider on success; cleans up on unmount; no key = no widget
(matches backend dev-bypass at internal/turnstile/verifier.go).
Gates green: typecheck + biome + stylelint + build (406ms, landing chunk
123KB / 38KB gzip).
Strip the inherited template aesthetic so the next AI/designer starts
from a true blank canvas. AI tunnel-vision pattern: when there's an
existing layout/sidebar/color palette, the model tends to redesign
in place rather than truly start fresh. Removing the visual scaffold
forces fresh-canvas thinking when operator-driven UI work begins.
Removed:
- pages/dashboard, pages/settings — template "Welcome / Available
Stores / Suggested Features" stubs that have no canary purpose.
- pages/landing/landing.module.scss, core/app/{shell,toast}.module.scss
— empty header stubs imported only for side effects.
- core/lib/shell.ui.store.ts — zustand store for sidebar open/
collapsed/theme state; entirely template-coupled. core/lib/index.ts
reduced to an empty barrel ready for canary stores.
- core/app/shell.tsx sidebar+nav+header layout; replaced with bare
ErrorBoundary > Suspense > Outlet. Keeps robustness pattern,
drops the navbar template a designer would otherwise inherit.
- App.tsx hardcoded dark Toaster theme + wrapping .app div.
- styles/_tokens.scss color palette ($bg-page, $text-default, etc.)
— biased toward a specific dark aesthetic. Spacing, typography,
weights, line heights, letter spacing, radius, z-index, transitions,
breakpoints, container widths all KEPT — neutral design primitives,
not aesthetic choices.
- styles/_reset.scss body bg-color: #fff / color: #000 defaults.
Designer chooses the palette; reset stays purely structural.
- styles.scss .app class wrapper.
- config.ts USERS endpoints, USERS query keys, STORAGE_KEYS,
HTTP_STATUS, PAGINATION — template constants with no canary
consumer. Reduced to ROUTES.HOME and QUERY_CONFIG (consumed by
query.config.ts). Designer adds back what they actually need.
Kept (logic / infrastructure / Phase 14 work):
- Full api/ and core/api/ — Zod schemas, axios client, hooks,
ApiError, QueryClient, retry strategies.
- main.tsx, index.html (already canary-titled with favicons in
public/asset).
- QueryClientProvider + RouterProvider + bare Toaster + ReactQuery
Devtools provider stack.
- Lazy-route + Suspense + ErrorBoundary pattern in shell.
- styles/_index.scss, _fonts.scss (system font stacks), _mixins.scss
(breakpoint utilities, flex helpers, sr-only, truncate, etc.) —
all neutral.
Gate: pnpm typecheck, pnpm biome check, pnpm lint:scss, pnpm build
all clean. Production bundle: 140-byte landing chunk (the <div />),
~37 KB index chunk (api/core/router/zod machinery), no leftover
template CSS.
Address audit findings S-1, S-3, S-4, S-7 pre-rollup (fix-in-phase).
S-1: Eliminate hand-rolled envelope error parser duplication.
Moved transformAxiosError out of core/api/errors.ts (where it
required a private parseEnvelopeError mirror of the Zod schema)
into api/client.ts where it now uses apiErrorEnvelopeSchema directly
via safeParse. core/api/errors.ts is now ApiError class + code
constants + module augmentation only, with no canary-envelope
knowledge — keeps layering core <- universal, api <- canary clean.
S-3: useManageToken query key now splays cursor/limit individually
instead of carrying the params object reference. Without this,
callers passing a freshly-allocated { cursor } each render would
miss the cache even with identical values.
S-4: unwrapEnvelope's PARSE_ERROR now includes the first Zod issue
(path + message) in the ApiError.message so operators see WHICH
field shape failed, not just that something did.
S-7: Add request interceptor rejection handler so request-phase
errors propagate via Promise.reject instead of throwing
synchronously.
Four hooks wired through the canary api/client envelope helpers.
Module-augmented defaultError surfaces ApiError everywhere.
- useTokenTypes: GET /tokens/types, parsed via typeDescriptorListSchema.
Uses QUERY_STRATEGIES.static (Infinity stale, no refetch on focus)
since the discoverable types list is build-time stable.
- useCreateToken: POST /tokens (CreateTokenInput -> CreateTokenResponse).
On success invalidates ['canary','manage', manage_id] so a freshly
created token's manage page hydrates with the new row on first hit.
- useManageToken: GET /m/{id} with cursor pagination. URLSearchParams
builds ?cursor=&limit= only for defined values; manageId is
URI-encoded. QUERY_STRATEGIES.frequent enables 30s polling so the
events list feels live. enabled=false when manageId is empty.
- useDeleteToken: DELETE /m/{id}. On success removes the cached
manage data entirely (no point keeping it after a delete).
- api/client.ts: axios instance with baseURL from VITE_API_URL env
(falls back to /api) and 15s timeout.
- Request interceptor adds CF-Turnstile-Response header from a
pluggable provider (setTurnstileTokenProvider). Backend's
middleware/turnstile.go reads header first then body field; using
the header keeps the request body shape clean and matches the plan.
- Response interceptor routes axios errors through transformAxiosError
(canary envelope-aware) so callers see typed ApiError everywhere.
- Envelope-aware helpers apiGet / apiPost / apiDelete:
- apiGet/apiPost unwrap {success:true,data:T} via successEnvelope
factory; on shape mismatch throw ApiError(PARSE_ERROR).
- apiDelete returns void for 204 responses; envelope error path
handled by the interceptor.
- Plumbed into api/index.ts barrel.
Code-review audit flagged firstSubdivisionName as exercised only
transitively through extractLookup. Adds five direct unit tests
covering the documented branches (nil/empty slice, English-name
prefers, ISOCode fallback, skip-empty-then-find-populated,
all-empty returns ""). Fix-in-phase per project convention; no
backlog rot.
openGeoIP opens cfg.GeoIP.Path and returns a (Lookuper, closer)
pair; on open failure (missing/unreadable mmdb) it returns
geoip.NopService() and a no-op closer so the rest of the wire-up
sees a consistent non-nil interface and the binary stays healthy
in environments without a MaxMind license. buildEventStack now
accepts a geoip.Lookuper and threads it into event.ServiceConfig.
Close is deferred in run() so the mmdb file handle releases on
graceful shutdown; a close-error is logged at warn (not fatal).
- entity: (*Event).AttachGeoIP(geoip.Lookup) sets Geo* nullable
pointer fields with overwrite semantics (empty/zero collapses to
nil so DB column stays NULL). Single-arg signature matches the
call shape in design spec §9 (line 1437) and keeps the helper
symmetric with the geoip.Lookup value type.
- service: ServiceConfig.GeoIP geoip.Lookuper plumbed through to
the Service.geo field. Record now calls enrichGeo(evt) BEFORE
s.repo.Insert so geo_country/region/city/asn/asn_org persist in
the same row. enrichGeo is nil-safe on three axes (geo, evt,
evt.SourceIP) — geo enrichment is best-effort, never an error
path.
- service_test: fakeLookuper + newSvcWithGeo helper; five new
tests (enriches-before-insert, no-geo-leaves-nil, empty-IP-
skips-lookup, NopService-leaves-nil, best-effort-when-insert-
fails). All pre-existing tests pass unchanged because GeoIP
defaults to nil.
- entity_test: seven AttachGeoIP unit tests covering populate-all,
empty Lookup, partial fields, zero/negative ASN guards, overwrite
semantics, and ASN-pointer independence from the input value.
- integration_test: two real-Postgres tests (stub Lookuper writes
populated geo_* columns; NopService leaves them NULL) verify
the wiring round-trips through the Insert SQL.
- internal/geoip: Service over oschwald/geoip2-golang v2 with City db
enrichment (Country/Region/Subdivision/City) and a NopService
factory for environments without the mmdb file. Nil-safe Lookup
returns a zero geoip.Lookup on any failure path (nil receiver,
nil reader, empty/malformed IP, reader error, !rec.HasData).
- internal/geoip: unexported cityReader interface allows whitebox
fake-based unit testing without bundling a real .mmdb fixture;
extractLookup is a pure function for synthetic-record coverage.
- config: GeoIPConfig{Path} koanf section; GEOLITE_PATH env binding
matching compose.yml; default /data/GeoLite2-City.mmdb.
- deps: github.com/oschwald/geoip2-golang/v2 v2.1.0.
ASN/ASNOrg fields are reserved on geoip.Lookup but stay zero with a
City-only mmdb; populating them is a future-extension concern (ASN
db is not fetched by scripts/init.sh per design §12.5).
- Rename admin.Handler.Register → RegisterRoutes for parity with
health.Handler.RegisterRoutes and token.Handler.RegisterAPIRoutes /
RegisterManageRoutes / RegisterTriggerRoutes. Spec §6.3 line 558
also illustrates the canonical wire-up as
`adminH.RegisterRoutes(api)`.
- Drop the local strItoa helper in handler_test.go; use strconv.Itoa.
The helper was a code-reviewer nit — equivalent stdlib already
exists; fewer lines, less surface to drift.
Both findings are non-blocking but covered by the fix-in-phase rule
(no backlog rot). Phase 12 audits otherwise PASS on first dispatch:
- superpowers:code-reviewer: PASS
- general-purpose spec-adherence: PASS
- cmd/canary/main.go: mountAdminRoutes helper keeps run() and
mountRouter under funlen 100. Admin routes mounted INSIDE the /api
subrouter so they inherit read-tier rate limit + CORS; gated by
OperatorBearer(cfg.Operator.Token). If OPERATOR_TOKEN is unset, the
entire mount is skipped (no Bearer middleware, no routes — admin
endpoints simply do not exist), and a structured warn log records
the reason. This is the production safety: misconfiguration leaves
the endpoint nonexistent rather than reachable.
- internal/admin/integration_test.go: testcontainers-backed end-to-end
tests exercising the full /api/admin/* stack:
* 404 on missing Authorization (no WWW-Authenticate leak)
* 404 on wrong token
* 200 on /stats with seeded mixed tokens + events
* 200 on /tokens?limit=2 with HasMore/NextOffset accuracy
* 204 on disable + DB-level verification token.Enabled flipped
* 404 on disable for a missing id
All requests go through the OperatorBearer middleware to verify the
wire-up matches the unit behavior.
- internal/admin/handler.go: replaces unused template stats handler
with the canonical Phase 12 endpoints from spec §8.1:
GET /stats — tokens_count, events_count, by_type,
by_alert_channel
GET /tokens — offset-based pagination, default 50,
cap 100; returns full token.Response
list with trigger/manage URLs via
injected URLBuilder; 400 BAD_PARAM on
negative/non-int offset
POST /tokens/{id}/disable — 204 on success; 404 NOT_FOUND envelope
when token.ErrNotFound; 500 envelope
on other repo errors
Handler takes TokenRepository + EventRepository + URLBuilder
interfaces (test seam); wire-up supplies *token.Repository,
*event.Repository, *token.Service (which already implements
TriggerURL/ManageURL).
- internal/admin/dto.go: Stats, TokenListPage, TokenListResponse.
- token.Repository.CountByType / CountByAlertChannel: GROUP BY type
and GROUP BY alert_channel; returned shapes typed as TypeCount /
ChannelCount with json+db tags for direct JSON emission.
- event.Repository.CountAll: global event count.
- Handler tests cover happy path + 500 propagation for each repo
dependency; pagination (default/limit-cap/offset paging); 400 on
bad offset; 404 on disable miss; 405 on GET to disable endpoint
(sanity check that POST-only routing is intentional).
- internal/middleware/operator_bearer.go: returns 404 on miss (NOT 401)
per spec §11.5; subtle.ConstantTimeCompare for timing safety; rejects
when configured token is empty (defense-in-depth even though wire-up
skips mount in that case).
- Table-driven test covers: missing header, empty header, wrong scheme
(Basic), case-sensitive scheme (lowercase bearer), no payload,
no-space-after-Bearer, equal-length wrong token, different-length
wrong token, correct token, empty-config rejects both empty and
populated headers, extra-whitespace rejection. Also asserts no
WWW-Authenticate header on 404 (endpoint-hiding intent).
- config.OperatorConfig + OPERATOR_TOKEN env mapping; default empty
(mount is skipped when empty).
Both audits flagged buildPage as a non-blocking but worth-cleaning nit:
it re-derived next_cursor from len(events) and the last event's ID
rather than using the HasMore + NextCursor flags that
event.Repository.ListByToken already computes via the LIMIT+1 peek
trick. Both calculations agreed today, but the duplication would
mask a divergence if the repo's pagination logic ever evolved
(e.g. switched to opaque token cursors).
Inline buildPage into gatherManageData and use list.HasMore +
list.NextCursor as the authoritative signals. Drops the buildPage
helper. Behavior identical; tests still pass.
- cmd/canary/main.go: tokenH.RegisterManageRoutes(api) mounts GET +
DELETE /m/{manage_id} inside the /api subrouter (read-tier rate
limit applies; no Turnstile, no create-tier limits — read-style
endpoints by spec §8.1).
- Extract buildHTTPDeps to keep run() under the funlen ceiling (was
102, now under 100). Pulls out registry build + tokenSvc + verifier
+ healthH + tokenH construction. Returns the four handles run()
needs to wire the router and shutdown path.
Integration tests (internal/event/integration_test.go,
testcontainers-Postgres + miniredis):
- TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount:
POST creates a webbug, three triggers from the same CF-Connecting-IP
fire (one notification, two deduped per the Phase 10 dedup gate);
GET /api/m/{manage_id} returns token with TriggerCount=3,
EventsTotal=3, EventsSilencedActive=2, three event records with
exactly one NotifySent and two NotifyDeduped, no next page.
- TestIntegration_ManagePageReturns404OnUnknownManageID
- TestIntegration_ManageDeleteCascadesEvents: delete via DELETE
/api/m/{manage_id}, verify CountByToken == 0 (FK ON DELETE CASCADE)
and the token is gone (token.ErrNotFound).
Adds the HTTP layer for spec §8.4. The UUID in the URL is the
capability — no other auth required, by design.
- token/dto.go: ManageTokenView (slimmer than Response — no manage_id,
no manage_url, no metadata, since the user is already on the manage
page and those would be redundant), ManagePage{NextCursor, HasMore},
ManageResponse{Token, Events, EventsTotal, EventsSilencedActive, Page}.
Token.ToManageView(triggerURL) builder. Events use the existing
event.Response (already matches spec shape exactly).
- token/handler.go: EventQuery + DedupCounter interfaces declared near
the existing EventRecorder + FingerprintRecorder; same dependency-
injection style. NewHandler signature gains the two new args (5 → 6;
test sites + main.go updated).
- GetManage(w, r): parses ?cursor= (int64, must be >= 0) and ?limit=
(defaults 20, capped at 100). Returns 404 envelope for unknown
manage_id (envelope, not bare http.NotFound, so frontend gets a
parseable error code). 400 BAD_CURSOR for non-numeric or negative.
Calls svc.GetByManageID + eventQuery.ListByToken + CountByToken +
dedupCounter.CountActiveDedup; assembles the manage payload.
- DeleteManage(w, r): 204 on success, 404 on miss. Cascade-delete is
via the existing tokens-events FK ON DELETE CASCADE.
- buildPage helper: NextCursor is the string of the last event's ID
iff len(events) == limit (matches spec example "next_cursor": "41").
Tests cover happy path, 404 on unknown id, 400 on bad/negative cursor,
?limit= respected, ?limit=999 capped at 100, DELETE happy + 404.
Adds the two service primitives Phase 11's manage handler needs.
- event.Service.CountActiveDedup(ctx, tokenID): Redis SCAN over
dedup:trigger:{tokenID}:* in batches of 100, sums (value-1) for each
key (value=1 = first trigger fired notification, value=N>1 = N-1
silenced). Returns 0 when no rdb is configured.
- token.Service.DeleteByManageID: thin wrapper around
Repository.DeleteByManageID; ServiceRepository interface gains the
method so test fakes can implement it (fakeRepo updated). Returns
ErrNotFound on no-rows.
Tests cover: empty pattern returns 0, first-trigger-only returns 0
(notify fired, nothing silenced), aggregation across IPs sums correctly,
other tokens' keys are excluded by the prefix match, nil rdb returns 0.
Audit catch (code-reviewer agent). formatGeo emitted literal `(` and `)`
around the geo block (e.g. `(Toronto, CA)`), but `(` and `)` are in the
MarkdownV2 reserved set when used outside `[text](url)` link delimiter
positions. Telegram would reject the entire message with
`Bad Request: can't parse entities: Character '(' is reserved`.
Fix: write the wrapping parens as `\(` and `\)` literals in the three
parens-construction switch arms. The dynamic field values inside still
flow through EscapeMD as before. The `[View full event timeline](url)`
inline link is left alone — its `(` `)` ARE link delimiters and per V2
rules must NOT be escaped (only `)` and `\` inside the URL portion need
escapes, and our manage URL has neither).
Regression test: TestSender_Send_MessageContainsKeyFields now asserts
`require.Contains(text, \\(Toronto, CA\\))` so substring matching can't
hide future regressions on the wrapping parens.
Closes deferred Phase-9 item 1 (testcontainers full-flow test) and
exercises the Phase 10 dedup gate, retention loop, and notify writeback
end to end.
internal/event/integration_test.go (build tag integration):
- TestIntegration_FullCreateAndTriggerFlow: spins up testcontainers
Postgres + miniredis, builds the same chi router shape main.go uses
(RequestID + Recovery + tokenH.RegisterTriggerRoutes + /api subrouter
with /tokens POST). POSTs a webbug create, then GETs /c/{id} twice
from the same CF-Connecting-IP. Asserts: only one notification fires
(capturingSender callCount == 1), both events recorded (CountByToken
== 2), one event marked NotifySent + one NotifyDeduped, dedup INCR
bumped the Redis counter to "2", trigger_count == 2 on the token.
- TestIntegration_DifferentIPsBothNotify: same setup, three different
IPs each fire their own notification (no cross-IP dedup).
- TestIntegration_DedupTTLExpiry: builds an event.Service with 50ms
DedupTTL, records two events (second deduped), miniredis.FastForward
past TTL, third record notifies again — proves the SetNX TTL is the
authoritative window boundary.
- TestIntegration_RetentionLoopPrunes: inserts 7 events, runs
RunRetentionLoop with limit=3, asserts the table converges to 3
rows (newest kept) and the loop exits cleanly on context cancel.
The capturingSender is a notify.Sender impl; this lets us assert
on whether the dispatch goroutine actually called Send() without
mocking the HTTP layer of the real telegram/webhook senders.
Closes deferred Phase-9 item 4. Phase 9's rollup added G107 + G704 to
the global gosec.excludes as a stop-gap so the Turnstile siteverify
call wouldn't flag SSRF taint. With Phase 10 introducing webhook
sender that takes user-supplied URLs (validated at the call site via
webhook.validateURL before the request is built), the global excludes
hide the very kind of issue we want flagged anywhere new outbound HTTP
lands.
- gosec.excludes: drop G107 + G704 globally; keep only G104 + G706
(the codebase-wide errcheck/subprocess policies).
- exclusions.rules path-scope G107|G704 to:
* internal/notify/telegram/sender.go (operator-config bot URL)
* internal/notify/webhook/sender.go (user URL with validateURL gate)
* internal/turnstile/verifier.go (operator-config siteverify URL)
- exclusions.rules path-scope G101 to internal/config/config.go where
the env-var-name map (WEBHOOK_HMAC_SECRET, TURNSTILE_SECRET_KEY,
OPERATOR_TOKEN, etc.) trips the hardcoded-credentials regex on
literal env var names — false positives, not actual secrets.
Any new client.Do(req) outside these three packages will surface
G107 at the call site and force the author to add URL validation.
Closes deferred Phase-9 item 8. Phase 9 promoted realIP / lastNonEmptyXFF /
OptionalHeader to internal/middleware and refactored webbug only; the
other generators kept their byte-identical local copies. Sweeping them
all over now while the touch surface is already open for Phase 10.
- docx, envfile, kubeconfig, pdf, slowredirect: drop the local
realIP / lastNonEmptyXFF / optionalHeader functions plus the
CF-Connecting-IP / X-Forwarded-For / X-Real-IP header constants.
Trigger paths now call middleware.RealIP / middleware.OptionalHeader
directly. Drops the unused "net" import from each.
- slowredirect/fingerprint_handler.go: same treatment for the
AttachFingerprint call site.
No behavior change — middleware.RealIP is byte-identical to what was
previously inlined per generator; verified by re-running the per-package
trigger tests post-refactor.
Swaps the Phase-9 directEventRecorder/mysqlEventRecorder shims for
real event.Service.Record via an eventRecorderAdapter. Constructs
notify.Service with telegram + webhook senders. Spawns the retention
loop goroutine off the main wait group; gracefulShutdown now also
notifySvc.Wait()s for in-flight notifications to drain.
Closes deferred Phase-9 items 2 (real event recorder), 3 (nested
create-tier rate limits), 5 (FingerprintRecorder wiring), 7 (drop
required tag on TurnstileResp).
- cmd/canary/main.go:
* buildEventStack constructs telegram + webhook senders and registers
them on notify.Service. event.Service wired with eventRepo (Store +
StatusWriter), tokenRepo (TokenIncrementer), redis client, notifier.
* eventRecorderAdapter and a single mysqlEventRecorder both delegate
to event.Service.Record(ctx, t.NotifyInfo(), evt) — one path,
same dedup + notify semantics for HTTP triggers and mysql triggers.
* fingerprintRecorderAdapter wraps event.Repository.AttachFingerprint
with the configured window (defaults to 5 min) so the slow-redirect
fingerprint POST writes through.
* spawnRetentionLoop runs event.Service.RunRetentionLoop on the
shared wait group; configured interval + per-token limit gate it.
* /api/tokens POST gains nested create-tier limits per spec §11.3:
LimitCreateMin (5/min) + LimitCreateHour (20/hr) chained ahead of
Turnstile, with their own fingerprint-keyed Redis namespaces so
they don't share buckets with the read-tier limiter.
* Shutdown order: server → telemetry → redis → db, then
notifySvc.Wait() to drain in-flight goroutines, then wg.Wait()
for retention loop + mysql listener.
- internal/config/config.go:
* NotifyConfig: dedup_ttl, send_timeout, max_tries, max_elapsed,
initial_interval, retention_interval, retention_limit,
webhook_hmac_secret, telegram_api_base, fingerprint_window.
* RateLimitConfig: create_min_rate / burst, create_hour_rate / burst.
* Defaults + env mappings (NOTIFY_*, RATE_LIMIT_CREATE_*, plus the
spec'd WEBHOOK_HMAC_SECRET).
- internal/token/dto.go: drop the `validate:"required"` tag on
cf_turnstile_response. The middleware (TurnstileVerify) is the sole
authority — it bypasses on empty SecretKey for dev mode and would
reject empty-token submissions in prod via the Verifier's own
ErrEmptyToken path. The validator tag was forcing tests to pass
a placeholder string in dev mode which conflicted with the bypass.
Audit found that registry.Build was calling mysql.New() with hardcoded
localhost:3306 defaults, ignoring cfg.MySQL.PublicHost and PublicPort.
Result: every emitted mysql:// connection string said
"mysql://canary_<id>@localhost:3306/internal_db" regardless of actual
deployment address — useless for any non-localhost deployment.
Fix:
- registry.Config: adds MySQLPublicHost + MySQLPublicPort fields
- registry.Build: switches between mysql.New() and
mysql.NewWithAddress(host, port) based on config
- cmd/canary/main.go: passes cfg.MySQL.PublicHost/PublicPort
through to registry.Build
- registry_test.go: new TestBuild_MySQLUsesConfiguredPublicAddress
asserts the connection string reflects configured host/port
Other Phase 9 audit observations DEFERRED to Phase 10 (not blocking
rollup):
- §11.3 create-tier rate limit on POST /api/tokens (Turnstile
already provides spam protection; tiered limits are
belt-and-suspenders)
- scoping the gosec G107/G704 exclude to specific packages
(revisit when Phase 10's webhook sender lands and needs
case-by-case URL validation)
- TurnstileResp dto required-tag clashes with dev-mode bypass
(low severity — current behavior is sound)
- /api/m/{manage_id} GET/DELETE (spec §8.1 lines 740-741) — Phase 11
- FingerprintRecorder is nil at startup (slowredirect fingerprint
handler wiring) — Phase 10
- mysql_username metadata write in token.Service (mysql trigger
lookup goes by token ID, not username — low impact)
Pre-rollup gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all pass (~50 new test cases)
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues
- grep -rn "//nolint" --include="*.go" returns nothing
Phase 9 tasks 9.7 + 9.8 + closes Phase 8 task 8.5 deferral.
config.Config additions:
- Canary {BaseURL, ManageURL} — public URLs for trigger
+ manage redirects
- Turnstile {SecretKey, SiteKey} — Cloudflare Turnstile
(empty SecretKey = dev bypass)
- MySQL {Enabled, Addr, PublicHost, — fake TCP listener config
PublicPort}
cmd/canary/main.go full wire-up per spec §6.3 + §11.1:
- Global middleware: RequestID → Logger → Recovery → SecurityHeaders
- /healthz: no further middleware (router-mounted directly)
- /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: mounted at
router root, OUTSIDE /api subrouter, so they skip CORS + rate
limit (spec §11.1 line 1552 — we want to record EVERY hit)
- /api subrouter: CORS → RateLimit (KeyByFingerprint, FailOpen=true)
- /api/tokens/types: no further middleware (read-only public list)
- /api/tokens POST: + TurnstileVerify middleware
- mysql goroutine: spawned via spawnMySQLListener iff
cfg.MySQL.Enabled; uses mysqlTokenLookup + mysqlEventRecorder
adapters bridging mysql.{TokenLookup,EventRecorder} to
token.Service + event.Repository
Adapter types in cmd/canary/main.go:
- registryAdapter: bridges registry.Registry (map type) to
token.Registry interface (Get method)
- directEventRecorder: bridges token.EventRecorder to
event.Repository.Insert + token.Repository.IncrementTriggerCount
(synchronous; Phase 10 swaps in event.Service for dedup + async
notify)
- mysqlTokenLookup, mysqlEventRecorder: same shape for the TCP
mysql handler
webbug refactor (task 9.8):
- Delete local realIP/lastNonEmptyXFF/optionalHeader copies (~30 LOC)
- Use middleware.RealIP and middleware.OptionalHeader instead
- Tests still pass (the IP-precedence suite in webbug_test.go
exercises middleware.RealIP behavior end-to-end via the Trigger
contract)
Pre-rollup gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues
- grep -rn "//nolint" --include="*.go" returns nothing
DEFERRED (not in Phase 9 scope):
- Phase 9 task 9.9 integration test (full POST /api/tokens against
testcontainers) — defer to Phase 10's broader integration suite
along with event.Service + notify.Service end-to-end coverage
- Phase 10 brings: event.Service with dedup + async notify, swaps
in for the directEventRecorder/mysqlEventRecorder adapters
- Phase 13 brings: geoip.Service wired into event recording
Phase 9 task 9.5.
Resolved a long-standing import-cycle constraint: the Generator
interface + Artifact + ArtifactKind + TriggerResponse types now live
at internal/token/contract.go (the token package, which both the
service and concrete generators can import). The
internal/token/generators/generator.go file now re-exports these
as Go type aliases for backwards compatibility — concrete generators
(webbug/slowredirect/docx/pdf/kubeconfig/envfile/mysql) keep their
existing `generators.Artifact` / `generators.KindFile` references
unchanged.
This unlocks token.Service.Create, which would otherwise be unable
to type-reference Artifact (since generators imports token for
token.Type/token.Token).
token.Service:
- Create(ctx, req, fp, ip) (*Token, Artifact, error):
- go-playground/validator on the request DTO
- type-specific metadata validation:
- slowredirect → metadata.destination_url required, must be
http:// or https://
- envfile → metadata.include_keys (if present) must be a
subset of {aws,stripe,github,db}; malformed JSON rejected
- others → no extra validation
- registry.Get(type) → ErrUnknownGeneratorType on miss
- generateTokenID: 12-char [a-z0-9] via crypto/rand
- uuid.NewString for manage_id
- call Generator.Generate (which may mutate t.Metadata for
mysql's mysql_username persist)
- repo.Insert; rollback semantics: if generator fails, no
persistence happens
- GetByID / GetByManageID: proxy to repo, return (nil, nil) on
not-found (so handlers can distinguish 404 from real errors)
- IncrementTriggerCount: proxy to repo
- TriggerURL / ManageURL: URL builders so handlers don't duplicate
path joining
- Generator(t Type): registry lookup for handler dispatch
Tests (~15 cases): ID + manage_id format regexes, repo persistence,
generator call, unknown type, validation failure, slowredirect
metadata variants (missing/invalid-scheme/valid), envfile include_keys
(invalid/valid), generator error path skips persistence, distinct IDs
across 30 calls, GetByID nil-nil semantics, URL builders.
Phase 9 task 9.2 + 9.3. Cloudflare Turnstile integration for
POST /api/tokens spam protection.
turnstile/verifier.go:
- Verifier{secret, verifyURL, client, rdb}
- Verify(ctx, token, fingerprint) error:
- empty secret → dev-mode bypass (return nil)
- empty token → ErrEmptyToken
- cache hit on "turnstile:fp:<fp>" → return nil (5-min TTL)
- else: POST to challenges.cloudflare.com/turnstile/v0/siteverify
with secret + response, decode, return ErrVerifyFailed on
false or wrap network err
- on success, write cache entry (best-effort, logged on error)
- RedisClient interface (Get + SetEx) so tests can use a fake
without miniredis dep
- HTTP client timeout 10s, defaults to DefaultVerifyURL constant
middleware/turnstile.go:
- TurnstileVerifier interface (Verify) decouples middleware from
the concrete verifier — lets tests inject a fake
- TurnstileVerify(v) middleware: extracts token from
CF-Turnstile-Response header OR JSON body's cf_turnstile_response
field (header wins); preserves request body for downstream
handlers by re-wrapping io.NopCloser; on verify failure returns
400 with {success:false, error:{code:"TURNSTILE_FAILED"}}
.golangci.yml: adds G104, G706, G107, G704 to gosec excludes.
G704/G107 are SSRF taint-analysis rules that fire on any HTTP client
.Do call where the URL came from a struct field. In this codebase
all outbound HTTP calls go to operator-configured endpoints (Turnstile
siteverify, future Phase 10 Telegram), with user-supplied webhook
URLs validated at the call site before reaching client.Do. The
global excludes are policy-level (matches the G104/G706 precedent
of "this codebase doesn't do X").
Tests (~14 cases):
- verifier: empty-secret bypass, empty-token error, success cached
by fingerprint, cache miss for different fingerprint, failed
siteverify returns ErrVerifyFailed, nil-redis works without
caching, network error wrapped (and NOT ErrVerifyFailed)
- middleware: header token passes, body token passes, body
preserved for downstream, failure returns 400 with code, header
wins over body when both present, empty-token still calls
verifier
Phase 9 first commit. Three middleware additions that the token
handler + service will consume.
- realip.go: RealIP(r) + OptionalHeader(v) + lastNonEmptyXFF —
promotion of the helpers currently duplicated across webbug/
slowredirect/docx/pdf/kubeconfig/envfile/mysql generators. The
bodies are byte-identical to those copies. Phase 9 task 9.8
refactors webbug to use this shared helper next; the other
generators' duplication remains until Phase 10's Trigger plumbing
refactor (Phase 9 keeps the scope to webbug per the plan)
- fingerprint.go: ExtractFingerprint(r) = hex(sha256(realIP|UA))[:16]
+ KeyByFingerprint(r) = "ratelimit:fp:" + fp, per spec §11.2
lines 1568-1591. The 16-char prefix is a deliberate space/
collision trade-off documented in the spec — full sha256 is 64
chars, 16 chars = 64 bits ≈ 1e19 keyspace, fine for fingerprint
rate-limiting where collisions are caught by the rate limit
granularity anyway
- recovery.go: Recovery(logger) middleware — defer recover(),
logs panic + request_id + path + stack, writes a 500 with the
project's standard {success:false, error:{code,message}}
envelope. Per spec §11.1 line 1543
Tests (~16 cases): 11 source-IP precedence cases matching the
generator suites + OptionalHeader empty/whitespace/value + fingerprint
determinism/length/hex-format/different-IPs-distinct + KeyByFingerprint
prefix + recovery panic-returns-500 + recovery passes-through-non-panic
+ recovery logs request ID through the RequestID middleware chain.
Phase 9 task 9.1 + 9.4 discharged.
Phase 8 third commit. Closes the generator set — all 7 token types are
now registered.
mysql/generator.go:
- Generator with publicHost / publicPort / database fields. New()
returns localhost:3306/internal_db defaults; NewWithAddress(host,
port) overrides for Phase 9 wire-up
- Generate writes mysql_username into t.Metadata (json.RawMessage
merge via setMySQLUsername helper — same defensive map[string]
json.RawMessage pattern as envfile's extractIncludeKeys: malformed
existing metadata is replaced rather than erroring, existing
fields preserved, prior mysql_username overwritten)
- Returns Artifact{Kind: KindConnectionString, ConnectionString:
"mysql://canary_<id>@<host>:<port>/internal_db"} per spec §9.7
lines 1345-1352. baseURL ignored (mysql connection strings don't
use it)
- Trigger(ctx, t, r) returns (nil, nil, ErrHTTPTriggerNotSupported)
sentinel error — mysql triggers fire over the TCP listener (see
server.go/handler.go), not via the HTTP router. Phase 9's router
will not mount any HTTP route to mysql.Trigger; this is defense
for accidental programmatic calls
- Note on spec §9.7 line 1343: `t.Metadata["mysql_username"] =
username` doesn't compile against the real Token.Metadata
(json.RawMessage). The setMySQLUsername helper is the sanctioned
*string-shape adaptation, matching the slowredirect/envfile
metadata patterns
mysql/generator_test.go (~11 cases):
- Type / KindConnectionString / default connection string format /
custom address / canary_ prefix invariant / metadata persistence
on empty token / preservation of existing metadata fields /
malformed-metadata fallback / stale-mysql_username overwrite /
baseURL ignored / Trigger returns ErrHTTPTriggerNotSupported with
nil event + nil response
registry update: cardinality 6 → 7; pending list now empty.
TestBuild_AllSevenGeneratorsRegistered replaces the
TypesPresentInPhaseN test pattern from prior phases, asserting all 7
generator types are present (closes the generator set; future phases
add services/handlers, not generators).
DEFERRED TO PHASE 9 (task 8.5): wiring `go mysql.Run(ctx, addr,
handler)` into cmd/canary/main.go. The handoff anti-relitigation set
documents "Phase 9 wires the registry into main.go" — the mysql
listener goroutine fits naturally into Phase 9's main.go work
alongside config.Config.MySQL.Enabled, config.Config.MySQL.Addr,
the token.Service (for TokenLookup), and the event.Service (for
EventRecorder). Wiring it in Phase 8 would require partial config
additions that Phase 9 then re-touches.
Pre-rollup acceptance gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all packages pass (~115 tests
added across Phase 8 alone)
- go test -tags=integration -race -timeout=300s pass
- golangci-lint run ./... → 0 issues.
- zero //nolint pragmas anywhere
Phase 8 second commit. TCP listener orchestration + per-connection
business logic.
server.go (~70 LOC):
- mysql.Run(ctx, addr, ConnectionHandler) error
Listens via net.ListenConfig (context-aware), accepts in a loop,
dispatches each connection to handler.HandleConnection in its own
goroutine, waits on context cancellation to close the listener
and drain in-flight connections via WaitGroup
- ConnectionHandler interface (single HandleConnection method) so
tests can substitute a stub without standing up real handler deps
- Logs listener bind + accept errors via slog; suppresses net.ErrClosed
on graceful shutdown so the shutdown path is silent
handler.go (~170 LOC):
- TokenLookup interface (GetByID) and EventRecorder interface (Record)
decouple the handler from Phase 9's token.Service and Phase 10's
event.Service. Production wire-up in Phase 9 (deferred — see Phase
8 rollup notes on task 8.5 deferral)
- HandleConnection writes HandshakeV10 → reads HandshakeResponse41 →
extracts username → strips canary_ prefix → looks up token →
records event with mysql_username + mysql_client_capabilities +
mysql_client_charset in event.Extra (mirrors kubeconfig's
kubectl_* forensic capture) → writes ERR_Packet 1045 → defers
close. 10-second connection deadline.
- Defense-in-depth: usernames without canary_ prefix dropped
silently, unknown tokens dropped silently, lookup errors dropped
silently — no behavior signal to a probing attacker
- Nil EventRecorder tolerated (writes ERR but skips event) — Phase 9
can wire the handler before event.Service exists if needed
Tests (~14 cases):
- server: nil-handler rejected, basic dispatch, 10 concurrent
connections, context cancellation closes listener cleanly,
invalid address rejected
- handler: handshake sent first, non-canary username drops silently,
known token records event AND sends ERR, event.Extra carries
all three kubectl-equivalent fields with correct hex formatting,
unknown token records nothing, lookup error silent, nil
EventRecorder tolerated (still sends ERR), bad auth packet
silent
- Uses real net.Listener + net.Conn pairs (not mocks) for
behavioral fidelity — TCP wire-protocol tests should hit real
sockets
go test -race -timeout=60s passes. golangci-lint clean.
Phase 8 first commit. Pure encoding/decoding layer for the MySQL
wire protocol — no I/O orchestration, no DB lookups, no business
logic. Three primary functions plus utility helpers, all driven by
the byte-exact spec §9.7 lines 1363-1403:
- BuildHandshakeV10(connID, authData[20]) []byte
Server's initial greeting packet (0x0a protocol version,
"5.7.40-canary" server version, random auth-plugin-data split
into 8+12 byte parts, capability flags 0xf7ff / 0x81ff,
utf8mb4_unicode_ci charset, mysql_native_password plugin name).
Wraps in 3-byte LE length + 1-byte sequence ID (0x00).
- ReadClientAuth(r io.Reader) (*ClientAuth, error)
Parses HandshakeResponse41. Returns the username (null-terminated,
extracted starting at byte offset 32 after the 4+4+1+23-byte
fixed header) plus the client's capabilities, max packet size,
and charset for forensic richness. Defensive bounds checking
against ErrInvalidPayload / ErrUsernameMissing / ErrShortPacket /
ErrPacketTooLarge sentinels. 64KB max packet size cap.
- BuildAccessDeniedErr(username, sourceHost string) []byte
Standard MySQL ERR_Packet 1045 with SQL state "28000" and the
exact "Access denied for user '%s'@'%s' (using password: YES)"
message kubectl and the mysql client both display verbatim.
Sequence ID 0x02 (after handshake=0 and client auth=1).
Plus crypto/rand-backed helpers NewRandomAuthData (20 bytes for the
challenge) and NewRandomConnectionID (uint32). Consistent with the
crypto/rand discipline from Phase 0 supplement, Phase 5 (pdf), Phase 7
(envfile).
Tests (~24 cases): byte-exact assertions on packet headers, payload
layout, sequence IDs; round-trip Build/Read for 5 username variants;
error paths (short packet, missing username terminator, empty reader,
oversize packet rejected mid-stream); randomness distinctness (50
calls → near-50 unique values).
Handler + server land in next commit; generator + registry in the
one after. Trigger interface conformance for the mysql Generator will
be a sentinel-error no-op (mysql triggers over TCP, not HTTP — Phase
9 router doesn't mount any HTTP path to mysql.Trigger).
go test -race -timeout=60s ./internal/token/generators/mysql/...
passes, golangci-lint clean.
Fifth entry in the registry map. Cardinality bumps 4 → 5; pending list
shrinks to {envfile, mysql} (2 remaining).
- registry.go: adds the import + token.TypeKubeconfig: kubeconfig.New()
- registry_test.go:
- TestBuild_RegistersKubeconfig added (mirrors RegistersPDF)
- TypeKubeconfig removed from TestBuild_PendingTypesNotYetRegistered
- TestBuild_OnlyExpectedTypesPresentInPhase5 renamed → InPhase6,
cardinality assertion bumped 4 → 5
Pre-audit gate clean at HEAD: go build ./... + go vet ./... + go test
-race -timeout=60s ./... + go test -tags=integration -race -timeout=300s
./internal/token/... ./internal/event/... + golangci-lint run ./...
all pass with 0 issues.
Trigger implementation completes the kubeconfig Generator's interface
conformance. Behavior matches spec §9.5 + §8.5 defense-in-depth:
- ALWAYS returns 403 + Kubernetes-shaped Status JSON (whether the
token is valid or not). Attackers cannot distinguish "valid
token, no permission" from "no such token" by status code or
response shape — both are 403 with byte-identical structure modulo
the resource/verb message slot.
- Status JSON conforms to k8s.io Status object: kind=Status,
apiVersion=v1, metadata={}, status=Failure, reason=Forbidden,
code=403, message=verisimilar real-kubectl error string.
- Message format mirrors kubectl's actual output:
<resource> is forbidden: User "system:anonymous" cannot <verb>
resource "<resource>" in API group "" in the namespace "default"
where <resource> is the last segment of r.URL.Path and <verb> is
derived from the HTTP method (GET/HEAD → list, POST → create, PUT
→ update, PATCH → patch, DELETE → delete, others → list). This is
richer than spec §9.5's hardcoded "list" — kubectl ships the verb
based on the action it's attempting, so a /apis/.../pods DELETE
response saying "cannot list" would tip the attacker off. Method
mapping is forensic gold for the operator (which kubectl action
actually fired) and verisimilar to the attacker.
- Event.Extra captures the kubectl_path / kubectl_method /
kubectl_query / kubectl_ua fields per spec §9.5 line 1234. UA
capture is the forensic prize — kubectl ships its version + arch
in the UA string ("kubectl/v1.30.0 (linux/amd64) kubernetes/...").
- Source-IP triplet (realIP / lastNonEmptyXFF / optionalHeader) is
a content-copy of webbug/docx/pdf — sanctioned duplication per
Phase 2/3/4 anti-relitigation set. Phase 9 middleware extraction
collapses these into one shared helper.
- Nil-token path returns the same 403+Status with verisimilar
message + headers, and nil event so the handler cannot persist
a row with empty TokenID (FK violation) — spec §8.5.
Tests cover (~36 cases including subtests):
- 403 + JSON content-type + cache headers
- Status JSON shape (Kind/APIVersion/metadata={}/Status/Reason/Code)
- Message resource extraction: pods, secrets, single-resource get,
API version probe, trailing slash, healthz probe (6 paths)
- Verb derivation from HTTP method (7 methods, 5 distinct verbs)
- User "system:anonymous" impersonation present
- kubectl_path/method/query/ua in Extra JSON
- Empty query string handled
- Source-IP precedence (11 subcases mirroring docx/pdf for parity)
- Missing UA/Referer → nil pointers
- Nil-token defense-in-depth: still returns 403 + parseable Status,
nil event, response-shape parity with valid-token case (HTTP code
+ JSON kind/version/status/reason/code all identical so an
attacker cannot probe token validity by diffing responses)
Generator now satisfies generators.Generator interface. Registry wire-up
in next commit.
Phase 6 first commit. Generator produces a real-looking kubeconfig
artifact that points kubectl at the canary's /k/{id} endpoint:
- text/template embedded YAML following the spec §9.5 layout (one
cluster + one context + one user, all referencing the same
prod-cluster + svc-backup-reader names for verisimilitude)
- APIServerURL = baseURL + "/k/" + t.ID (trailing-slash safe via
strings.TrimRight)
- Token = t.ID embedded as the user's bearer token — when kubectl
fires a request, the bearer in the Authorization header is exactly
this id, giving us a second lookup path beyond the URL path
- ClusterName = "prod-cluster", UserName = "svc-backup-reader" —
hardcoded per spec §9.5 lines 1193-1194 (tempting bait names that
look like a real prod service-account kubeconfig)
- Artifact.Kind = KindText (different from docx/pdf KindFile — YAML
is text)
- ContentType "application/yaml"
- Filename default "kubeconfig" with the standard *string deref+trim
pattern from docx/pdf
The template file is named template.yaml.tmpl (not .yaml) so the
repo's pre-commit check-yaml hook does not parse it as pure YAML —
the {{.X}} Go-template tokens are not valid YAML flow mappings and
would fail the parser. .tmpl is the conventional extension for
Go-templated source files; identify-cli (which check-yaml uses to
match files) does not classify .yaml.tmpl as YAML. Future phases
needing templated YAML (envfile recipes, etc.) should follow the
same convention.
Tests parse the rendered output through gopkg.in/yaml.v3 against a
typed schema struct, asserting every field flows through correctly:
APIVersion, Kind, current-context, Clusters[0].name + .cluster.server,
Contexts[0].name + .context.cluster + .context.user, Users[0].name +
.user.token. Plus Filename defaulting subtests, base-URL
trailing-slash trim, subpath preserve, distinct-ids → distinct
outputs, bearer-token-embedded check, ends-with-newline check.
Handler (Trigger) ships in the next commit per spec §9.5 file split
({generator, handler, template}). At this commit kubeconfig.Generator
has Type() + Generate() but no Trigger() — the package compiles in
isolation but does not yet satisfy generators.Generator interface.
The registry wires it in commit 3.
go test -race -timeout=60s ./internal/token/generators/kubeconfig/...
passes.
Adds the foundations tier: three Python projects pitched at someone
who has never written Python before, ramping from a single-file hash
identifier up to the cryptographic boundary of the beginner tier.
Projects
- hash-identifier: identify ~30 hash formats by prefix, length, and
charset (one file, ~30 tests).
- http-headers-scanner: fetch a URL and grade its security headers
A–F (httpx + rich + respx, single-file scanner, 13 tests).
- password-manager: Argon2id + AES-256-GCM encrypted CLI vault with
atomic durable writes and advisory file locking (the hardest
foundations project — stepping stone into the beginner tier).
Each project ships with
- Heavily-commented source as a teaching aid (foundations-tier override
of the no-comments rule — explanations targeted at first-time readers).
- A full learn/ folder: Overview, Concepts, Architecture, line-by-line
Implementation walkthrough, Challenges.
- README with ASCII banner, badges (incl. tier + difficulty), demo
sessions, command tables, learn-folder index, see-also links.
- install.sh + justfile + uv-managed dependencies.
- pytest + ruff + mypy + pylint config, all linters at 10.00/10.
Also centralizes the docs/ exclude in .gitignore (was a per-project
list of advanced/beginner docs paths) so dev-only audit reports and
plan documents stay local across the whole repo without needing
individual entries.
Phase 5 first commit. Adds the committed template.pdf binary blob plus
the pure-Go one-shot utility that produced it, matching the docx Phase
4 pattern (cmd/build<format>template/main.go excluded from production).
cmd/buildpdftemplate/main.go assembles a minimal PDF-1.4 by hand:
- Header: %PDF-1.4 + 4 high-bit bytes to mark as binary
- Object 1: Catalog → Pages root
- Object 2: Pages → single Kids ref + Count 1
- Object 3: Page with /AA << /O << /Type /Action /S /URI /URI (...) >> >>
where the URI value is the 76-char placeholder
HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH______________________________________
- xref table with computed byte offsets
- trailer + startxref + %%EOF
The placeholder is a direct dictionary value (NOT inside a stream), so
substitution can be a plain byte-replace at runtime without breaking the
cross-reference table — spec §9.4 line 1133. No FlateDecode anywhere.
pdfcpu (v0.12.1) is used for validation only — the builder calls
api.Validate before writing, and tests will call it again on substituted
output to confirm the PDF stays well-formed.
Verification at HEAD:
- 477-byte template.pdf
- sha256: 70c6359016ceb780539f8c4497991b98ff82201e35a38d36090d88856027e103
- Reproducible: rebuild produces byte-identical output
- grep -aob HONEY_TRACK_URL → one offset (240)
- file: PDF document, version 1.4, 1 page(s)
- api.Validate passes
go.mod adds pdfcpu as a direct dep with its transitive deps via go mod
tidy. golang.org/x/crypto returns to direct (pdfcpu uses it for AES
encryption support).
The standing "no //nolint anywhere" rule (handoff anti-relitigation
set) was honored by Phases 2-4 but inherited template code in
core/database.go, middleware/ratelimit.go, and health/handler.go
carried 6 pragmas from the original template import. Replacing each
with proper error handling rather than silencing the linter:
core/database.go
NewDatabase ping-failure path: propagates db.Close() error via
multi-%w fmt.Errorf rather than discarding it via _ = db.Close().
Pattern matches the existing rollback error wrap on line 102.
InTx + InTxWithOptions panic-recovery defer: rollback failure now
logs via slog.Error before re-panicking, rather than discarding
the error via _ = tx.Rollback(). The original error context is
preserved by the panic; the rollback failure is observable.
jitteredDuration: switches from math/rand/v2 to crypto/rand +
math/big. The function runs once per pool init at startup so the
per-call cost (~microseconds) is irrelevant. Eliminates the gosec
G404 trigger without a global exclude (handoff anti-relitigation:
"don't add new excludes for one-off issues"). Adds the missing
maxJitter <= 0 guard that the rand.Int64N call would have panicked
on with a base smaller than 7ns.
middleware/ratelimit.go
writeRateLimitExceeded: json.NewEncoder().Encode error is now
logged via slog.Error rather than discarded. slog is already
imported and used elsewhere in the file (line 60).
health/handler.go
writeStatus: same pattern; adds log/slog import. Brings the file
in line with the rest of the codebase's slog-everywhere discipline.
After: zero //nolint pragmas anywhere in backend Go code
(grep -rn "//nolint" returns nothing). golangci-lint run ./... reports
0 issues. All unit tests pass under -race; all integration tests pass
under -race -tags=integration with the testcontainer Postgres.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing the Phase 0 lint-discipline alignment so Phases 5+
inherit a fully consistent codebase.
Auth-template residue in core/errors.go + core/response.go:
- UnauthorizedError() / ForbiddenError() + Err{Unauthorized,Forbidden}
sentinels
- TokenExpiredError() / TokenInvalidError() / TokenRevokedError() +
Err{TokenExpired,TokenInvalid,TokenRevoked} sentinels
- core.Unauthorized() / core.Forbidden() response wrappers
Zero call sites anywhere in the codebase. Worse, the Token*Error names
semantically collide with canary tokens (same package, completely
unrelated meaning) — a future-reader trap that would compound once
Phase 9 wires real operator-bearer auth gates on the admin handler.
Generic AppError plumbing (NewAppError, NotFoundError, DuplicateError,
ValidationError, InternalError, RateLimitError, IsAppError, GetAppError)
is preserved — all in active use.
When Phase 9 needs operator-bearer or turnstile-gated responses, helpers
can be reintroduced with names appropriate to the actual auth design
rather than carrying generic JWT-shaped vocabulary forward into a
canary-token namespace.
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared per
the fix-in-phase rule. BACKLOG closed section records the resolution.
internal/core/security.go was preserved through Phase 0's auth prune
untouched. Audit: zero callers anywhere in the codebase, yet the file
kept golang.org/x/crypto as a direct dep and ran a full Argon2id KDF
on every process start via a package-level init() that pre-computed
a dummy hash "to prevent timing attacks" — wasted boot CPU for an
unused codepath.
Deleted symbols:
- HashPassword, VerifyPassword, VerifyPasswordWithRehash
- VerifyPasswordTimingSafe + dummyHash init()
- decodeHash, needsRehash internal helpers
- GenerateSecureToken, GenerateRefreshToken
- HashToken, CompareTokenHash
go mod tidy demotes golang.org/x/crypto from direct to indirect
(pgx/v5 still pulls it transitively).
Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing Phase 0's auth prune rather than logged as deferred
work. BACKLOG closed section records the item + resolution.
Phase 4 audit (2 agents) returned PASS with three NITs. N1 + N2 cleared
in-phase per the standing fix-in-phase rule. N3 was stylistic-consistency
positive and not actionable.
N1 — generator_test.go IP-precedence subtests: added two missing cases
present in webbug's identical suite (the realIP helper triplet is byte-
identical to webbug, so this is symmetry rather than coverage):
- "RemoteAddr loopback IPv6 strips brackets and port" → "[::1]:9999"
- "XFF IPv6 rightmost" → "198.51.100.1, 2001:db8::dead"
N2 — generator.go patchTemplate loop: the second `cErr := ...` shadowed
nothing but reused the name from the close-error block earlier in the
same iteration. Renamed the header-create variant to `hErr` for clarity.
Audit verdicts:
- superpowers:code-reviewer: PASS (0 B / 0 S / 3 N)
- general-purpose spec-adherence vs §9.3 / §8.5: PASS (0 violations,
6/6 invariants MATCH, BACKLOG.md still empty)
Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word
document by embedding template.docx and runtime-substituting the
HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token
trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so
Word/LibreOffice still opens the result; non-footer entry bodies are
byte-identical to the template.
Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx",
Content: <patched zip>, ContentType: wordprocessingml MIME}. The
spec §9.3 reference snippet uses `t.Filename == ""` which won't
compile against the real schema — Token.Filename is *string. The
generator dereferences safely via resolveFilename, trimming and
falling back to the default for nil/empty/whitespace pointers.
Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte
transparent GIF via pixel.Clone() + cache-control no-store + pragma
no-cache. Nil-token returns the same response with nil event
(spec §8.5 — no token-existence enumeration). realIP /
lastNonEmptyXFF / optionalHeader helpers are copied from webbug per
the standing rule (sanctioned duplication until Phase 9 middleware
extraction).
24 test cases including the six prescribed by implementation plan
§4.3 (OutputIsValidZip, FooterContainsTriggerURL,
FooterDoesNotContainPlaceholder, OtherEntriesUnchanged,
PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding
correctness/regression coverage (type, kind, content type, filename
defaulting incl. whitespace, trigger-URL trailing-slash + subpath +
uniqueness, source-IP precedence with 9 subtests mirroring webbug,
GIF body independence per Trigger call, nil-token defense).
Also fixes pre-existing lint debt in cmd/builddocxtemplate from
commit f36cce9a: errcheck on the deferred f.Close (now propagates
via named return) and gosec G304 on os.OpenFile (now filepath.Clean
on the operator-supplied -out path). Rebuilding template.docx after
this change produces byte-identical output (verified via sha256sum).
Phase 4 Task 4.1+4.2 — pure-Go OOXML template builder under
backend/cmd/builddocxtemplate/. Run:
go run ./cmd/builddocxtemplate \
-out ./internal/token/generators/docx/template/template.docx
produces a minimal valid .docx (5 zip entries, mixed STORE/DEFLATE
compression) whose word/footer2.xml carries an INCLUDEPICTURE field
referencing the literal placeholder string HONEY_TRACK_URL with the
\\d switch (forces fetch-on-open, no local cache).
The committed template.docx is the embedded source for the Phase 4
docx generator (next commit). Mixed compression methods in the template
make Task 4.3's TestGenerate_PreservesCompressionMethods a meaningful
regression guard — a hardcode-DEFLATE generator would now mismatch on
[Content_Types].xml + word/_rels/document.xml.rels (both STORE).
cmd/builddocxtemplate is excluded from the production binary (separate
cmd/ subdir from cmd/canary).