Commit Graph

862 Commits

Author SHA1 Message Date
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
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
CarterPerez-dev 6f994f3756 feat(canary): kubeconfig generator + YAML template
Phase 6 first commit. Generator produces a real-looking kubeconfig
artifact that points kubectl at the canary's /k/{id} endpoint:

  - text/template embedded YAML following the spec §9.5 layout (one
    cluster + one context + one user, all referencing the same
    prod-cluster + svc-backup-reader names for verisimilitude)
  - APIServerURL = baseURL + "/k/" + t.ID (trailing-slash safe via
    strings.TrimRight)
  - Token = t.ID embedded as the user's bearer token — when kubectl
    fires a request, the bearer in the Authorization header is exactly
    this id, giving us a second lookup path beyond the URL path
  - ClusterName = "prod-cluster", UserName = "svc-backup-reader" —
    hardcoded per spec §9.5 lines 1193-1194 (tempting bait names that
    look like a real prod service-account kubeconfig)
  - Artifact.Kind = KindText (different from docx/pdf KindFile — YAML
    is text)
  - ContentType "application/yaml"
  - Filename default "kubeconfig" with the standard *string deref+trim
    pattern from docx/pdf

The template file is named template.yaml.tmpl (not .yaml) so the
repo's pre-commit check-yaml hook does not parse it as pure YAML —
the {{.X}} Go-template tokens are not valid YAML flow mappings and
would fail the parser. .tmpl is the conventional extension for
Go-templated source files; identify-cli (which check-yaml uses to
match files) does not classify .yaml.tmpl as YAML. Future phases
needing templated YAML (envfile recipes, etc.) should follow the
same convention.

Tests parse the rendered output through gopkg.in/yaml.v3 against a
typed schema struct, asserting every field flows through correctly:
APIVersion, Kind, current-context, Clusters[0].name + .cluster.server,
Contexts[0].name + .context.cluster + .context.user, Users[0].name +
.user.token. Plus Filename defaulting subtests, base-URL
trailing-slash trim, subpath preserve, distinct-ids → distinct
outputs, bearer-token-embedded check, ends-with-newline check.

Handler (Trigger) ships in the next commit per spec §9.5 file split
({generator, handler, template}). At this commit kubeconfig.Generator
has Type() + Generate() but no Trigger() — the package compiles in
isolation but does not yet satisfy generators.Generator interface.
The registry wires it in commit 3.

go test -race -timeout=60s ./internal/token/generators/kubeconfig/...
passes.
2026-05-13 14:21:57 -04:00
CarterPerez-dev 4c25db7b1a docs(readme): add docs link for ai-threat-detection
AI Threat Detection row now has the same [Source Code] | [Docs] link
pair as the other advanced projects with learn/ content.
2026-05-13 14:05:16 -04:00
CarterPerez-dev 2856e2444f chore(canary-phase5): pdf generator complete
Phase 5 ships the fourth generator in the canary registry. PDF canary
tokens are produced by in-place byte-replacing a 76-char fixed-width
placeholder in a committed template.pdf — the placeholder occupies a
direct dictionary value in the page's /AA /O /URI action (uncompressed,
not inside any Flate stream), so substitution preserves byte length and
keeps the cross-reference table valid.

Implementation commits in this phase:
  - c9c93332 feat(canary): pdf template (binary blob + build helper)
  - 13cd6cd1 feat(canary): pdf generator (byte-replace fixed-width URI action)
  - 73dd8373 feat(canary): register pdf generator in registry

Deliverables:
  - 477-byte committed template.pdf (sha256 70c63590…7e103), reproducible
    via go run ./cmd/buildpdftemplate -out ... (rebuild produces byte-
    identical output)
  - cmd/buildpdftemplate hand-builds the PDF-1.4 with computed xref byte
    offsets, validates via pdfcpu.api.Validate before writing
  - pdf.Generator: ErrTriggerURLTooLong sentinel, length-preservation
    guard, post-substitution bytes.Contains check, _ underscore padding
    (handoff overrides spec §9.4 line 1150 space padding — see anti-
    relitigation set), defaultFilename fallback for nil/empty/whitespace
    *string Filename
  - 22 top-level tests + 11 IP-precedence subtests + 4 filename subtests
    + 3 trigger-URL subtests = ~40 effective cases under -race. All 5
    spec-required tests present (LengthUnchanged, ContainsTriggerURL,
    PlaceholderRemoved, TooLongURL_ReturnsError, PdfcpuValidates) plus
    template-invariant + boundary + same-position regression guards
  - Registry expanded 3 → 4; pending list shrinks to {kubeconfig,
    envfile, mysql}

Audit outcome (2 agents in parallel per standing pattern):
  - superpowers:code-reviewer: PASS (0 BLOCKER, 0 SHOULD-FIX, 5 NITs all
    flagged by the agent as "not actionable" / "leave as-is")
  - general-purpose spec-adherence vs §9.4 + §8.5: PASS
    (0 BLOCKER, 0 SHOULD-FIX, 2 NITs: N1 was a miscount — pdf has 11 IP
    precedence subcases identical to docx, verified via grep; N2 was the
    Chromium/PDF.js /AA-stripping caveat, sanctioned-deferred to
    learn/04-CHALLENGES.md per the post-Phase-17 learn/ allowance)

No audit-fix commit needed this phase — zero actionable findings. Unlike
Phase 3 (a46fa446) and Phase 4 (10ae118d) which cleared real findings
before rollup, Phase 5 lands clean.

Pre-rollup acceptance gates clean at HEAD 73dd8373:
  - 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 (lint discipline
    inherited from Phase 0 supplement preserved)
  - BACKLOG.md open section still _(none)_

Forward state for Phase 6 (kubeconfig generator): different shape from
file-artifact generators — kubeconfig is YAML text + a path-based
/k/{id}/* handler that records kubectl method/path/UA and responds with
a Kubernetes-shaped 403. Trigger pattern departs from the /c/{id}+GIF
convention webbug/slowredirect/docx/pdf share.
2026-05-13 13:58:55 -04:00
CarterPerez-dev 73dd837362 feat(canary): register pdf generator in registry
Fourth entry in the registry map. Cardinality bumps 3 → 4; pending list
shrinks to {kubeconfig, envfile, mysql} (3 remaining).

  - registry.go: adds the import + token.TypePDF: pdf.New() entry
  - registry_test.go:
      - TestBuild_RegistersPDF added (mirrors RegistersDocx)
      - TypePDF removed from TestBuild_PendingTypesNotYetRegistered
      - TestBuild_OnlyExpectedTypesPresentInPhase4 renamed → InPhase5,
        cardinality assertion bumped 3 → 4

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 13:54:33 -04:00
CarterPerez-dev 13cd6cd1a3 feat(canary): pdf generator (byte-replace fixed-width URI action)
Generator runs at Token-create time. Substitutes the 76-byte placeholder
in the embedded template.pdf with the canary trigger URL padded to the
same byte length. Length preservation is critical: the PDF's xref table
indexes objects by byte offset, so anything that shifts bytes after the
catalog corrupts the file.

Algorithm (spec §9.4 lines 1145-1165):

  triggerURL := strings.TrimRight(baseURL, "/") + "/c/" + t.ID
  if len(triggerURL) > 76 → ErrTriggerURLTooLong
  padded := triggerURL + strings.Repeat("_", 76 - len(triggerURL))
  out := bytes.Replace(pdfTemplate, placeholder, padded, 1)

Defensive checks after substitution:
  - len(out) == len(pdfTemplate) (xref offsets stay valid)
  - bytes.Contains(out, triggerURL) (substitution actually happened)

Trigger is a content-copy of webbug's response (sanctioned per Phase
2/3/4 anti-relitigation set): 200 + 43-byte pixel.Clone() + Cache-Control
+ Pragma headers. Nil-token path returns the same artifact shape with no
event (spec §8.5 defense-in-depth). realIP / lastNonEmptyXFF /
optionalHeader are duplicated byte-identically with docx and slowredirect
— Phase 9 collapses this into a middleware helper.

Test suite (~32 cases, mirrors the docx Phase 4 shape plus PDF-specific
structural guards):

  Template invariants (5):
    - placeholder root byte-locatable exactly once (literal, not encoded)
    - full 76-byte placeholder byte-locatable exactly once
    - starts with %PDF- header
    - ends with %%EOF marker
    - pdfcpu.api.Validate passes on the raw template

  Generator behavior (10):
    - Type() is TypePDF, Artifact.Kind is KindFile
    - ContentType is application/pdf
    - Filename defaulting (nil / empty / whitespace / set)
    - trigger URL precedence (trailing-slash trim, subpath preserve,
      distinct ids → distinct outputs)
    - output length == template length (xref preservation)
    - output contains trigger URL
    - placeholder root removed from output
    - URL > 76 chars returns ErrTriggerURLTooLong (errors.Is)
    - boundary URL at exactly 76 chars succeeds (zero padding)
    - api.Validate still passes on substituted output
    - substitution preserves byte offset (URL occupies the same position
      the placeholder did)

  Trigger behavior (~16 incl subtests):
    - response shape identical to webbug (200 + pixel.Clone + headers)
    - records event with token id / source IP / UA / Referer
    - source-IP precedence (CF > XFF rightmost > XRI > RemoteAddr),
      11 subcases mirroring docx + webbug for full symmetry
    - missing UA/Referer → nil pointers
    - per-call body is an independent slice (mutate one, other unchanged)
    - nil-token path returns GIF + nil event (FK-safe)

go test -race -timeout=60s ./internal/token/generators/pdf/... passes.
2026-05-13 13:54:24 -04:00
CarterPerez-dev c9c9333294 feat(canary): pdf template (binary blob + build helper)
Phase 5 first commit. Adds the committed template.pdf binary blob plus
the pure-Go one-shot utility that produced it, matching the docx Phase
4 pattern (cmd/build<format>template/main.go excluded from production).

cmd/buildpdftemplate/main.go assembles a minimal PDF-1.4 by hand:

  - Header: %PDF-1.4 + 4 high-bit bytes to mark as binary
  - Object 1: Catalog → Pages root
  - Object 2: Pages → single Kids ref + Count 1
  - Object 3: Page with /AA << /O << /Type /Action /S /URI /URI (...) >> >>
    where the URI value is the 76-char placeholder
    HONEY_TRACK_URL_PADDED_TO_FIXED_WIDTH______________________________________
  - xref table with computed byte offsets
  - trailer + startxref + %%EOF

The placeholder is a direct dictionary value (NOT inside a stream), so
substitution can be a plain byte-replace at runtime without breaking the
cross-reference table — spec §9.4 line 1133. No FlateDecode anywhere.

pdfcpu (v0.12.1) is used for validation only — the builder calls
api.Validate before writing, and tests will call it again on substituted
output to confirm the PDF stays well-formed.

Verification at HEAD:
  - 477-byte template.pdf
  - sha256: 70c6359016ceb780539f8c4497991b98ff82201e35a38d36090d88856027e103
  - Reproducible: rebuild produces byte-identical output
  - grep -aob HONEY_TRACK_URL → one offset (240)
  - file: PDF document, version 1.4, 1 page(s)
  - api.Validate passes

go.mod adds pdfcpu as a direct dep with its transitive deps via go mod
tidy. golang.org/x/crypto returns to direct (pdfcpu uses it for AES
encryption support).
2026-05-13 13:54:06 -04:00
CarterPerez-dev 13b2604f18 fix(canary): replace //nolint pragmas with explicit error handling
The standing "no //nolint anywhere" rule (handoff anti-relitigation
set) was honored by Phases 2-4 but inherited template code in
core/database.go, middleware/ratelimit.go, and health/handler.go
carried 6 pragmas from the original template import. Replacing each
with proper error handling rather than silencing the linter:

core/database.go
  NewDatabase ping-failure path: propagates db.Close() error via
  multi-%w fmt.Errorf rather than discarding it via _ = db.Close().
  Pattern matches the existing rollback error wrap on line 102.

  InTx + InTxWithOptions panic-recovery defer: rollback failure now
  logs via slog.Error before re-panicking, rather than discarding
  the error via _ = tx.Rollback(). The original error context is
  preserved by the panic; the rollback failure is observable.

  jitteredDuration: switches from math/rand/v2 to crypto/rand +
  math/big. The function runs once per pool init at startup so the
  per-call cost (~microseconds) is irrelevant. Eliminates the gosec
  G404 trigger without a global exclude (handoff anti-relitigation:
  "don't add new excludes for one-off issues"). Adds the missing
  maxJitter <= 0 guard that the rand.Int64N call would have panicked
  on with a base smaller than 7ns.

middleware/ratelimit.go
  writeRateLimitExceeded: json.NewEncoder().Encode error is now
  logged via slog.Error rather than discarded. slog is already
  imported and used elsewhere in the file (line 60).

health/handler.go
  writeStatus: same pattern; adds log/slog import. Brings the file
  in line with the rest of the codebase's slog-everywhere discipline.

After: zero //nolint pragmas anywhere in backend Go code
(grep -rn "//nolint" returns nothing). golangci-lint run ./... reports
0 issues. All unit tests pass under -race; all integration tests pass
under -race -tags=integration with the testcontainer Postgres.

Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing the Phase 0 lint-discipline alignment so Phases 5+
inherit a fully consistent codebase.
2026-05-13 13:28:44 -04:00
CarterPerez-dev fdc4144cbc chore(canary): drop unused auth error helpers from core
Auth-template residue in core/errors.go + core/response.go:

  - UnauthorizedError() / ForbiddenError() + Err{Unauthorized,Forbidden}
    sentinels
  - TokenExpiredError() / TokenInvalidError() / TokenRevokedError() +
    Err{TokenExpired,TokenInvalid,TokenRevoked} sentinels
  - core.Unauthorized() / core.Forbidden() response wrappers

Zero call sites anywhere in the codebase. Worse, the Token*Error names
semantically collide with canary tokens (same package, completely
unrelated meaning) — a future-reader trap that would compound once
Phase 9 wires real operator-bearer auth gates on the admin handler.

Generic AppError plumbing (NewAppError, NotFoundError, DuplicateError,
ValidationError, InternalError, RateLimitError, IsAppError, GetAppError)
is preserved — all in active use.

When Phase 9 needs operator-bearer or turnstile-gated responses, helpers
can be reintroduced with names appropriate to the actual auth design
rather than carrying generic JWT-shaped vocabulary forward into a
canary-token namespace.

Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared per
the fix-in-phase rule. BACKLOG closed section records the resolution.
2026-05-13 13:28:30 -04:00
CarterPerez-dev 31f1fff343 chore(canary): drop unused argon2 password machinery from core
internal/core/security.go was preserved through Phase 0's auth prune
untouched. Audit: zero callers anywhere in the codebase, yet the file
kept golang.org/x/crypto as a direct dep and ran a full Argon2id KDF
on every process start via a package-level init() that pre-computed
a dummy hash "to prevent timing attacks" — wasted boot CPU for an
unused codepath.

Deleted symbols:
  - HashPassword, VerifyPassword, VerifyPasswordWithRehash
  - VerifyPasswordTimingSafe + dummyHash init()
  - decodeHash, needsRehash internal helpers
  - GenerateSecureToken, GenerateRefreshToken
  - HashToken, CompareTokenHash

go mod tidy demotes golang.org/x/crypto from direct to indirect
(pgx/v5 still pulls it transitively).

Surfaced by the pre-Phase-5 cross-phase alignment audit. Cleared as
part of finishing Phase 0's auth prune rather than logged as deferred
work. BACKLOG closed section records the item + resolution.
2026-05-13 13:28:17 -04:00
CarterPerez-dev 0e47274525 chore(canary-phase4): docx generator complete
Phase 4 ships the third generator in the canary registry. docx canary
tokens are produced by zip-patching a committed template.docx — the
HONEY_TRACK_URL placeholder in word/footer2.xml's INCLUDEPICTURE field
is replaced at Generate time with the canary's /c/{id} trigger URL.
Per-entry zip Method (STORE/DEFLATE) is preserved so Word/LibreOffice
still opens the document. Trigger is shared with webbug (spec §9.3
line 1118): 200 + 43-byte transparent GIF + cache headers; nil-token
returns the same response per spec §8.5 defense-in-depth.

Implementation commits:
  f36cce9a feat(canary): docx template (binary blob + build helper)
  82bb3a9f feat(canary): docx generator (zip-patch INCLUDEPICTURE)
  5884f986 feat(canary): register docx generator in registry

Audit (two agents in parallel; spec §9.3 + §8.5):
  superpowers:code-reviewer        PASS — 0 BLOCKER / 0 SHOULD-FIX / 3 NIT
  general-purpose (spec adherence) PASS — 0 SPEC-VIOLATION, 6/6 invariants MATCH

Cleared in-phase via 10ae118d (fix(canary-phase4): address audit nits
before rollup):
  N1 — added 'RemoteAddr loopback IPv6 [::1]:9999' + 'XFF IPv6 rightmost'
       subtests to docx generator_test.go for symmetry with webbug
  N2 — renamed shadowed cErr to hErr in patchTemplate's loop body
  N3 — newDocxToken helper sets Memo/Type matching webbug's pattern;
       not actionable (stylistic positive)

Decisions baked in this phase (anti-relitigation):
- Spec §9.3 Filename example (`if t.Filename == ""`) does not compile
  against the actual schema — Token.Filename is *string per entity.go:52.
  resolveFilename safely deref's, trims, and falls back to 'Document.docx'
  for nil / empty / whitespace pointers.
- Spec example does not propagate rc.Close() after io.ReadAll — the
  implementation does, ordered so a read error wins over a close error
  (required by .golangci.yml errcheck.check-blank: true).
- cmd/builddocxtemplate is a pure-Go OOXML builder rather than the
  spec's suggested LibreOffice/python-docx workflow. Matches the
  Phase 5 cmd/buildpdftemplate precedent in the implementation plan.
  Lives at cmd/builddocxtemplate (excluded from the production binary).
- cmd helper uses filepath.Clean(out) before os.OpenFile to satisfy
  gosec G304 on an operator-supplied -out flag — no //nolint pragma
  (banned by project rule).

State after Phase 4:
  Registered generators: 3 (webbug, slowredirect, docx)
  Pending generators:    4 (pdf, kubeconfig, envfile, mysql)
  BACKLOG.md open section: still empty
  Pre-audit gate: BUILD/VET/unit/integration/lint clean across backend

Manual smoke (Task 4.6) is operator-deferred: open a generated docx
in LibreOffice with a network monitor and confirm the GET to /c/{id}
fires when the document opens. Not blocking phase rollup.

Phase 5 next: cmd/buildpdftemplate + pdf generator with byte-padded
HONEY_TRACK_URL_PADDED_…_76 placeholder in an /AA /O /URI action.
2026-05-13 03:19:54 -04:00
CarterPerez-dev 10ae118d53 fix(canary-phase4): address audit nits before rollup
Phase 4 audit (2 agents) returned PASS with three NITs. N1 + N2 cleared
in-phase per the standing fix-in-phase rule. N3 was stylistic-consistency
positive and not actionable.

N1 — generator_test.go IP-precedence subtests: added two missing cases
present in webbug's identical suite (the realIP helper triplet is byte-
identical to webbug, so this is symmetry rather than coverage):
- "RemoteAddr loopback IPv6 strips brackets and port" → "[::1]:9999"
- "XFF IPv6 rightmost" → "198.51.100.1, 2001:db8::dead"

N2 — generator.go patchTemplate loop: the second `cErr := ...` shadowed
nothing but reused the name from the close-error block earlier in the
same iteration. Renamed the header-create variant to `hErr` for clarity.

Audit verdicts:
- superpowers:code-reviewer: PASS (0 B / 0 S / 3 N)
- general-purpose spec-adherence vs §9.3 / §8.5: PASS (0 violations,
  6/6 invariants MATCH, BACKLOG.md still empty)
2026-05-13 03:19:13 -04:00
CarterPerez-dev 5884f98616 feat(canary): register docx generator in registry
Phase 4 Task 4.5 — adds token.TypeDocx: docx.New() to registry.Build.

Test updates:
- TestBuild_RegistersDocx: new, mirrors the webbug + slowredirect
  registration tests
- TestBuild_PendingTypesNotYetRegistered: drops TypeDocx from the
  pending list (4 remain: pdf, kubeconfig, envfile, mysql)
- TestBuild_OnlyExpectedTypesPresentInPhase3 →
  TestBuild_OnlyExpectedTypesPresentInPhase4: cardinality assertion
  bumped from 2 to 3
2026-05-13 03:15:00 -04:00
CarterPerez-dev 82bb3a9f14 feat(canary): docx generator (zip-patch INCLUDEPICTURE)
Phase 4 Task 4.3+4.4 — docx Generator that produces a tracked Word
document by embedding template.docx and runtime-substituting the
HONEY_TRACK_URL placeholder in word/footer2.xml with the per-token
trigger URL. Preserves per-entry zip Method (STORE/DEFLATE) so
Word/LibreOffice still opens the result; non-footer entry bodies are
byte-identical to the template.

Generate returns {Kind: KindFile, Filename: t.Filename ?? "Document.docx",
Content: <patched zip>, ContentType: wordprocessingml MIME}. The
spec §9.3 reference snippet uses `t.Filename == ""` which won't
compile against the real schema — Token.Filename is *string. The
generator dereferences safely via resolveFilename, trimming and
falling back to the default for nil/empty/whitespace pointers.

Trigger mirrors webbug exactly per spec §9.3 line 1118: 200 + 43-byte
transparent GIF via pixel.Clone() + cache-control no-store + pragma
no-cache. Nil-token returns the same response with nil event
(spec §8.5 — no token-existence enumeration). realIP /
lastNonEmptyXFF / optionalHeader helpers are copied from webbug per
the standing rule (sanctioned duplication until Phase 9 middleware
extraction).

24 test cases including the six prescribed by implementation plan
§4.3 (OutputIsValidZip, FooterContainsTriggerURL,
FooterDoesNotContainPlaceholder, OtherEntriesUnchanged,
PreservesCompressionMethods, ReturnsGIFLikeWebbug) plus surrounding
correctness/regression coverage (type, kind, content type, filename
defaulting incl. whitespace, trigger-URL trailing-slash + subpath +
uniqueness, source-IP precedence with 9 subtests mirroring webbug,
GIF body independence per Trigger call, nil-token defense).

Also fixes pre-existing lint debt in cmd/builddocxtemplate from
commit f36cce9a: errcheck on the deferred f.Close (now propagates
via named return) and gosec G304 on os.OpenFile (now filepath.Clean
on the operator-supplied -out path). Rebuilding template.docx after
this change produces byte-identical output (verified via sha256sum).
2026-05-13 03:14:12 -04:00
CarterPerez-dev f36cce9a01 feat(canary): docx template (binary blob + build helper)
Phase 4 Task 4.1+4.2 — pure-Go OOXML template builder under
backend/cmd/builddocxtemplate/. Run:

  go run ./cmd/builddocxtemplate \
    -out ./internal/token/generators/docx/template/template.docx

produces a minimal valid .docx (5 zip entries, mixed STORE/DEFLATE
compression) whose word/footer2.xml carries an INCLUDEPICTURE field
referencing the literal placeholder string HONEY_TRACK_URL with the
\\d switch (forces fetch-on-open, no local cache).

The committed template.docx is the embedded source for the Phase 4
docx generator (next commit). Mixed compression methods in the template
make Task 4.3's TestGenerate_PreservesCompressionMethods a meaningful
regression guard — a hardcode-DEFLATE generator would now mismatch on
[Content_Types].xml + word/_rels/document.xml.rels (both STORE).

cmd/builddocxtemplate is excluded from the production binary (separate
cmd/ subdir from cmd/canary).
2026-05-13 03:04:36 -04:00
CarterPerez-dev fe7d641af8 chore(canary-phase3): slowredirect generator complete
Phase 3 of 18. Adds the second token type: a slowredirect generator
that returns a `KindURL` artifact pointing at `/c/{id}`, serves an
embedded HTML+JS fingerprinting page on trigger, and exposes a
sibling `POST /c/{id}/fingerprint` endpoint that enriches the most
recent matching event's `Extra` JSONB via the existing
event.Repository.AttachFingerprint method. Generator interface
contract from Phase 2 unchanged; registry adds the second entry.

Implementation commits:
  71823f5d feat(canary): slowredirect generator (Generate + Trigger + template)
  6dd520f9 feat(canary): slowredirect fingerprint endpoint
  be13d2f1 feat(canary): register slowredirect generator in registry
  a46fa446 fix(canary-phase3): address audit findings before rollup

Audit outcome — TWO agents (superpowers:code-reviewer + general-purpose
spec adherence) dispatched in parallel. Code-reviewer returned FAIL
with 1 blocker + 4 should-fix + 7 nits; spec-adherence returned PASS-
with-1-violation. Both findings cleared in commit a46fa446 per the
fix-in-phase rule:

  • BLOCKER B1 — template.html used `{{...|js}}` which Go's html/
    template double-escapes in a JS string-literal context. The
    on-the-wire output became `\\u003D` / `\\u0026` (double backslash),
    which JS parses as literal backslash-u-text rather than `=` / `&`.
    Realistic URLs like `?utm_source=newsletter&utm_medium=email`
    were silently corrupted before `window.location.replace`. The
    bug was faithfully transcribed from spec §9.2 lines 1031, 1038,
    1045 — the spec's parenthetical claiming `js` is a custom func
    is factually wrong. Resolution: drop `| js` from both JS-context
    actions; rely on html/template's built-in jsstrescaper.
    Regression test TestTrigger_DestinationRoundtripsThroughJSStringDecode
    asserts the absence of `\\u003D` / `\\u0026` for a real URL.
    Spec doc (local-only) updated to match.

  • SPEC-VIOLATION #1 — nil-token Trigger returned `404 + "Not Found"`,
    contradicting spec §8.5 ("token not found | 200, ... deliberately
    do NOT 404") and §8.5 line 964 ("/c/* endpoints never return JSON
    errors"). 404 is a token-enumeration oracle. Resolution: nil-token
    branch renders the same template with a benign decoy destination
    ("/") and returns 200 + text/html + the full CSP/cache header set.
    Body is byte-shape indistinguishable from a valid response except
    for the embedded destination URL. The `(nil event, &resp, nil
    error)` return shape from the Phase 2 anti-relitigation rule is
    preserved. New test
    TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken
    asserts the indistinguishability invariant.

  • SHOULD-FIX S1 — extractDestination allowlists `http(s)://`
    (case-insensitive); other schemes (`javascript:`, `data:`,
    `file:`, `vbscript:`, scheme-relative `//host`, no-scheme) now
    return the new exported ErrInvalidDestinationScheme. Phase 9's
    upstream `validate:"url"` is necessary but not sufficient (the
    validator's `url` tag accepts any scheme). 8-case
    TestGenerate_RejectsDangerousDestinationSchemes + 4-case
    TestGenerate_AcceptsHTTPAndHTTPSSchemes added.

  • SHOULD-FIX S3 — fingerprint handler silently 204s any
    Content-Type that doesn't start with `application/json` (the
    embedded template always sends JSON). Rejects before the
    MaxBytesReader read. Test added.

  • SHOULD-FIX S4 — added integration tests for oversize body
    (128 KiB → 204, Extra untouched) and empty token id
    (`/c//fingerprint`).

  • SHOULD-FIX S2 — nonce-based CSP suggested as a senior-bar polish.
    Deferred: this is a spec deviation (§9.2 line 1047 specifies
    `script-src 'unsafe-inline'` verbatim) and crosses into Phase 9
    territory (global SecurityHeaders middleware). Re-evaluate when
    Phase 9 wires the trigger handler.

  • Nits N1-N7 reviewed; the realIP/lastNonEmptyXFF duplication
    is sanctioned per the Phase 2 anti-relitigation note (Phase 9
    middleware promotion is the cleanup point). Other nits either
    correct as-is or deferred to Phase 9+ tasks.

Pre-rollup gate clean: `go build ./...`, `go vet ./...`,
`go test -race -timeout=60s ./...` (unit), `go test -tags=integration
-race -timeout=300s ./internal/token/... ./internal/event/...`, and
`golangci-lint run ./...` (0 issues). BACKLOG.md open section
remains empty.

Registry cardinality: 2 of 7 token types live (webbug + slowredirect).
Five remain: docx (Phase 4), pdf (Phase 5), kubeconfig (Phase 6),
envfile (Phase 7), mysql (Phase 8).
2026-05-12 06:47:46 -04:00
CarterPerez-dev a46fa4465e fix(canary-phase3): address audit findings before rollup
Phase 3 audits returned one BLOCKER and one SPEC-VIOLATION plus two
should-fix items; all cleared in-phase per the no-rot rule.

BLOCKER — template.html double-escaped JS-context interpolations.
Go's html/template auto-escapes {{.X}} in <script> string-literal
position via its built-in jsstrescaper. Piping through the explicit
`| js` (JSEscaper) re-ran the escaper, producing `\\u003D` /
`\\u0026` on the wire (double backslash). JS parses `\\u003D` as a
literal backslash followed by "u003D" text rather than `=`, so any
realistic destination URL — e.g.
https://news.example.com/article?utm_source=newsletter&utm_medium=email
— was corrupted into
https://news.example.com/article?utm_source=newsletter&utm_medium=email
before being passed to window.location.replace. The unit tests
previously missed it because every test destination was an
escape-free string. Fix: drop `| js` from both JS-context actions
in template.html; the contextual auto-escaper alone produces single-
backslash `=` which JS correctly decodes. Spec §9.2 line 1031
and 1038 (and the explanatory paragraph 1045) are factually wrong
about `| js` being a custom template func — it is `JSEscaper`, and
adding it on top of the built-in contextual JS string escaper is
pure double-escape harm. Spec docs (local-only, not committed)
amended accordingly. Added
TestTrigger_DestinationRoundtripsThroughJSStringDecode as a
regression guard against re-introducing the pipeline.

SPEC-VIOLATION — nil-token Trigger returned `404 + "Not Found"`,
breaking spec §8.5 ("token not found | 200, ... deliberately do NOT
404") and §8.5 line 964 ("/c/* endpoints never return JSON errors").
A 404 on a non-existent token ID lets scanners enumerate which IDs
are real. Fix: nil-token branch now renders the same template with a
benign decoy destination ("/") and returns 200 + text/html with the
full CSP override and cache headers. Response is byte-shape
indistinguishable from a valid-token response except for the
destination URL embedded in the script. Added
TestTrigger_TokenNotFound_HasSameResponseShapeAsValidToken to
assert the indistinguishability invariant. The `(nil event, &resp,
nil error)` return shape from the handoff anti-relitigation is
preserved.

SHOULD-FIX S1 — extractDestination now allowlists `http://` and
`https://` (case-insensitive) and returns the new
ErrInvalidDestinationScheme sentinel otherwise. The noscript
meta-refresh sits in an HTML-attribute context where html/template
does NOT recognize `url=...` as a URL context, so `javascript:`,
`data:`, `file:`, `vbscript:` and scheme-relative `//example.com`
would otherwise render verbatim and follow on JS-disabled clients.
Phase 9's `validate:"url"` on input is necessary but not sufficient
(the validator's `url` tag accepts any scheme). Added
TestGenerate_RejectsDangerousDestinationSchemes (8 cases) and
TestGenerate_AcceptsHTTPAndHTTPSSchemes (4 case-variants).

SHOULD-FIX S3 — fingerprint handler now silently 204s on any
Content-Type that does not start with "application/json". The
embedded template is the only legitimate caller and always sends
JSON; this rejects adversarial probes before the MaxBytesReader
read. Added
TestFingerprintHandler_WrongContentType_Returns204AndDoesNotTouchEvent.

SHOULD-FIX S4 — added integration tests for the oversize-body cap
(128 KiB body returns 204, Extra untouched) and the empty-token-id
guard (`/c//fingerprint`); the latter accepts chi's natural routing
behavior alongside the in-handler 204.

Non-functional cleanups: extracted shared template-render path
into renderWith / renderResponse / renderDecoyResponse to keep the
nil-token + happy-path bodies definitionally identical;
ErrMissingDestination and ErrInvalidDestinationScheme exported so
callers (and tests) can ErrorIs them; TestGenerate_RejectsMissing*
tightened to use require.ErrorIs(tc.wantErr) instead of bare
require.Error.

Pre-rollup gate (go build / vet / test -race unit+integration /
golangci-lint) is clean.
2026-05-12 06:46:45 -04:00
CarterPerez-dev be13d2f1c4 feat(canary): register slowredirect generator in registry
Phase 3 wiring: registry.Build now maps token.TypeSlowRedirect to
slowredirect.New() alongside the existing webbug entry. The pending-
types regression guard sheds SlowRedirect from its list (5 remain:
docx, pdf, kubeconfig, envfile, mysql) and the cardinality assertion
moves from "exactly one generator" to "exactly two".
2026-05-12 06:34:12 -04:00
CarterPerez-dev 6dd520f966 feat(canary): slowredirect fingerprint endpoint
POST /c/{id}/fingerprint handler that decodes a JSON fingerprint body
and merges it into the most recent event's Extra JSONB for the same
(token_id, source_ip) tuple within a 30s window via the existing
event.Repository.AttachFingerprint method. Decoupled from the concrete
repository through a small FingerprintAttacher interface so future
event.Service can swap in without touching this package.

Per spec §9.2 the fingerprint POST is enrichment-only: a missing match
(event.ErrNotFound), an invalid JSON body, an empty body, and a body
exceeding 64 KiB all silently return 204. Only unexpected repository
errors are logged via slog. The token id is read from chi.URLParam("id")
so the route mounts naturally under the existing trigger router in
Phase 9.

Three integration tests against testcontainers cover the happy-path
JSONB merge, the no-matching-event path (silent 204), and the
invalid-JSON path (204 plus stored Extra left untouched).
2026-05-12 06:34:04 -04:00
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