Commit Graph

1202 Commits

Author SHA1 Message Date
Carter Perez 0817997585
Update hash_identifier.py 2026-05-17 14:11:54 -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 37cdf97133 chore(canary-phase14): Frontend API client complete
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).
2026-05-17 13:04:03 -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 4d0d151569 chore(canary-phase13): GeoIP integration complete
Phase 13 deliverables (audited PASS):
- internal/geoip: nil-safe Service over oschwald/geoip2-golang v2
  with NopService factory; Open/Close/Lookup contract; cityReader
  interface for fixture-free unit testing; extractLookup +
  firstSubdivisionName helpers fully covered.
- internal/event: AttachGeoIP(geoip.Lookup) on *Event with empty-
  collapses-to-nil overwrite semantics; Service.enrichGeo runs
  before repo.Insert so geo_* columns persist in the same row;
  ServiceConfig.GeoIP plumbed through; 5 new service tests +
  7 entity tests + 2 real-Postgres integration tests.
- config: GeoIPConfig{Path}, GEOLITE_PATH env binding matching
  compose.yml, default /data/GeoLite2-City.mmdb.
- cmd/canary/main: openGeoIP wires the mmdb with NopService
  fallback on any open failure; close-on-shutdown deferred.

Audited: superpowers:code-reviewer + general-purpose spec
adherence — PASS on first dispatch. One non-blocking nit (direct
unit coverage for firstSubdivisionName) fixed in 32e9c0a3 per
fix-in-phase discipline.

13 of 18 phases closed.
2026-05-17 05:30:47 -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
CarterPerez-dev ac334b14f9 feat(canary): geoip wrapper package + config wiring
- 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).
2026-05-17 05:23:53 -04:00
CarterPerez-dev d870e85f23 chore(canary-phase12): admin endpoints + OperatorBearer complete
Phase 12 deliverables (audited PASS):
- internal/middleware/operator_bearer.go — 404-on-miss (not 401),
  subtle.ConstantTimeCompare on token, defense-in-depth empty-config
  rejection; per spec §11.5.
- internal/admin/{handler,dto}.go — three endpoints from spec §8.1:
  GET /api/admin/stats (tokens_count + events_count + by_type +
  by_alert_channel), GET /api/admin/tokens (offset paging, 50/100
  default/cap), POST /api/admin/tokens/{id}/disable (204 / 404).
  Replaces the unused template stats package per spec §5 line 282
  ("KEEP, repurpose for operator stats; gate via OperatorBearer").
- internal/{token,event}/repository.go — CountByType + CountByAlertChannel
  + event.CountAll feed the stats endpoint via repo grouping queries.
- cmd/canary/main.go — mountAdminRoutes helper skips mount entirely
  when OPERATOR_TOKEN is unset (production safety; runtime warn log).
- config.OperatorConfig + OPERATOR_TOKEN env mapping.
- Unit + integration test coverage at admin/middleware boundaries
  including the 404-no-WWW-Authenticate regression assertion.

Audited: superpowers:code-reviewer + general-purpose spec adherence — PASS.
2026-05-14 07:02:49 -04:00
CarterPerez-dev 3cf198657b fix(canary-phase12): address audit findings before phase rollup
- 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
2026-05-14 07:02:39 -04:00
CarterPerez-dev 9adb3d2eb8 feat(canary): wire /api/admin mount + integration tests
- 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.
2026-05-14 06:57:39 -04:00
CarterPerez-dev 9e82f70841 feat(canary): admin handler (stats + tokens list + disable) + repo methods
- 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).
2026-05-14 06:57:28 -04:00
CarterPerez-dev 426ed54594 feat(canary): OperatorBearer middleware + operator token config
- 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).
2026-05-14 06:57:10 -04:00
CarterPerez-dev de236328f8 chore(canary-phase11): manage page API complete (GET + DELETE /m/{id})
Phase 11 deliverables (audited PASS):

Service primitives
- event.Service.CountActiveDedup(ctx, tokenID): Redis SCAN over
  dedup:trigger:{tokenID}:* (token-scoped pattern), batched 100,
  returns sum(value-1) — value=1 (first trigger fired) contributes 0,
  value=N (N-1 INCRs) contributes N-1 silenced events. Matches
  spec §10.5 "counter = total silenced" semantics.
- token.Service.DeleteByManageID: thin wrapper around
  Repository.DeleteByManageID; ServiceRepository interface gains the
  method.

DTOs
- ManageTokenView (slimmer than the create-time Response — drops
  manage_id / manage_url / metadata since the client is already on
  the manage page); ManagePage{NextCursor, HasMore}; ManageResponse
  shape exactly matches spec §8.4 example. event.Response (already
  in place from Phase 1) reused for the events array.

Handlers
- GET /api/m/{manage_id}: parses ?cursor (int64, must be ≥ 0; 400
  BAD_CURSOR otherwise) and ?limit (default 20, capped at 100).
  Calls GetByManageID + ListByToken + CountByToken + CountActiveDedup
  via the new EventQuery + DedupCounter interfaces, assembles
  ManageResponse. Page derived from list.HasMore + list.NextCursor
  (audit-fix ca67cf63 — was re-deriving from len before).
- DELETE /api/m/{manage_id}: 204 on success; 404 envelope on miss.
  Cascade is via the existing tokens-events FK ON DELETE CASCADE
  (verified by integration test).
- 404 on unknown manage_id returns the JSON envelope (not bare
  http.NotFound) so the frontend gets a parseable error code.
- NewHandler signature 5 → 6 args (added eventQuery + dedupCounter).
  Both test sites and main.go updated.

Wire-up
- cmd/canary/main.go: tokenH.RegisterManageRoutes(api) inside the
  /api subrouter. Inherits CORS + read-tier rate limit from /api;
  no Turnstile, no create-tier limits (read-style endpoint per
  spec §8.1).
- Extracted buildHTTPDeps to keep run() under the funlen ceiling.

Integration tests (testcontainers-Postgres + miniredis):
- ManagePageReturnsTokenAndEventsAndSilencedCount: 3 same-IP triggers
  → events_total=3, events_silenced_active=2, one NotifySent + two
  NotifyDeduped, no next page.
- ManagePageReturns404OnUnknownManageID
- ManageDeleteCascadesEvents: DELETE → CountByToken==0 + token gone

Audited: superpowers:code-reviewer + general-purpose spec adherence —
PASS. Both audits flagged one non-blocking nit (buildPage was
re-deriving NextCursor from len rather than using the repo's HasMore
flag); cleaned up in ca67cf63 to avoid future drift if the repo's
pagination logic evolves.
2026-05-14 01:12:43 -04:00
CarterPerez-dev ca67cf6313 fix(canary-phase11): use repo-supplied HasMore/NextCursor before rollup
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.
2026-05-14 01:12:07 -04:00
CarterPerez-dev 884215288e feat(canary): wire manage routes + extract buildHTTPDeps + integration tests
- 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).
2026-05-14 01:07:38 -04:00
CarterPerez-dev 593433fc03 feat(canary): manage page handlers + DTOs (GET + DELETE /m/{manage_id})
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.
2026-05-14 01:07:09 -04:00
CarterPerez-dev e3f9ff48f9 feat(canary): event.CountActiveDedup + token.DeleteByManageID for manage page
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.
2026-05-14 01:06:45 -04:00
CarterPerez-dev 2041c557ad chore(canary-phase10): event service + notify dispatcher complete
Phase 10 deliverables (audited PASS):

Domain layer
- event.NotifyInfo / Notifier / TokenIncrementer / Store contracts
  in event/contract.go (breaks event→notify→token cycle without
  the type-alias relocation pattern from Phase 9)
- token.Token.NotifyInfo() method bridges *Token → event.NotifyInfo
- notify.Sender + StatusWriter interfaces in notify/types.go

Senders
- telegram.Sender: POST /bot{TOKEN}/sendMessage with MarkdownV2
  body, cenkalti/backoff/v5 retry (3 tries / 30s window), permanent
  on 4xx, 10s overall + 5s connect timeouts. Sanctioned spec deviation:
  parse_mode=MarkdownV2 (spec body says Markdown but escape table is
  V2); makes the escape rules consistent. Documented in commit body.
- webhook.Sender: POST user URL with versioned JSON envelope
  (§10.4 shape verified field-by-field), optional HMAC-SHA256
  signing via X-Canary-Signature header, URL validation at the
  call site (rejects non-http(s), missing host, userinfo).

Services
- notify.Service: per-channel sender registry, async fire-and-forget
  Notify with bounded sendTimeout, sync.WaitGroup for graceful drain
  (notifySvc.Wait() called from main.go gracefulShutdown).
- event.Service.Record: Insert → IncrementTriggerCount → Redis SetNX
  dedup gate (15m TTL). First trigger calls notifier; duplicates INCR
  + UpdateNotifyStatus(deduped). Fail-open on Redis errors so we don't
  silently miss alerts.
- event.Service.RunRetentionLoop: tickered prune to per-token limit.

Wire-up
- cmd/canary/main.go: buildEventStack constructs senders + notify.Service
  + event.Service. eventRecorderAdapter swaps the Phase-9
  directEventRecorder for the real event.Service.Record path. Same
  adapter wires the mysql listener — one path, one dedup policy.
- fingerprintRecorderAdapter wires event.Repository.AttachFingerprint
  with the configured window.
- spawnRetentionLoop runs RunRetentionLoop on the shared wait group.
- Three nested rate limits on POST /api/tokens (read-tier from /api +
  createMin 5/min + createHour 20/hr) with distinct Redis namespaces.
- gracefulShutdown drains notifySvc + retention/mysql wg before return.

Closed deferred Phase-9 items
- (1) testcontainers integration test — full create + trigger + dedup
- (2) directEventRecorder → event.Service.Record swap
- (3) nested create-tier rate limit on POST /api/tokens
- (4) gosec G107/G704 path-scoped to telegram/webhook/turnstile;
  G101 path-scoped to config (env-var-name false positives)
- (5) FingerprintRecorder wiring (5-min window default)
- (7) drop required tag on TurnstileResp (middleware sole authority)
- (8) consolidate realIP into middleware across 5 generators
  (docx, envfile, kubeconfig, pdf, slowredirect)

Audited: superpowers:code-reviewer + general-purpose spec adherence —
PASS. Code-reviewer caught one bug pre-rollup: formatGeo emitted
literal '(' and ')' which are MarkdownV2 reserved chars outside link
delimiters; would crash Telegram with a parse error. Fixed in
41b9d2c9 with regression assertion on the escaped wrapping.
2026-05-14 00:47:49 -04:00
CarterPerez-dev 41b9d2c911 fix(canary-phase10): escape geo wrapping parens for MarkdownV2 before rollup
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.
2026-05-14 00:47:08 -04:00
CarterPerez-dev 82085711b6 test(canary): testcontainers integration test for trigger + dedup + retention
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.
2026-05-14 00:32:28 -04:00
CarterPerez-dev 0524d84c89 chore(canary): scope gosec G101/G107/G704 to outbound HTTP packages
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.
2026-05-14 00:32:07 -04:00
CarterPerez-dev 2799045ee7 refactor(canary): consolidate realIP into middleware across 5 generators
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.
2026-05-14 00:31:49 -04:00
CarterPerez-dev f9b012699a feat(canary): wire event + notify into runtime + create-tier rate limits
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.
2026-05-14 00:31:24 -04:00
CarterPerez-dev 0757d9f196 feat(canary): event + notify domain — contracts, senders, services
Lays the domain layer for Phase 10 notification dispatch.

- event/contract.go: NotifyInfo (token-shape DTO that breaks the would-be
  event→notify→token cycle), Notifier interface, TokenIncrementer interface,
  Store interface (Insert+UpdateNotifyStatus+PruneToLimit).
- token.Token.NotifyInfo() helper bridges *token.Token → event.NotifyInfo
  for the main.go adapter.
- notify/types.go: Sender + StatusWriter interfaces.
- notify.Service: per-channel sender registry, async fire-and-forget
  Notify with bounded sendTimeout, WaitGroup for graceful drain, status
  writeback (sent / failed) on the configured StatusWriter.
- telegram.Sender: POST /bot{TOKEN}/sendMessage with MarkdownV2-escaped
  body, cenkalti/backoff/v5 retry (3 tries / 30s window), permanent on
  4xx, 10s overall + 5s connect timeouts. Note: spec body said
  parse_mode=Markdown but escape table is the V2 set; using MarkdownV2
  keeps the escape rules consistent.
- webhook.Sender: POST user URL with versioned JSON envelope (§10.4),
  optional HMAC-SHA256 signing via X-Canary-Signature, URL validation
  at the call site (rejects non-http(s), missing host, userinfo). Same
  retry/timeout policy as telegram.
- event.Service: Record(ctx, info, evt) inserts → IncrementTriggerCount
  → Redis SetNX dedup gate (15m TTL, fail-open on Redis error). First
  trigger calls notifier; duplicates INCR + UpdateNotifyStatus(deduped).
- event.Service.RunRetentionLoop(ctx, interval, limit) tickered prune.

Adds cenkalti/backoff/v5 + miniredis/v2 to go.mod for backoff API and
unit-test Redis fakes.
2026-05-14 00:30:42 -04:00
CarterPerez-dev 1fe61345ef chore(canary-phase9): wire-up complete — API + middleware + Turnstile + token service
Phase 9 is the wire-up phase: brings together everything the generator
phases produced into a working HTTP service plus the TCP mysql
listener. All 7 generators are now reachable end-to-end through
HTTP routes (or TCP for mysql), and POST /api/tokens creates tokens
backed by a real registry.

Implementation commits in this phase:
  - c988e4b9 feat(canary): middleware additions (realip, fingerprint,
    recovery)
  - 537b0e3f feat(canary): turnstile verifier + middleware
  - 019bfda4 feat(canary): token service + contract relocation
  - 0a456f97 feat(canary): token HTTP handlers (API + trigger routes)
  - 8d1016b8 feat(canary): full wire-up + webbug refactor + mysql
    goroutine
  - 6c370825 fix(canary-phase9): plumb mysql public host/port through
    to generator

Deliverables:

middleware/
  - realip.go: RealIP + OptionalHeader (promoted from webbug)
  - fingerprint.go: ExtractFingerprint (sha256[:16] of realIP|UA) +
    KeyByFingerprint (rate-limit key builder)
  - recovery.go: defer recover + 500 JSON envelope + request_id log
  - turnstile.go: header-or-body extraction + verifier delegation

turnstile/
  - verifier.go: cloudflare siteverify call + 5-min fingerprint cache +
    dev-mode bypass when SecretKey empty + 10s HTTP timeout

token/
  - contract.go (NEW LOCATION): Generator interface + Artifact +
    ArtifactKind + TriggerResponse moved from generators package
    (resolved long-standing import cycle); generators/generator.go
    now re-exports as type aliases for backwards compatibility
  - service.go: Create (validation + ID/manage_id gen + registry
    dispatch + repo persist), GetByID, GetByManageID,
    IncrementTriggerCount, TriggerURL/ManageURL builders
  - handler.go: RegisterAPIRoutes (GET /tokens/types, POST /tokens),
    RegisterTriggerRoutes (GET /c/{id}, POST /c/{id}/fingerprint,
    HandleFunc /k/{id}, /k/{id}/* for any method)
  - types.go: TypeDescriptors() — static 7-type catalog for the
    /tokens/types endpoint

cmd/canary/main.go: full wire-up per spec §11.1
  - global: RequestID → Logger → Recovery → SecurityHeaders
  - /healthz: no further mw
  - /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: OUTSIDE /api
    (no CORS, no rate limit, no Turnstile — we want every attacker
    hit recorded per spec §11.1 line 1552)
  - /api: CORS → RateLimit (KeyByFingerprint, FailOpen=true)
  - /api/tokens/types: read-only public
  - /api/tokens POST: + TurnstileVerify middleware
  - mysql goroutine via spawnMySQLListener iff cfg.MySQL.Enabled
    (closes Phase 8 task 8.5 deferral); uses mysqlTokenLookup +
    mysqlEventRecorder adapters
  - directEventRecorder bridges token.EventRecorder to
    event.Repository + token.Repository (Phase 10 swaps in
    event.Service for dedup + async notify)

webbug refactor (task 9.8): deletes local realIP/lastNonEmptyXFF/
optionalHeader copies, uses middleware helpers. Other 5 generators
still have their copies (sanctioned per handoff).

config additions: Canary {BaseURL, ManageURL}, Turnstile {SecretKey,
SiteKey}, MySQL {Enabled, Addr, PublicHost, PublicPort}. Env keys +
defaults wired.

.golangci.yml: adds gosec G107 + G704 to global excludes (SSRF taint
analysis on operator-configured outbound HTTP). Documented as
policy-level (matches G104/G706 precedent). Phase 10's webhook sender
will validate user-supplied URLs at the call site.

~50 new test cases across the 5 new packages:
  - middleware: 16 (IP precedence + fingerprint + recovery +
    Turnstile mw incl. header/body/preservation)
  - turnstile: 14 (bypass + cache hit/miss + failure + nil-Redis +
    network error)
  - service: 15 (ID generation + repo persistence + metadata
    validation per type + rollback on generator failure + URL
    builders + ~30-call distinctness)
  - handler: 10 (envelope + happy path + bad JSON + validation +
    trigger known/unknown/disabled token + ArtifactJSON discriminator)
  - registry: +1 (MySQLUsesConfiguredPublicAddress regression guard)

Audit outcome (2 agents in parallel per standing pattern):
  - superpowers:code-reviewer: PASS-WITH-NITS (0 BLOCKER, 3 SHOULD-FIX,
    5 NITs). One actionable SHOULD-FIX (mysql public address ignored)
    cleared in 6c370825 before rollup. Other two SHOULD-FIX items
    (rate-limit create-tier; scope G107 exclude) deferred to Phase 10
    with explicit rationale.
  - general-purpose spec-adherence vs §6/§8/§11: PASS-WITH-NITS. All
    12 tasks discharged or properly deferred (9.9 → Phase 10, 9.12 =
    this rollup). All §11.1 middleware order layers verified. All
    §8.2 request/response shapes verified. §11.2 fingerprint and
    §11.4 Turnstile both match spec.

Pre-rollup gate clean at HEAD 6c370825:
  - go build / vet / test -race / test -tags=integration /
    golangci-lint run — all clean
  - zero //nolint pragmas
  - BACKLOG open section still _(none)_
  - go.mod surface unchanged (no new direct deps in Phase 9)

DEFERRED (sanctioned):
  - Task 9.9 testcontainers integration test → Phase 10 (alongside
    event.Service + notify.Service end-to-end coverage)
  - §11.3 create-tier rate-limit nesting → Phase 10 (Turnstile
    already provides spam protection at the create path)
  - Scope gosec G107/G704 to specific packages → Phase 10 (need
    webhook sender first to validate the call-site validation
    pattern)
  - mysql_username metadata write in token.Service → Phase 10 (mysql
    trigger lookup is by token ID, not username — current Phase 8
    impl already handles this in the generator)
  - FingerprintRecorder real wiring (currently nil → 204) → Phase 10
  - /api/m/{manage_id} GET/DELETE → Phase 11 (manage page API)

Forward state for Phase 10 (event service + dedup + notify dispatcher):
swaps directEventRecorder + mysqlEventRecorder for event.Service.Record,
brings notify.Service with Telegram + webhook senders, Redis SetNX
dedup gate, retention loop. Then Phase 11 wires /api/m/{manage_id}
manage page endpoints, Phase 12 wires admin + OperatorBearer, Phase 13
wires GeoIP.
2026-05-13 15:27:55 -04:00
CarterPerez-dev 6c37082598 fix(canary-phase9): plumb mysql public host/port through to generator
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
2026-05-13 15:27:21 -04:00
CarterPerez-dev 8d1016b87d feat(canary): full wire-up + webbug refactor + mysql goroutine
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
2026-05-13 15:20:01 -04:00
CarterPerez-dev 0a456f97ae feat(canary): token HTTP handlers (API + trigger routes)
Phase 9 task 9.6.

Routes:
  RegisterAPIRoutes(r):
    GET  /tokens/types  → static list of 7 type descriptors
    POST /tokens        → CreateToken (validation + service.Create)

  RegisterTriggerRoutes(r):
    GET  /c/{id}                 → HandleTrigger (webbug, slowredirect,
                                    docx, pdf, envfile use this path)
    POST /c/{id}/fingerprint     → HandleFingerprint (slowredirect)
    HandleFunc /k/{id}           → HandleTrigger (kubeconfig, bare path)
    HandleFunc /k/{id}/*         → HandleTrigger (kubeconfig wildcard,
                                    any HTTP method)

HandleTrigger flow:
  - extract token id from chi.URLParam
  - service.GetByID (returns nil-nil for not-found)
  - if token exists and !enabled, treat as unknown
  - resolve generator (known token → its type; unknown + path
    prefix /k/ → kubeconfig; unknown otherwise → webbug)
  - generator.Trigger(ctx, tok, r) → event + response
  - record event via EventRecorder interface if present (Phase 10
    wires real event.Service)
  - write response: headers + body OR 302 + Location for redirect

CreateToken flow:
  - 64KB body cap via http.MaxBytesReader
  - JSON decode (bad → 400 BAD_JSON)
  - extract fingerprint + real IP via middleware helpers
  - service.Create
  - error switch: validation/unknown-type/destination/include-keys
    map to 400; generator-failure to 500
  - happy path: 201 with {token, artifact} envelope per spec §8.2

ArtifactJSON discriminator pattern:
  - kind=url     → url + destination_url
  - kind=file    → filename + content_type + content_b64 (base64)
  - kind=text    → filename + content_type + content (raw)
  - kind=conn... → connection_string

Tests (~10 cases): 7-type list, happy path with envelope assertion,
bad JSON, validation failure, known-token records event + writes
response, unknown-token still returns shape (defense-in-depth),
disabled-token treated as unknown, fingerprint without recorder
returns 204, ArtifactJSON discriminator for all 4 kinds.

Phase 9 task 9.6 discharged. Wire-up + config + webbug refactor +
integration test next.
2026-05-13 15:16:41 -04:00
CarterPerez-dev 019bfda437 feat(canary): token service + contract relocation
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.
2026-05-13 15:13:43 -04:00
CarterPerez-dev 537b0e3fd8 feat(canary): turnstile verifier + middleware
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
2026-05-13 15:08:37 -04:00
CarterPerez-dev c988e4b902 feat(canary): middleware additions (realip, fingerprint, recovery)
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.
2026-05-13 15:04:29 -04:00
CarterPerez-dev 47907a3eb4 chore(canary-phase8): mysql fake TCP server complete — generator set CLOSED
Phase 8 ships the SEVENTH AND FINAL generator. All 7 token types
(webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql) are now
registered; the canary token-type set is closed. Phases 9-18 are
service/handler/wire-up/notification/manage-page/admin/geoip/frontend
work — no more generators.

mysql is the only non-HTTP generator: the trigger fires when an
attacker tries to authenticate against our fake MySQL server using
the canary-issued connection string. We accept the TCP connection,
write a real-looking HandshakeV10 packet (5.7.40-canary banner +
mysql_native_password auth plugin + valid capability mask), read the
client's HandshakeResponse41, extract the username (which is
"canary_"+token.ID per spec §9.7 line 1341), look up the token by
stripping the prefix, record an event with the kubectl-equivalent
forensic fields (mysql_username + mysql_client_capabilities formatted
as 0x%08x + mysql_client_charset), and respond with the canonical
ERR_Packet 1045 "Access denied" message before closing. To the
attacker the response is byte-indistinguishable from a real MySQL
server rejecting bad credentials.

Implementation commits in this phase:
  - 712202ab feat(canary): mysql wire protocol (handshake + auth +
    ERR packets)
  - 4d4d4951 feat(canary): mysql server + connection handler
  - fe80c94a feat(canary): mysql generator + register in registry

Deliverables:
  - protocol.go: byte-exact HandshakeV10 builder per spec §9.7 lines
    1366-1379 (3-byte LE length + seq id, protocol version 0x0a,
    "5.7.40-canary\x00", random conn id, 8+12 split auth-plugin-data,
    capability flags 0xf7ff/0x81ff, charset 0x21, status 0x0002,
    auth-plugin-data length 0x15, mysql_native_password); ClientAuth
    parser for HandshakeResponse41; BuildAccessDeniedErr ERR_Packet
    with code 1045 + SQL state 28000; crypto/rand-backed
    NewRandomAuthData (20 bytes) + NewRandomConnectionID (uint32);
    error sentinels (ErrShortPacket, ErrPacketTooLarge,
    ErrInvalidPayload, ErrUsernameMissing, ErrHTTPTriggerNotSupported)
  - handler.go: HandleConnection per spec §9.7 lines 1410-1442 —
    10-second deadline, handshake-first, defense-in-depth silent
    drops for non-canary username / unknown token / lookup error /
    nil recorder; TokenLookup + EventRecorder interfaces decouple
    from Phase 9/10 services
  - server.go: ctx-aware net.ListenConfig listener, per-connection
    goroutines drained via WaitGroup on shutdown, net.ErrClosed
    silenced during graceful close
  - generator.go: New() + NewWithAddress(host, port); Generate writes
    mysql:// connection string and persists mysql_username into
    t.Metadata via setMySQLUsername (map[string]json.RawMessage
    merge — sanctioned deviation from spec §9.7 line 1343's
    map[string]any indexing because real Token.Metadata is
    json.RawMessage); Trigger returns ErrHTTPTriggerNotSupported
    (TCP-only, no HTTP route)
  - Registry expanded 6 → 7; TestBuild_AllSevenGeneratorsRegistered
    closes the cardinality-test sequence (no more "pending types"
    list — the set is complete)

~115 test cases added across 4 test files:
  - protocol_test.go (~24): byte-exact packet header re-decode, LE
    conn-id round-trip, 5-username Build/Read round-trip, error
    paths (short, missing terminator, empty reader, oversize),
    randomness distinctness for both crypto/rand helpers
  - handler_test.go (~10): real socket pairs (net.Listen + Dial),
    handshake-first assertion, non-canary username silent drop,
    full known-token flow with ERR packet + event Extra capture,
    unknown token silent, lookup error silent, nil EventRecorder
    tolerated, bad auth packet silent
  - server_test.go (~5): nil handler rejection, real-dial dispatch,
    10 concurrent connections, ctx cancellation graceful shutdown,
    invalid address rejection
  - generator_test.go (~11): Type, KindConnectionString, default +
    custom address, canary_ prefix invariant, metadata persistence
    on empty token, existing-fields preservation (non-string field
    round-trips), malformed-metadata recovery, stale-username
    overwrite, baseURL irrelevance, Trigger sentinel error path
    (happy + nil token)

Audit outcome (2 agents in parallel per standing pattern):
  - superpowers:code-reviewer: PASS-WITH-NITS (0 BLOCKER, 0
    SHOULD-FIX, 8 NITs all explicitly marked "not worth fixing" /
    "leave as-is" / "theoretical, never happens")
  - general-purpose spec-adherence vs §9.7 + sanctioned deviations:
    PASS (0 BLOCKER, 0 SHOULD-FIX, 0 NITs — confirmed both
    sanctioned deviations matched the slowredirect/envfile metadata
    pattern and the TCP-only Trigger shape)

No audit-fix commit this phase. Matching Phase 5 / Phase 7 pattern
(Phase 6 was the only phase to require an audit-fix commit before
rollup).

DEFERRED TO PHASE 9 (sanctioned per handoff anti-relitigation):
  - Task 8.5: wire `go mysql.Run(ctx, addr, handler)` into
    cmd/canary/main.go via cfg.MySQL.Enabled + cfg.MySQL.Addr.
    Requires config.Config.MySQL field which lands in Phase 9
    alongside config.Config.Canary.BaseURL. Wiring it in Phase 8
    would require partial config additions Phase 9 then re-touches.

  - Task 8.6: manual `mysql -h ... -u canary_<id> -ptest internal_db`
    smoke test. Operator-deferred per standing rule.

Pre-rollup acceptance gate clean at HEAD fe80c94a:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... all packages pass
  - go test -tags=integration -race -timeout=300s ./internal/token/...
    ./internal/event/... pass
  - golangci-lint run ./... → 0 issues.
  - grep -rn "//nolint" --include="*.go" returns nothing
  - BACKLOG.md open section still _(none)_
  - go.mod surface unchanged (no new deps for the entire phase)

# GENERATOR SET CLOSED

  webbug         (Phase 2) — /c/{id} GIF, simplest case
  slowredirect   (Phase 3) — HTML page + fingerprint endpoint
  docx           (Phase 4) — zip-patched OOXML INCLUDEPICTURE
  pdf            (Phase 5) — byte-replace fixed-width URI action
  kubeconfig     (Phase 6) — YAML + fake K8s /k/{id}/* handler
  envfile        (Phase 7) — shuffled bait + INTERNAL_METRICS_ENDPOINT
  mysql          (Phase 8) — TCP wire protocol + ERR_Packet 1045

Forward state for Phase 9 (token service + handlers + Turnstile +
fingerprint mw): wires the registry into cmd/canary/main.go, brings
config.Config.Canary{BaseURL} + config.Config.MySQL{Enabled,Addr,
PublicHost,PublicPort}, defines token.Service.Create (id+manage_id
generation, registry dispatch, repository persistence) and
token.Handler (POST /api/tokens, GET /tokens/types, route mounting
for /c/{id} + /c/{id}/fingerprint + /k/{id}/*), promotes the realIP
helper from webbug into middleware, adds turnstile verification +
fingerprint rate-limit middleware. Closes Phase 8's deferred task 8.5
by spawning `go mysql.Run(...)` from main.go.
2026-05-13 14:59:56 -04:00
CarterPerez-dev fe80c94a73 feat(canary): mysql generator + register in registry
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
2026-05-13 14:55:37 -04:00
CarterPerez-dev 4d4d4951ba feat(canary): mysql server + connection handler
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.
2026-05-13 14:53:22 -04:00
CarterPerez-dev 712202abc0 feat(canary): mysql wire protocol (handshake + auth + ERR packets)
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.
2026-05-13 14:49:03 -04:00
CarterPerez-dev 3d9b97da21 chore(canary-phase7): envfile generator complete
Phase 7 ships the sixth generator in the canary registry. envfile
canary tokens are rendered .env-style text artifacts containing
shuffled bait sections (aws/stripe/github/db) plus an
INTERNAL_METRICS_ENDPOINT line carrying the actual canary URL.
The bait keys look real enough to pass gitleaks-class regex
scanners but are NOT live credentials — the detection mechanism
is the embedded canary URL.

Implementation commits in this phase:
  - 003aa997 feat(canary): envfile bait recipes (aws/stripe/github/db)
  - 2a2a3c4d feat(canary): envfile generator (shuffled bait + embedded
    canary URL)
  - 1cde61ef feat(canary): register envfile generator in registry

Deliverables:
  - envfile/recipes/ sub-package with 4 bait recipes + shared
    crypto/rand helpers (RandomAlnumUpper/RandomAlnumMixed/
    RandomHexLower/RandomBase64/RandomChoice)
  - aws.go: AKIA + 16-char [A-Z0-9] body + 40+ base64 secret + region
    + bucket
  - stripe.go: sk_live_/pk_live_/whsec_ + 24/24/32 base62 bodies
  - github.go: ghp_ + 42 base62 (36 body + 6 checksum) + base64
    deploy key
  - db.go: postgres://app_writer:<24-char>@db.internal:5432/app_prod
    + redis://default:<32-char>@cache-prod.internal:6379/0
  - envfile/generator.go: extractIncludeKeys with 7 defensive fallback
    cases (empty/invalid/wrong-type/null/empty-array/missing/whitespace)
    + Fisher-Yates shuffle on crypto/rand + canary section appended
    last before shuffle so canary URL can land at any position
  - Spec §9.6 deviation: crypto/rand throughout (vs spec's math/rand
    + rng.Shuffle) — matches Phase 0 supplement's discipline, no
    gosec G404 fight, consistent with Phase 5 (pdf) and Phase 6
    (kubeconfig)
  - Trigger: byte-identical content-copy of webbug/docx/pdf
    (200 + pixel.Clone GIF + cache headers + realIP triplet)
  - Registry expanded 5 → 6; pending list shrinks to {mysql}
    (1 remaining — Phase 8)
  - ~60 test cases across recipes + generator including 11-subcase
    IP precedence parity, 6 malformed-metadata fallback subcases,
    shuffle variability across 30 invocations, format-regex assertions
    per recipe, URL parsing for postgres/redis (real net/url validation),
    token-id-leak invariant (uniqueprobe appears exactly once)

Audit outcome (2 agents in parallel per standing pattern):
  - superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 6 NITs
    all marked informational / below-threshold / acceptable-trade)
  - general-purpose spec-adherence vs §9.6 + §8.5: PASS (0 BLOCKER,
    0 SHOULD-FIX, 2 LOW informational items — hostname pick is one of
    spec's enumerated values, extra realism keys are additions not
    violations)

No audit-fix commit needed this phase — zero actionable findings,
matching the Phase 5 pattern (Phase 6 had one actionable header-
filename NIT cleared before rollup; Phase 7 is fully clean).

Pre-rollup gate clean at HEAD 1cde61ef:
  - go build / vet clean
  - go test -race -timeout=60s ./... all packages 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
  - BACKLOG.md open section still _(none)_
  - go.mod surface unchanged (no new deps for this phase)

Forward state for Phase 8 (mysql fake server): the LAST generator.
TCP wire-protocol listener (NOT HTTP) that accepts MySQL handshake,
extracts username from the auth packet, looks up token by
metadata.mysql_username, records event, returns ERR_Packet 1045.
First time we spawn a non-HTTP listener from main.go (cmd/canary
wire-up via `if cfg.MySQL.Enabled { go mysql.Run(...) }`). All
7 generators close after Phase 8.
2026-05-13 14:40:37 -04:00
CarterPerez-dev 1cde61efbb feat(canary): register envfile generator in registry
Sixth entry in the registry map. Cardinality bumps 5 → 6; pending list
shrinks to {mysql} (1 remaining).

  - registry.go: adds the import + token.TypeEnvfile: envfile.New()
  - registry_test.go:
      - TestBuild_RegistersEnvfile added (mirrors RegistersKubeconfig)
      - TypeEnvfile removed from TestBuild_PendingTypesNotYetRegistered
      - TestBuild_OnlyExpectedTypesPresentInPhase6 renamed → InPhase7,
        cardinality assertion bumped 5 → 6

Pre-audit gate clean at HEAD: go build / vet / test -race / integration
tests / golangci-lint run all pass with 0 issues.
2026-05-13 14:37:02 -04:00
CarterPerez-dev 2a2a3c4d8f feat(canary): envfile generator (shuffled bait + embedded canary URL)
Generator builds the artifact per spec §9.6:

  - extractIncludeKeys(t.Metadata) reads include_keys from JSON
    metadata, defaults to ["aws", "db"] on missing/malformed metadata
    (defensive fallback covers empty raw, invalid JSON, wrong type,
    null value, empty array, missing field, all-whitespace entries)
  - buildSections iterates recipe keys, appending the canary section
    last: INTERNAL_METRICS_ENDPOINT=<baseURL>/c/<t.ID> +
    INTERNAL_METRICS_TOKEN=tok_live_<32-char alnum>
  - shuffleSections uses crypto/rand-driven Fisher-Yates so the canary
    section can land at any position. Spec §9.6 line 1301 uses
    math/rand.Shuffle; we use crypto/rand to stay consistent with the
    Phase 0 supplement choice and the recipes package
  - renderSections writes the fixed "# Production environment / NODE_ENV
    / PORT" header followed by each section's comment + key=value lines
  - Artifact: KindText, ContentType "text/plain; charset=utf-8",
    Filename default ".env"

Trigger is a byte-identical content-copy of webbug/docx/pdf: 200 +
pixel.Clone() GIF + cache headers + realIP triplet + nil-token-returns-
GIF defense-in-depth.

Tests (~30 cases):
  - Generator: Type, KindText, ContentType, Filename defaulting (4),
    header presence, canary URL embedded, canary token format
    (tok_live_+32 alnum regex), trailing-slash trim, default
    include_keys vs metadata-driven include_keys, unknown keys
    skipped, 6 malformed-metadata fallback subcases, empty-string
    filtering in keys list, shuffle variability (30 invocations →
    canary at multiple positions), distinct outputs (random bait +
    random shuffle), token id appears exactly once in output
  - Trigger: GIF response shape, source-IP precedence (11 subcases
    matching docx/pdf/kubeconfig), missing UA/Referer → nil pointers,
    response body independence (mutate one, other unchanged),
    nil-token still returns GIF + nil event

Registry wire-up next.
2026-05-13 14:36:06 -04:00
CarterPerez-dev 003aa9970c feat(canary): envfile bait recipes (aws/stripe/github/db)
Phase 7 first commit. Four recipes for generating realistic-looking
credentials that pass syntactic validation by tools like gitleaks but
are NOT real credentials — they're decoration that makes the env file
look like a legitimate prod config dump. The detection mechanism is
the canary URL embedded by the generator (next commit); the bait keys
themselves don't trigger anything.

Package layout (sub-package under envfile/):
  - recipes.go: EnvLine, Recipe interface, internal registry, shared
    crypto/rand helpers (RandomAlnumUpper / RandomAlnumMixed /
    RandomHexLower / RandomBase64 / RandomChoice). All randomness
    uses crypto/rand — no math/rand or gosec G404 fight, matches the
    Phase 0 supplement's discipline.
  - aws.go: AKIA + 16 [A-Z0-9] body (gitleaks regex
    `AKIA[0-9A-Z]{16}`) + 40+ base64-encoded secret + canonical
    region (us-east-1 etc.) + stable S3 bucket name
  - stripe.go: sk_live_/pk_live_/whsec_ prefixes with 24/24/32-char
    [A-Za-z0-9] bodies (matches Stripe's documented live-key format)
  - github.go: ghp_ + 36 base62 body + 6 base62 "checksum" = 42 chars
    after the prefix (matches gitleaks
    `ghp_[0-9a-zA-Z]{36,255}`); plus base64 deploy-key, stable owner +
    repo names (acme-corp / internal-platform)
  - db.go: postgres://app_writer:<24-char pass>@db.internal:5432/
    app_prod?sslmode=require + redis://default:<32-char pass>@
    cache-prod.internal:6379/0 — realistic internal-DNS-style
    hostnames per spec §9.6 "Bait realism principles"

Public API:
  - recipes.Get(key) (Recipe, bool) — registry lookup
  - recipes.AvailableKeys() []string — sorted snapshot
  - Recipe types (AWS, Stripe, GitHub, DB) exported so callers can
    construct them directly if needed (registry is the normal path)

Tests (~30 cases): one test file per recipe plus shared helpers.
  - Format-regex assertions: AKIA + base32-upper, sk_live_/pk_live_/
    whsec_ + base62, ghp_ + base62, region xx-name-n
  - URL parsing for postgres + redis (real net/url validation, not
    string matching)
  - Distinct-invocations checks (20 calls → near-20 unique secrets)
  - Helper coverage: RandomAlnumUpper/Mixed/HexLower/Base64/Choice
    length + alphabet + empty-input handling

Recipes package compiles + tests pass in isolation. Generator and
registry wire-up land in next commits.

go test -race -timeout=60s ./internal/token/generators/envfile/recipes/...
passes with 0 lint issues.
2026-05-13 14:33:16 -04:00
CarterPerez-dev a36292b786 chore(canary-phase6): kubeconfig generator complete
Phase 6 ships the fifth generator in the canary registry plus the
first fake-API style trigger response. kubeconfig canary tokens are
rendered YAML pointing kubectl at /k/{id}; on any HTTP call to that
path, the handler records kubectl_path/method/query/ua in event.Extra
and responds with a Kubernetes-shaped 403 Status JSON whose message is
parameterized by the resource and the HTTP verb so the attacker sees
real-looking "Error from server (Forbidden): X is forbidden..." output.

Implementation commits in this phase:
  - 6f994f37 feat(canary): kubeconfig generator + YAML template
  - bd0c6646 feat(canary): kubeconfig fake K8s API handler
    (forbidden response)
  - ceec4d37 feat(canary): register kubeconfig generator in registry
  - 982beafd fix(canary-phase6): address audit nit before rollup

Deliverables:
  - text/template-rendered kubeconfig with cluster=prod-cluster,
    user=svc-backup-reader, bearer=t.ID, server=baseURL+/k/+t.ID
  - template.yaml.tmpl convention (not template.yaml) so pre-commit
    check-yaml hook doesn't trip on Go template tokens
  - K8s Status JSON 403 response with verb derived from HTTP method
    (GET/HEAD → list, POST → create, PUT → update, PATCH → patch,
    DELETE → delete, others → list) — richer than spec §9.5's
    hardcoded "list", forensic enhancement
  - Resource extraction via path.Base with empty/root fallback to
    "resource"
  - Event.Extra captures kubectl_path/method/query/ua as JSON
  - Defense-in-depth: nil-token path returns 403 + valid Status JSON
    + nil event (FK-safe), response shape byte-identical to valid-
    token case modulo the resource/verb message slots
  - Registry expanded 4 → 5; pending list shrinks to {envfile, mysql}
  - ~36 test cases including YAML round-trip parsing, 11-subcase IP
    precedence parity with docx/pdf, verb-derivation table, status
    JSON shape validation, nil-token shape equivalence

Audit outcome (2 agents in parallel per standing pattern):
  - superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 5 NITs
    all marked optional / defensible / not-actionable)
  - general-purpose spec-adherence vs §9.5 + §8.5: PASS (0 BLOCKER,
    0 SHOULD-FIX, 1 actionable NIT cleared in 982beafd, 2 INFO items
    documenting sanctioned deferrals to later phases)

Pre-rollup acceptance gates clean at HEAD 982beafd:
  - go build ./... + go vet ./... clean
  - go test -race -timeout=60s ./... — all packages 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
  - BACKLOG.md open section still _(none)_

Forward state for Phase 7 (envfile generator + bait recipes): pure-
string-rendering generator using crypto/rand + math/rand seeded from
crypto/rand for shuffled bait sections (aws/stripe/github/db) plus
the actual canary URL line embedded as INTERNAL_METRICS_ENDPOINT.
Trigger pattern returns to webbug-style (/c/{id} GIF). No new HTTP
route shape needed.
2026-05-13 14:26:34 -04:00
CarterPerez-dev 982beafdc9 fix(canary-phase6): address audit nit before rollup
Phase 6 audit (2 agents in parallel) returned PASS. The
general-purpose spec-adherence agent flagged one actionable NIT (the
superpowers:code-reviewer flagged the same item as defensible — fixing
it per the fix-in-phase rule regardless).

N1 — template.yaml.tmpl line 2 file-header comment read
"# template.yaml" but the on-disk filename is template.yaml.tmpl
(renamed in commit 6f994f37 to avoid the repo's check-yaml pre-commit
hook). Project file-header rule is "©AngelaMos | 2026 + filename only
(NOT path)" — the comment must match the actual filename. Updated to
"# template.yaml.tmpl".

Other audit observations marked NOT-ACTIONABLE:
- NIT (verb table): verbFromMethod handles GET/POST/PUT/PATCH/DELETE
  + default-to-list. OPTIONS/CONNECT/TRACE fall through to "list"
  which is the sanctioned ceiling per the verb-derivation enhancement
  scope. Phase 14 may revisit if scan-OPTIONS forensics matters.
- NIT (defaultResource stutter): "resource is forbidden: ... cannot
  list resource resource ..." for empty-path edge cases. Real kubectl
  never emits an empty path; cosmetic only.
- NIT (empty baseURL): TrimRight on empty string produces empty +
  "/k/" + id which is relative. Registry wire-up guarantees non-empty
  baseURL; defensive guard would be belt-and-suspenders only.
- INFO (Bearer extraction): plan task 6.2 mentions "extracts Bearer
  (or path id)" — Phase 6's generator receives *token.Token
  already-resolved per the §6.2 Generator interface, so the path/Bearer
  dispatch lands at the routing layer in Phase 9. Phase 6 punt is
  legitimate.
- POSITIVE observations from both agents are retained as-is.

Pre-rollup gate clean: go build / vet / test -race / test -tags=
integration / golangci-lint run all pass.
2026-05-13 14:26:15 -04:00
Carter Perez 7aab0c6faf
Update README.md 2026-05-13 14:25:09 -04:00
Carter Perez 972729135d
Merge pull request #233 from CarterPerez-dev/project/foundations-tier
feat(foundations): add foundations tier — three teaching-first Python projects
2026-05-13 14:24:37 -04:00
CarterPerez-dev ceec4d37d1 feat(canary): register kubeconfig generator in registry
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.
2026-05-13 14:22:32 -04:00
CarterPerez-dev bd0c664602 feat(canary): kubeconfig fake K8s API handler (forbidden response)
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.
2026-05-13 14:22:19 -04:00