Commit Graph

10 Commits

Author SHA1 Message Date
CarterPerez-dev 9655120ebe chore(canary): phase B audit fixes + frontend asset rename + gitignore
Audit Phase B (backend): F5 trusted-proxy gate wiring, F7 ErrValidation
sentinel, F8 dedup unique-IP semantic via SCard, F9 notify worker pool +
graceful Shutdown, F12 token ID collision retry.

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

Housekeeping: .npmrc strict-dep-builds=false (pnpm fix), .gitignore now
excludes all .env.* except .env.example.
2026-05-17 23:34:42 -04:00
CarterPerez-dev 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 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 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 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 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 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 c39b56b9af fix(canary): clear pre-audit lint debt + migrate golangci config to v2
Pre-audit gate (`golangci-lint run`) at the start of Phase 2 surfaced 15
issues that should have been zeroed before Phase 1 rollup. Per the
fix-in-phase rule (no backlog rot), clearing everything before Phase 2
audit agents run on a green tree.

Config:
- .golangci.yml: migrate `issues.exclude-rules` and `issues.exclude-dirs`
  to v2 syntax (`linters.exclusions.{rules,paths}`); test-file funlen/
  dupl/goconst exclusion now actually applies under golangci-lint v2.10
- .golangci.yml: add G706 to gosec excludes — false-positive log-injection
  reports on slog structured-logging call sites (slog separates message
  from kv args, immune to log-line injection by construction)

errcheck (5):
- cmd/canary/main.go: `_ = telemetry.Shutdown(...)` → log on error
- internal/token/repository.go: `defer stmt.Close()` → log on close error
- internal/event/repository.go: same as token
- internal/testutil/postgres.go: `_ = pgContainer.Terminate(...)` and
  `_ = db.Close()` → t.Logf on cleanup error (use distinct err names to
  avoid govet shadow)

funlen (1):
- cmd/canary/main.go: split `run` into `run` + `initTelemetry` +
  `mountRouter` + `gracefulShutdown` (was 55 statements, now under
  the 50 cap; helpers are individually well under)

govet shadow (1):
- cmd/canary/main.go: migrations check switched from `if err :=` to
  `if err =` (reuses outer err whose value was already consumed) — the
  inner short-decl was lexically shadowing without intent
- cmd/canary/main.go: select-case `err` renamed to `startErr` to avoid
  the same shadow pattern

golines (6, all auto-fixed by `golangci-lint run --fix`):
- main.go, config.go, telemetry.go, event/repository.go, token/dto.go,
  token/repository.go — long lines wrapped to 80-col

Verified post-fix: build OK, vet OK, unit tests PASS under -race,
integration tests PASS under -tags=integration -race (12s testcontainers),
golangci-lint reports 0 issues.
2026-05-12 02:49:56 -04:00
CarterPerez-dev 697f4909d7 fix(canary-phase1): clear all post-phase-1 audit observations + header normalization
No 'logged for later' — every non-blocking finding from the Phase 1 audits
is fixed now, in this commit, before declaring the phase truly closed.

Code cleanups (5 items):
1. Database.SQLDB() *sql.DB accessor on core.Database; main.go uses
   db.SQLDB() instead of the awkward db.DB.DB triple-chain.
2. Hoisted list-default literals (50, 20) to package-level
   defaultListLimit consts in token + event repositories. MEMORY.md
   'no magic numbers' rule honored.
3. Moved ptr[T any] helper to internal/testutil/ptr.go as testutil.Ptr;
   removed local copies from token + event repository_test.go.
4. Renamed CHECK constraints in 0001_create_tokens.sql to match spec
   text exactly: chk_token_type → chk_type, chk_alert_channel → chk_channel.
   Spec was the original contract; impl now aligns.
5. Resolved APP_ENVIRONMENT vs ENVIRONMENT divergence per spec §12.1:
   - compose.yml: ENVIRONMENT=production → APP_ENVIRONMENT=production
   - dev.compose.yml: ENVIRONMENT=development → APP_ENVIRONMENT=development
   - config.go envKeyMap: ENVIRONMENT → APP_ENVIRONMENT mapping

Concurrency bug fix (real, not just polish):
- core/migrations.go: added sync.Mutex around goose calls. goose's
  package-level state (SetBaseFS, SetDialect) raced under t.Parallel()
  testcontainers tests. Race detector caught it under -race after
  parallel test counts climbed. Mutex serializes goose invocation
  globally; testcontainer-parallelism otherwise unaffected.

Header normalization (50 files):
- Bulk-normalized every project file's header to canonical
  '©AngelaMos | 2026' (no space, with © glyph, year 2026) per MEMORY.md
  style rule. Eliminated 4 distinct non-canonical variants: '// AngelaMos',
  '// ©AngelaMos | 2025', '// © AngelaMos | 202X', '# AngelaMos'.
- Verified clean: grep returns zero non-canonical headers across the
  whole project tree (excluding gitignored docs/ and node_modules/).

Backlog discipline:
- Created docs/plans/BACKLOG.md with strict format: open items only,
  HIGH/MEDIUM/LOW severity, must-clear-before-ship contract.
- BACKLOG currently has zero open items — every observation was fixed
  here, not deferred. Closed-items section logs what was cleared.

Verification:
- go build ./...                                     clean
- go vet ./...                                       clean
- go test -tags=integration -race ./internal/token/...  11/11 PASS
- go test -tags=integration -race ./internal/event/...   9/9 PASS
- docker compose -f compose.yml config              parses
- docker compose -f dev.compose.yml config          parses
- grep for non-canonical headers                     zero matches
2026-05-10 06:15:26 -04:00
CarterPerez-dev b2558e2e41 feat(canary): Phase 1 — Postgres schema + token & event repositories
Schema (3 goose migrations under internal/core/migrations/):
- 0001_create_tokens.sql: tokens table per spec §7.1
  Columns: id (varchar 12, PK), manage_id (UUID unique), type, memo,
           filename, alert_channel, telegram_bot/chat, webhook_url,
           created_at/ip/fp, enabled, trigger_count, last_triggered, metadata (jsonb)
  CHECK constraints: chk_token_type (7-token enum), chk_alert_channel (telegram|webhook),
                     chk_telegram_complete (when channel=telegram, bot+chat required),
                     chk_webhook_complete (when channel=webhook, url required)

- 0002_create_events.sql: events table
  Columns: id (bigserial PK), token_id (FK with ON DELETE CASCADE),
           triggered_at, source_ip (inet), user_agent, referer,
           geo_country/region/city/asn/asn_org, extra (jsonb),
           notify_status, notified_at
  CHECK chk_notify_status (pending|sent|failed|deduped)

- 0003_indexes.sql: full index set per spec §7.1
  tokens(created_ip), tokens(created_fp), tokens(created_at DESC),
  tokens(type), partial idx_tokens_trigger_count WHERE trigger_count > 0,
  events(token_id, triggered_at DESC), events(source_ip),
  partial idx_events_notify_pending WHERE notify_status = 'pending'

Migration runner (internal/core/migrations.go):
- go:embed migrations/*.sql baked into binary
- core.RunMigrations(*sql.DB) called from main.go after DB connect
- pressly/goose v3.27.1 (research-recommended; no dirty-state bug)

Token domain (internal/token/):
- entity.go: Token struct + typed Type/AlertChannel enums with Valid() guards
- dto.go: CreateRequest with validator/v10 tags (oneof, required_if, url, max),
          Response shape with ToResponse(triggerURL, manageURL) helper
- repository.go: Insert (RETURNING created_at + counters), GetByID,
                 GetByManageID, DeleteByManageID (FK cascade), IncrementTriggerCount,
                 SetEnabled, ListAll + CountAll. ErrNotFound on sql.ErrNoRows.
                 Named-parameter binding via sqlx.NamedExecContext.
- repository_test.go (//go:build integration): 11 tests covering insert,
  get-by-id, get-by-manage-id, not-found paths, delete with cascade,
  trigger count increment, enable toggle, list pagination, type CHECK

Event domain (internal/event/):
- entity.go: Event struct + NotifyStatus enum
- dto.go: GeoView + Response with ToResponse() flattens geo into nested object
- repository.go: Insert, GetByID, ListByToken (cursor pagination via
                 LIMIT N+1, returns NextCursor + HasMore), CountByToken,
                 AttachFingerprint (UPDATE most-recent within window using
                 jsonb || merge), UpdateNotifyStatus, PruneToLimit
                 (window function row_number() PARTITION BY token_id)
- repository_test.go (//go:build integration): 9 tests covering insert,
  cursor-paginated listing, FK cascade from token deletion, fingerprint
  merge into existing extra jsonb, notify status update, prune-to-N
  preserves newest-first, prune rejects zero limit

Test infrastructure:
- internal/testutil/postgres.go: testcontainers-go helper that spins up
  postgres:18-alpine, applies migrations via core.RunMigrations, returns
  ready *sql.DB with t.Cleanup-registered teardown. ~5-7s per test;
  20 integration tests run in ~13s end-to-end.

Wired into main.go:
- core.RunMigrations(db.DB.DB) called after NewDatabase
- token + event repos instantiated; blank-assigned for now (services
  consume them in Phase 9/10)

Verification:
- go build ./...                                      clean
- go vet ./...                                        clean
- go test -tags=integration ./internal/token/...      11 of 11 PASS
- go test -tags=integration ./internal/event/...      9 of 9 PASS
2026-05-10 05:47:24 -04:00