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.
Phase 14 deliverables (audited PASS):
- Zod 4 schemas mirror backend handler/DTO byte-shapes (verified
against backend/internal/token/{handler,dto,types}.go,
backend/internal/event/{dto,entity}.go, and
backend/internal/middleware/turnstile.go — not just the spec).
- api/types/token.ts: tokenResponseSchema, manageTokenViewSchema,
artifactSchema (z.discriminatedUnion on kind), typeDescriptorSchema,
createTokenRequestSchema with superRefine for telegram/webhook
required_if semantics, per-type metadata helpers.
- api/types/event.ts: eventResponseSchema (with GeoView, NotifyStatus
enum, nullable user_agent/referer/notified_at) and
manageResponseSchema covering cursor pagination.
- api/types/error.ts: apiErrorEnvelopeSchema + successEnvelope<T>
factory; single source of truth for envelope shape.
- core/api/errors.ts refactored: ApiError carries canary error codes
(VALIDATION_ERROR, TURNSTILE_FAILED, BAD_CURSOR, BAD_PARAM, ...)
and a Record<string,string> fields map. No more FastAPI-style
parsing. Module augmentation flows ApiError as defaultError to all
TanStack hooks. Obsolete template api.config.ts removed.
- api/client.ts: axios instance over VITE_API_URL (fallback /api),
request interceptor adds CF-Turnstile-Response header from
pluggable provider when present, response interceptor routes axios
errors through Zod-backed transformAxiosError -> ApiError.
apiGet/apiPost/apiDelete typed via ZodType<T>; PARSE_ERROR carries
the failing field path + message for debuggability.
- 4 hooks: useTokenTypes (static, no polling), useCreateToken
(invalidates manage on success), useManageToken (URI-encoded id,
splayed cursor/limit query key, frequent polling), useDeleteToken
(removeQueries on success).
Implicit contract documented here for the next AI: the
cf_turnstile_response body field is intentionally optional in
createTokenRequestSchema because backend middleware/turnstile.go
reads the CF-Turnstile-Response HEADER first (set via the request
interceptor). Operators wiring the Turnstile widget should call
setTurnstileTokenProvider(() => widgetToken) on app init; the form
need not carry the token in the body.
Audited: superpowers:code-reviewer + general-purpose spec adherence
— PASS on first dispatch. Audit findings S-1/S-3/S-4/S-7 fixed
pre-rollup in 7c0a504d. I-1 (config_schema gap in
backend TypeDescriptor — spec stale vs code) and I-2 (turnstile
body-field optional) flagged for future phases; not Phase 14 issues.
OPERATOR PAUSE BOUNDARY (spec §16.3, plan line 2314). No UI/
component/page/styling code in this phase. Phase 15 resumes with
infrastructure (nginx + telemetry).
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).