Commit Graph

112 Commits

Author SHA1 Message Date
CarterPerez-dev 80bd617378 style(canary): format config_test.go with golines (max-len 80)
Vertically wrap the long table-driven case literals and one Fatalf call to
satisfy the project's golines linter (max-len 80, reformat-tags), which the CI
Go lint job flagged. Formatting only; no test behavior change.
2026-07-19 03:18:17 -04:00
CarterPerez-dev 97da7fb3a0 feat(canary): add configurable trusted-proxy CIDRs for X-Forwarded-For
RealIP only reads XFF when the immediate peer sits in TRUSTED_PROXY_CIDRS,
so an untrusted client can no longer spoof its source IP while a real client
behind a known reverse proxy still resolves correctly. Parses the
comma-separated list via a knadh/koanf ProviderWithValue callback (blank or
whitespace-only input leaves the built-in default intact), wires the var
through both compose files and .env.example, and adds MYSQL_FAKE_* and
TURNSTILE_SECRET env aliases. Covered by config_test.go.
2026-07-19 02:45:10 -04:00
dependabot[bot] e196026071
chore(deps): bump golang.org/x/crypto
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:32:07 +00:00
dependabot[bot] 8af023afab
chore(deps): bump golang.org/x/net
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 17:55:50 +00:00
Carter Perez 21741e0906
Merge pull request #286 from CarterPerez-dev/project/ja3-ja4-tls-fingerprinting
Project/ja3 ja4 tls fingerprinting
2026-06-18 19:38:51 -04:00
CarterPerez-dev e87efe7e29 i count the tiles every morning. today there's one more. there's always one more. 2026-06-18 19:28:21 -04:00
dependabot[bot] f742a39b65
chore(deps): bump github.com/go-chi/chi/v5
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.2.3 to 5.2.4.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.3...v5.2.4)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-18 14:09:37 +00:00
dependabot[bot] f18e8e5e5e
chore(deps): bump axios
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 08:33:15 +00:00
Carter Perez 163d126655
Merge pull request #256 from CarterPerez-dev/chore/add-linting-to-yml-and-pre-commit
Chore/add linting to yml and pre commit
2026-05-23 06:43:38 -04:00
CarterPerez-dev 0006ddad3d cracked 2026-05-23 05:01:01 -04:00
dependabot[bot] 3250ddbbe3
chore(deps): bump github.com/redis/go-redis/v9
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.7.0 to 9.7.3.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.7.0...v9.7.3)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.7.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 14:18:33 +00:00
Carter Perez cad4abf19a
Merge pull request #241 from CarterPerez-dev/dependabot/go_modules/PROJECTS/beginner/canary-token-generator/backend/github.com/go-viper/mapstructure/v2-2.4.0
chore(deps): bump github.com/go-viper/mapstructure/v2 from 2.2.1 to 2.4.0 in /PROJECTS/beginner/canary-token-generator/backend
2026-05-18 15:34:04 -04:00
dependabot[bot] 7623b8f557
chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.2.1 to 2.4.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.2.1...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:03:28 +00:00
dependabot[bot] adf26769a9
chore(deps): bump flatted
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:02:41 +00:00
Carter Perez c1f5322ea2
Merge branch 'main' into dependabot/npm_and_yarn/PROJECTS/beginner/canary-token-generator/frontend/axios-1.15.2 2026-05-18 10:00:49 -04:00
dependabot[bot] a71926ca09
chore(deps): bump fast-uri
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 04:48:05 +00:00
dependabot[bot] edf66fb576
chore(deps): bump axios
Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 04:47:56 +00:00
Carter Perez fa70d35345
Update README.md 2026-05-18 00:46:55 -04:00
Carter Perez b247504c35
Update README.md 2026-05-18 00:46:31 -04:00
CarterPerez-dev 615efe051d update 2026-05-18 00:41:25 -04:00
CarterPerez-dev de6ac71954 chore(canary): drop stray /healthcheck binary + ignore cmd build artifacts
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.
2026-05-18 00:03:20 -04:00
CarterPerez-dev 10321a9a61 fix(canary): prod compose can actually start
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.
2026-05-18 00:02:51 -04:00
CarterPerez-dev 9655120ebe chore(canary): phase B audit fixes + frontend asset rename + gitignore
Audit Phase B (backend): F5 trusted-proxy gate wiring, F7 ErrValidation
sentinel, F8 dedup unique-IP semantic via SCard, F9 notify worker pool +
graceful Shutdown, F12 token ID collision retry.

Frontend: public/asset/ → public/assets/ rename + index.html paths.

Housekeeping: .npmrc strict-dep-builds=false (pnpm fix), .gitignore now
excludes all .env.* except .env.example.
2026-05-17 23:34:42 -04:00
CarterPerez-dev d96de872b0 chore(canary): halve webbug pixel.jpg dimensions (736→368) 2026-05-17 22:58:53 -04:00
CarterPerez-dev 159c942551 feat(canary): webbug serves a real (visible) image instead of 1x1 GIF
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.
2026-05-17 22:55:47 -04:00
CarterPerez-dev 4b54c1b825 chore(canary): align e2e + handler tests with mysqlEnabled signature
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.
2026-05-17 22:55:28 -04:00
CarterPerez-dev 8d2599c01a feat(canary): mark MySQL type as disabled when mysql.enabled=false
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).
2026-05-17 22:45:53 -04:00
CarterPerez-dev 22f1daf431 test(canary): e2e suite — create→trigger→notify→manage across all 7 types
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.
2026-05-17 19:08:12 -04:00
CarterPerez-dev 2c033c58f2 fix(canary): webhook sender blocks SSRF to private/loopback/IMDS hosts
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.
2026-05-17 19:08:01 -04:00
CarterPerez-dev 30ebda39d5 fix(canary): PDF trigger URL uses ?p= query pad; handler trims trailing _
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.
2026-05-17 19:07:50 -04:00
CarterPerez-dev 4382ba5e17 fix(canary): CSP allows challenges.cloudflare.com for Turnstile
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.
2026-05-17 19:07:41 -04:00
CarterPerez-dev e78c33ae18 fix(canary): declare VITE_TURNSTILE_SITE_KEY ARG in prod Dockerfile
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.
2026-05-17 19:07:39 -04:00
CarterPerez-dev 20b6fc86ce fix(canary): prod nginx proxy_pass /api/, /c/, /k/ to canary upstream
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).
2026-05-17 19:07:32 -04:00
CarterPerez-dev 5884349336 fix(canary): dev nginx routes /c/, /k/, /api/ to canary + manage_url defaults to base_url
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.
2026-05-17 18:12:09 -04:00
CarterPerez-dev 2d395d0691 fix(canary): map PUBLIC_BASE_URL env var to canary.base_url
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.
2026-05-17 18:02:48 -04:00
CarterPerez-dev 44927b245c fix(canary): accept null events from manage response
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.
2026-05-17 17:58:13 -04:00
CarterPerez-dev 915d471b8d fix(canary): vite dev proxy — correct backend port + stop stripping /api
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).
2026-05-17 17:37:36 -04:00
CarterPerez-dev 6ef5f68b59 feat(canary): web fonts — Archivo Black / Space Grotesk / Departure Mono
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).
2026-05-17 17:27:35 -04:00
CarterPerez-dev da126ad889 fix(canary-phase-ui-design): single-source form fields + URL revoke + eventExtra schema
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).
2026-05-17 15:07:50 -04:00
CarterPerez-dev 4a4f465d24 feat(canary): manage dossier page + /m/:manageId route + 404 fallback
Manage route renders the per-token dossier — the "specimen catalog entry"
twin of the intake page. Same vector-minimalism mind: archive strip,
big-blocky headline, hairline-rule cards, mono labels, alarm-red only
when the trap is alive (eventsTotal > 0) or disabled.

Page composition:
- Top Strip (FIELD STATION / SECTION / SPECIES pill)
- Hero block — headline state-shifts: QUIET (no events, ink), TRAP TRIPPED
  (alarm red), TRAP MUTED (ink-mute). Memo rendered as printer-block quote.
- Halftone separator
- SpecimenCard with armed status / trigger count / silenced (15m window) /
  last seen / created / alert channel / filename — plus Trigger URL CopyField
- EventLogSection — cursor-paginated list of EventRow cards (timestamp +
  source_ip + geo + ASN + UA + referer + notify_status pill + expandable
  fingerprint extra). "Load older →" + "← back to most recent" controls.
- DeleteSection — two-step arm/confirm pattern (no native confirm() dialog),
  alarm-tone Button on confirm, success → toast + navigate('/')
- Bottom Strip with "new specimen ↗" link

States: useManageToken loading | ApiError(NOT_FOUND) → ManageNotFound |
other error → ManageError with retry | success → ManageView.

Pagination policy per plan anti-relit: singular useQuery, NO useInfiniteQuery.
Each cursor change replaces the visible page (operator-friendly default).
Auto-refresh every 5s via QUERY_STRATEGIES.frequent (Phase 14 contract).

Routes:
- /  → landing
- /m/:manageId → manage (new)
- *  → notfound (new — replaces previous fallback-to-landing)

config.ts: ROUTES.MANAGE added; manageRoute(id) helper centralizes the
URI-encoded path so result.tsx and any future caller cannot drift.

notfound page: same ethos in miniature. 404 strip pill, big OFF-ROUTE
headline, link back to intake.

Gates green: typecheck + biome + stylelint + build (414ms, manage chunk
10.9KB / 3.7KB gzip, notfound chunk 1.0KB / 0.5KB gzip).
2026-05-17 14:54:27 -04:00
CarterPerez-dev 5562f82f34 feat(canary): landing — specimen requisition page + Turnstile widget
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).
2026-05-17 14:49:37 -04:00
CarterPerez-dev f5d893ab8c feat(canary): UI design layer — vector-minimalism tokens + primitives
Operator (Carter) redirected from infra Phase 15 to UI design layer; design ethos
"vector-minimalism" (specimen-cataloging mind, off-white paper, deep ink, alarm-red
accent, hairline rules, halftone/grain overlays). Foundation only — pages follow.

Tokens: 7-step paper/ink/alarm/signal palette + paper-grain SVG noise + halftone
dot data-uri + hairline width. Stripped color palette in white-canvas commit is
deliberately NOT re-introduced; this is a project-owned palette, not the template's.

Primitives (each in own folder with module SCSS):
- Strip / StripItem    — archive header (vol/issue/serial pills)
- LeaderLabel          — index + rule + caption annotation
- SpecimenCard         — tag + body + serial container (paper|ink|alarm tone)
- DataRow              — field=value with dotted leader (mono|emphasize|alarm)
- Pill                 — paper|ink|alarm|signal label
- CopyField            — labelled mono code with clipboard button
- Halftone             — repeating dot strip (sparse|normal|dense)
- SerialBar            — fingerprint bar + code two-row mono display
- Glyph                — 7 token-type SVG icons (currentColor, 24×24)
- Field (Text/Textarea/Select/Wrap) — labelled form inputs with leader rule
- Button               — primary|ghost|alarm × sm|md|lg

Gates green: pnpm typecheck + pnpm biome check + pnpm lint:scss + pnpm build (294ms).

No backend changes. No page-level routing changes yet.
2026-05-17 14:39:30 -04:00
CarterPerez-dev 1d205c1791 chore(canary): white-canvas frontend design layer pre-operator UI
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.
2026-05-17 13:12:24 -04:00
CarterPerez-dev 7c0a504d0b fix(canary-phase14): single-source envelope parsing + query-key splay
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.
2026-05-17 13:03:27 -04:00
CarterPerez-dev f6194d87b9 feat(canary): TanStack Query hooks for token lifecycle
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).
2026-05-17 12:56:03 -04:00
CarterPerez-dev 2d13e649a6 feat(canary): axios client with Turnstile header interceptor
- 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.
2026-05-17 12:55:42 -04:00
CarterPerez-dev db98a0d497 feat(canary): Zod schemas + canary envelope error normalization
- Add Zod 4 schemas mirroring backend handler shapes:
  - api/types/token.ts: tokenResponseSchema, manageTokenViewSchema,
    artifactSchema (discriminatedUnion on kind), typeDescriptorSchema,
    createTokenRequestSchema (with superRefine for telegram/webhook
    conditional required), per-type metadata helpers.
  - api/types/event.ts: eventResponseSchema with GeoView, NotifyStatus,
    manageResponseSchema with cursor pagination.
  - api/types/error.ts: apiErrorEnvelopeSchema (canary {success,error}
    envelope) + successEnvelope<T> factory for typed unwrap.
- Refactor core/api/errors.ts: ApiError now carries canary error codes
  (VALIDATION_ERROR, TURNSTILE_FAILED, BAD_CURSOR, etc.) and a
  fields: Record<string,string> map. transformAxiosError parses the
  canary envelope first, falls back to HTTP status code if envelope
  shape is missing.
- Update core/api/query.config.ts no-retry list for canary codes
  (user-error codes plus RATE_LIMITED to avoid burning rate budget).
- Remove obsolete template apiClient at core/api/api.config.ts; the
  new canary client lives at api/client.ts (Phase 14 next commit).
2026-05-17 12:55:10 -04:00
CarterPerez-dev 32e9c0a30e fix(canary-phase13): direct unit coverage for firstSubdivisionName
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.
2026-05-17 05:30:13 -04:00
CarterPerez-dev 0e5ce63dc8 feat(canary): wire geoip into main with NopService fallback
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).
2026-05-17 05:24:29 -04:00
CarterPerez-dev b3fd75b680 feat(canary): Event.AttachGeoIP + event.Service geo enrichment
- 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.
2026-05-17 05:24:17 -04:00