Commit Graph

833 Commits

Author SHA1 Message Date
CarterPerez-dev 71823f5de2 feat(canary): slowredirect generator (Generate + Trigger + template)
HTML+JS browser-fingerprint redirect page rendered via html/template:
Generate reads metadata.destination_url off the token row and returns
KindURL with the trigger URL + persisted destination; Trigger renders
the embedded template (noscript meta-refresh + inline fetch posting
the fingerprint, then window.location.replace), returns the page with
Content-Security-Policy override allowing inline script + connect-src
'self', and emits the event capturing token id / source IP / UA /
Referer.

Nil-token Trigger returns nil event (FK guard) plus a 404 HTML body,
matching the established defensive-rendering pattern from webbug.
Destination_url is extracted defensively (missing/empty/whitespace
all surface ErrMissingDestination). realIP precedence copied from
webbug pending the Phase 9 middleware promotion.

19 unit-test assertions cover Type, Generate's URL+destination output,
five missing-destination edge cases, XSS neutralization in two
injection contexts (attribute escape + script-closing escape), CSP
override + cache headers, event-recording metadata, nil-token contract,
and per-call response independence.
2026-05-12 06:33:52 -04:00
CarterPerez-dev 8207b2747b chore(canary-phase2): generator interface + webbug complete
Phase 2 of the 18-phase plan. Lands the plugin scaffolding (Generator
interface, shared transparent-pixel package, generator registry) and the
first concrete generator (webbug). Phase 2 is intentionally the smallest
phase — subsequent phases add slowredirect, docx, pdf, kubeconfig,
envfile, and mysql generators against the same Generator interface.

Implementation commits (oldest → newest):

  6cc724c1 feat(canary): Generator interface for token-type plugins
  0b6e586e feat(canary): shared 43-byte transparent GIF for image triggers
  9c0ad749 feat(canary): webbug generator (Generate + Trigger)
  bb95f745 feat(canary): generator registry (token.Type → Generator)
  c39b56b9 fix(canary): clear pre-audit lint debt + migrate golangci config to v2
  1a240952 fix(canary-phase2): address audit findings before rollup

Deliverables vs. plan:

- Generator interface (spec §6.2): Type/Generate/Trigger contract with
  ArtifactKind url|file|text|connection_string, Artifact discriminated
  by Kind, TriggerResponse value type so generators stay pure.
- Pixel package: 43-byte canonical GIF89a literal exposed via
  pixel.Clone() (immutable source, independent slice per call) +
  pixel.Len() helper + ContentType const.
- Webbug generator (spec §9.1 + §8.5): Generate returns KindURL
  artifact with baseURL/c/{id} (trailing slash trimmed). Trigger returns
  200 + image/gif + 43-byte pixel + no-store cache headers.
  realIP precedence: CF-Connecting-IP > X-Forwarded-For (rightmost
  non-empty) > X-Real-IP > net.SplitHostPort(RemoteAddr). Handles
  IPv4/IPv6 (including bracketed form). Empty XFF entries (trailing
  comma, all-empty list) fall through to the next source. Nil token
  returns nil event + non-nil GIF response — the contract is explicit
  in the return shape, so the future Phase 9 handler cannot accidentally
  insert an event row with empty TokenID (which would fail the events
  → tokens FK anyway).
- Generator registry: lives in backend/internal/token/generators/registry/
  (sub-package, NOT the generators package itself) to break the
  generators → webbug → generators import cycle the implementation plan
  inadvertently created. registry.Config + registry.Registry +
  registry.Build. Phase 2 registers webbug only; cardinality test
  asserts exactly 1 and enumerates the 6 future types as absent.
- Cross-cutting cleanup (c39b56b9): pre-audit gate found Phase 0/1
  golangci-lint debt the prior phase audits didn't catch. Per
  fix-in-phase rule, cleared all 14 (5 errcheck, 6 golines, 1 funlen
  via run() → run+initTelemetry+mountRouter+gracefulShutdown split,
  1 govet shadow, plus the 1 Phase-2-introduced funlen-on-test that
  the broken .golangci.yml v1-syntax exclude-rules failed to suppress
  under golangci-lint v2). Also migrated .golangci.yml to v2 schema
  (linters.exclusions.{paths,rules}) and added gosec G706 to excludes
  (false-positive log-injection on slog structured-logging call sites).

Phase 2 audit — TWO agents in parallel per standing pattern:

  superpowers:code-reviewer       → PASS (9 findings, all cleared in-phase
                                          by commit 1a240952)
  general-purpose (spec-adherence)→ PASS (every spec contract item
                                          verified; 3 known deltas
                                          accepted: Type() refined to
                                          token.Type, registry moved to
                                          subpackage to break cycle,
                                          optionalHeader bridges
                                          plan-vs-schema pointer types)

Findings cleared in-phase (from code-reviewer):

  MEDIUM — realIP fallback returned "IP:port"; now net.SplitHostPort
  MEDIUM — XFF rightmost empty entry skipped fallback; now walks right-
           to-left and falls through cleanly
  MEDIUM — pixel.TransparentGIF was exported mutable []byte; now
           unexported + pixel.Clone() per call
  LOW    — nil token returned non-nil event with empty TokenID; now
           returns nil event (explicit contract)
  LOW    — no IPv6 coverage in IP precedence tests; added 3 IPv6 cases
           + XFF IPv6 + port-less RemoteAddr
  NIT    — gracefulShutdown swallowed shutdown errors; now errors.Join

Accepted as-is (NIT, Phase 3+ concern):

  NIT — Artifact discriminated-union may want sealed-interface
        refactor once non-URL kinds land
  NIT — Registry exposed bare map (acceptable: read-only post-Build,
        Go memory model permits concurrent map reads with no writes)
  NIT — registry.Build accepts but ignores its Config (signature
        reserved for future stateful generators like mysql)

Forward-looking notes for Phase 3 (NOT deferred items, just heads-up):

  - Phase 3's main.go wire-up cannot copy spec §6.3's
    `generators.BuildRegistry(cfg.Canary)` verbatim; the call is now
    registry.Build(registry.Config{...}) and the cfg.Canary field will
    need to be declared on config.Config.

Deferred items: NONE. BACKLOG.md open section remains empty.

Quality gates (final, post-fix):

  go build ./...                      OK
  go vet ./...                        OK
  go test -race ./...                 PASS (4 packages, 20 unit tests)
  go test -tags=integration -race     PASS (token + event integration,
                                            ~12s testcontainers)
  golangci-lint run ./...             0 issues

Audited: PASS.
2026-05-12 02:57:29 -04:00
CarterPerez-dev 1a240952c7 fix(canary-phase2): address audit findings before rollup
Two audit agents (code-reviewer + spec-adherence) both returned PASS but
flagged substantive findings. Per fix-in-phase / no-backlog-rot, clearing
every MEDIUM + LOW in-phase.

Findings addressed:

MEDIUM — realIP RemoteAddr fallback returned "IP:port" verbatim, where
the spec wants the bare IP (geoip + downstream parsing assume host-only).
Now uses net.SplitHostPort with raw-string fallback if not host:port.
Adds 4 RemoteAddr cases: IPv4 strips port, IPv6 bracket form strips
brackets+port, loopback IPv6, and the port-less raw fallback.

MEDIUM — realIP XFF branch accepted whatever rightmost-comma-split
produced, so headers like "198.51.100.1, " (trailing comma) yielded
"" and skipped XRI/RemoteAddr entirely. Extracted into lastNonEmptyXFF
which walks right-to-left and falls through cleanly. Adds two cases:
trailing-comma falls through, all-empty entries fall through.

MEDIUM — pixel.TransparentGIF was an exported mutable []byte; any caller
could clobber it process-wide (mutating one response's body would affect
every subsequent webbug trigger). Renamed to unexported transparentGIF
+ exposed pixel.Clone() and pixel.Len(). Webbug now calls pixel.Clone()
per trigger; new test TestTrigger_ResponseBodyIsIndependentCopyPerCall
verifies two triggers return independent slices.

LOW (reviewer-escalated to HIGH-for-Phase-3) — Trigger on nil token
returned a non-nil event with empty TokenID. Since events.token_id is a
NOT NULL FK to tokens.id, the Phase 3 handler could not have persisted
that event anyway, and the contract was implicit (would have required
an inline comment, which the no-comments rule forbids). Now: nil token
in → nil event, non-nil response out. Contract is explicit in the
return shape. Test asserts evt is nil for nil-token path.

LOW — no IPv6 coverage in IP-precedence tests. Added IPv6 cases for
XFF rightmost and three RemoteAddr forms (bracketed, loopback, no-port).

NIT — gracefulShutdown swallowed every shutdown error, returning nil
unconditionally. Now collects with errors.Join so callers can detect
partial-shutdown for telemetry/exit-code purposes.

Audits accepted as-is (NIT, not blocking):
- Artifact discriminated-union shape (Phase 3+ concern when other Kinds
  are produced)
- Registry returning bare map (read-only post-Build; concurrent reads
  are safe by Go's memory model)
- Build(_ Config) ignoring its arg (signature reserved for future
  stateful generators)

Quality verified post-fix: build/vet/lint clean (0 issues),
14 webbug tests + 6 pixel tests + 4 registry tests PASS under -race,
integration tests unchanged.
2026-05-12 02:56:29 -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 bb95f74579 feat(canary): generator registry (token.Type → Generator)
Lives in its own subpackage to break the otherwise-cyclic import
(generators interface → webbug impl → generators interface): the registry
must import concrete implementations, which themselves import the
generators package for Artifact/TriggerResponse types.

- registry.Config { BaseURL string } (reserved for future stateful generators)
- registry.Registry = map[token.Type]generators.Generator
- registry.Build registers webbug only; subsequent phases append
  slowredirect, docx, pdf, kubeconfig, envfile, mysql

Tests assert webbug present, unknown type returns nil, pending types
are not yet claimed, Phase 2 cardinality is exactly 1.
2026-05-12 02:43:53 -04:00
CarterPerez-dev 9c0ad74973 feat(canary): webbug generator (Generate + Trigger)
- Generate returns KindURL artifact: baseURL/c/{id} (trailing slash trimmed)
- Trigger builds event from CF-Connecting-IP|XFF(rightmost)|XRI|RemoteAddr precedence,
  captures UA + Referer (nil when absent), returns 43-byte GIF response with
  no-store cache headers
- Nil-token path still returns GIF (spec §8.5 defense-in-depth)
- Tests: artifact shape, request metadata capture, IP precedence chain (5 cases),
  nil-pointer mapping for absent headers, GIF response shape + cache headers,
  missing-token path

realIP() lives here temporarily; promotes to middleware in Phase 9.
2026-05-12 02:42:17 -04:00
CarterPerez-dev 0b6e586eb1 feat(canary): shared 43-byte transparent GIF for image triggers
Used by webbug, docx, pdf, envfile triggers. Single source of truth.
Test asserts length, magic bytes, GIF89a trailer, and decodes via image/gif.
2026-05-12 02:40:24 -04:00
CarterPerez-dev 6cc724c19f feat(canary): Generator interface for token-type plugins
- ArtifactKind enum: url, file, text, connection_string
- Artifact struct discriminated by Kind
- TriggerResponse value-typed (handler writes; generator stays pure)
- Generator interface: Type, Generate, Trigger
2026-05-12 02:40:00 -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 7a98ef8d72 chore(canary-phase1): database & repositories complete
Phase 1 deliverables (audited PASS by superpowers:code-reviewer + general-purpose):

Implementation (1 commit):
- b2558e2e  Postgres schema + token & event repositories + testcontainers helper

Schema:
- 3 goose migrations under internal/core/migrations/ (tokens, events, indexes)
- All spec §7.1 columns + types + CHECK constraints present
- All 8 spec indexes present, including 2 partial indexes
- FK ON DELETE CASCADE on events.token_id (verified by TestRepository_FKCascade)
- chk_token_type enforces the 7-token locked enum exactly

Repositories:
- token: Insert / GetByID / GetByManageID / DeleteByManageID / IncrementTriggerCount /
         SetEnabled / ListAll / CountAll
- event: Insert / GetByID / ListByToken (cursor pagination, LIMIT N+1 trick) /
         CountByToken / AttachFingerprint (jsonb || merge into most-recent within
         configurable window) / UpdateNotifyStatus / PruneToLimit (window function)
- Both: ErrNotFound on sql.ErrNoRows; all errors wrapped with %w; Repository struct
        + NewRepository(*sqlx.DB) constructor pattern matches template

Tests:
- 11 token integration tests + 9 event integration tests, all PASS
- testcontainers/postgres:18-alpine spins per test (~5-7s each)
- Tagged //go:build integration so plain `go test ./...` stays cheap
- Coverage: insert, get, not-found, delete with cascade, atomic increment,
  enable toggle, list pagination across 3 pages, fingerprint merge preserving
  existing extras, prune-keeps-newest, prune-rejects-zero, type CHECK rejection

Wired into main.go: core.RunMigrations(db.DB.DB) on startup; tokenRepo +
eventRepo blank-assigned (services consume them in Phase 9/10).

Audit findings (informational, non-blocking, deferred):
- Spec text says CHECK names 'chk_type'/'chk_channel'; implementation uses
  'chk_token_type'/'chk_alert_channel' (audit prompt's expected names — both
  agents endorse). Functionally identical; spec literal could be amended.
- main.go uses 'db.DB.DB' triple-chain to pass *sql.DB to goose. Visually
  awkward; a Database.SQLDB() accessor would clean it up. Cleanup pass later.
- Default list limits (50, 20) are inline literals; hoisting to package consts
  would satisfy MEMORY.md 'no magic numbers' rule. Minor.
- ptr[T any] helper duplicated across token + event test files. Could move to
  testutil. Trivial.
- spec §12.4 ENVIRONMENT vs APP_ENVIRONMENT mismatch (logged in Phase 0
  rollup) carries forward; not Phase 1's concern.

Verification at phase boundary:
- go build ./...                                clean
- go vet ./...                                  clean
- go test -tags=integration ./internal/token/...   11/11 PASS
- go test -tags=integration ./internal/event/...    9/9 PASS

Phase 1 complete. Next: Phase 2 (generator interface + webbug + 1×1 GIF).
2026-05-10 05:54:03 -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
CarterPerez-dev a86939f412 chore(canary-phase0): bootstrap & cleanup complete
Phase 0 deliverables (audited via superpowers:code-reviewer + general-purpose):

Implementation (8 commits + 1 audit-fix commit):
- 5ccabe00  Comprehensive .gitignore (Go, Node, env, OS, builds, caches)
- 382890cb  Rebrand no-auth-template → canary-token-generator across compose
- 7fa2861e  Strip JWT/users, rename module to project-local path, rewrite main
- 8b70bfbb  Merge backend compose into project-root (single deployment unit)
- a5c8d171  canary.dev (Air) + canary.prod (distroless static) Dockerfiles
- 369c9548  Unified Justfile with frontend/backend/lint/compose/tunnel groups
- a2d6f09a  Project-root .env.example + idempotent init.sh
- a4811d1c  Import operator's React + Vite frontend template
- 98c00689  Address audit findings (vite.prod paths, broken backend/Dockerfile,
            backend/Justfile, admin AuthService residue, header glyphs)

Verification at phase boundary:
- go build ./... + go vet ./...    clean
- docker compose -f compose.yml config + dev.compose.yml config + cloudflared overlay  all parse
- just --list parses, randomized host ports preserved (22784, 58495, 15723, 5447, 6022, 16686, 4317, 4318)
- grep returns zero hits for: react-scss, carterperez-dev/templates, JWTConfig,
  JWT_PRIVATE, lestrrat, InvalidateAllSessions
- backend has no host port in production compose
- module path: github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend

Deliberately deferred (logged for later phases):
- Template-imported Go files (server.go, logging.go, request_id.go, headers.go,
  ratelimit.go, health/handler.go, core/*.go, config/config.go) carry
  '// AngelaMos | 2026' headers without ©. Out of Phase 0 scope. Bulk hygiene
  pass possible later.
- frontend/vite.config.ts and frontend/stylelint.config.js carry 2025 in their
  headers (operator-imported template files). Same deferred bucket.
- backend/internal/admin/handler.go now has handler-only structure; OperatorBearer
  middleware wiring is Phase 12.
- Recovery middleware not yet present in main.go middleware chain — Phase 9
  introduces it alongside the token domain.
- config.go envKeyMap does not yet know about canary-specific env vars
  (PUBLIC_BASE_URL, TURNSTILE_SECRET, OPERATOR_TOKEN, GEOLITE_PATH,
  WEBHOOK_HMAC_SECRET, MYSQL_FAKE_ENABLED). compose.yml passes them in via
  ENV but config.go cannot read them yet — Phase 9 extends this.

Phase 0 complete. Next: Phase 1 (database schema + repositories).
2026-05-10 05:38:27 -04:00
CarterPerez-dev 98c006897d fix(canary-phase0): address audit findings before phase rollup
Phase 0 audit (superpowers:code-reviewer + general-purpose) returned FAIL
on shared findings. This commit resolves the blockers + important items.

Blockers:
- infra/docker/vite.prod: replace 'react-scss/' COPY paths with 'frontend/'
  (would have broken 'docker compose build nginx')
- Delete backend/Dockerfile (broken template debris referencing deleted
  cmd/api, missing migrations/, deleted keys/)
- Delete backend/Justfile (docker compose recipes pointed at deleted
  backend/compose.yml + backend/dev.compose.yml; project-root justfile
  [backend] group covers everything operationally)
- backend/internal/admin/handler.go: strip the residual JWT/auth scaffolding
  (AuthService interface, authSvc field, authenticator+adminOnly mw params).
  Phase 12 will gate /admin under OperatorBearer; for now RegisterRoutes
  takes only chi.Router.
- Delete backend/.env + backend/.env.example: stale template duplicates
  containing only template keys; project-root .env.example is the source
  of truth per spec §12.4.

Important:
- Add © glyph to file headers in cloudflared.compose.yml, infra/docker/
  vite.{dev,prod}, backend/.gitignore (operator's standing rule:
  '©AngelaMos | 2026', not 'AngelaMos | 2026')
- backend/.gitignore: drop stale 'keys/*.pem' / 'keys/*.key' entries
  (backend/keys/ no longer exists)
- scripts/init.sh: rename 'local_tmp' variable to 'tmp_dir' to remove
  visual ambiguity with the 'local' bash keyword (both audit agents
  briefly misread it as 'local local_tmp=...')
- .env.example: add inline comment explaining the dual NGINX_HOST_PORT
  defaults (22784 prod compose, 58495 dev compose; do not override in
  shared .env)

Deferred (logged but not blocking Phase 0):
- Pre-existing template Go files (internal/{config,core,server,health,
  middleware/{request_id,logging,headers,ratelimit},admin}/*.go) carry
  '// AngelaMos | 2026' without ©. Bulk-fixing these belongs to a
  hygiene pass; not Phase 0 scope.
- frontend/vite.config.ts and frontend/stylelint.config.js reference
  '2025'; operator-imported template files, separate concern.
- APP_ENVIRONMENT vs ENVIRONMENT spec/impl mismatch: spec §12.1 sample
  uses APP_ENVIRONMENT; envKeyMap in config.go uses ENVIRONMENT (template
  default). Implementation is internally consistent. Logged in plan
  Appendix C as a non-blocking spec amendment candidate.

Verification: go build ./... + go vet ./... clean. grep returns no
hits for react-scss / carterperez-dev/templates / JWTConfig /
InvalidateAllSessions across backend/, infra/, scripts/, compose files.
2026-05-10 05:37:32 -04:00
CarterPerez-dev a4811d1c04 feat(canary): import operator's React + Vite frontend template
Frontend stack (pre-rebranded by operator before Phase 0):
- React 19 + react-router-dom v7
- TypeScript 5.9 with project references
- Vite 7 (rolldown-vite — Rust port)
- TanStack Query v5 + axios for data
- Zustand v5 for client state
- Zod v4 for runtime validation
- SCSS (Sass), not Tailwind — operator stylistic choice
- Biome 2 (lint + format), Stylelint for SCSS
- react-error-boundary, sonner (toasts)
- react-icons

Source layout (operator-authored skeleton):
- src/App.tsx, main.tsx, config.ts, styles.scss
- src/api/{hooks,types,index.ts}        TanStack Query hooks
- src/components/                        UI primitives (operator-designed later)
- src/core/{api,app,lib}                 axios client, routers, utilities
- src/pages/                             page-level containers
- src/styles/                            SCSS partials

Per implementation plan §16.3, Phase 14 stops at API client only —
operator owns visual/component design from there.

Pre-commit hooks fixed:
- site.webmanifest: missing trailing newline
- index.html: trailing whitespace stripped
- .stylelintignore + stylelint.config.js: chmod -x (no shebang, not executable)

node_modules/ is gitignored.
2026-05-10 05:28:09 -04:00
CarterPerez-dev a2d6f09ab3 feat(canary): add project-root .env.example + idempotent init.sh
.env.example covers every env var the canary stack reads:
- Public: APP_NAME, NGINX_HOST_PORT, PUBLIC_BASE_URL, VITE_APP_TITLE, VITE_API_URL
- Anti-bot: TURNSTILE_SITE_KEY/SECRET + VITE_TURNSTILE_SITE_KEY (frontend mirror)
- Operator: OPERATOR_TOKEN (admin endpoints)
- DB: POSTGRES_PASSWORD + POSTGRES_DEV_PORT
- Cache: REDIS_DEV_PORT
- GeoIP: MAXMIND_ACCOUNT_ID/LICENSE_KEY (optional)
- Webhooks: WEBHOOK_HMAC_SECRET (optional)
- Fake MySQL: MYSQL_FAKE_ENABLED + MYSQL_HOST_PORT (optional, off by default)
- Logging: LOG_LEVEL, LOG_FORMAT
- Telemetry: OTEL_ENABLED, OTEL_EXPORTER_OTLP_ENDPOINT + Jaeger ports
- Tunnel: CLOUDFLARE_TUNNEL_TOKEN (for cloudflared.compose.yml)

scripts/init.sh rewritten as idempotent setup helper:
- Copies .env.example -> .env on first run
- Generates POSTGRES_PASSWORD + OPERATOR_TOKEN via openssl rand -hex 32 if blank
- Skips already-set values (idempotent)
- Conditionally fetches GeoLite2-City.mmdb when MAXMIND creds present
- All sed usage is operator-side (script run-time), not Claude tool-time

scripts/randomize-ports.sh kept as-is (operator helper for spinning up
sibling projects on non-conflicting ports).
2026-05-10 05:27:30 -04:00
CarterPerez-dev 369c954892 feat(canary): unified Justfile with frontend + backend + compose groups
Replaces template Justfile (which referenced ./react-scss/). Groups:

[frontend]   pnpm install/dev/build/preview, biome, stylelint, tsc
             Targets at fe-install/fe-dev/fe-build/fe-preview prefixed for clarity
[backend]    go mod tidy, vet, golangci-lint, test (unit + integration tags),
             coverage HTML, run, static build, air hot reload
[lint]       lint = be-lint + biome + stylelint + tsc;  ci = lint + test
[compose]    docker compose lifecycle for prod (up/start/down/stop/build/logs/ps)
[tunnel]     overlay with cloudflared.compose.yml for prod+tunnel
[dev]        docker compose lifecycle for dev.compose.yml
[util]       init (gen secrets + fetch GeoLite2), ports, info, clean

Justfile parses cleanly via 'just --list'. Frontend dir reference fixed
(now ./frontend, not ./react-scss).
2026-05-10 05:25:27 -04:00
CarterPerez-dev a5c8d17134 feat(canary): add canary.dev + canary.prod Dockerfiles + import infra/
Production Dockerfile (infra/docker/canary.prod):
- Multi-stage: golang:1.25-alpine builder → distroless/static:nonroot final
- CGO_ENABLED=0, -trimpath, -ldflags='-s -w' → minimal static binary
- Embeds config.yaml; runs as nonroot user
- ENTRYPOINT /canary, CMD -config /config.yaml
- EXPOSE 8080

Development Dockerfile (infra/docker/canary.dev):
- golang:1.25-alpine + air-verse/air for hot reload
- Source bind-mounted by dev.compose.yml; .air.toml drives rebuilds
- go mod download cached at image-build time, dep refresh at runtime if changed
- EXPOSE 8080

Also imports the rest of the template's infra/ that was untracked:
- infra/docker/vite.dev + vite.prod (existing, unchanged)
- infra/nginx/{nginx.conf, dev.nginx, prod.nginx, nginx.prod.conf}
  These will be extended in Phase 15 with /api, /c, /k upstream blocks.
2026-05-10 05:24:42 -04:00
CarterPerez-dev 8b70bfbba9 chore(canary): merge backend compose into project root
Single project-root compose.yml + dev.compose.yml manage all services.

Production stack (compose.yml):
- nginx          public ingress on \${NGINX_HOST_PORT:-22784}:80
- canary         Go backend, NO host port (reachable only via nginx)
                 distroless/static:nonroot Dockerfile
                 healthcheck → /healthz, depends on postgres + redis
- postgres       postgres:18-alpine, no host port, named volume pgdata
- redis          redis:7-alpine, no host port, named volume redisdata
- (geolite vol)  read-only mount /data for GeoLite2-City.mmdb

Dev stack (dev.compose.yml):
- nginx          dev ingress on \${NGINX_HOST_PORT:-58495}:80
- frontend       Vite HMR on \${FRONTEND_HOST_PORT:-15723}:5173
- canary         Air hot-reload (canary.dev Dockerfile, bind-mounts ./backend)
                 OTel exports to jaeger:4317
- postgres       host port \${POSTGRES_DEV_PORT:-5447}:5432 for psql access
- redis          host port \${REDIS_DEV_PORT:-6022}:6379 for redis-cli access
- jaeger         UI 16686, OTLP gRPC 4317, OTLP HTTP 4318
- volumes        gocache + gomodcache speed up rebuilds

All randomized host ports preserved exactly:
  22784 (prod nginx), 58495 (dev nginx), 15723 (vite), 5447 (pg dev),
  6022 (redis dev), 16686/4317/4318 (jaeger).

Backend has no host port in production: nginx is the single entrypoint.
Cloudflare Tunnel sidecar terminates at nginx:80 via cloudflared.compose.yml.

backend/compose.yml + backend/dev.compose.yml deleted (merged here).

Validation:
- docker compose -f compose.yml config           — OK
- docker compose -f dev.compose.yml config       — OK
- docker compose -f compose.yml -f cloudflared.compose.yml config — OK
2026-05-10 05:23:55 -04:00
CarterPerez-dev 7fa2861e7a feat(canary): backend bootstrap — strip JWT/users, rename module, rewrite main
Removes the template's JWT auth + user domain (Phase 0 §0.4):
- Deleted backend/internal/auth/ (entire JWT auth domain)
- Deleted backend/internal/user/ (user CRUD domain)
- Deleted backend/keys/ (JWT signing keys directory)
- Deleted backend/internal/middleware/auth.go (Authenticator + RequireAdmin)
- Deleted Justfile generate-keys recipe (no JWT keys to generate)
- Removed lestrrat-go/jwx/v3 + transitive deps via go mod tidy
- Stripped JWTConfig type, defaults, env mappings, validators from config.go
- Removed jwt: section from config.yaml
- Removed JWT_* lines from backend/.env and .env.example

Renames Go module to project-local path (Phase 0 §0.5):
  github.com/carterperez-dev/templates/go-backend
  → github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend
- Rewrote imports in 6 remaining .go files using Edit tool (NEVER sed per repo rule)
- Updated .golangci.yml local-prefixes + gci section ordering

Renames cmd/api → cmd/canary and rewrites main.go (Phase 0 §0.6):
- mv cmd/api cmd/canary
- Rewrote cmd/canary/main.go as canary bootstrap (config, telemetry, db,
  redis, middleware chain, health, /api stub) — no auth wiring
- Updated .air.toml cmd path
- Updated backend/Justfile run/build targets to cmd/canary + bin/canary
- Renamed docker-build image tag to canary-token-generator:latest

Rebrands defaults:
- config.go: app.name "Go Backend" → "Canary Token Generator"
- config.go: otel.service_name "go-backend" → "canary-token-generator"
- config.yaml: app.name "Go Backend Template" → "Canary Token Generator"
- backend/.env(.example): OTEL_SERVICE_NAME → canary-token-generator

Drops user-aware rate-limit helpers from middleware/ratelimit.go:
- Removed KeyByUser, KeyByUserAndEndpoint, normalizeEndpoint, isUUID, isNumeric
- Removed TierConfig, DefaultTiers, TieredRateLimiter (referenced GetUserID)
- KeyByIP, NewRateLimiter, PerMinute/PerSecond/PerHour preserved

Verification:
- go build ./...    — clean
- go vet ./...      — clean
- go mod tidy       — silent (deps consolidated)
- grep -r "carterperez-dev/templates|JWTConfig|JWT_PRIVATE|lestrrat" → empty

backend/compose.yml + backend/dev.compose.yml are still present here; they
get merged into project-root compose files in Task 0.7 (next commit).
2026-05-10 05:22:08 -04:00
CarterPerez-dev 382890cb56 chore(canary): rebrand from no-auth-template to canary-token-generator
- compose.yml: APP_NAME default → canary-token-generator
- dev.compose.yml: APP_NAME default → canary-token-generator
                   ./react-scss bind paths → ./frontend (template flatten)
- cloudflared.compose.yml: APP_NAME default → canary-token-generator
- VITE_APP_TITLE default in both compose files → "Canary Token Generator"

Frontend package.json and index.html were already rebranded by operator
between sessions. All three compose stacks now validate via docker compose
config (prod, dev, prod+tunnel overlay).
2026-05-10 05:15:09 -04:00
CarterPerez-dev 5ccabe00b5 chore(canary): expand project .gitignore for Go + Node + secrets
- Ignore docs/ (dev-only planning material)
- Ignore env files except .env.example
- Ignore backend Go build/test artifacts (bin, tmp, coverage)
- Ignore frontend Node + Vite caches and dist
- Ignore local data volumes (geoip mmdb, sqlite databases)
- Ignore OS/editor cruft and lint caches
2026-05-10 05:13:19 -04:00
CarterPerez-dev 7d69339413 fix: extract osv package string literals to constants
goconst counts occurrences across the whole package (including test files)
when deciding whether to flag a non-test file — the exclusion only suppresses
reports FROM test files. Add unexported constants for severity levels, CVSS
types, ecosystem, and reference types in client.go, and update client_test.go
to use them so the total raw-string count per literal drops below threshold.
2026-05-08 06:30:08 -04:00
CarterPerez-dev 25671b4f9d fix: resolve simple-vulnerability-scanner goconst failures
Migrate .golangci.yml from v1 issues.exclude-rules to v2
linters.exclusions.rules — the v1 key was silently ignored by golangci-lint
v2, so test-file goconst exclusions weren't applied. With the config fixed,
all test-file violations disappear. Extract severity constants in output.go
to fix the 4 remaining non-test goconst violations (CRITICAL/HIGH/MODERATE/
LOW each appear 3x in severityRank, severityBreakdown, and severityColorFn).
2026-05-08 06:21:26 -04:00
CarterPerez-dev 5863be7dac fix: pin pnpm to v10 in CI and add .npmrc to all frontend projects
pnpm latest on Node 22 resolves to pnpm 11 which rejects lockfileVersion
9.0 lockfiles with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Pinning to pnpm 10
keeps compatibility with existing lockfiles. .npmrc sets
strict-dep-builds=false so build-script warnings don't hard-fail the
frozen install.
2026-05-08 05:46:38 -04:00
CarterPerez-dev adeec9b36d fix: resolve CI lint failures across frontend and Go projects
Node.js bumped to 22 in lint workflow (pnpm 11.x requires >=22.13).
Extract goconst-flagged string literals in secrets-scanner to named
constants. Carry along monitor-dashboard docker/package fixes.
2026-05-08 05:40:04 -04:00
Carter Perez ca8382482b
Merge pull request #208 from CarterPerez-dev/project/monitor-the-situation-dashboard
add learn folder to IM MONITORING THE SITUATION DASHBOARD project
2026-05-08 04:55:09 -04:00
CarterPerez-dev 1b9fcbac13 add learn folder to IM MONITORING THE SITUATION DASHBOARD project 2026-05-08 04:43:29 -04:00
Carter Perez 95f5622320
Merge pull request #205 from CarterPerez-dev/dependabot/npm_and_yarn/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/axios-1.15.0
chore(deps): bump axios from 1.13.2 to 1.15.0 in /PROJECTS/advanced/monitor-the-situation-dashboard/frontend
2026-05-04 19:52:33 -04:00
dependabot[bot] 509c79a3d1
chore(deps): bump axios
Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 22:59:57 +00:00
Carter Perez 1a04003ce1
Merge pull request #204 from CarterPerez-dev/project/monitor-the-situation-dashboard
Project/monitor the situation dashboard
2026-05-04 18:56:41 -04:00
CarterPerez-dev 5e7f2c36de I'm being gangstalked by the CIA, they put a chip in my brain 2026-05-04 18:53:54 -04:00
Carter Perez 480503dbfa
Update README.md 2026-05-04 03:48:28 -04:00
CarterPerez-dev 6e35f49d2a feat(monitor/backend): swpc emits rich space_weather payload (kp + bz_gsm + speed/density + xray flux/class) 2026-05-03 19:39:41 -04:00
CarterPerez-dev 6f062bf49b feat(monitor/frontend): earthquake + outage panels (left + right column, USGS / CF Radar) 2026-05-03 19:25:03 -04:00
CarterPerez-dev 887dc217d2 fix(monitor/frontend): drop --type-body 13px → 12px (more density for table data; KPI/hero numbers unchanged) 2026-05-03 19:18:03 -04:00
CarterPerez-dev 0695f5d9a4 fix(monitor/frontend): bgp panel CC column wider (HK was truncating to H...) 2026-05-03 19:15:26 -04:00
CarterPerez-dev cf38f88efd fix(monitor/frontend): supplementary centroids (HK et al) + bgp schema lenient (victimAsns nullable, dedup geo/threat types) 2026-05-03 19:11:12 -04:00
CarterPerez-dev 0f53e67236 feat(monitor/frontend): bgp hijack panel + globe plot via abuseipdb-enriched country 2026-05-03 19:06:11 -04:00
CarterPerez-dev c25fd57eaf feat(monitor/frontend): dashboard layout — CVE Velocity to left under ISS, BGP HIJACKS to right top 2026-05-03 19:05:45 -04:00
CarterPerez-dev 0705f37330 fix(monitor/frontend): dshield panel (topports record has date/limit siblings of port entries) 2026-05-03 18:54:34 -04:00
CarterPerez-dev 038de64605 fix(monitor/backend): gdelt switch to timelinevol mode (article-snippet source of malformed escapes is gone) 2026-05-03 18:42:27 -04:00
CarterPerez-dev e216331aeb feat(monitor/backend): abuseipdb enrichment for bgp hijacks (country + abuse_score in payload) 2026-05-03 18:20:15 -04:00
CarterPerez-dev e690b30e92 feat(monitor/frontend): country-centroid plotting for ransomware + internet_outage on globe 2026-05-03 18:11:15 -04:00
CarterPerez-dev 0a41102e75 fix(monitor/frontend): globe snapshot seed + pushPoint dedup-by-id (instant eq+iss dots, single moving iss) 2026-05-03 17:56:50 -04:00
CarterPerez-dev a6dcaed7dc feat(monitor/frontend): globe country outlines (thin --fg-4 stroke, local 110m geojson) 2026-05-03 17:49:55 -04:00
CarterPerez-dev aa9a24bdd0 feat(monitor/frontend): dashboard lifecycle hook (snapshot bootstrap → WS → typed routing)
useDashboardLifecycle() orchestrates the three pieces that make the
dashboard alive:

1. Audio gesture unlock — unlockOnFirstGesture() on mount so playChime()
   is ready when Phase 6 wires alert triggers.
2. Snapshot-then-WS — useSnapshot()'s isSuccess gates createDashboardWS
   so we never open the WS before initial state is in TanStack cache
   (per spec §10.2 race resolution). After connect+setReady the server
   stops buffering and starts streaming deltas.
3. Globe ring eviction — every 5min calls useGlobeEvents.evict(now)
   so expired pulse rings drop off.

Topic routing (typed switch, no `as any` — every payload narrows to a
local interface that matches the verified backend shape):
  cve_new          → useCveStore.push
  kev_added        → useKevStore.push
  ransomware_victim → useRansomwareStore.push
  coinbase_price   → usePrices.pushTick (ISO ts → number, snake_case
                     volume_24h → camelCase)
  earthquake       → globe point + ring pulse (4s TTL)
  iss_position     → globe point (id='iss-current' so each tick replaces
                     the previous) + setQueryData(iss_position) so the
                     ISS panel sees the latest
  wiki_itn         → ticker push (source 'Wikipedia')
  gdelt_spike      → ticker push (source 'GDELT', headline includes
                     z-score and count)
  space_weather/internet_outage/bgp_hijack/scan_firehose → setQueryData
                     to merge into snapshot (panels read from there)
  heartbeat        → no-op (connection liveness only)

Small bug fix in browserDriver: WebSocket.send() throws when readyState
is CONNECTING. createDashboardWS.setReady() can be called before the
socket opens (we call it immediately after snapshot resolves), so the
inner sock.send is now guarded by a readyState===OPEN check. The
onOpen handler in createDashboardWS resends init when the socket
actually opens — belt-and-suspenders.

This is the load-bearing wiring per the plan. Panels stop rendering
single snapshot rows and start growing as WS events arrive.
2026-05-03 09:47:40 -04:00
CarterPerez-dev 71e0529960 feat(monitor/frontend): about modal (meme + project explainer, ethos-minimal)
Native <dialog> opened from the TopStrip ? icon via useUIStore.aboutOpen.
Ref-driven showModal()/close() in useEffect, onClose synced to the store
so Escape closes both the dialog and the store flag in one round trip
(no listener fight). Three short paragraphs: the meme origin, the data
sources, and the keyboard shortcuts. The kbd-styled spans (mono +
1px --fg-4 outline) call out F and Esc.

No animation on open/close beyond the browser's default <dialog>
showModal transition (which is OS UI, not decoration). No backdrop-click
to close — Escape and the close button are enough; backdrop-click adds
a fragile target===dialog comparison for marginal value. ::backdrop
gets a 60% black tint so it reads as a modal, not a layer cake.

prose line-height 1.4 (looser than the 1.25 table tightness because
prose needs readability — operator-density still applies but Tufte
data-ink isn't violated by paragraph leading).

Plan 5 Task 23 Design QA: no illustration, no confetti, no tooltip
arrows, monochrome border, native ::backdrop only.
2026-05-03 09:43:27 -04:00
CarterPerez-dev 21f54390df feat(monitor/frontend): audio chime infrastructure (load/unlock/play, phase 6 wires the trigger)
Per spec §10.5 — three pure functions sitting on top of the existing
useAudioStore (so React surfaces can subscribe to .unlocked if a
"click anywhere to enable audio" toast is needed later):

  loadChime(file)              fetch / decode arraybuffer into AudioBuffer
  unlockOnFirstGesture()       attach one-shot pointerdown listener that
                               resumes a suspended AudioContext, sets
                               unlocked=true. Idempotent across re-mounts
                               via module-level listenerAttached guard
                               (StrictMode double-mount safe).
  playChime() → boolean        returns false if the audio context isn't
                               ready / buffer not loaded / not unlocked,
                               so callers can fall back to a toast.

Phase 6 calls playChime() on alert match; Phase 5 only wires the
unlockOnFirstGesture() call into the dashboard lifecycle (Task 24)
so the audio is ready when Phase 6 lands.
2026-05-03 09:41:22 -04:00
CarterPerez-dev 43df53f845 feat(monitor/frontend): ISS panel (live position + next-pass placeholder for phase 6)
Four-row layout: Lat/Lon (degree symbol, mono), Alt (km, mono tabular),
Vel (km/h with thousands separator, mono tabular), Next Pass (— stub).

Reads snapshot.iss_position directly (no per-panel store — current
position is the only thing the panel cares about, and the wheretheiss.at
collector pushes a complete state every 10s, so latest-event semantics
match what the panel needs). Real shape verified: latitude/longitude/
altitude/velocity/timestamp/fetched_at — all numeric except fetched_at
ISO string. Stale threshold 30s (3x the 10s poll cadence).

Next Pass row stays "—" muted --fg-3 — Phase 6 wires the SGP4-driven
user-observer-location pass prediction per spec §10.4. No "sign in to
add observer" CTA: operator UI doesn't onboard.

Plan 5 Task 21 Design QA: degree symbol + mono coords, alt/vel mono
tabular, no decorative ISS-tracing widget (the centerpiece globe owns
that), next-pass row visible-but-muted (never hidden).
2026-05-03 09:40:02 -04:00
CarterPerez-dev f0bb0dcb8d feat(monitor/frontend): space weather panel (Kp 9-bar, solar wind, X-ray flux)
Three-row layout (label | value | secondary or 9-bar). Kp index renders as
a 9-segment grid bar — segments below kp filled --fg-2, at-or-above outlined
--fg-4. When kp >= 7 (G3+ geomagnetic storm) the entire filled portion goes
amber AND the panel's stale dot fires amber — one of the few legitimate
amber-data uses per ethos. X-ray class M or X also flips amber.

Backend snapshot caveat: the SWPC collector currently emits only
{ts, pushed} as the WS event payload — the actual kp / bz_gsm / speed_kms
/ density / xray_class / xray_flux readings live in Redis ring buffers
(per spec §6.2) but aren't yet projected into snapshot.space_weather.
The TS interface defines all fields optimistically; the panel renders
"—" / empty 9-bar for missing fields. When the backend extends the SWPC
event payload (or a separate snapshot enrichment lands), the panel
auto-fills with no frontend changes.

KP_SEGMENT_KEYS is an explicit string array instead of Array.from(...,
i) to avoid biome's noArrayIndexKey warning while preserving the
positional segment semantics.

Plan 5 Task 20 Design QA: Kp bar uses ONLY --fg-2 / --fg-4 / --amber
(no other colors), values mono tabular, X-ray class letter sans, no
sun glyph / aurora photo / weather-forecast iconography.
2026-05-03 09:38:52 -04:00