Commit Graph

4 Commits

Author SHA1 Message Date
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 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 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 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