POST /c/{id}/fingerprint handler that decodes a JSON fingerprint body
and merges it into the most recent event's Extra JSONB for the same
(token_id, source_ip) tuple within a 30s window via the existing
event.Repository.AttachFingerprint method. Decoupled from the concrete
repository through a small FingerprintAttacher interface so future
event.Service can swap in without touching this package.
Per spec §9.2 the fingerprint POST is enrichment-only: a missing match
(event.ErrNotFound), an invalid JSON body, an empty body, and a body
exceeding 64 KiB all silently return 204. Only unexpected repository
errors are logged via slog. The token id is read from chi.URLParam("id")
so the route mounts naturally under the existing trigger router in
Phase 9.
Three integration tests against testcontainers cover the happy-path
JSONB merge, the no-matching-event path (silent 204), and the
invalid-JSON path (204 plus stored Extra left untouched).
HTML+JS browser-fingerprint redirect page rendered via html/template:
Generate reads metadata.destination_url off the token row and returns
KindURL with the trigger URL + persisted destination; Trigger renders
the embedded template (noscript meta-refresh + inline fetch posting
the fingerprint, then window.location.replace), returns the page with
Content-Security-Policy override allowing inline script + connect-src
'self', and emits the event capturing token id / source IP / UA /
Referer.
Nil-token Trigger returns nil event (FK guard) plus a 404 HTML body,
matching the established defensive-rendering pattern from webbug.
Destination_url is extracted defensively (missing/empty/whitespace
all surface ErrMissingDestination). realIP precedence copied from
webbug pending the Phase 9 middleware promotion.
19 unit-test assertions cover Type, Generate's URL+destination output,
five missing-destination edge cases, XSS neutralization in two
injection contexts (attribute escape + script-closing escape), CSP
override + cache headers, event-recording metadata, nil-token contract,
and per-call response independence.
Phase 2 of the 18-phase plan. Lands the plugin scaffolding (Generator
interface, shared transparent-pixel package, generator registry) and the
first concrete generator (webbug). Phase 2 is intentionally the smallest
phase — subsequent phases add slowredirect, docx, pdf, kubeconfig,
envfile, and mysql generators against the same Generator interface.
Implementation commits (oldest → newest):
6cc724c1 feat(canary): Generator interface for token-type plugins
0b6e586e feat(canary): shared 43-byte transparent GIF for image triggers
9c0ad749 feat(canary): webbug generator (Generate + Trigger)
bb95f745 feat(canary): generator registry (token.Type → Generator)
c39b56b9 fix(canary): clear pre-audit lint debt + migrate golangci config to v2
1a240952 fix(canary-phase2): address audit findings before rollup
Deliverables vs. plan:
- Generator interface (spec §6.2): Type/Generate/Trigger contract with
ArtifactKind url|file|text|connection_string, Artifact discriminated
by Kind, TriggerResponse value type so generators stay pure.
- Pixel package: 43-byte canonical GIF89a literal exposed via
pixel.Clone() (immutable source, independent slice per call) +
pixel.Len() helper + ContentType const.
- Webbug generator (spec §9.1 + §8.5): Generate returns KindURL
artifact with baseURL/c/{id} (trailing slash trimmed). Trigger returns
200 + image/gif + 43-byte pixel + no-store cache headers.
realIP precedence: CF-Connecting-IP > X-Forwarded-For (rightmost
non-empty) > X-Real-IP > net.SplitHostPort(RemoteAddr). Handles
IPv4/IPv6 (including bracketed form). Empty XFF entries (trailing
comma, all-empty list) fall through to the next source. Nil token
returns nil event + non-nil GIF response — the contract is explicit
in the return shape, so the future Phase 9 handler cannot accidentally
insert an event row with empty TokenID (which would fail the events
→ tokens FK anyway).
- Generator registry: lives in backend/internal/token/generators/registry/
(sub-package, NOT the generators package itself) to break the
generators → webbug → generators import cycle the implementation plan
inadvertently created. registry.Config + registry.Registry +
registry.Build. Phase 2 registers webbug only; cardinality test
asserts exactly 1 and enumerates the 6 future types as absent.
- Cross-cutting cleanup (c39b56b9): pre-audit gate found Phase 0/1
golangci-lint debt the prior phase audits didn't catch. Per
fix-in-phase rule, cleared all 14 (5 errcheck, 6 golines, 1 funlen
via run() → run+initTelemetry+mountRouter+gracefulShutdown split,
1 govet shadow, plus the 1 Phase-2-introduced funlen-on-test that
the broken .golangci.yml v1-syntax exclude-rules failed to suppress
under golangci-lint v2). Also migrated .golangci.yml to v2 schema
(linters.exclusions.{paths,rules}) and added gosec G706 to excludes
(false-positive log-injection on slog structured-logging call sites).
Phase 2 audit — TWO agents in parallel per standing pattern:
superpowers:code-reviewer → PASS (9 findings, all cleared in-phase
by commit 1a240952)
general-purpose (spec-adherence)→ PASS (every spec contract item
verified; 3 known deltas
accepted: Type() refined to
token.Type, registry moved to
subpackage to break cycle,
optionalHeader bridges
plan-vs-schema pointer types)
Findings cleared in-phase (from code-reviewer):
MEDIUM — realIP fallback returned "IP:port"; now net.SplitHostPort
MEDIUM — XFF rightmost empty entry skipped fallback; now walks right-
to-left and falls through cleanly
MEDIUM — pixel.TransparentGIF was exported mutable []byte; now
unexported + pixel.Clone() per call
LOW — nil token returned non-nil event with empty TokenID; now
returns nil event (explicit contract)
LOW — no IPv6 coverage in IP precedence tests; added 3 IPv6 cases
+ XFF IPv6 + port-less RemoteAddr
NIT — gracefulShutdown swallowed shutdown errors; now errors.Join
Accepted as-is (NIT, Phase 3+ concern):
NIT — Artifact discriminated-union may want sealed-interface
refactor once non-URL kinds land
NIT — Registry exposed bare map (acceptable: read-only post-Build,
Go memory model permits concurrent map reads with no writes)
NIT — registry.Build accepts but ignores its Config (signature
reserved for future stateful generators like mysql)
Forward-looking notes for Phase 3 (NOT deferred items, just heads-up):
- Phase 3's main.go wire-up cannot copy spec §6.3's
`generators.BuildRegistry(cfg.Canary)` verbatim; the call is now
registry.Build(registry.Config{...}) and the cfg.Canary field will
need to be declared on config.Config.
Deferred items: NONE. BACKLOG.md open section remains empty.
Quality gates (final, post-fix):
go build ./... OK
go vet ./... OK
go test -race ./... PASS (4 packages, 20 unit tests)
go test -tags=integration -race PASS (token + event integration,
~12s testcontainers)
golangci-lint run ./... 0 issues
Audited: PASS.
Two audit agents (code-reviewer + spec-adherence) both returned PASS but
flagged substantive findings. Per fix-in-phase / no-backlog-rot, clearing
every MEDIUM + LOW in-phase.
Findings addressed:
MEDIUM — realIP RemoteAddr fallback returned "IP:port" verbatim, where
the spec wants the bare IP (geoip + downstream parsing assume host-only).
Now uses net.SplitHostPort with raw-string fallback if not host:port.
Adds 4 RemoteAddr cases: IPv4 strips port, IPv6 bracket form strips
brackets+port, loopback IPv6, and the port-less raw fallback.
MEDIUM — realIP XFF branch accepted whatever rightmost-comma-split
produced, so headers like "198.51.100.1, " (trailing comma) yielded
"" and skipped XRI/RemoteAddr entirely. Extracted into lastNonEmptyXFF
which walks right-to-left and falls through cleanly. Adds two cases:
trailing-comma falls through, all-empty entries fall through.
MEDIUM — pixel.TransparentGIF was an exported mutable []byte; any caller
could clobber it process-wide (mutating one response's body would affect
every subsequent webbug trigger). Renamed to unexported transparentGIF
+ exposed pixel.Clone() and pixel.Len(). Webbug now calls pixel.Clone()
per trigger; new test TestTrigger_ResponseBodyIsIndependentCopyPerCall
verifies two triggers return independent slices.
LOW (reviewer-escalated to HIGH-for-Phase-3) — Trigger on nil token
returned a non-nil event with empty TokenID. Since events.token_id is a
NOT NULL FK to tokens.id, the Phase 3 handler could not have persisted
that event anyway, and the contract was implicit (would have required
an inline comment, which the no-comments rule forbids). Now: nil token
in → nil event, non-nil response out. Contract is explicit in the
return shape. Test asserts evt is nil for nil-token path.
LOW — no IPv6 coverage in IP-precedence tests. Added IPv6 cases for
XFF rightmost and three RemoteAddr forms (bracketed, loopback, no-port).
NIT — gracefulShutdown swallowed every shutdown error, returning nil
unconditionally. Now collects with errors.Join so callers can detect
partial-shutdown for telemetry/exit-code purposes.
Audits accepted as-is (NIT, not blocking):
- Artifact discriminated-union shape (Phase 3+ concern when other Kinds
are produced)
- Registry returning bare map (read-only post-Build; concurrent reads
are safe by Go's memory model)
- Build(_ Config) ignoring its arg (signature reserved for future
stateful generators)
Quality verified post-fix: build/vet/lint clean (0 issues),
14 webbug tests + 6 pixel tests + 4 registry tests PASS under -race,
integration tests unchanged.
Pre-audit gate (`golangci-lint run`) at the start of Phase 2 surfaced 15
issues that should have been zeroed before Phase 1 rollup. Per the
fix-in-phase rule (no backlog rot), clearing everything before Phase 2
audit agents run on a green tree.
Config:
- .golangci.yml: migrate `issues.exclude-rules` and `issues.exclude-dirs`
to v2 syntax (`linters.exclusions.{rules,paths}`); test-file funlen/
dupl/goconst exclusion now actually applies under golangci-lint v2.10
- .golangci.yml: add G706 to gosec excludes — false-positive log-injection
reports on slog structured-logging call sites (slog separates message
from kv args, immune to log-line injection by construction)
errcheck (5):
- cmd/canary/main.go: `_ = telemetry.Shutdown(...)` → log on error
- internal/token/repository.go: `defer stmt.Close()` → log on close error
- internal/event/repository.go: same as token
- internal/testutil/postgres.go: `_ = pgContainer.Terminate(...)` and
`_ = db.Close()` → t.Logf on cleanup error (use distinct err names to
avoid govet shadow)
funlen (1):
- cmd/canary/main.go: split `run` into `run` + `initTelemetry` +
`mountRouter` + `gracefulShutdown` (was 55 statements, now under
the 50 cap; helpers are individually well under)
govet shadow (1):
- cmd/canary/main.go: migrations check switched from `if err :=` to
`if err =` (reuses outer err whose value was already consumed) — the
inner short-decl was lexically shadowing without intent
- cmd/canary/main.go: select-case `err` renamed to `startErr` to avoid
the same shadow pattern
golines (6, all auto-fixed by `golangci-lint run --fix`):
- main.go, config.go, telemetry.go, event/repository.go, token/dto.go,
token/repository.go — long lines wrapped to 80-col
Verified post-fix: build OK, vet OK, unit tests PASS under -race,
integration tests PASS under -tags=integration -race (12s testcontainers),
golangci-lint reports 0 issues.
Production Dockerfile (infra/docker/canary.prod):
- Multi-stage: golang:1.25-alpine builder → distroless/static:nonroot final
- CGO_ENABLED=0, -trimpath, -ldflags='-s -w' → minimal static binary
- Embeds config.yaml; runs as nonroot user
- ENTRYPOINT /canary, CMD -config /config.yaml
- EXPOSE 8080
Development Dockerfile (infra/docker/canary.dev):
- golang:1.25-alpine + air-verse/air for hot reload
- Source bind-mounted by dev.compose.yml; .air.toml drives rebuilds
- go mod download cached at image-build time, dep refresh at runtime if changed
- EXPOSE 8080
Also imports the rest of the template's infra/ that was untracked:
- infra/docker/vite.dev + vite.prod (existing, unchanged)
- infra/nginx/{nginx.conf, dev.nginx, prod.nginx, nginx.prod.conf}
These will be extended in Phase 15 with /api, /c, /k upstream blocks.
Single project-root compose.yml + dev.compose.yml manage all services.
Production stack (compose.yml):
- nginx public ingress on \${NGINX_HOST_PORT:-22784}:80
- canary Go backend, NO host port (reachable only via nginx)
distroless/static:nonroot Dockerfile
healthcheck → /healthz, depends on postgres + redis
- postgres postgres:18-alpine, no host port, named volume pgdata
- redis redis:7-alpine, no host port, named volume redisdata
- (geolite vol) read-only mount /data for GeoLite2-City.mmdb
Dev stack (dev.compose.yml):
- nginx dev ingress on \${NGINX_HOST_PORT:-58495}:80
- frontend Vite HMR on \${FRONTEND_HOST_PORT:-15723}:5173
- canary Air hot-reload (canary.dev Dockerfile, bind-mounts ./backend)
OTel exports to jaeger:4317
- postgres host port \${POSTGRES_DEV_PORT:-5447}:5432 for psql access
- redis host port \${REDIS_DEV_PORT:-6022}:6379 for redis-cli access
- jaeger UI 16686, OTLP gRPC 4317, OTLP HTTP 4318
- volumes gocache + gomodcache speed up rebuilds
All randomized host ports preserved exactly:
22784 (prod nginx), 58495 (dev nginx), 15723 (vite), 5447 (pg dev),
6022 (redis dev), 16686/4317/4318 (jaeger).
Backend has no host port in production: nginx is the single entrypoint.
Cloudflare Tunnel sidecar terminates at nginx:80 via cloudflared.compose.yml.
backend/compose.yml + backend/dev.compose.yml deleted (merged here).
Validation:
- docker compose -f compose.yml config — OK
- docker compose -f dev.compose.yml config — OK
- docker compose -f compose.yml -f cloudflared.compose.yml config — OK
goconst counts occurrences across the whole package (including test files)
when deciding whether to flag a non-test file — the exclusion only suppresses
reports FROM test files. Add unexported constants for severity levels, CVSS
types, ecosystem, and reference types in client.go, and update client_test.go
to use them so the total raw-string count per literal drops below threshold.
Migrate .golangci.yml from v1 issues.exclude-rules to v2
linters.exclusions.rules — the v1 key was silently ignored by golangci-lint
v2, so test-file goconst exclusions weren't applied. With the config fixed,
all test-file violations disappear. Extract severity constants in output.go
to fix the 4 remaining non-test goconst violations (CRITICAL/HIGH/MODERATE/
LOW each appear 3x in severityRank, severityBreakdown, and severityColorFn).
pnpm latest on Node 22 resolves to pnpm 11 which rejects lockfileVersion
9.0 lockfiles with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Pinning to pnpm 10
keeps compatibility with existing lockfiles. .npmrc sets
strict-dep-builds=false so build-script warnings don't hard-fail the
frozen install.
Node.js bumped to 22 in lint workflow (pnpm 11.x requires >=22.13).
Extract goconst-flagged string literals in secrets-scanner to named
constants. Carry along monitor-dashboard docker/package fixes.
useDashboardLifecycle() orchestrates the three pieces that make the
dashboard alive:
1. Audio gesture unlock — unlockOnFirstGesture() on mount so playChime()
is ready when Phase 6 wires alert triggers.
2. Snapshot-then-WS — useSnapshot()'s isSuccess gates createDashboardWS
so we never open the WS before initial state is in TanStack cache
(per spec §10.2 race resolution). After connect+setReady the server
stops buffering and starts streaming deltas.
3. Globe ring eviction — every 5min calls useGlobeEvents.evict(now)
so expired pulse rings drop off.
Topic routing (typed switch, no `as any` — every payload narrows to a
local interface that matches the verified backend shape):
cve_new → useCveStore.push
kev_added → useKevStore.push
ransomware_victim → useRansomwareStore.push
coinbase_price → usePrices.pushTick (ISO ts → number, snake_case
volume_24h → camelCase)
earthquake → globe point + ring pulse (4s TTL)
iss_position → globe point (id='iss-current' so each tick replaces
the previous) + setQueryData(iss_position) so the
ISS panel sees the latest
wiki_itn → ticker push (source 'Wikipedia')
gdelt_spike → ticker push (source 'GDELT', headline includes
z-score and count)
space_weather/internet_outage/bgp_hijack/scan_firehose → setQueryData
to merge into snapshot (panels read from there)
heartbeat → no-op (connection liveness only)
Small bug fix in browserDriver: WebSocket.send() throws when readyState
is CONNECTING. createDashboardWS.setReady() can be called before the
socket opens (we call it immediately after snapshot resolves), so the
inner sock.send is now guarded by a readyState===OPEN check. The
onOpen handler in createDashboardWS resends init when the socket
actually opens — belt-and-suspenders.
This is the load-bearing wiring per the plan. Panels stop rendering
single snapshot rows and start growing as WS events arrive.
Native <dialog> opened from the TopStrip ? icon via useUIStore.aboutOpen.
Ref-driven showModal()/close() in useEffect, onClose synced to the store
so Escape closes both the dialog and the store flag in one round trip
(no listener fight). Three short paragraphs: the meme origin, the data
sources, and the keyboard shortcuts. The kbd-styled spans (mono +
1px --fg-4 outline) call out F and Esc.
No animation on open/close beyond the browser's default <dialog>
showModal transition (which is OS UI, not decoration). No backdrop-click
to close — Escape and the close button are enough; backdrop-click adds
a fragile target===dialog comparison for marginal value. ::backdrop
gets a 60% black tint so it reads as a modal, not a layer cake.
prose line-height 1.4 (looser than the 1.25 table tightness because
prose needs readability — operator-density still applies but Tufte
data-ink isn't violated by paragraph leading).
Plan 5 Task 23 Design QA: no illustration, no confetti, no tooltip
arrows, monochrome border, native ::backdrop only.
Per spec §10.5 — three pure functions sitting on top of the existing
useAudioStore (so React surfaces can subscribe to .unlocked if a
"click anywhere to enable audio" toast is needed later):
loadChime(file) fetch / decode arraybuffer into AudioBuffer
unlockOnFirstGesture() attach one-shot pointerdown listener that
resumes a suspended AudioContext, sets
unlocked=true. Idempotent across re-mounts
via module-level listenerAttached guard
(StrictMode double-mount safe).
playChime() → boolean returns false if the audio context isn't
ready / buffer not loaded / not unlocked,
so callers can fall back to a toast.
Phase 6 calls playChime() on alert match; Phase 5 only wires the
unlockOnFirstGesture() call into the dashboard lifecycle (Task 24)
so the audio is ready when Phase 6 lands.
Four-row layout: Lat/Lon (degree symbol, mono), Alt (km, mono tabular),
Vel (km/h with thousands separator, mono tabular), Next Pass (— stub).
Reads snapshot.iss_position directly (no per-panel store — current
position is the only thing the panel cares about, and the wheretheiss.at
collector pushes a complete state every 10s, so latest-event semantics
match what the panel needs). Real shape verified: latitude/longitude/
altitude/velocity/timestamp/fetched_at — all numeric except fetched_at
ISO string. Stale threshold 30s (3x the 10s poll cadence).
Next Pass row stays "—" muted --fg-3 — Phase 6 wires the SGP4-driven
user-observer-location pass prediction per spec §10.4. No "sign in to
add observer" CTA: operator UI doesn't onboard.
Plan 5 Task 21 Design QA: degree symbol + mono coords, alt/vel mono
tabular, no decorative ISS-tracing widget (the centerpiece globe owns
that), next-pass row visible-but-muted (never hidden).