diff --git a/.env.example b/.env.example index 15352fb..f081966 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,15 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Google AdSense publisher ID — builds the Google Privacy & Messaging CMP script URL. # Public identifier (appears in every visitor's script URL), not a secret. NEXT_PUBLIC_GOOGLE_CMP_PUB_ID=pub-XXXXXXXXXXXXXXXX + +# ParseForge Discord's #announcements incoming webhook URL. A server-only +# secret (no NEXT_PUBLIC_ prefix — never bundled to the client). Leaving this +# blank disables the auto-post feature entirely; no code change needed. +DISCORD_ANNOUNCEMENTS_WEBHOOK_URL= + +# D-16: server-only shared secret (at least 32 characters, no NEXT_PUBLIC_ +# prefix) that the ParseForge Discord bot on Railway sends in the +# x-parseforge-bot-key header so its calls to /api/analyze, /api/raid-overview +# and /api/report/{code} are limited per Discord member instead of by its one +# shared Railway IP. Leaving it blank keeps every request on the per-IP limits. +BOT_INTERNAL_API_KEY= diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 6cd7d6c..42821cc 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -37,7 +37,13 @@ A player pastes a Warcraft Logs URL and instantly gets **accurate**, actionable - ✓ Healer-relevant analysis: effective HPS / overheal % / uptime from one shared helper on both the player card and the raid Healer Breakdown, plus healer-only suggestion rules thresholded against top healers (ACC-04) — Phase 2 - ✓ Game data as generated truth: `npm run regen-game-data` wago.tools pipeline, three era modules with Classic/TBC-first collision precedence, `lib/cla-constants.ts` a thin re-export, `docs/GAME-DATA-AUDIT.md` as the standing review artifact (ACC-01) — Phase 2 - ✓ Regression net: recorded WCL fixtures drive tests for `cla-engine`, `raid-overview-engine`, `wcl-client`, timeline and healer engines (14 files / 135 tests); a red suite now blocks a deploy in the OPS-01 gate (ACC-02) — Phase 2 -- ✓ Phase 2 shipped to production (`dpl_5bwk1fJJNuZXkoC5poPGFZQpGy6c`) after a preview sweep; PR #15 open for review — Phase 2 +- ✓ Phase 2 shipped to production (`dpl_5bwk1fJJNuZXkoC5poPGFZQpGy6c`) after a preview sweep; PR #15 merged 2026-09-14 — Phase 2 +- ✓ Phase 2 code-review findings (CR-01, WR-01..03) fixed and live (PR #16, 2026-09-14); Phase 2.1 review fixes (CR-01, WR-01..04 incl. `consent_resolved` dedupe) live in `dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5` — Phase 2 / 2.1 +- ✓ PostHog capture restored for every non-EEA/UK visitor: server-side geo opt-in via `/api/geo` (`x-vercel-ip-country`, fail-closed to the consent region), no `__tcfapi` dependency; EEA/UK/CH TCF gate byte-for-byte unchanged and confirmed in a real browser (reject / accept / CMP re-confirm, WR-03 dedupe live); every event carries `consent_gate_path` (MONY-01 preserved, OPS-01 re-verified) — Phase 2.1 +- ✓ OPS-01 gate hardened: mandatory post-deploy live-traffic check (item 7: HogQL thresholds, zero-events-is-a-FAIL, bounded netlog proof); Phase 1/2 records carry dated append-only addenda; Phase 2.1 signed 2026-09-15 on counted evidence (25 `$pageview` / 3 countries / 25:23 vs Vercel) — Phase 2.1 +- ✓ Share loop v2: fight-level roast/award cards (15-rule `computeAwards` pool held to the D-01 tone bar), per-player permalinks whose OG image shows that player's parse, normalized share CTAs reachable on desktop and at phone width, and a `ref=` landing funnel instrumented in PostHog (`share_action` / `share_landing` carrying `consent_gate_path`) — Phase 3 +- ✓ `docs/PROTECTED-ELEMENTS.md` checklist + `npm run protected-elements` machine check (6 protected DOM attributes, 4 route/canonical checks, exit 0/1/2 discipline) so Phases 4 and 7 cannot silently regress the share CTA or OG pipeline — Phase 3 +- ✓ OPS-01 Phase 3 gate signed (`docs/OPS-01-SHIP-GATE.md` Part 5, 2026-09-19, `sign-now-conditional`): GSC no-regression on `/` and `/analyze/*` with no separately indexed `?view=`/`?ref=` permutation, item-7 live-traffic thresholds re-measured PASS on a full hour; ASVS L1 security review `03-SECURITY.md` 50/50 threats closed — Phase 3 ### Active @@ -50,7 +56,7 @@ A player pastes a Warcraft Logs URL and instantly gets **accurate**, actionable - [ ] Indexing plumbing: structured data fixes (FAQPage not detected on `/tbc-audit`), sitemap health, recrawl nudges, internal links **Viral loop:** -- [ ] Raise share rate from ~2.8% of analyses — share UX, shared-page landing experience +- [ ] Raise share rate from ~2.8% of analyses — share UX and landing funnel shipped in Phase 3; first post-ship reading 0/19 on ~7.5h exposure; confirmatory 7-day re-read due on/after 2026-09-23T09:15Z (WINDOWS #11), then iterate **Design & UX:** - [ ] Site-wide design overhaul (via impeccable workflow) — current UI is "super messy"; reference top ~3 closest-matching styles from https://styles.refero.design/; full redesign allowed but must stay recognizably ParseForge (colors/identity preserved) @@ -61,7 +67,6 @@ A player pastes a Warcraft Logs URL and instantly gets **accurate**, actionable **Product quality:** - [ ] Expand tool capabilities beyond the Phase 2 accuracy work (per-player share image bakes in the healer percentile basis — a `costly` decision to revisit in Phase 3) -- [ ] Close the Phase 2 code-review findings: CR-01 healer suggestions fire when `hasHealing` is false (wrong recommendation, live), WR-01 timeline "player not in fight" check is report-scoped not fight-scoped, WR-02 StrictMode double-fires `timeline_filter_used`, WR-03 stale precedence comment in `game-data.test.ts` (`.planning/phases/02-accuracy-analysis-depth/02-REVIEW.md`) **Ecosystem & community:** - [ ] Connect LootList+ ↔ ParseForge (cross-promotion between the two sites) @@ -89,7 +94,8 @@ A player pastes a Warcraft Logs URL and instantly gets **accurate**, actionable - Site-wide CTR is structurally misleading (navigational impressions dominate) — never treat it as a health metric; segment first - Title template appends " | ParseForge" (13 chars) — child page titles must stay under ~47 chars; always render pages to verify, don't eyeball metadata - **Next keyword candidate**: `wow log analyzer` (392 impr/28d, pos 3.5, tool intent) — segment before building -- **Known concerns** (from CONCERNS.md): CSP still report-only. *(Resolved Phase 1: PostHog EU consent flow now gated by the Google CMP. Resolved Phase 2: core engines now under a fixture-driven regression net.)* Deferred from Phases 1 and 2: formal ASVS security review — gsd security tooling not installed in this profile; per-plan STRIDE registers exist +- **Known concerns** (from CONCERNS.md): CSP still report-only. *(Resolved Phase 1: PostHog EU consent flow now gated by the Google CMP. Resolved Phase 2: core engines now under a fixture-driven regression net. Resolved Phase 2.1: the Phase 1 CMP gate had silently dropped ~99.9 % of PostHog capture for non-EEA visitors from 2026-09-06 to 2026-09-14 — fixed by server-side geo opt-in; `02.1-DIAGNOSIS.md`.)* Deferred from Phases 1 and 2: formal ASVS security review — gsd security tooling not installed in this profile; per-plan STRIDE registers exist. Phase 2.1's own `02.1-SECURITY.md` was closed at ASVS L1 grep-depth (45 threats, 0 open) +- **Operational carry-forward from Phase 2.1**: the `dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5` redeploy's own OPS-01 item-7 row is unsigned (low-traffic window) — re-measure a busier UTC hour at the Phase 3 gate; a PostHog-vs-Vercel capture gap for BR/SG (likely ad blockers) is a `WINDOWS.md` candidate; `NEXT_PUBLIC_GOOGLE_CMP_PUB_ID` is Production-only, so the TCF path is observable only on prod - **Manual follow-ups after Phase 2**: paste `https://parseforge.gg/privacy` into AdSense → Privacy & messaging → message site settings (still open); PostHog event definitions (`timeline_viewed`, `timeline_error`, `timeline_filter_used`, `analysis_complete`, `theme_changed`, `consent_resolved`) — confirmed by the developer at the Phase 2 UAT, but the PostHog MCP connection returns `INVALID_API_KEY` and its active project is LootList+ App, so re-wire it before the Phase 3 gate; GSC shows `/` and `/analyze/*` as "Crawled – currently not indexed" with pre-deploy crawl dates — request recrawl of the Phase 2 build and watch `/` specifically; `origin/main` is at `55d2010` (pre-Phase-2) and Phase 2 lives on PR #15 (`growth/phase-2-accuracy-depth`) — merge or push to restore parallel worktree execution; two game-data ids (96264, 96294) flagged for human review in `.planning/WINDOWS.md` - **Feedback source today**: owner's personal Discord (uncomfortable) — motivates the community requirement @@ -123,6 +129,12 @@ A player pastes a Warcraft Logs URL and instantly gets **accurate**, actionable | One healer-metrics helper feeds both the player card and the raid overview | D-08: two surfaces must never disagree; parity is asserted by test and was confirmed live in production | ✓ Good (Phase 2) | | Preview-first deploy plus push + PR before shipping Phase 2 | Regenerated game-data names had the least automated coverage; PR #15 makes the regen diff reviewable (D-12) | ✓ Good (Phase 2) — prod `dpl_5bwk1fJJNuZXkoC5poPGFZQpGy6c` | | Defer the formal ASVS security review for Phase 2 (same as Phase 1) | `gsd-secure-phase` still not installed in this profile; per-plan STRIDE registers exist | — Pending (install tooling, run `/gsd-secure-phase 02`) | +| Server-side geo opt-in for non-consent-region visitors, decided from `x-vercel-ip-country`, fail-closed to the TCF path | `cookieless_mode: "on_reject"` + `__tcfapi`-dependent opt-in dropped all PENDING-consent capture for fresh visitors; a boolean `/api/geo` keeps PII out of the request path and leaves EEA/UK semantics untouched | ✓ Good (Phase 2.1) — capture restored; TCF path confirmed in-browser at UAT | +| OPS-01 gate can never pass on pre-deploy data: mandatory counted live-traffic check, zero events = FAIL, append-only corrections to past records | Phases 1 and 2 were both signed on event-definition presence while ingestion was ~0; the gate must measure visitors, not code | ✓ Good (Phase 2.1) — Phase 2.1 signed on counted evidence; 02.1-07 redeploy row honestly left unsigned | +| Record "not performed" rather than infer a pass from unit tests or code review for the EEA/UK behaviour | An LLM cannot obtain an EEA egress; honesty about unobserved behaviour beats a manufactured pass | ✓ Good (Phase 2.1) — closed by a developer-run VPN session at UAT, 2026-09-15 | +| Sign the Phase 3 OPS-01 gate with two dated re-deferrals (share-rate re-read, real Discord unfurl) rather than hold Phase 4 for the 7-day window | Every countable row was counted; holding would have blocked on a calendar date, not on evidence; the `sign-now-conditional` obligation keeps the re-read binding | ✓ Phase 3 — unfurl and first-hand tone verdict passed in UAT 2026-09-19; re-read due ≥2026-09-23 | +| Run the ASVS L1 security review orchestrator-side when the `gsd-secure-phase` skill wrapper is missing (as Phase 2.1 did) | The gate blocks advancement; the workflow and template exist in gsd-core, only the skill is absent | ✓ Phase 3 — `03-SECURITY.md` 50/50 closed, 0 open | +| RPGLogs API Terms and API-docs pages captured with provenance (2026-09-21); approval address identified as `advertising@archon.gg` (§2a Commercial Usage); developer deferred sending the commercial-use approval request on 2026-09-21 | Both canonical pages 403 automated readers — developer captured both in-browser; the client display name still needs to be read from the WCL client-management page before the email can be sent | — Pending (04-01) — the production ad deploy (04-07) stays gated on the reply per D-12; build/preview work (04-03..04-06) proceeds because it earns no money and is therefore not commercial use under Terms §2a until ads go live | ## Evolution @@ -142,4 +154,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-09-08 after Phase 2* +*Last updated: 2026-09-19 after Phase 3* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 668a66b..1730192 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -17,10 +17,10 @@ Requirements for this milestone. Each maps to roadmap phases. ### Viral Loop -- [ ] **SHARE-01**: User can generate a roast/award-style shareable card per fight (auto-generated awards, Discord-shareable image built on existing OG infra) -- [ ] **SHARE-02**: User can share a per-player permalink with a player-specific OG image ("look at MY parse") -- [ ] **SHARE-03**: Share actions are prominent in the analyze UI and protected from ad/redesign crowding (share CTA + OG unfurl on the protected-elements checklist) -- [ ] **SHARE-04**: New public reports can auto-post to Discord via bot/webhook (sequenced after COMM-01 exists) +- [x] **SHARE-01**: User can generate a roast/award-style shareable card per fight (auto-generated awards, Discord-shareable image built on existing OG infra) +- [x] **SHARE-02**: User can share a per-player permalink with a player-specific OG image ("look at MY parse") +- [x] **SHARE-03**: Share actions are prominent in the analyze UI and protected from ad/redesign crowding (share CTA + OG unfurl on the protected-elements checklist) +- [x] **SHARE-04**: New public reports can auto-post to Discord via bot/webhook (sequenced after COMM-01 exists) ### Design & UX @@ -44,12 +44,12 @@ Requirements for this milestone. Each maps to roadmap phases. ### Community & Cross-Promotion -- [ ] **COMM-01**: Dedicated ParseForge Discord exists — minimal channels, seeded activity for 1–2 weeks, then promoted site-wide (widget/link); feedback no longer lands in owner's personal Discord -- [ ] **COMM-02**: Two-way cross-promotion live between ParseForge and LootList+ (nav/footer links with UTM attribution on both sites) — LootList+ repo is locally available at `/Users/alexander.mayes/Code/loot-list-plus` (live sister product: Next.js + Supabase, deployed on Railway, has its own `discord-bot/`), so both sides can be implemented directly +- [x] **COMM-01**: Dedicated ParseForge Discord exists — minimal channels, seeded activity for 1–2 weeks, then promoted site-wide (widget/link); feedback no longer lands in owner's personal Discord +- [x] **COMM-02**: Two-way cross-promotion live between ParseForge and LootList+ (nav/footer links with UTM attribution on both sites) — LootList+ repo is locally available at `/Users/alexander.mayes/Code/loot-list-plus` (live sister product: Next.js + Supabase, deployed on Railway, has its own `discord-bot/`), so both sides can be implemented directly ### Operations (standing invariants) -- [x] **OPS-01**: Every user-facing change ships WITH PostHog instrumentation and GSC verification (sitemap/indexing/metadata) — enforced as a phase-gate, not a follow-up task. *Phase 1 gate closed 2026-09-06 (`docs/OPS-01-SHIP-GATE.md`, dated sign-off); standing gate — re-run for every later phase.* +- [x] **OPS-01**: Every user-facing change ships WITH PostHog instrumentation and GSC verification (sitemap/indexing/metadata) — enforced as a phase-gate, not a follow-up task. *Phase 1 gate closed 2026-09-06 (`docs/OPS-01-SHIP-GATE.md`, dated sign-off); standing gate — re-run for every later phase. Correction (2026-09-14): the Phase 1 and Phase 2 PostHog halves of that gate were not actually met at their respective sign-offs — see the dated addenda in `01-VERIFICATION.md` and `02-VERIFICATION.md` — and were met for real by Phase 2.1, which also hardened this gate to require a mandatory post-deploy live-traffic check.* - [ ] **OPS-02**: CSP promoted from report-only to enforcing at end of milestone, after all ad/CMP script sources are discovered via violation reports ## v2 Requirements @@ -92,10 +92,10 @@ Which phases cover which requirements. Updated during roadmap creation. | SEO-03 | Phase 6 | Pending | | SEO-04 | Phase 6 | Pending | | SEO-05 | Phase 7 | Pending | -| SHARE-01 | Phase 3 | Pending | -| SHARE-02 | Phase 3 | Pending | -| SHARE-03 | Phase 3 | Pending | -| SHARE-04 | Phase 5 | Pending | +| SHARE-01 | Phase 3 | Complete | +| SHARE-02 | Phase 3 | Complete | +| SHARE-03 | Phase 3 | Complete | +| SHARE-04 | Phase 5 | Complete | | DSGN-01 | Phase 1 | Complete | | DSGN-02 | Phase 7 | Pending | | DSGN-03 | Phase 1 | Complete | @@ -107,9 +107,9 @@ Which phases cover which requirements. Updated during roadmap creation. | ACC-02 | Phase 2 | Complete | | ACC-03 | Phase 2 | Complete | | ACC-04 | Phase 2 | Complete | -| COMM-01 | Phase 5 | Pending | -| COMM-02 | Phase 5 | Pending | -| OPS-01 | Phase 1 | Phase 1 gate closed 2026-09-06 — docs/OPS-01-SHIP-GATE.md (standing gate, re-run every phase) | +| COMM-01 | Phase 5 | Complete | +| COMM-02 | Phase 5 | Complete | +| OPS-01 | Phase 1 | Phase 1 gate closed 2026-09-06 — docs/OPS-01-SHIP-GATE.md (standing gate, re-run every phase); PostHog half not actually met until Phase 2.1 (addenda dated 2026-09-14 in 01-VERIFICATION.md / 02-VERIFICATION.md) | | OPS-02 | Phase 7 | Pending | **Coverage:** @@ -121,3 +121,300 @@ Which phases cover which requirements. Updated during roadmap creation. --- *Requirements defined: 2026-09-04* *Last updated: 2026-09-04 after roadmap creation (7 phases, 100% coverage)* + +### OPS-01 Addendum (2026-09-14, Phase 2.1 Plan 04) + +The OPS-01 traceability row above records Phase 2.1 as having "met [the PostHog criterion] for +real" — that claim is premature. Phase 2.1's own post-deploy live-traffic gate +(`docs/OPS-01-SHIP-GATE.md` Part 4) ran its production deploy and live-traffic check on +2026-09-14, and two of its three item-7 thresholds passed with counted events (≥ 20 `$pageview`: +25 counted; ≥ 2 distinct non-consent-region countries: 3 counted). The third threshold — PostHog +`$pageview` at least 50% of Vercel Web Analytics page views for the same window — is recorded as +**PENDING**, not evaluated and not failed, because the Vercel Web Analytics figure for that window +could not be read from the session that ran the check (see Part 4 for the full reason and the +exact figure still needed). + +**OPS-01's PostHog criterion is therefore not-yet-met for Phase 2.1.** It remains not-yet-met +until the pending Vercel Web Analytics figure is read, the ratio is evaluated, and — only if it +passes — `docs/OPS-01-SHIP-GATE.md` Part 4 is signed with a date, at which point this addendum +should be superseded by a line recording the real date the criterion was met (D-09). + +### OPS-01 Addendum 2 (2026-09-15, Phase 2.1 gap closure 02.1-05) + +This addendum supersedes `### OPS-01 Addendum (2026-09-14, Phase 2.1 Plan 04)` above it, which +stays in place unedited. + +The Vercel Web Analytics page-view figure for the window `2026-09-14T22:16:59Z`– +`2026-09-14T23:16:59Z` that the 2026-09-14 addendum recorded as PENDING has now been read: **23** +page views (recorded as an upper bound — the range's end time was illegible in the screenshot the +developer supplied; see `docs/OPS-01-SHIP-GATE.md` Part 4 for the full provenance). Scored against +that figure, all three of item 7's thresholds read PASS: + +- ≥ 20 `$pageview` events: 25 counted — PASS +- ≥ 2 distinct non-consent-region countries: 3 counted — PASS +- PostHog `$pageview` count ≥ 50% of the Vercel figure: 25 / 23 ≈ 108.7% — PASS + +**OPS-01's PostHog criterion was met for Phase 2.1 on 2026-09-15**, against production deployment +`dpl_HY5319wSDVw3M4ibBU42JrSTgw4e` (created 2026-09-14T22:16:59Z). `docs/OPS-01-SHIP-GATE.md` +Part 4 is signed accordingly: `### Phase 2.1 Sign-off — SIGNED (2026-09-15, gap-closure 02.1-05)`. + +### MONY-01 Addendum (2026-09-15, Phase 2.1 gap closure 02.1-08) + +The MONY-01 row above records "Complete" from Phase 1. That row covers the consent-management +layer's existence and its non-EEA/UK behaviour, which Phase 2.1 has now proven live in production. +It does **not** cover a real-browser observation of the EEA/UK/CH TCF path itself — that live proof +**remains outstanding**. + +**Reason.** No EEA/UK/CH consent-region browser session was performed this plan. The developer +declined the manual ask, saying verbatim: "Stop asking for me to do things. Do it for me please." +The orchestrator could not perform it either — no EEA/UK/CH egress IP exists in this environment, +the Claude-in-Chrome extension was not connected this session, and no VPN is available to the +automation. + +**What is proven and what is not.** `deriveConsentGateOutcome`'s consent-region branch +(`lib/consent.ts`) is exhaustively unit-tested (21 tests) and confirmed by code review to reproduce +the pre-hotfix SDK-call mapping byte for byte. That is evidence about the logic. No browser has ever +executed `PostHogProvider.tsx`'s TCF wiring against that logic — the preview never rendered the +Google CMP (`NEXT_PUBLIC_GOOGLE_CMP_PUB_ID` is Production-only) and zero EEA/UK/CH visitors arrived +in any measured production window this phase. That gap is evidence about the wiring, and it is not +closed by the unit tests or the code review. See `docs/OPS-01-SHIP-GATE.md` Part 4, +`### EEA/UK TCF observation (gap closure 02.1-08, 2026-09-15)`, for the full record, including the +WR-03 dedupe's not-exercised status (commit `9da21c4` is live in production and ready to test, but +untested by a browser). + +**The test that would close this.** A developer-run session, from a real EEA/UK/Swiss VPN egress and +a fresh private browser window opened after the VPN connects, covering explicit reject, full opt-in, +and a CMP re-confirmation (testing the WR-03 dedupe) — exact steps in the gate document section +above. + +**MONY-01's live proof therefore stays outstanding.** ROADMAP SC2 and `02.1-VERIFICATION.md` +Truth 4 stay behaviour-unverified for the same reason. This addendum does not change the MONY-01 +traceability row above; it appends the live-proof status the row itself does not carry. + +### OPS-01 Addendum 3 (2026-09-16, Phase 3 gate 03-07) + +This addendum records the standing gate's next phase re-run (Phase 3, `03-share-loop`) — it does +not touch Addendum 2's Phase 2.1 sign-off, which stays in place unedited. + +Phase 3 ran its own re-run of the standing OPS-01 gate against production deployment +`dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx` (`docs/OPS-01-SHIP-GATE.md` Part 5). Per the developer's own +**2026-09-16T16:47Z "Record now, leave open"** decision, Part 5 ends with `### Part 5 status update +(2026-09-16, 03-07 final continuation) — Sign-off: LEFT OPEN — not signed` rather than a dated +sign-off. The item-7 live-traffic check scored threshold 1 FAIL (7 pageviews, need 20), threshold 2 +PASS (2 non-consent-region countries: UA, US), threshold 3 NOT EVALUABLE (the personal Vercel CLI +token exposes no Web Analytics endpoint); the D-14 share-rate figure read 0.0% as a first reading +(0 `share_action` sessions / 19 `analysis_complete` sessions, against a ~2.8% baseline); Search +Console and the three developer-only backstops (D-04 tone, real Discord unfurl, D-13 mobile) remain +NOT PERFORMED. Nine outstanding items and their exact closing tests are recorded in +`docs/OPS-01-SHIP-GATE.md`'s final `### Part 5 status update` table. + +**OPS-01's Phase 3 re-run is therefore not met.** The traceability row above still reflects Phase +1's own original closure plus the Phase 2.1 correction (Addenda 1–2); this addendum records, per the +same pattern, that the Phase 3 re-run specifically remains open. It stays open until a future +session re-measures item 7 on a busier UTC hour, re-runs the share-rate HogQL on or after +2026-09-23T09:15Z, and completes the Search Console pass and the three developer backstops, closing +with a dated sign-off in `docs/OPS-01-SHIP-GATE.md` Part 5. + +### SHARE-01 / SHARE-02 / SHARE-03 Addendum (2026-09-16, Phase 3 gate 03-07) + +The SHARE-01, SHARE-02 and SHARE-03 rows above already read "Complete" — that marking was applied +by 03-06's `update_requirements` step (commit `adaea2f`), before 03-07 (a sibling plan in the same +phase directory that also declares all three IDs) had produced a `03-07-SUMMARY.md`. This plan's +own frontmatter states the intended rule explicitly: *"docs/OPS-01-SHIP-GATE.md Part 5 is the +single record of this phase's gate; its sign-off is what marks SHARE-01, SHARE-02 and SHARE-03 and +the OPS-01 re-run as met."* Part 5 was left explicitly unsigned at 03-07's close (OPS-01 Addendum 3 +above), so by the plan's own stated rule these three requirements are not yet fully met, even +though the checkboxes above already read complete. + +**What is proven.** All three requirements' underlying code capability is live in production and +directly confirmed this session: the awards OG card, the per-player OG card and the bare report OG +card all return `200 image/png` from `https://parseforge.gg`; the production analyze canonical +carries no query string; and `npm run protected-elements` exits 0 with all ten rows PASS against +production (`docs/OPS-01-SHIP-GATE.md`, `### Post-deploy production route-contract evidence`). + +**What is not yet proven.** The plan's own `must_haves.truths` backstop — *"Live PostHog shows at +least one `share_action` and at least one `share_landing` from real traffic, each carrying a +`consent_gate_path` value"* — is unobserved: zero `share_action` and zero `share_landing` events +exist in either the item-7 60-minute window or the D-14 7-day window counted this session. The +three developer-only checks (D-04 tone, real Discord unfurl, D-13 mobile reachability) also remain +NOT PERFORMED. + +**These checkboxes are left as-is** (not reverted) because the underlying capability genuinely works +in production, and reverting a checked box without new evidence of a regression would misrepresent +what changed. This addendum instead makes the ledger honest: SHARE-01/02/03 should be read as +*capability confirmed live in production; the phase's own live-traffic and human-review backstops +remain open* — not as fully verified per the plan's own sign-off rule. Closes when a future session +observes at least one real `share_action`/`share_landing` event with `consent_gate_path` present and +the three developer backstops are performed, at which point `docs/OPS-01-SHIP-GATE.md` Part 5 can be +signed and this addendum superseded. + +### OPS-01 Addendum 4 (2026-09-19, Phase 3 gap closure 03-10) + +This addendum supersedes `### OPS-01 Addendum 3 (2026-09-16, Phase 3 gate 03-07)` above it, which +stays in place unedited; it does not touch Addendum 2's Phase 2.1 sign-off. + +Plan 03-10 closed the eight-of-nine-counted state that Addendum 3 recorded as left open, per +`docs/OPS-01-SHIP-GATE.md`'s new `### Part 5 close-out (2026-09-19, gap closure 03-10)` subsection: + +- Item 7's live-traffic thresholds were re-measured on a fully elapsed 60-minute window + (`2026-09-17T06:00:00Z`–`07:00:00Z`, deployment `dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx`): threshold 1 + PASS (87 `$pageview` events), threshold 2 PASS (3 distinct non-consent-region countries: US, KZ, + UA), threshold 3 PASS (PostHog 87 vs. Vercel 27 for the same hourly bucket, 322% ≥ 50%). +- The D-14 share-rate figure remains a standing first reading — 0 `share_action` sessions / 19 + `analysis_complete` sessions = 0.0%, below the ~2.8% baseline, from ~7.5h of live exposure — + accepted, not improved, under the developer's `sign-now-conditional` decision (quoted verbatim in + the gate document); the re-run stays date-gated to on/after 2026-09-23T09:15Z and was not + performed in this plan. +- Search Console showed no regression on either route: `/` improved from "Crawled – currently not + indexed" to "Page is indexed" (last crawl 2026-09-17), with a recrawl requested at + 2026-09-18T21:47Z; the analyze route remained indexed and unchanged (last crawl 2026-09-15); none + of the `?view=awards`, `?ref=awards` or `?ref=parse` permutations appears separately indexed. +- The three developer-only backstops read: D-04 tone review PASS on a delegated verdict (not the + developer's own words); the real Discord unfurl NOT OBTAINED (2026-09-18), with Discordbot-UA + proxy evidence recorded; D-13 mobile reachability PASS from a resized 384px-viewport observation. + +**OPS-01's Phase 3 re-run is met on 2026-09-19** against production deployment +`dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx`, with `docs/OPS-01-SHIP-GATE.md` Part 5 signed accordingly: +`### Phase 3 Sign-off — SIGNED (2026-09-19, gap closure 03-10)`. That signature is explicitly +conditional: the D-14 re-read, once run on or after 2026-09-23T09:15Z, must append a dated +confirmation or correction to Part 5, and `.planning/WINDOWS.md #11` stays open until it does. +`.planning/WINDOWS.md #9` also stays open — the D-04 verdict is delegated rather than first-hand, +and the real Discord unfurl was not obtained. + +### SHARE-01 / SHARE-02 / SHARE-03 Addendum 2 (2026-09-19, Phase 3 gap closure 03-10) + +This addendum supersedes `### SHARE-01 / SHARE-02 / SHARE-03 Addendum (2026-09-16, Phase 3 gate +03-07)` above it, which stays in place unedited. + +Of the backstops that addendum listed as open, the following are now closed with counted evidence: + +- **The live-PostHog backstop** — two real `share_landing` events were captured in the re-measured + `2026-09-17T06:00Z`–`07:00Z` window, both carrying `consent_gate_path = geo-non-consent-region` + (RESEARCH A3; `docs/OPS-01-SHIP-GATE.md` `### Item 7 — re-measured counted result (2026-09-18, gap + closure 03-09)`). +- **D-13 phone-width reachability** — a resized-browser observation at a 384px CSS-pixel viewport + found both the "Copy awards link" and "Share my parse" buttons reachable without scrolling past + the analysis tables (`### Developer review backstops — counted result (2026-09-18, gap closure + 03-08)`). + +The following remain open: + +- **The real Discord unfurl of both production share links** (SHARE-01's awards-link case and + SHARE-02's player-link case) — NOT OBTAINED (2026-09-18); no real Discord channel was available. + Closing test: paste + `https://parseforge.gg/analyze/ZjKgNYxVcAqR8pGJ?fight=23&view=awards&ref=awards&v=` and + `https://parseforge.gg/analyze/ZjKgNYxVcAqR8pGJ?fight=23&source=12&ref=parse&v=` into a + real Discord channel and confirm each unfurls with rows/receipts legible and the longest names + clipped with an ellipsis rather than overflowing. +- **A first-hand (non-delegated) D-04 tone verdict** — the developer delegated the verdict three + times ("Just do whatever you think is best.") rather than stating one in their own words. Closing + test: the developer states, in their own words, that the award pool clears the D-01 bar or names + the failing row. + +With `docs/OPS-01-SHIP-GATE.md` Part 5 now signed (`### Phase 3 Sign-off — SIGNED (2026-09-19, gap +closure 03-10)`), SHARE-01, SHARE-02 and SHARE-03's underlying capability and their live-traffic +backstop now rest on their own counted evidence rather than a sibling plan's premature marking. The +Discord-unfurl and first-hand-D-04 backstops above remain open and are tracked in +`.planning/WINDOWS.md #9`; nothing here reverts or edits the checkbox lines above. + +### MONY-02 / MONY-03 Addendum (2026-09-22, Phase 4 gate 04-07) + +The MONY-02 and MONY-03 rows above still read "Pending" — this addendum does **not** flip either +checkbox to complete. The production deploy alone does not prove either requirement in full, per +this plan's own explicit prohibition: "Do not mark either requirement complete on the strength of +the deploy alone." + +**What is closed by counted evidence (MONY-02's ad-serving-and-placement half).** The production +deploy (`dpl_HRmX5EMk5aKgXtDp4z9FYW6jXNih`, commit `cc931c0`, 2026-09-22T09:23:24Z) proves, with +captured evidence in `docs/OPS-01-SHIP-GATE.md` Part 6: all four reserved AdSlot components render +on the two whitelisted routes (`/tbc-audit`: `tbc-audit-mid`, `tbc-audit-end`; the analyze page: +`analyze-mid`, `analyze-end`), gated correctly behind the consent-gate loader (a real production ad +request fires only for an admitted, non-consent-region visitor — confirmed this session via a fresh +CDP capture on real production traffic, both routes, 12 ad-host requests each including real +slot-scoped `doubleclick.net` ad calls); `npm run protected-elements` passes 25/25 against +production; `/ads.txt` returns a `DIRECT` line; every canonical stays param-free; and +`npm run seo-invariants` shows no unexplained diff on either ad route or `/privacy`. **This half is +met by counted evidence** — ads render in the correct reserved space, on the correct routes, gated +correctly, without disturbing SEO surface. + +**What remains open (MONY-03's monitoring half, and MONY-02's own revenue-adjacent edge).** The CWV +baseline itself is captured (D-09, Part 6, pre-ad) and rollback criteria are defined (D-10) — that +half of MONY-03 was already met before this deploy. What stays open: the day-2 CWV re-read (due on +or after 2026-09-24T09:23:24Z), the day-7 CWV re-read (due on or after 2026-09-29T09:23:24Z), both +scored against the D-09 baseline and D-10 triggers; the OPS-01 item 7 live-traffic PostHog +thresholds (this session had no PostHog query channel — see `docs/OPS-01-SHIP-GATE.md` +`### Item 7 — attempted counted result, PostHog access limitation (Phase 4, 2026-09-22)`); Search +Console rows for the two ad routes (no GSC access this session); and AdSense reporting real revenue +(not measurable — the AdSense account's own dashboard read "Getting ready" / `ads.txt` "Not found" +as of 2026-09-22, i.e. Google has not yet approved the account to serve real creative on this +domain, a separate fact from this session's own network capture showing genuine ad requests firing +but not yet fillable). + +**R0-1's status, recorded here for completeness — not a REQUIREMENTS.md-tracked row.** The R0-x +requirement family is not present in this file's traceability table at all (a pre-existing gap +`04-04-SUMMARY.md` already recorded). RPGLogs' written commercial-use approval was never obtained; +the developer issued an explicit, consequence-informed operator override instead +(`docs/OPS-01-SHIP-GATE.md` `### Deploy decision (Phase 4, 2026-09-22)`). R0-1's own gating purpose +— a recorded yes before shipping — was not satisfied; it was consciously bypassed by the account +owner, a materially different outcome from "met," and is not claimed as one here. + +**Closing tests, restated.** MONY-03 (and this addendum) close only when the day-2 and day-7 CWV +reads are appended to `docs/OPS-01-SHIP-GATE.md` Part 6 with a dated PASS against every D-10 +trigger, the OPS-01 item 7 PostHog thresholds are counted in a session with real PostHog access, and +Search Console rows are recorded for the two ad routes. MONY-02's remaining open edge (AdSense +actually serving and reporting revenue) closes once the AdSense dashboard shows an approved status +and revenue accrues. + +### COMM-01 / SHARE-04 Addendum (2026-09-28, Phase 5 gap closure 05-08) + +Both rows above still read complete/checked — this addendum does **not** flip either checkbox; it +records what plan 05-08 actually proved on production and what still remains, per this file's +append-only addendum convention. + +**Context.** COMM-01's checkbox was set on the 2026-09-25 Part 7 sign-off's +"infrastructure-met, seeding-pending" state. UAT test 16 then found the `#welcome` landing unpinned +and empty (G-05-16). Plan 05-07 refreshed the server (new `#welcome` invite, five channels, the +D-13 role set) at its checkpoint; this plan (05-08) deployed that refresh plus the plan 05-09 +crawler filter to production, proved both, and attempted — but did not complete — the D-11 (b) +`#announcements` purge. + +**COMM-01 — met for the new-member refresh, on production evidence.** The G-05-16 refresh closes: +the checklist in `docs/DISCORD-SEED.md` shows every Required row done; Discord's own public invite +lookup on the `lib/cross-promo.ts` constant returns guild `ParseForge`, channel `welcome`, +`expires_at` `null`; production HTML on `/`, `/tbc-audit`, a guide, and a report page each serve +that invite and no other ParseForge invite (`docs/OPS-01-SHIP-GATE.md`'s G-05-16 addendum, +Post-deploy evidence rows 0–1). A first-time visitor tapping the site's Discord link now lands in +`#welcome`, not the empty `#feedback` UAT test 16 found. + +**SHARE-04 — the crawler-filtered trigger is proven live; the backlog it produced is not yet +cleared.** Plan 05-09's filter and plan 05-05's request-time-only trigger are both live and proven +by direct production evidence (G-05-16 addendum, Post-deploy evidence rows 4–5): a Googlebot +request for a never-rendered public report logged a `bot`/`google` gate line and posted nothing; a +Chrome-UA request for another logged a `human` gate line and posted exactly one card +(`ok: true, status: 204`); every `discord_report_announced` line since the deploy correlates with +an earlier `human` gate line for the same report on the same `/analyze/{code}` path. The +WINDOWS #28 crawler-tally verdict itself, read over an incomplete window, is recorded `not +confirmed` by the plan's strict numeric rule — that reading does not affect the trigger proof +above, which is unconditional and direct. + +**What is not yet closed.** The D-11 (b) purge — bulk-deleting the pre-deploy crawler-posted +backlog (5,302 messages by this plan's dry run) while keeping the four known cards and everything +posted at or after the deploy — was **deferred to the developer** at their own explicit choice in +this session, rather than run by the executor. The dry run, the exact command, and the +2026-10-09T21:48Z UTC deadline (Discord's 14-day bulk-delete window) are recorded in +`docs/DISCORD-SEED.md`'s `## #announcements purge (D-11)`. The drafted first `#changelog` post is +also not yet live: `--execute` failed with Discord permission error 403/50013 (the bot's role has +no channel-specific Send Messages overwrite in the read-only `#changelog`) — a real Discord +permission gap in the D-13 channel setup, not a code defect; the two-line corrected text (with the +"backlog was cleared" claim removed, since it would have been false) is staged and ready in the +same file once either the permission gap is fixed or the developer posts it directly. **WINDOWS #26 +and #28 are left open** — neither was marked fixed, per this plan's own rule that a closure +condition failing means neither row may be marked fixed. `docs/OPS-01-SHIP-GATE.md`'s G-05-16 +addendum `#### Sign-off` carries the full six-condition accounting. + +**Closing tests, restated.** WINDOWS #26 and #28 (and this addendum's own outstanding half) close +when: the developer runs the D-11 (b) purge and its before/deleted/after counts are recorded; the +`#changelog` post either gets its permission fix or is posted by the developer directly; and the +three `no-data` items from this session (PostHog item 7, Search Console, the full-60-minute +crawler-tally re-query) are re-checked with real access, all recorded in +`docs/OPS-01-SHIP-GATE.md`'s G-05-16 addendum. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 07567d9..0b58dfe 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -21,12 +21,14 @@ Decimal phases appear between their surrounding integers in numeric order. - [x] **Phase 1: Foundation — Themes & Consent** - Token audit unlocks a dark/light toggle; consent layer gates ads and PostHog replay (completed 2026-09-06) - [x] **Phase 2: Accuracy & Analysis Depth** - Verified game data, tested engines, cast timeline, healer metrics (completed 2026-09-08) -- [ ] **Phase 2.1: PostHog Consent Gate Hotfix** (INSERTED) - Server-side geo opt-in for non-EEA; live-traffic OPS-01 re-verification -- [ ] **Phase 3: Share Loop** - Roast/award cards, per-player permalinks, share CTA that survives later phases +- [x] **Phase 2.1: PostHog Consent Gate Hotfix** (INSERTED) - Server-side geo opt-in for non-EEA; live-traffic OPS-01 re-verification (completed 2026-09-15) +- [x] **Phase 3: Share Loop** - Roast/award cards, per-player permalinks, share CTA that survives later phases (completed 2026-09-19) - [ ] **Phase 4: Ads Live** - AdSense in reserved, consent-gated slots that never crowd the core flow - [ ] **Phase 5: Community & Cross-Promotion** - Dedicated Discord, LootList+ two-way links, auto-posted reports +- [ ] **Phase 05.1: ParseForge Discord bot — LootList+ parity** (INSERTED) - One always-on Railway bot: feedback → GitHub issues with fix follow-ups, ops digest, /help, /analyze + /raid (reverses 05 D-12) - [ ] **Phase 6: Discoverability & Content** - Programmatic pages, fixed guides, CTR metadata, structured data - [ ] **Phase 7: Redesign, De-bloat & Hardening** - Route-by-route visual overhaul behind an SEO gate; CSP enforcing +- [ ] **Phase 8: WoW Forever Readiness (F0)** - Data-free prep so Forever support becomes "add rows + record fixtures" once WCL/wago expose it (queued 2026-09-19; unblocked, may be pulled forward) ## Phase Details @@ -136,9 +138,30 @@ Plans: 3. `consent_resolved` / `consent_unavailable` / `theme_changed` / `timeline_*` events appear in project 337485 within one hour of the prod deploy, with a `consent_gate_path` property distinguishing geo / TCF / timeout. 4. `docs/OPS-01-SHIP-GATE.md` requires a post-deploy live-traffic check (≥ N `$pageview` from ≥ 2 non-EEA countries within 60 min) and this phase passes it for real; Phase 1 and 2 VERIFICATION docs carry an addendum noting their PostHog criterion was only met here. -**Plans**: TBD +**Plans**: 8/8 plans complete (incl. 4 gap-closure plans) — phase completed 2026-09-15 after human UAT (2/2), Nyquist validation and security verification **UI hint**: no +Plans: +**Wave 1** + +- [x] 02.1-01-PLAN.md — Tracer: server-side geo classification through `/api/geo` to an immediate PostHog opt-in, with the first `$pageview` gated on the consent path resolving +- [x] 02.1-02-PLAN.md — OPS-01 hardening: mandatory post-deploy live-traffic check, plus dated addenda correcting the Phase 1 and Phase 2 records + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 02.1-03-PLAN.md — Preview deploy and the bounded headless-Chrome netlog proof that the first `$pageview` leaves the browser + +**Wave 3** *(blocked on Wave 2 completion)* + +- [x] 02.1-04-PLAN.md — Approval-gated production deploy, the live-traffic gate run for real, and the dated Phase 2.1 sign-off + +**Gap closure** *(added 2026-09-14 from `02.1-VERIFICATION.md` — run with `/gsd-execute-phase 02.1 --gaps-only`; each depends on the previous)* + +- [x] 02.1-05-PLAN.md — Gap B: read the pending Vercel Web Analytics figure for the 22:16:59Z–23:16:59Z window, score item-7 threshold 3, then sign Part 4 with a date or record FAIL honestly (never lower the bar) +- [x] 02.1-06-PLAN.md — Gap A (automatable half): drive the theme toggle and Timeline tab on production from a non-consent-region session, re-run the item-7 HogQL, record `theme_changed` / `timeline_*` with their `consent_gate_path` +- [x] 02.1-07-PLAN.md — Developer-approved redeploy of the five code-review fixes (CR-01, WR-01..04) so WR-03 is testable, plus a fresh 60-minute item-7 window measured for real +- [x] 02.1-08-PLAN.md — Truth 4 / MONY-01: EEA/UK TCF observation (reject, full opt-in, CMP re-confirm) as a human checkpoint with an honest not-performed branch + **Notes**: Diagnosis in `02.1-DIAGNOSIS.md`. Root cause: `cookieless_mode: "on_reject"` makes PENDING consent *drop* events in posthog-js 1.360, and opt-in depended on `__tcfapi` calling back — which it never does for fresh visitors. Capture fell from ~4–10k events/day to 3–7/day on 2026-09-06. Not a traffic collapse. ### Phase 3: Share Loop @@ -154,17 +177,52 @@ Plans: 3. Share actions are reachable without hunting on the analyze page on both desktop and mobile, and a protected-elements checklist naming the share CTA and OG pipeline exists for Phases 4 and 7 to honor. 4. PostHog reports share rate per analysis against the ~2.8% baseline, and GSC verification confirms the new share routes/OG changes did not disturb indexing or canonicals (OPS-01 gate). -**Plans**: TBD +**Plans**: 10 plans — 7 executed; 03-08 through 03-10 are gap closure for the OPS-01 / GSC half of success criterion 4 (pending) **UI hint**: yes +Plans: +**Wave 1** + +- [x] 03-01-PLAN.md — Awards tracer: fight → awards engine → OG image → forwarded metadata, plus the one normalized share-link module + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 03-02-PLAN.md — Fill the award pool to fifteen conditional, stat-backed rules and lock the pool-wide invariants +- [x] 03-03-PLAN.md — Per-player permalink: OG receipts (Kill/Wipe, length, vs-top-N, proof line) and the Share my parse button +- [x] 03-04-PLAN.md — Analyze-page share surfaces: Raid-tab awards panel with its real preview, normalized header Share, bottom bar removed, landing rules and `ref` attribution + +**Wave 3** *(blocked on Wave 2 completion)* + +- [x] 03-05-PLAN.md — `docs/PROTECTED-ELEMENTS.md`, the `protected-elements` gate script, and the OPS-01 item plus share-rate HogQL + +**Wave 4** *(blocked on Wave 3 completion)* + +- [x] 03-06-PLAN.md — Full local gate, preview deploy, and the developer's award-pool / Discord-unfurl / mobile review (OPS-01 Part 5, unsigned) + +**Wave 5** *(blocked on Wave 4 completion)* + +- [x] 03-07-PLAN.md — Approval-gated production deploy, the item-7 live-traffic and share-rate measurement, GSC pass, and an honest Part 5 sign-off + +**Wave 6** *(gap closure — blocked on Wave 5 completion)* + +- [x] 03-08-PLAN.md — The Search Console pass that was never run, plus the three developer-only backstops (Part 5 rows 4-9) + +**Wave 7** *(gap closure — blocked on Wave 6 completion)* + +- [x] 03-09-PLAN.md — Item-7 re-measure on a fully elapsed busier UTC window, the Web Analytics read for threshold 3, and the RESEARCH A3 consent-path disposition (Part 5 rows 1 and 3) + +**Wave 8** *(gap closure — blocked on Wave 7 completion)* + +- [x] 03-10-PLAN.md — The date-gated share-rate row (on/after 2026-09-23T09:15Z), the Part 5 close-out and its single sign-off, REQUIREMENTS Addendum 4, and the WINDOWS ledger dispositions (Part 5 row 2) + **Notes**: Built on the existing `/og/route.tsx` infrastructure. The protected-elements checklist produced here is a hard input to the Phase 4 ad placement whitelist. ### Phase 4: Ads Live **Goal**: ParseForge earns ad revenue without the paste-and-analyze flow getting measurably worse **Mode:** mvp -**Depends on**: Phase 1 (consent), Phase 3 (protected-elements checklist) -**Requirements**: MONY-02, MONY-03 +**Depends on**: Phase 1 (consent), `docs/PROTECTED-ELEMENTS.md` (Phase 3) +**Requirements**: MONY-02, MONY-03 (+ R0 contract scope from `.planning/research/PARSEFORGE-RANKINGS-SPEC.md` §7, folded in 2026-09-19 — no new v1 requirement ID) **Success Criteria** (what must be TRUE): 1. A visitor sees ads only in whitelisted slots; the paste box, analysis tables, and share CTA are never covered, pushed down, or delayed by ad loading. @@ -173,10 +231,36 @@ Plans: 4. LCP/INP/CLS on `/`, `/analyze/*`, and `/tbc-audit` are captured before ad code ships and re-measured after, with a written rollback trigger that has been agreed before launch. 5. AdSense reports revenue on live traffic, PostHog tracks ad-slot impact on analyze completion, and GSC shows no ranking movement attributable to the change (OPS-01 gate). -**Plans**: TBD +**Plans**: 6/7 plans executed + +Plans: +**Wave 1** + +- [x] 04-01-PLAN.md — R0-1 approval request + the pre-ad CWV baseline and rollback trigger (OPS-01 Part 6) +- [x] 04-02-PLAN.md — R0-2: rankings + rate-limit fixtures via the extended WCL recorder + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 04-03-PLAN.md — **Tracer**: one reserved, consent-gated ad slot end-to-end, plus CSP hosts, /ads.txt and the protected-elements slot gate +- [x] 04-04-PLAN.md — R0-3: rankings blob types, pure parse-lens engine, Redis rate-budget gate (no UI) + +**Wave 3** *(blocked on Wave 2 completion)* + +- [x] 04-05-PLAN.md — the remaining three D-02 placements, the /privacy disclosure, and AdSense account configuration + +**Wave 4** *(blocked on Wave 3 completion)* + +- [x] 04-06-PLAN.md — preview deploy: netlog proof of the consent gate, measured boxes, CSP harvest, human passes + +**Wave 5** *(blocked on Wave 4 completion)* + +- [x] 04-07-PLAN.md — approval-gated production ads deploy + post-deploy OPS-01 evidence and sign-off + **UI hint**: yes -**Notes**: Ads ship while CSP is still report-only so all new script sources surface as violation reports (feeds OPS-02 in Phase 7). Research flag: verify current ad-network eligibility thresholds directly at signup before finalizing; model expected revenue (est. $50–300/mo) against the CWV/UX cost and treat as a reversible experiment if marginal. +**Notes**: Ads ship while CSP is still report-only so all new script sources surface as violation reports (feeds OPS-02 in Phase 7). + +**R0 — WCL contract & approval (folded into this phase, decided 2026-09-19):** the RPGLogs API Terms make advertising "commercial" use that needs prior written approval, so Phase 4's first plan must (R0-1) save dated copies of the ToS + API-docs pages to `.planning/research/`, send the approval request (AdSense on the analyzer + the planned rankings lens + expected volume + Redis caching) and record the thread; the ads deploy plan is **gated on that reply**. Also in scope: (R0-2) extend `scripts/record-wcl-fixtures.mjs` to record `rateLimitData`, `report.rankings`, `zoneRankings`/`encounterRankings`, `characterRankings`/`fightRankings` page 1 and guild `members`/`attendance` for the public demo entities as `lib/__fixtures__/rankings-*.json`; (R0-3) `lib/wcl-types.ts` rankings types, a pure `lib/rankings/parse-lens.ts` (blob → per-player parse rows, honouring `hidden`) and a `rateLimitData`→Redis budget reader with a 90% gate — tests only, **no UI** (R1+ stay un-executed until the approval reply). `/privacy` must mention WCL-sourced data and ad sharing before ads ship. Research flag: verify current ad-network eligibility thresholds directly at signup before finalizing; model expected revenue (est. $50–300/mo) against the CWV/UX cost and treat as a reversible experiment if marginal. The ad-placement whitelist must be checked against `docs/PROTECTED-ELEMENTS.md` — no ad slot may cover, push down, or delay an element that file names — and `npm run protected-elements` must stay green. ### Phase 5: Community & Cross-Promotion @@ -192,11 +276,108 @@ Plans: 4. New public reports auto-post to Discord, and the post unfurls with the Phase 3 roast card. 5. Cross-promo links and Discord entry points are instrumented in PostHog and GSC-verified as crawlable real `` links that do not leak PageRank badly or break the sitemap (OPS-01 gate). -**Plans**: TBD +**Plans**: 10/10 plans executed — 4 executed; 05-05 through 05-10 are gap closure for UAT G-05-16, WINDOWS #28 (the `#announcements` crawler flood) and the 2026-09-28 role-picker follow-up (D-12) (pending) + +Plans: +**Wave 1** + +- [x] 05-01-PLAN.md — **Tracer**: the dedicated Discord (human-action gate) + a newly public report auto-posting its awards card to `#announcements`, dedup-guarded, proven on a preview deploy + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 05-02-PLAN.md — site-wide `Footer` with the Discord + LootList+ links, the `lib/cross-promo.ts` UTM contract, and the deliberate resolution of the homepage's pre-existing second Discord link +- [x] 05-03-PLAN.md — the LootList+ half of the cross-promo (sister repo, its own branch and history) + the Discord seed kit the developer posts + +**Wave 3** *(blocked on Wave 2 completion)* + +- [x] 05-04-PLAN.md — approval-gated production deploys (Vercel + Railway) and OPS-01 Part 7 evidence + sign-off + +**Gap closure** *(added 2026-09-27 from `05-UAT.md` G-05-16 and the developer's decisions recorded there — run with `/gsd-execute-phase 05 --gaps-only`)* + +**Wave 4** *(gap closure)* + +- [x] 05-05-PLAN.md — only a real request-time `/analyze/{code}` render can trigger a Discord auto-post; the ISR homepage and build/revalidation renders never can, locked by call-site tests; sitemap pipeline untouched +- [x] 05-06-PLAN.md — dated D-03 revision (`#welcome` read-only landing channel, three channels, no Community mode), the WINDOWS ownership row, reconciled `docs/DISCORD.md`, and `docs/DISCORD-SEED.md` rewritten into a 15-row new-member checklist with exact copy + +**Wave 5** *(gap closure — blocked on Wave 4 completion)* + +- [x] 05-07-PLAN.md — the developer works the Discord checklist at a blocking human-action checkpoint; the new `#welcome` invite is verified through Discord's public invite lookup and switched in `lib/cross-promo.ts` + `docs/DISCORD.md` + +**Wave 6** *(gap closure — blocked on Wave 5 completion; added 2026-09-28 for D-11)* + +- [x] 05-09-PLAN.md — crawler filter: an `/analyze` render only schedules the Discord announcement for a person's browser (crawlers, unfurlers incl. Discordbot, headless browsers, scripts and UA-less requests never do), every decision logged as `discord_announce_gate` with `ua_class`; 05-05 invariants and the sitemap pipeline untouched; no deploy + +**Wave 7** *(gap closure — blocked on Wave 6 completion; amended 2026-09-28 for D-11)* + +- [x] 05-08-PLAN.md — pre-deploy gate (incl. the crawler filter), the developer's explicit deploy go-ahead (blocking decision checkpoint), production proof of the trigger (a person's open posts once, a Googlebot request posts nothing; no backfill URL opened), the D-11 `#announcements` backlog purge before the 14-day window closes (~2026-10-09), first `#changelog` post, OPS-01 Part 7 and COMM-01 addenda, WINDOWS #26 and #28 closed on evidence + +**Wave 8** *(gap closure follow-up — blocked on Wave 7 completion; added 2026-09-28 for D-12)* + +- [x] 05-10-PLAN.md — role picker answered by parseforge.gg: Ed25519-verified Discord interactions route, allowlisted self-assignable roles, per-member rate limit, `/raid` and `/analyze` retired, its own blocking deploy go-ahead, picker posted in `#welcome` + **UI hint**: yes **Notes**: **Multi-repo phase.** The LootList+ side of COMM-02 is implemented in a separate repo at `/Users/alexander.mayes/Code/loot-list-plus` (Next.js + Supabase, deployed on **Railway**, separate GitHub repo `alexandermayes/loot-list-plus`) — its deploy path differs from ParseForge's Vercel CLI flow and needs its own confirmation. In-phase sequencing is COMM-01 → seed activity 1–2 weeks → site-wide promotion → SHARE-04 bot. LootList+'s existing `discord-bot/` is reusable prior art. Open question for planning: dedicated server vs. channel in the existing LootList+ server (empty-room risk). +**Resolved at planning (2026-09-23):** the open question above is answered by 05-CONTEXT.md **D-01** — a dedicated ParseForge server, with the empty-room risk deliberately accepted and mitigated by D-10's seeding before site-wide promotion. Two path corrections for downstream agents: the sister repo is at `/Users/alexander.mayes/Code/personal/loot-list-plus` (not `Code/loot-list-plus`), and LootList+'s `discord-bot/` is **not** reused — 05-RESEARCH.md records the bot-token pattern as an anti-pattern here, since a plain incoming webhook needs no bot user (see `05-.../COVERAGE.md`). + +### Phase 05.1: ParseForge Discord bot — LootList+ parity (INSERTED) + +**Goal:** ParseForge's Discord members get everything LootList+'s bot gives its members: feedback that becomes tracked GitHub issues and reports back when fixed, a daily ops digest, a /help for product questions, and in-Discord /analyze + /raid. One always-on bot on Railway runs all of it. +**Requirements**: TBD +**Depends on:** Phase 5 +**Success Criteria** (what must be TRUE): + + 1. A new #feedback forum post, or Zev's 🐛/💡 reaction on any message, files exactly one issue on alexandermayes/parseforge. The issue has the message text and a Discord link but no member name. Closing it posts the fix in the thread and retitles it (FIXED)/(SHIPPED), and a fixed bug nudges Zev in the private #ops channel. + 2. A daily digest lands in #ops and files any #feedback post missed while the bot was down, without duplicating ones already filed. + 3. /help answers product questions from a knowledge base seeded from ParseForge's own guides/FAQ/docs. It declines parse or gameplay advice, hands off when unsure, and posts drafts for uncovered questions to #ops for ✅/🗑️ review. + 4. /analyze, /raid and fight-link replies work again as public replies. Per-member cooldowns and a server-wide daily cap keep Anthropic spend and the site's WCL quota bounded. + 5. One bot on Railway answers every interaction, including the existing #welcome role-picker buttons. parseforge.gg's interactions route and its Vercel Discord secrets are removed, the bot's Redis keys never touch the sitemap pipeline, and bot activity is measured per OPS-01. + +**Plans:** 10/10 plans executed + +Plans: +**Wave 1** + +- [x] 05.1-01-PLAN.md — **Tracer (code)**: package legitimacy gate + one vetted bot install; a `#feedback` forum post becomes one name-free issue and its closure comes back to the thread `(FIXED)`; bot-only `pf:bot:` store with a source gate; commands gated off +- [x] 05.1-02-PLAN.md — parseforge.gg's authenticated bot lane: `/api/analyze`, `/api/raid-overview`, `/api/report` limited per Discord member under a bot-wide ceiling, failing closed (no deploy) + +**Wave 2** *(blocked on 05.1-01)* + +- [x] 05.1-03-PLAN.md — **Tracer (production)**: Railway config + preflight tooling, go-ahead, labels and two private channels (`#ops`, the new forum), Railway deploy, first real public issue filed and closed back into its thread; OPS-01 Part 8 opened + +**Wave 3** *(blocked on 05.1-03 — D-03)* + +- [x] 05.1-04-PLAN.md — Zev's 🐛/💡 from any channel (Developer only, relabel), Feature tag, Claude titles and duplicate hints, reopen, the private `#ops` Bug Hunter nudge with reporter retention, daily digest and start-up backfill + +**Wave 4** *(blocked on 05.1-04)* + +- [x] 05.1-05-PLAN.md — role picker in the bot on the same `custom_id`s, roles generated into `bot/src` by `scripts/build-bot-data.mjs` and drift-checked in the root `npm test` + +**Wave 5** *(blocked on 05.1-05 and 05.1-02)* + +- [x] 05.1-06-PLAN.md — `/analyze`, `/raid` and fight-link replies restored behind a Redis cooldown and a fail-closed daily cap, in the bot lane + +**Wave 6** *(blocked on 05.1-06)* + +- [x] 05.1-07-PLAN.md — `/help`: product-only answers from a KB seeded from the site's guides, FAQ and public Discord copy; gameplay redirect; handoff to Zev; drafts reviewed ✅/🗑️ in `#ops` + +**Wave 7** *(blocked on 05.1-07)* + +- [x] 05.1-08-PLAN.md — go-ahead + Railway deploy (commands still off); `#feedback` opened as the forum, old channel archived read-only unless the developer chooses otherwise; D-16 numbers documented + +**Wave 8** *(blocked on 05.1-08)* + +- [x] 05.1-09-PLAN.md — the D-05 cutover relay: bot-lane site deploy + `BOT_INTERNAL_API_KEY`, clear the Interactions Endpoint URL, real picker tap, then commands on; `/help` model chosen with its cost shown + +**Wave 9** *(blocked on 05.1-09)* + +- [x] 05.1-10-PLAN.md — D-08: delete parseforge.gg's interactions route/library/tests, separate site deploy, remove `DISCORD_BOT_TOKEN`/`DISCORD_PUBLIC_KEY` from Vercel, OPS-01 Part 8 sign-off + +**Wave 10** *(gap closure — blocked on 05.1-10)* + +- [ ] 05.1-11-PLAN.md — gap closure for 05.1-REVIEW WR-01…19 + 05.1-SECURITY T-05.1-41/-10/-26, R-01…R-04: log hygiene, /help deterministic guardrails + URL allow-list, per-member caps + guild pin, runtime hardening, feedback-loop throttles + channel allow-list, script guards, Railway redeploy checkpoint + ### Phase 6: Discoverability & Content **Goal**: More of the queries ParseForge can genuinely answer land on a ParseForge page @@ -233,23 +414,44 @@ Plans: **Plans**: TBD **UI hint**: yes -**Notes**: Rollout order is low-traffic routes first, high-traffic (`/`, `/analyze/*`, `/tbc-audit`) last. Uses the project's `impeccable` skill for the design work. OPS-02 lands at the very end of the milestone, after every ad/CMP source has been discovered through report-only violation reports. +**Notes**: Rollout order is low-traffic routes first, high-traffic (`/`, `/analyze/*`, `/tbc-audit`) last. Uses the project's `impeccable` skill for the design work. OPS-02 lands at the very end of the milestone, after every ad/CMP source has been discovered through report-only violation reports. The redesign may not remove any element listed in `docs/PROTECTED-ELEMENTS.md`, and `npm run protected-elements` is part of the per-route gate. + +### Phase 8: WoW Forever Readiness (F0) + +**Goal**: Every *structural* blocker to supporting World of Warcraft Forever is removed now, so the gated F1/F2 phases become data-only work the day WCL exposes Forever logs (raids from 9 Dec 2026) +**Mode:** mvp +**Depends on**: Phase 2 (generated game data, fixtures recorder) +**Requirements**: none of the 24 v1 IDs — prep queued by the developer on 2026-09-19 from `.planning/research/WOW-FOREVER.md` §5 (F0-1…F0-4); F1/F2 stay outside this milestone until the upstream signal fires (re-check 4 Nov / 9 Dec 2026) +**Success Criteria** (what must be TRUE): + + 1. `scripts/probe-wcl-game-versions.mjs` lists WCL `worldData` expansions/zones/partitions from env credentials without printing secrets, snapshots them into `docs/WCL-GAME-VERSIONS.md`, and exits 1 when a Forever zone appears (the unblock detector). + 2. `npm run regen-game-data -- --report` accepts a fourth, report-only `forever` era descriptor with `hasGems:false` tolerated, while Classic+TBC/WotLK/Cata output is byte-identical (audit diff = build line only). + 3. Domain-scoped lookups (`lookupEnchantName/lookupGem…/lookupConsumable(id, domain)`) exist with tests; `lib/cla-constants.ts` re-exports stay byte-compatible; pinned facts 2667/3003/32196 unchanged. + 4. A single WCL link helper maps domain → subdomain and replaces the hard-coded `classic.warcraftlogs.com` links; a low-cardinality `expansion_domain` PostHog property ships with the change (OPS-01). + 5. Full `npm test`, `npx tsc --noEmit`, `npm run lint` green with no new findings. + +**Plans**: TBD +**UI hint**: no + +**Notes**: Deliberately data-free — no `game-data.forever.ts` is written by default (`--enable-forever` flag only). R0-2's fixtures extension and F0-1's probe are **one script, two consumers** (see PARSEFORGE-RANKINGS-SPEC §7 R0-2); if Phase 4 lands R0-2 first, F0-1 extends it rather than duplicating it. ## Progress **Execution Order:** -Phases execute in numeric order: 1 → 2 → 2.1 → 3 → 4 → 5 → 6 → 7 +Phases execute in numeric order: 1 → 2 → 2.1 → 3 → 4 → 5 → 5.1 → 6 → 7 → 8 (Phase 8 is unblocked and may be pulled forward at the developer's call) | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation — Themes & Consent | 9/9 | Complete | 2026-09-06 | | 2. Accuracy & Analysis Depth | 9/9 | Complete | 2026-09-08 | -| 2.1. PostHog Consent Gate Hotfix (INSERTED) | 0/TBD | Not started | - | -| 3. Share Loop | 0/TBD | Not started | - | -| 4. Ads Live | 0/TBD | Not started | - | -| 5. Community & Cross-Promotion | 0/TBD | Not started | - | +| 2.1. PostHog Consent Gate Hotfix (INSERTED) | 8/8 | Complete | 2026-09-15 | +| 3. Share Loop | 10/10 | Complete | 2026-09-19 | +| 4. Ads Live | 6/7 | In Progress| | +| 5. Community & Cross-Promotion | 10/10 | In Progress| | +| 05.1. ParseForge Discord bot — LootList+ parity (INSERTED) | 10/11 | In Progress| | | 6. Discoverability & Content | 0/TBD | Not started | - | | 7. Redesign, De-bloat & Hardening | 0/TBD | Not started | - | +| 8. WoW Forever Readiness (F0) | 0/TBD | Not started | - | ## Requirement Coverage @@ -264,6 +466,7 @@ All 24 v1 requirements mapped to exactly one phase. No orphans, no duplicates. | 5. Community & Cross-Promotion | COMM-01, COMM-02, SHARE-04 | 3 | | 6. Discoverability & Content | SEO-01, SEO-02, SEO-03, SEO-04 | 4 | | 7. Redesign, De-bloat & Hardening | DSGN-02, DSGN-04, SEO-05, OPS-02 | 4 | +| 8. WoW Forever Readiness (F0) | — (prep; no v1 requirement) | 0 | | **Total** | | **24** | ## Sequencing Constraints Honored diff --git a/.planning/STATE.md b/.planning/STATE.md index 2313e55..e3807da 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,44 +1,45 @@ --- gsd_state_version: 1.0 -current_phase: "2.1" -current_phase_name: PostHog Consent Gate Hotfix -status: planning -stopped_at: Phase 2.1 inserted (urgent) — PostHog capture regression diagnosed, ready to plan -last_updated: "2026-09-14T19:00:48.962Z" -last_activity: 2026-09-14 -last_activity_desc: Phase 2 review fixes (PR #16); PostHog capture regression found, Phase 2.1 inserted -state_head: 53db5a0f5d1c0c1ccefad272df2e9abe2e6345ec +current_phase: 05 +current_phase_name: Community & Cross-Promotion +status: verifying +stopped_at: Completed 05.1-10-PLAN.md +last_updated: "2026-10-14T05:28:20.712Z" +last_activity: 2026-09-28 +last_activity_desc: Phase 05 gap closure verified — human_needed (3 items) +state_head: 66d67727bbb256a29ce16e2a6d1fe3cc3e1d6163 progress: - total_phases: 8 - completed_phases: 2 - total_plans: 18 - completed_plans: 18 - percent: 25 + total_phases: 10 + completed_phases: 4 + total_plans: 63 + completed_plans: 63 + percent: 40 --- # Project State ## Project Reference -See: .planning/PROJECT.md (updated 2026-09-08 after Phase 2) +See: .planning/PROJECT.md (updated 2026-09-19 after Phase 3) **Core value:** A player pastes a Warcraft Logs URL and instantly gets accurate, actionable answers to "why is my parse low" — accuracy is non-negotiable. -**Current focus:** Phase 2.1 — PostHog Consent Gate Hotfix (URGENT: capture ~99.9% down since 2026-09-06; see `02.1-DIAGNOSIS.md`). Phase 3 Share Loop follows. +**Current focus:** Phase 05 — Community & Cross-Promotion ## Current Position -Phase: 3 — Share Loop -Plan: Not started -Status: planning -Last activity: 2026-09-08 — Phase 02 complete, transitioned to Phase 3 +Phase: 05 (Community & Cross-Promotion) — VERIFYING +Next: Phase 05.1 (ParseForge Discord bot — LootList+ parity) — PLANNED, 10 plans in 9 waves, ready to execute (current phase stays 05 until 05-UAT tests 18-20 close, per 05.1-CONTEXT.md; 05.1's #welcome/#feedback production cutovers are gated on the same UAT items) +Plan: 10 of 10 +Status: Verification human_needed — 05-UAT.md tests 18-20 pending (PostHog/GSC figures, role-picker tap test, #announcements purge before 2026-10-09T21:48Z) +Last activity: 2026-09-28 — gap closure 05-05..05-10 executed, deployed (fd8e9ba, 8165d97), code-reviewed (0 critical / 7 warning), verified -Progress: [███░░░░░░░] 29% (2/7 phases; 18/18 planned plans complete) +Progress: [████░░░░░░] 40% (4/8 phases incl. 2.1; 36/36 planned plans complete) ## Performance Metrics **Velocity:** -- Total plans completed: 18 +- Total plans completed: 36 - Average duration: — - Total execution time: 0.0 hours @@ -48,6 +49,8 @@ Progress: [███░░░░░░░] 29% (2/7 phases; 18/18 planned plans |-------|-------|-------|----------| | 01 | 9 | - | - | | 02 | 9 | - | - | +| 02.1 | 8 | - | - | +| 03 | 10 | - | - | **Recent Trend:** @@ -77,6 +80,51 @@ Progress: [███░░░░░░░] 29% (2/7 phases; 18/18 planned plans | Phase 02-accuracy-analysis-depth P07 | 45min | 2 tasks | 5 files | | Phase 02-accuracy-analysis-depth P08 | 55min | 3 tasks | 6 files | | Phase 02 P09 | 35min | 3 tasks | 1 files | +| Phase 02.1 P02 | 25min | 2 tasks | 4 files | +| Phase 02.1 P01 | continuation | 3 tasks | 7 files | +| Phase 02.1 P03 | 30min | 3 tasks | 2 files | +| Phase 02.1 P04 | 25min | 3 tasks | 3 files | +| Phase 02.1 P05 | 20min | 2 tasks | 2 files | +| Phase 02.1 P06 | 20min | 3 tasks | 1 files | +| Phase 02.1 P07 | continuation | 3 tasks | 1 files | +| Phase 02.1 P08 | continuation | 2 tasks | 2 files | +| Phase 03 P01 | 12min | 2 tasks | 7 files | +| Phase 03-share-loop P02 | 12min | 2 tasks | 3 files | +| Phase 03 P03 | 8min | 2 tasks | 3 files | +| Phase 03 P04 | 11min | 3 tasks | 2 files | +| Phase 03 P05 | 10min | 3 tasks | 5 files | +| Phase 03 P06 | 25min | 2 tasks | 3 files | +| Phase 03 P07 | 35min | 3 tasks | 3 files | +| Phase 03-share-loop P08 | 12min | 2 tasks | 2 files | +| Phase 03-share-loop P09 | 8min | 1 tasks | 1 files | +| Phase 03-share-loop P10 | 15min | 2 tasks | 3 files | +| Phase 04 P02 | 35min | 3 tasks | 10 files | +| Phase 04 P04 | 25min | 2 tasks | 5 files | +| Phase 04 P01 | 55min | 3 tasks | 5 files | +| Phase 04 P03 | 45min | 3 tasks | 10 files | +| Phase 04-ads-live P05 | 45min | 3 tasks | 7 files | +| Phase 04 P06 | 12min | 3 tasks | 1 files | +| Phase 04-ads-live P07 | 55min | 1 tasks | 3 files | +| Phase 05 P01 | 2h55m | 3 tasks | 8 files | +| Phase 05 P02 | 20min | 3 tasks | 5 files | +| Phase 05 P03 | ~45min | 2 tasks | 2 files | +| Phase 05 P04 | ~2h | 3 tasks | 1 files | +| Phase 05 P05 | 15min | 2 tasks | 4 files | +| Phase 05 P06 | 12min | 2 tasks | 5 files | +| Phase 05 P07 | ~10min (continuation) | 2 tasks | 5 files | +| Phase 05 P09 | 25min | 2 tasks | 5 files | +| Phase 05 P08 | ~50min | 3 tasks | 5 files | +| Phase 05 P10 | ~20min (continuation) | 2 tasks | 4 files | +| Phase 05.1 P02 | 12min | 2 tasks | 7 files | +| Phase 05.1 P01 | 70min | 3 tasks | 14 files | +| Phase 05.1 P03 | ~15min (continuation) | 3 tasks | 6 files | +| Phase 05.1-parseforge-discord-bot-lootlist-parity P04 | 40min | 3 tasks | 9 files | +| Phase 05.1 P05 | 20min | 2 tasks | 6 files | +| Phase 05.1-parseforge-discord-bot-lootlist-parity P06 | 25min | 2 tasks | 10 files | +| Phase 05.1-parseforge-discord-bot-lootlist-parity P07 | 32min | 3 tasks | 12 files | +| Phase 05.1-parseforge-discord-bot-lootlist-parity P08 | 25min (continuation) | 3 tasks | 4 files | +| Phase 05.1-parseforge-discord-bot-lootlist-parity P09 | 2 sessions (developer-run steps) | 3 tasks | 4 files | +| Phase 05.1 P10 | 2 sessions (developer-gated) | 3 tasks | 8 files | ## Accumulated Context @@ -96,6 +144,75 @@ Recent decisions affecting current work: - [Phase 02 / 02-04, 02-07]: One `lib/healer-metrics.ts` helper feeds both the player card and the raid Healer Breakdown (D-08, parity asserted by test and confirmed live in prod); healer suggestion rules threshold against top healers' own values and DPS-shaped rules are role-gated off. - [Phase 02 / 02-02, 02-05]: Timeline idle threshold `max(2000ms, 3×median inter-cast gap)`; death rows merged chronologically; hand-rolled windowing (no virtualisation dependency); WCL `begincast` probes filtered, `-1` targetID = no target. - [Phase 02 / 02-09]: Shipped preview-first with push + PR #15 before prod (`dpl_5bwk1fJJNuZXkoC5poPGFZQpGy6c`); the harness classifier denied `vercel deploy --prod` for both executor and orchestrator until the developer granted `Bash(vercel deploy:*)`. ASVS review deferred again (tooling absent) with the developer's explicit choice. +- [Phase 02.1]: [Phase 02.1 / 02-02]: OPS-01 gate hardened with a mandatory post-deploy live-traffic check (item 7: HogQL thresholds, zero-events-is-a-FAIL rule, bounded netlog proof) - an event definition's presence is no longer accepted as ingestion proof (D-08). Phase 1 and Phase 2 VERIFICATION.md records + REQUIREMENTS.md carry dated append-only addenda correcting the PostHog criterion (D-09). +- [Phase 02.1]: Task 2 (02.1-01): user-selected option a at checkpoint — Gibraltar, Isle of Man, Jersey and Guernsey stay outside CONSENT_REGIONS (no code change needed, Task 1 already implemented the default). +- [Phase 02.1]: [Phase 02.1 / 02.1-03]: Preview dpl_7JGkziQrpZrhKfvxHJNAmtuXfuFy proved the consent-gate fix end-to-end via headless-Chrome netlog (3 capture requests vs a production baseline of zero); RESEARCH Open Question 1 answered (preview deployments receive real x-vercel-ip-country geo headers). +- [Phase 02.1]: [Phase 02.1 / 02.1-03]: Found and worked around a previously-unknown false-negative trap in the D-10 netlog technique - posthog-js's built-in bot filter drops all captures for any User-Agent containing "HeadlessChrome" by default; fixed via --user-agent override for this run, logged as WINDOWS.md todo #7 for Part 1's own command text. +- [Phase 02.1]: [Phase 02.1 / 02.1-04]: Production deploy dpl_HY5319wSDVw3M4ibBU42JrSTgw4e ran the hardened OPS-01 live-traffic gate for the first time - 2 of 3 item-7 thresholds passed with counted PostHog evidence (25 pageviews across 3 non-consent-region countries); the Vercel Web Analytics ratio threshold is PENDING (unreadable this session), so Phase 2.1 was left deliberately unsigned rather than fabricated as complete. OPS-01 remains not-yet-met in REQUIREMENTS.md via an appended addendum. +- [Phase 02.1]: [Phase 02.1 / 02.1-05]: OPS-01 gate SIGNED (2026-09-15) — Vercel Web Analytics figure (23 page views, recorded as an upper bound) scored PASS against 25 PostHog pageviews (25/23 ~ 108.7% >= 50%); all three item 7 thresholds now PASS. — Developer read the dashboard directly (route a); figure recorded as an upper bound because the range's end time was illegible, but the PASS is invariant to that imprecision since any true figure <= 23 also clears the <= 50 PASS line. +- [Phase 02.1]: theme_changed, timeline_viewed and timeline_filter_used counted-observed in PostHog project 337485 with consent_gate_path=geo-non-consent-region via a targeted manual test (route: automation-performed, orchestrator-driven headless Chrome scoped to parseforge.gg, developer authorized "you drive it"); consent_resolved/consent_unavailable recorded structurally unobservable from a non-consent-region egress and handed off to 02.1-08; timeline_error not triggered by design (optional step not authorized). +- [Phase 02.1]: [Phase 02.1 / 02.1-07]: Deployed dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5 (commit dd19b0b) on the developer's verbatim "ship it" - all five 02.1-REVIEW-FIX.md commits (90f5a46, 0e03d9b, 7a7cf0c, 9657535, 9da21c4, incl. WR-03 consent_resolved dedupe) are now live in production. — Makes the WR-03 dedupe testable in 02.1-08 against actual reviewed source rather than known-unfixed code; the fail-closed geo body validation (CR-01/90f5a46) is now protecting real visitors too. +- [Phase 02.1]: [Phase 02.1 / 02.1-07]: This deploy's own OPS-01 item 7 gate row is recorded NOT SIGNED - the new 60-minute window (2026-09-15T19:16:13Z-20:16:13Z) landed on a low-traffic UTC hour: Threshold 1 FAIL (3 pageviews, need 20), Threshold 2 FAIL (1 non-consent-region country, need 2), Threshold 3 NOT EVALUABLE (Vercel CLI API only returns an hour-rounded upper bound, not a window-exact figure). — 02.1-05's 2026-09-14 sign-off is untouched and rests on its own evidence; this deploy needs its own re-measurement on a busier UTC hour with a window-granularity Vercel read to close its own row. Recorded honestly rather than reused, softened, or omitted (docs/OPS-01-SHIP-GATE.md). +- [Phase 02.1]: [Phase 02.1 / 02.1-08]: EEA/UK/CH TCF observation not performed - developer declined the manual VPN session verbatim ("Stop asking for me to do things. Do it for me please"), and no automation-side EEA egress exists in this environment; recorded as not-performed with the exact test that would close it, rather than upgraded on unit tests or code review. — Commit 9da21c4 (WR-03 dedupe) is confirmed live in the deployed production build (dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5, commit dd19b0b), so the test remains ready to run and meaningful whenever an EEA/UK/CH session becomes available. +- [Phase 03]: [Phase 03 / 03-01]: Shipped the phase tracer - lib/awards-engine.ts (5-rule seed pool: first-to-die, top-dps, top-hps, flaskless, best-prepared) is the single source of truth for both the OG image and (03-04) the in-app panel; app/og/route.tsx's view=awards branch falls back to ReportCard on any failure or a thin award set; lib/share-links.ts is now the one tested module every later share button in this phase builds its URL from. — Local next dev has no WCL credentials (Vercel-only per CLAUDE.md), so the awards-card render path itself is unverified against real data locally - the plan's own must_haves flags this exact truth as a backstop verification item, closed on the first preview/prod deploy. +- [Phase 03]: [Phase 03 / 03-02]: AWARD_POOL filled to the full 15-rule D-02 pool (priorities 6-15 appended); pool-wide invariant tests (unique ids, contiguous priorities, no unconditional firing, stat/winner shape, cap, stable sort, non-mutation) lock the contract for future rule additions. Full 15-row pool recorded in 03-02-SUMMARY.md for the developer's D-04 tone review at the 03-06 preview gate. +- [Phase 03]: [Phase 03 / 03-03]: Player OG card gained D-10 receipts (Kill/Wipe, fight length, vs-top-N, one healer/DPS proof line) reading only data.healer and the metricPercentiles activeTime entry; Share my parse is now the primary button on the player scorecard (data-protected=share-player), Copy for Discord demoted to secondary (data-protected=share-discord), both dual-emitting share_action. Local next dev still lacks WCL credentials, so the receipts render path is unverified against real data locally (same gap as 03-01/03-02) — closes on first preview/prod deploy. +- [Phase 03]: [Phase 03 / 03-04]: Raid tab AwardsPanel wired to computeAwards over the in-memory RaidOverviewResult and the real /og preview image; header Share normalized off buildReportShareUrl (no more raw location.href); bottom "Found this useful?" bar removed; landing rules open Player tab on source-present and scroll the awards panel into view on view=awards; one guarded mount effect captures share_landing for an allowlisted ref then strips it from the address bar. — Local next dev lacks WCL credentials (Vercel-only per CLAUDE.md), so the panel's real-data render and the two landing-behavior truths remain unverified locally — same gap already recorded by 03-01/03-02/03-03, closed at the 03-06 preview gate per the plan's own must_haves backstop entries. +- [Phase 03]: [Phase 03 / 03-05]: Shipped the protected-elements checklist (docs/PROTECTED-ELEMENTS.md, all 6 shipped data-protected attributes) and the third node-script gate (scripts/protected-elements.mjs, seo-invariants/token-audit shape) — 10/10 checks pass live against production. OPS-01 gained item 8 plus the D-14 share-rate HogQL under item 4; ROADMAP Phase 4/7 entries now name the checklist as a hard input. +- [Phase 03]: 03-06: Sourced VERCEL_AUTOMATION_BYPASS_SECRET from the Vercel project REST record (protectionBypass key) when `vercel env pull` didn't surface it; confirmed non-empty by length only, never printed. +- [Phase 03]: 03-06: protected-elements.mjs has no bypass-header support and cannot gate an SSO-protected preview directly; recorded the gap (WINDOWS #8) and used curl for the real preview route evidence instead. +- [Phase 03]: 03-06: D-04 award-pool tone review, real Discord unfurl, and D-13 mobile reachability left not-performed by the executor per resume instructions; recorded in OPS-01-SHIP-GATE.md Part 5 with exact closing tests and preview URLs (WINDOWS #9). +- [Phase 03]: Phase 3 deployed to production (dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx, commit adaea2f, 2026-09-16T09:14:57Z) under a recorded deploy-now developer approval; all production OG/canonical/protected-elements contracts confirmed (10/10 PASS). +- [Phase 03]: Item 7 counted for this deployment's 09:14:57Z-10:14:57Z window: threshold 1 FAIL (7 pageviews), threshold 2 PASS (2 countries: UA, US), threshold 3 NOT EVALUABLE (no Vercel Web Analytics endpoint on the personal token). Re-measure test recorded: full 60-min window on a busier UTC hour (candidate ~22:00Z) plus a window-granularity Vercel read. +- [Phase 03]: Share-rate (D-14) read as a first reading: 0.0% (0 share_action / 19 analysis_complete sessions over 7 trailing days, only ~7.5h of live exposure) vs the ~2.8% baseline. Re-run scheduled on/after 2026-09-23T09:15Z. +- [Phase 03]: Developer decision (2026-09-16T16:47Z): 'Record now, leave open' - write counted evidence into Part 5 as FAIL/PENDING with the exact re-measure test; 03-07 completes with Part 5 explicitly unsigned (Sign-off: LEFT OPEN - not signed); OPS-01's Phase 3 re-run recorded not-met via REQUIREMENTS.md Addendum 3, and a SHARE-01/02/03 addendum documents the capability-proven/backstop-open split rather than reverting the prior sibling plan's premature Complete marking. +- [Phase 03-share-loop]: Row 7 (D-04 tone review) recorded PASS but explicitly labelled delegated, not the developer's own first-hand verdict — Developer delegated the verdict three times ('Just do whatever you think is best.') rather than stating one; the gate document preserves that distinction so a later reader does not mistake the orchestrator's assessment for a developer sign-off +- [Phase 03-share-loop]: Row 8 (real Discord unfurl) recorded NOT OBTAINED despite strong proxy evidence, per the plan's prohibition against folding a not-obtained result into a pass — No real Discord channel was available this session; a Discord-crawler-shaped fetch is not the same evidence as an actual Discord unfurl +- [Phase 03-share-loop]: Item-7 live-traffic thresholds re-measured on a full 60-minute window (2026-09-17T06:00-07:00Z) -- all three PASS; RESEARCH A3 closed on two real share_landing events carrying consent_gate_path. — The original deployment window's quiet-hour FAIL/NOT EVALUABLE reading was superseded (not overwritten) by a busier-hour re-measure per the developer's 'Record now, leave open' decision; threshold 3 became readable for the first time via the Vercel MCP aggregate_pageviews hourly breakdown. +- [Phase 3]: OPS-01 Phase 3 gate SIGNED (2026-09-19) under sign-now-conditional: 7/9 rows counted PASS, D-14 share-rate and real Discord unfurl recorded as accepted dated re-deferrals; D-14 re-read (on/after 2026-09-23T09:15Z) must append a dated confirmation/correction and WINDOWS.md #11 stays open until then. +- [Phase 3]: WINDOWS.md #10 (item-7 re-measure) marked fixed; #9 (developer backstops) and #11 (share-rate re-run) left open with dated reasons recorded in the Part 5 close-out. +- [Phase 04]: R0-2: recorded real WCL rankings/rate-limit/guild fixtures (6 files) with measured (not assumed) per-query point costs; demo report confirmed to have no guild, fallback guild Sage (Dreamscythe-US) found via a real public character ranking and confirmed public via the API's own visibility field (browser check blocked, 403, in this environment). +- [Phase 04]: [Phase 04 / 04-04]: R0-3 shipped as tested, unwired engine code - lib/wcl-types.ts gained a new rankings-blob type family verified against 04-02's rankings-report.json fixture (deliberately not extending WCLRanking/WCLRankingsData, per 04-RESEARCH.md Pitfall 1); lib/rankings/parse-lens.ts and lib/rankings/budget.ts (Redis-backed 90%-of-runtime-read-limit gate) both pass full TDD RED/GREEN and are imported by nothing (D-13) - confirmed via zero app/ imports and unchanged protected-elements/seo-invariants gates. +- [Phase 04]: [Phase 04 / 04-01]: Developer deferred sending the RPGLogs commercial-use approval request on 2026-09-21 after supplying browser captures; Task 1 recorded PARTIAL, not rounded up. +- [Phase 04]: [Phase 04 / 04-01]: vercel metrics --format json summary[] adopted as the Part 6 CWV baseline's number source (whole-window p75), distinct from the default text table's bucketed avg column, so 04-07's post-ship comparison reads the same statistic. +- [Phase 04]: [Phase 04 / 04-01]: /analyze/[reportCode] pre-ad CLS p75 (0.2344 desktop / 0.4768 mobile) recorded as-is in the OPS-01 Part 6 baseline, not smoothed over — it is the baseline the ad rollout must not worsen, not a target. +- [Phase 04]: [Phase 04 / 04-03]: Phase tracer shipped end-to-end on /tbc-audit — lib/consent.ts publishes the single resolved ConsentGatePath (PostHogProvider and the new ad gate both subscribe, neither re-derives it); lib/ads.ts's adsEnabled() (env flag + pub id) is split from adsConfigured() (adds the per-slot unit-wired check) so AdSlot reserves its exact box the moment ads are switched on even before a slot has a real AdSense unit id; scripts/protected-elements.mjs extended with a second machine-checked table for ad slot placements, proven with a real tamper test. +- [Phase 04]: checkAdSlotContainment's blanket owner-file rule (owner may never also own a data-protected element) was removed and replaced with the 15-line proximity scan alone, since AnalyzeClient.tsx legitimately owns both share-header and the two analyze slots far apart +- [Phase 04]: NEXT_PUBLIC_ADSENSE_PUB_ID stores digits only (no ca-pub- prefix) because lib/ads.ts and AdSlot.tsx both build the ca-pub- prefix themselves +- [Phase 04]: Auto ads OFF confirmed by the developer directly from the AdSense UI (2026-09-21); ads.txt status and the /privacy Privacy & messaging site-settings paste remain open, no code can observe either +- [Phase 04]: [Phase 04 / 04-06] Developer confirmed the fourth preview's ad-box visual defect (box sizing then occluding-cover mismatch) fully fixed in both themes, and confirmed /privacy reads true, closing this plan +- [Phase 04]: [Phase 04 / 04-06] Vercel automation-bypass secret briefly appeared in a diagnostic tool-call transcript during round-1 debugging (never committed); developer explicitly declined rotation when asked +- [Phase 04-ads-live]: OPS-01 item 7 and Search Console recorded NOT EVALUABLE/no-data for the Phase 4 post-deploy check (no PostHog/GSC access this dispatch); Phase 4 sign-off recorded NOT SIGNED with every open row named — No PostHog MCP or curl/API channel and no GSC tool were available to the executor; the project's own convention treats an unreadable figure as not-evaluable, never a fabricated pass +- [Phase 05]: Preview deployments on this Vercel project require SSO (Vercel Authentication); the Task 3 tracer render was driven from an authenticated browser session instead of curl, with Vercel runtime logs as the source of truth. — The plan's curl-based verification procedure didn't anticipate deployment protection; server-side after() behavior is identical regardless of client, so logs remain authoritative. +- [Phase 05]: [Phase 05 / 05-02]: Homepage's pre-existing 'Big Yikes' Discord credit kept its exact destination and gained an appositive naming it as the developer's own community, with a comment pointing to the new site-wide Footer.tsx Discord link — documented, not removed, since the two are already distinguished by label/icon/position and LootList+'s footer credits the same server identically. +- [Phase 05]: [Phase 05 / 05-02]: No new docs/PROTECTED-ELEMENTS.md row added for the two footer links — 05-UI-SPEC.md found no crowding risk and Phase 7's seo-invariants gate already covers the property that matters (internal links stay real anchors). +- [Phase 05]: [Phase 05 / 05-02]: PARSEFORGE_INBOUND_URL_FOR_LOOTLISTPLUS pinned as 'https://parseforge.gg/?utm_source=lootlistplus&utm_medium=footer&utm_campaign=cross_promo' in lib/cross-promo.ts for plan 05-03 to paste into the sister repo's footer verbatim. +- [Phase 05]: LootList+'s footer anchor now carries utm_source=lootlistplus/utm_medium=footer/utm_campaign=cross_promo and a two-property cross_promo_click PostHog event, committed on unpushed local branch growth/parseforge-cross-promo in the sister repo (COMM-02's other half; plan 05-02 shipped ParseForge's own side). +- [Phase 05]: docs/DISCORD-SEED.md ships the Discord day-one seed kit (welcome message, channel topics, invite-sharing copy, and a 3-report backfill list verified live via curl+/api/report); a retail Mythic+ report was excluded from the backfill on brand-fit grounds. +- [Phase 05]: WINDOWS #22 remediated: the LootList+ branch growth/parseforge-cross-promo got an unrelated commit from a concurrent Phase 6 session on top of plan 05-03's Task 1 commit. Cherry-picked 5d34b1f6 cleanly onto a NEW branch growth/05-03-parseforge-footer off current main (single commit a6f917ec, typecheck clean, unpushed). Plan 05-04 must push/merge growth/05-03-parseforge-footer, NOT growth/parseforge-cross-promo — the latter is left alone/untouched since a concurrent session may still be using it. — Cherry-pick onto a brand-new branch name is non-destructive to the concurrent session's work (nothing on growth/parseforge-cross-promo was deleted or rewritten); safer than rebasing/force-pushing a branch another live process might still be writing to. +- [Phase 05]: Phase 5 sign-off recorded PostHog live-traffic counts and Search Console per-route results as no-data rather than fabricated passes -- PostHog OAuth was not completed this session and no GSC tool/API access was available. Both are named as explicit pending re-checks in docs/OPS-01-SHIP-GATE.md Part 7's Sign-off, with the PostHog re-check needing the fixed window [2026-09-25T22:47:22Z, 2026-09-25T23:47:22Z] queried explicitly rather than a relative last-hour window. — Matches the project's established convention (Phase 4's own OPS-01 sign-off) that an unreadable figure is recorded as not-evaluable, never assumed or invented. +- [Phase 05]: [Phase 05 / 05-05]: Discord announce scheduling moved out of getReportMeta entirely into a new announceAnalyzeRender, gated on next/server's connection() and called only from AnalyzePage after notFound() -- the ISR homepage (FeaturedReports) and any build/revalidation render can no longer trigger a Discord post (G-05-16 decisions.homepage_autopost). — Developer's 2026-09-27 decision recorded in 05-UAT.md G-05-16: only a real /analyze/{code} render may trigger a Discord announcement; homepage/ISR rebuilds must never post. Repo-wide invariant tests (lib/report-meta.test.ts) pin the single call site so a future regression fails a test rather than silently reposting off-brand featured reports. +- [Phase 05]: [Phase 05 / 05-06]: D-03 revised to three channels (#welcome read-only landing + #announcements read-only + #feedback open), no Community mode; D-07 refined to fire only from a real, request-time /analyze/{code} render; D-10 given an owner (docs/DISCORD-SEED.md checklist worked at plan 05-07's checkpoint, tracked as open WINDOWS #26 until plan 05-08 Task 3 closes it). +- [Phase 05]: [Phase 05 / 05-07]: Discord new-member refresh resolved: the developer directed the ParseForge Discord bot (scoped, non-Administrator permissions) to perform the docs/DISCORD-SEED.md checklist instead of doing it by hand, superseding D-10's original clause for this one refresh; recorded as a dated D-10 revision in 05-CONTEXT.md, not silently folded into the original wording. The verified #welcome invite (https://discord.gg/AFCqMAUezq) is now live in lib/cross-promo.ts and docs/DISCORD.md; the plan 05-01 invite is recorded superseded-but-valid. Both #announcements backfill targets (Magtheridon, Archimonde) were already posted, so plan 05-08 opens neither backfill URL. New WINDOWS #28 (todo) flags the #announcements webhook-post volume (5,293 posts) as a likely crawler-driven finding needing a decision before Phase 5 sign-off. +- [Phase 05]: Ordered social_other (TelegramBot) before twitter (Twitterbot) in the crawler-ua vendor-rule table because Telegram's real bot UA literally contains the substring TwitterBot +- [Phase 05]: http_client and generic bot-token fallback require a literal slash + version immediately after the product token, which keeps phone-model strings like CUBOT from false-positiving as a bot +- [Phase 05]: 05-08: Deployed G-05-16 fixes + 05-09 crawler filter to production (dpl_BdiXwtnaPd2UHbHeSVJMA6V7NpF5, fd8e9ba) on the developer's verbatim in-session go-ahead; production A/B proof confirms Googlebot posts nothing and a person's browser posts exactly once. +- [Phase 05]: 05-08: D-11 (b) #announcements purge (5302-message delete bucket) and the first #changelog post left deferred to the developer -- purge by explicit developer choice, changelog blocked by a live Discord permission gap (403/50013). WINDOWS #26 and #28 stay open. +- [Phase 05]: [Phase 05]: 05-10: Retired /raid and /analyze rather than porting them (Claude's discretion under D-12) -- the gateway bot has been offline since its token reset and both commands already fail for anyone who tries them; restore path is re-registering from bot/src/index.ts. +- [Phase 05]: [Phase 05]: 05-10: Deployed the role picker to production (dpl_4tCBF3LquhZkqkSrwscmuZ3YTUi2, commit 8165d97) on the developer's verbatim in-session go-ahead ("deply role picker", typo included, recorded honestly rather than normalized); Interactions Endpoint URL now points parseforge.gg at /api/discord/interactions, /raid and /analyze are unregistered, and the picker is live in #welcome (message ids 1554222135575052399, 1554222137286332577, 1554222138611998793). +- [Phase 05]: [Phase 05]: 05-10: The #changelog post announcing the role picker and the human phone tap-test backstop are both recorded pending, not passed -- the #changelog write was blocked by this session's own harness (External System Writes) before any Discord-side attempt, and the tap-test needs a real member's device. Neither blocks the mechanism itself, which is proven live by automated probes and re-list/re-post-detection checks. +- [Phase 05]: [Phase 05.1 / 05.1-02]: Shipped lib/rate-limit.ts's checkCallerRateLimit/isAuthenticatedBotRequest (D-16) — the bot's authenticated calls to /api/analyze, /api/raid-overview and /api/report/{code} are limited per Discord member (bot-member, 12/60s) plus one all-bot ceiling (bot-aggregate, 120/60s) instead of by its one shared Railway IP; every unauthenticated or mismatched request still takes the unchanged per-IP path. 15/15 tests, root gate green, no deploy (BOT_INTERNAL_API_KEY is a name-only .env.example placeholder; plan 05.1-09 provisions and deploys it). +- [Phase 05]: [Phase 05.1 / 05.1-01] Shipped the phase tracer end to end against fakes: fileFeedback (claim-once, name-free D-09/D-10 filing) and runClosureSweep/scheduleClosureSweep (idempotent D-11 thread reply+rename) live in bot/src, with a pf:bot:-prefixed store (D-07) as the sole Upstash client constructor and a source gate proving no bot file touches the site's sitemap/cache/announce/rate-limit keys or reaches outside bot/. 16/16 tests, bot tsc/build/lint clean, root tsc clean, no file outside bot/ touched. Approved dependency versions confirmed byte-for-byte: discord.js 14.27.0, @upstash/redis 1.39.0, @anthropic-ai/sdk 0.129.0, vitest 4.1.11. +- [Phase 05.1]: Reused the pre-existing Railway service parseforge-bot (project wholesome-stillness) instead of creating a new one; disconnected its GitHub source and cleared Root Directory so bot/railway.json DOCKERFILE builder takes over and every future deploy stays CLI-only/manual (D-06). +- [Phase 05.1]: Fixed scripts/discord-bot.mjs preflight to treat either ApplicationFlags bit 18 (full Message Content intent) or bit 19 (limited, unverified app) as on -- an unverified Discord app only ever receives the limited bit, so the original check silently read the intent as off even after the developer enabled it in the Developer Portal. +- [Phase 05.1]: The developer, not Claude, ran every railway up/link command this session -- the harness denies Claude own Railway deploy commands, consistent with the plan prohibition against working around a harness block instead of asking the developer to run the exact command. +- [Phase 05.1]: [Phase 05.1 / 05.1-04]: Shipped the feedback half of LootList+ parity — Zev's reaction intake (DEVELOPER_ROLE_ID-gated), Claude (claude-opus-5) titles/duplicate hints, the forum Feature tag, the full closure/reopen lifecycle with a private non-pinging #ops Bug Hunter nudge, and the daily digest + 48h duplicate-proof backfill. fileFeedback now writes both a flat and a per-source pf:bot:feedback:filed key (no store.ts changes) so the digest can split counts by source via BotStore's existing sortedCount. 46/46 bot tests pass; no deploy (ships in plan 05.1-08). — D-01/D-09/D-10/D-11/D-12 all built and unit-tested against fakes on top of the plan 05.1-03 production tracer. +- [Phase 05]: [Phase 05.1 / 05.1-05]: Moved the #welcome role picker into the gateway bot — scripts/build-bot-data.mjs generates bot/src/generated/discord-roles.ts from lib/discord-roles.json (the Docker build boundary: bot/Dockerfile only COPYs bot/src), bot/src/roles.ts's handleRoleButton answers the same pf-role: custom_ids with a 10-toggle/60s per-member rate limit (fails open) and never throws, and lib/bot-data.test.ts makes the root npm test suite fail the instant the generated copy drifts from lib/discord-roles.json. 78/78 bot tests, 518/518 root tests, no deploy — plan 05.1-09 does the cutover. +- [Phase 05.1-parseforge-discord-bot-lootlist-parity]: [Phase 05.1 / 05.1-06]: /analyze and /raid are back as metered public replies via util/limits.ts's checkCommandAllowed (10s per-member cooldown, then a shared 200/day analysis cap, fails closed on any Redis error) and the authenticated bot lane; fixed a pre-existing bug where the fight-link regex never captured the fight/source suffix. 103/103 bot tests, 518/518 root tests, no deploy. +- [Phase 05.1-parseforge-discord-bot-lootlist-parity]: [Phase 05.1 / 05.1-07]: /help ships end to end - Claude (claude-opus-5) answers ParseForge product questions from a 12-article KB generated out of app/guides/*, app/tbc-audit's FAQ and docs/DISCORD-SEED.md, redirects gameplay/parse questions, hands off honestly to Zev, and grows the KB through Zev-reviewed drafts in #ops; behind the existing per-member cooldown + 100/day help cap. 27 new tests (bot 130/130, root 518/518), no deploy. — Reuses the plan 05.1-06 spend guard and 05.1-04/05 Claude client/generator patterns; no deploy, code-complete pending plan 05.1-09's cutover. +- [Phase 05]: [Phase 05.1 / 05.1-08]: Developer chose 'go live' (verbatim, 2026-10-02T17:45:05Z) and ran Railway deploy 2 themselves (deployment 5bd7e083-f993-46cb-b034-4a10958ebf78, commit a1f48ad) — bot_ready/help_seeded/backfill_run confirmed via logs. feedback-forum --open --execute and archive-channel --execute both ran: the #feedback forum is now public (D-11), the old #feedback text channel is archived read-only as #feedback-archive, never deleted. Human-check (phone post, next-morning #ops digest, outage/backfill test) recorded PENDING, not passed. +- [Phase 05]: [Phase 05.1 / 05.1-09]: D-05 cutover done. Developer chose 'cut over' (cutover-opus, /help on claude-opus-5, 2026-10-02T21:29:40Z). Interactions Endpoint URL cleared; developer's real picker tap passed on the gateway path; step a's BOT_INTERNAL_API_KEY was lost before reaching Railway, so a fresh key was swapped into Vercel and the site redeployed (dpl_DoWDyuJTU1ntLGJVBqamQ8F992XH, developer-run); Railway a1a7e859 logged commands_registered count=3, commands list guild: 3, Railway/Vercel key parity 'keys match', gate cutover-ok. parseforge.gg's interactions route stays as the one-command rollback until 05.1-10. Human-check (/analyze, /raid, /help in #log-review) PENDING. +- [Phase 05]: 05.1-10: retired parseforge.gg's interactions route (D-08); DISCORD_BOT_TOKEN and DISCORD_PUBLIC_KEY removed from Vercel Production after deploy dpl_GacDSHqUmFCPs1DrA2Q6qrMt8vfn returned 404 on the route; Phase 5.1 OPS-01 signed for mechanisms, member-facing checks open ### Pending Todos @@ -103,32 +220,49 @@ Recent decisions affecting current work: - `wow-forever-support.md` (2026-09-13) — add World of Warcraft Forever support once WCL exposes Forever logs; era module via wago regen, partition-aware rankings, fixtures. Not actionable until upstream data exists. -Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP now authenticates; its *default* project is still "LootList+ App" — `switch-project 337485` per session works, but fix the connector default; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) ~~merge PR #15~~ done 2026-09-14 (`origin/main` = `f1d74f4`); PR #16 (Phase 2 review fixes) awaits preview + prod deploy; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) ~~code-review findings in `02-REVIEW.md`~~ fixed on `growth/phase-2-review-fixes` (PR #16, 4/4) — still needs deploy. +Manual follow-ups (not todos): (1) AdSense → Privacy & messaging → European regulations → message → site settings: paste https://parseforge.gg/privacy (still open since 01-09); (2) PostHog MCP now authenticates; its *default* project is still "LootList+ App" — `switch-project 337485` per session works, but fix the connector default; (3) GSC: `/` and `/analyze/*` show "Crawled – currently not indexed" with crawl dates predating the Phase 2 deploy — request recrawl and watch `/` specifically; (4) ~~merge PR #15~~ done 2026-09-14; ~~PR #16~~ merged (fast-forward, `main` = `66b59da`) and deployed to prod 2026-09-14 (`dpl_4KnNGpjHrY9q1vwECEXZRaNFF5u7`), verified live: not-in-fight player → 404 on `/api/timeline`; (5) game-data ids 96264 / 96294 flagged for human review in `.planning/WINDOWS.md`; (6) ~~code-review findings in `02-REVIEW.md`~~ fixed (PR #16, 4/4) and live in prod since 2026-09-14; (7) PR #14 (external, Illidari-mark flasks — issue #13) must not merge as-is (hand-typed IDs, conflicting) — redo via `regen-game-data` + overrides with contributor credit, a `/gsd-quick` after Phase 2.1. ### Blockers/Concerns -- **[URGENT — Phase 2.1] PostHog capture has been ~99.9% down since the Phase 1 prod deploy (2026-09-06).** `cookieless_mode: "on_reject"` drops all PENDING-consent events; opt-in depended on `__tcfapi` calling back, which never happens for fresh visitors. Phase 1 & 2 OPS-01 PostHog criteria were not actually met. Fix: server-side geo opt-in (decided 2026-09-14). Evidence: `02.1-DIAGNOSIS.md`. -- Vercel CLI on this machine is logged into the `beast-app` team, not `loot-list-plus` — preview/prod deploys blocked until `vercel login` with the owning account. +- ~~[URGENT — Phase 2.1] PostHog capture ~99.9% down since 2026-09-06~~ resolved by Phase 2.1 (server-side geo opt-in, prod since 2026-09-14; gate signed 2026-09-15 on counted live traffic; EEA/UK TCF path confirmed in a real browser at UAT 2026-09-15). Evidence: `02.1-DIAGNOSIS.md`, `docs/OPS-01-SHIP-GATE.md` Part 4, `02.1-UAT.md`. +- ~~Vercel CLI logged into the wrong team~~ resolved 2026-09-14: personal login lives in `~/.vercel-personal`; every Vercel command needs `--global-config ~/.vercel-personal` (documented in CLAUDE.md). +- **Preview deployments are behind Vercel SSO** (302 → `vercel.com/sso-api`). Developer chose to enable "Protection Bypass for Automation"; once on, `VERCEL_AUTOMATION_BYPASS_SECRET` appears in `vercel env pull --environment=preview` and is sent as the `x-vercel-protection-bypass` header (or `?x-vercel-protection-bypass=…&x-vercel-set-bypass-cookie=true` for headless Chrome) — never printed, file deleted after use. Phase 2.1's D-10 netlog and the PR #16 smoke test both depend on it. +- **`NEXT_PUBLIC_GOOGLE_CMP_PUB_ID` (and `NEXT_PUBLIC_POSTHOG_HOST`) are Production-only env vars** — previews render no Google CMP, so the consent-region (EEA/UK/CH) path is only observable on production. Phase 2.1 verification must say so explicitly; adding the var to Preview is a developer decision (low risk). - Live brownfield product — deploys are manual Vercel CLI and require explicit user confirmation each time; the Claude Code auto-mode classifier also blocks `vercel deploy --prod` unless `Bash(vercel deploy:*)` is allowed (granted 2026-09-08 in `.claude/settings.local.json`). Preview-before-prod is the established pattern. -- [Phase 1+2 carry-forward] Security ASVS review deferred for both phases (tooling not installed — see Deferred Items); CSP still report-only. -- [Phase 2 carry-forward] `02-REVIEW.md` CR-01: healer overheal/uptime suggestions fire when `hasHealing` is false — a wrong recommendation live on parseforge.gg; plus WR-01..03. Fix before Phase 3 bakes healer numbers into share images. -- [Phase 2 carry-forward] `origin/main` at `55d2010`; Phase 2 on PR #15 — worktree isolation degrades to sequential until merged/pushed. +- [Phase 1+2 carry-forward] Security ASVS review deferred for both phases (tooling not installed — see Deferred Items); CSP still report-only. Phase 2.1's own `02.1-SECURITY.md` closed at ASVS L1 grep-depth (45 threats, 0 open, `gsd-security-auditor` still not installed). +- ~~[Phase 2 carry-forward] `02-REVIEW.md` CR-01 / WR-01..03~~ fixed and deployed 2026-09-14 (PR #16, `dpl_4KnNGpjHrY9q1vwECEXZRaNFF5u7`). +- ~~[Phase 2 carry-forward] `origin/main` behind~~ resolved 2026-09-14 (PR #15 and #16 merged; `main` = `66b59da`). - [Phase 2 carry-forward] GSC reports `/` as "Crawled – currently not indexed" (crawl 2026-09-05, pre-deploy) — pre-existing but unexplained for the homepage; investigate at the Phase 3 gate. - [Phase 1 carry-forward] Build-time `[kv-cache] getRecentReports failed: Dynamic server usage` noise during prerender of `/` and `/sitemap.xml` — pre-existing, harmless at runtime; small cleanup candidate. - Phase 4 research flag: verify current ad-network eligibility thresholds directly at signup (Ezoic source contradiction unresolved); model revenue vs. CWV/UX cost before committing. - Phase 5 open question: dedicated ParseForge Discord vs. channel in existing LootList+ server (empty-room risk). Phase touches a second repo at /Users/alexander.mayes/Code/loot-list-plus (Railway deploy). - Phase 6 research flag: define the per-page uniqueness rubric before scaling programmatic pages past the 10–15 pilot set (scaled-content-abuse risk). - Scheduled check-in 2026-09-08 reviews /tbc-audit + PR #10 outcomes — do not re-diagnose those before then. +- ~~[Phase 2.1 close] OPS-01 gate NOT signed / five review fixes not in production~~ resolved: gate signed 2026-09-15 (02.1-05, ratio 25/23); the five fix commits shipped in `dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5` (02.1-07). +- [Phase 2.1 carry-forward → Phase 3 gate] The dpl_CDCu1FVfPZcd8dHr4RpLcrHWJcJ5 deploy's own OPS-01 item 7 gate row is unsigned (thresholds 1/2 FAIL on a low-traffic window, threshold 3 NOT EVALUABLE - Vercel CLI API only exposes an hour-rounded aggregate, not a window-exact figure). Next action: re-measure a full 60-minute window against this same deployment on a busier UTC hour (old build showed 1-pageview hours at 12:00/13:00/16:00/17:00Z the same day and 24 at 22:00Z on 2026-09-14), with a window-granularity Vercel Web Analytics dashboard read. Not a precondition for 02.1-08. Separate finding, not a defect: a pre-existing PostHog-vs-Vercel capture gap for a subset of non-consent-region countries (BR, SG), present on the old build too, most plausibly ad-blocker-related - candidate WINDOWS.md follow-up to quantify the ratio per country over a week. +- ~~[Phase 02.1 close] ROADMAP SC2 / Truth 4 / MONY-01 behaviour-unverified~~ resolved 2026-09-15: the developer ran the EEA/UK/CH VPN session (reject, full opt-in, CMP re-confirm) and passed it as `02.1-UAT.md` Test 1; `consent_resolved` fired once (WR-03 dedupe holds in-browser). +- ~~[Phase 3 gate] Three developer-only backstops from 03-06 (D-04 tone, real Discord unfurl, D-13 mobile)~~ resolved: D-13 counted 2026-09-18 (384px viewport); real Discord unfurl and first-hand D-04 tone verdict passed by the developer in `03-UAT.md` tests 1–2 on 2026-09-19 (WINDOWS #9 closed). +- ~~[Phase 3 close] OPS-01 gate NOT signed for Phase 3~~ resolved 2026-09-19: `docs/OPS-01-SHIP-GATE.md` Part 5 signed under `sign-now-conditional` (7/9 counted PASS, rows 2 and 8 accepted dated re-deferrals; row 8 since closed via UAT). Phase 3 verified `passed`, `03-SECURITY.md` 50/50 closed. +- [Phase 3 carry-forward → 2026-09-23] **D-14 share-rate re-read obligation** (WINDOWS #11): run the 7-day HogQL on/after 2026-09-23T09:15Z and append a dated confirmation/correction to Part 5. UAT test 3 was passed by the developer on 2026-09-19 *without* the reading (noted in `03-UAT.md`), so this is Phase 3's only open evidence obligation — do not let it lapse. +- [Phase 3 carry-forward] `03-REVIEW.md` WR-01, WR-02, WR-03, IN-01 remain deferred to `/gsd-code-review 03 --fix`; explicitly outside the Part 5 signature and the security review. +- [Phase 4 dependency] RPGLogs/WCL API ToS requires approval for commercial/ads use (see `.planning/research/PARSEFORGE-RANKINGS-SPEC.md`) — send the approval request before Phase 4 ships ads. +- [Phase 4 dependency] R0-1 approval email deferred by developer on 2026-09-21 (not sent) — 04-07's production ad deploy checkpoint stays gated until a real 'sent' row with verbatim text lands in .planning/research/rpglogs-approval-request-2026-09-19.md's Thread table. Remaining: read WCL client name from warcraftlogs.com/api/clients, fill draft, send from info@lootlistplus.com to advertising@archon.gg. +- 04-05: AdSense ads.txt status for parseforge.gg not confirmed by developer; the Phase 1 Privacy & messaging site-settings paste of https://parseforge.gg/privacy also not confirmed — STATE.md follow-up (1) stays open. Resolve before 04-07's production ad launch. +- [Phase 04, 04-06 carry-forward to 04-07] AdSense account approval status still "Getting ready" (not approved); ads.txt "Not found" (expected pre-deploy); production deploy of env vars not yet shipped. 04-07's production ad launch checkpoint stays gated on account approval plus the RPGLogs approval reply (R0-1, deferred by developer per 04-01). +- Phase 05.1 plan 03: a GitHub fine-grained PAT and the Upstash REST token were typed into a scratch text file during Railway variable setup and surfaced into the Claude session transcript; no value was read, printed, or committed by Claude. Follow-up: regenerate the GitHub PAT (cheap, Railway-only); Upstash REST token rotation is the developer own call since it is shared with Vercel/parseforge.gg production. Also: migrate bot/railway.json to .railway/railway.ts before 2026-12-01 (Railway deprecation warning); the Task 3 human-check (phone/Discord-client confirmation of the tracer) is still outstanding. ### Quick Tasks Completed | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| | 260906-kzw | Add /privacy and /terms pages, footer links, sitemap entries | 2026-09-06 | 8dc7673 | [260906-kzw-add-privacy-and-terms-pages-footer-links](./quick/260906-kzw-add-privacy-and-terms-pages-footer-links/) | +| 261013-vma | Fix CR-01: bot /analyze and /raid score the last kill (not fights[0]) for fight=last or bare links | 2026-10-13 | 5062883 | [261013-vma-fix-cr-01-bot-analyze-and-raid-fall-back](./quick/261013-vma-fix-cr-01-bot-analyze-and-raid-fall-back/) | ### Roadmap Evolution - Phase 2.1 inserted after Phase 2: PostHog Consent Gate Hotfix — cookieless_mode on_reject dropped all PENDING-consent capture since the Phase 1 deploy (2026-09-06); server-side geo opt-in for non-EEA, live-traffic OPS-01 re-verification (URGENT) +- Phase 05.1 inserted after Phase 5: ParseForge Discord bot — LootList+ parity (URGENT) +- Phase 05.1 edited: edited fields: goal, success_criteria, summary line (from 05.1-CONTEXT.md) ## Deferred Items @@ -136,11 +270,13 @@ Items acknowledged and deferred at milestone close, most recent first: | Category | Item | Status | Deferred At | Milestone | |----------|------|--------|-------------|-----------| +| Validation | Nyquist validate-phase for Phase 3 (`03-VALIDATION.md` remains `draft`, `nyquist_compliant: false`) — `gsd-validate-phase` skill not installed in this profile (verify:post hook active, `Unknown skill`); vitest 226/226 green and `03-VERIFICATION.md` passed. Close via full-profile install + `/gsd-validate-phase 03` | Deferred | 2026-09-19 (Phase 3 close) | v1 growth | +| UI review | Visual quality audit of Phase 3 frontend changes (share CTAs, AwardsPanel, OG cards) — `gsd-ui-review` skill not installed (verify:post hook active, `Unknown skill`). Close via full-profile install + `/gsd-ui-review 03`, or fold into the Phase 7 redesign | Deferred | 2026-09-19 (Phase 3 close) | v1 growth | | Security | Formal ASVS L1 security review of Phase 2 (`02-SECURITY.md`) — `gsd-secure-phase` still not installed in this profile (verify:post hook active, `Unknown skill`); per-plan STRIDE registers exist. Developer chose to defer at UAT close. Close via full-profile install + `/gsd-secure-phase 02` | Deferred | 2026-09-08 (Phase 2 close) | v1 growth | | Security | Formal ASVS L1 security review of Phase 1 (`01-SECURITY.md`) — gsd security tooling (gsd-secure-phase skill, gsd-security-auditor agent) not installed in this profile; per-plan STRIDE registers exist. Close via full-profile install + `/gsd-secure-phase 01` | Deferred | 2026-09-07 (Phase 1 close) | v1 growth | ## Session Continuity -Last session: 2026-09-08T21:30:00Z -Stopped at: Phase 02 complete (verified, UAT 1/1, shipped dpl_5bwk1fJJNuZXkoC5poPGFZQpGy6c), ready to plan Phase 3 +Last session: 2026-10-14T05:28:20.405Z +Stopped at: Completed 05.1-10-PLAN.md Resume file: None diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 821f668..7210f1c 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 6 +open_count: 24 waived_count: 0 -fixed_count: 0 -total_count: 6 -last_updated: 2026-09-08T02:21:39.073Z +fixed_count: 5 +total_count: 29 +last_updated: 2026-09-28T06:07:44.881Z --- # Broken Windows Ledger @@ -21,6 +21,29 @@ last_updated: 2026-09-08T02:21:39.073Z | 4 | 02 | deviation | lib/generated/index.ts | | Enchant/gem era composition uses Classic+TBC-first precedence (not later-era-wins as the plan's literal text describes) — required to keep cla-constants.test.ts passing against real cross-era ID reuse. | open | | 2026-09-08T02:21:30.999Z | | | 5 | 02 | deviation | lib/generated/game-data-overrides.json | | Cata weapon-enhancement consumable ids 96264 (Pyrium Weapon Chain) and 96294 (Pyrium Shield Spike) resolve to a SpellName value that looks unrelated to the curated item and could not be corroborated against another era; preserved via override pending a future human/ItemSparse-based verification pass. | open | | 2026-09-08T02:21:38.992Z | | | 6 | 02 | deviation | lib/generated/game-data.consumables.ts | | Consumable-name derivation via direct SpellName.Name_lang per-id lookup resolved only 48/178 (27%) names reliably; the remaining 130 needed a consumableNames override (prefix loss, generic buff-aura collapse, or unresolvable era coverage) — a future item-name-based (ItemSparse) derivation pass could raise the wago-verified ratio for this map. | open | | 2026-09-08T02:21:39.073Z | | +| 7 | 02.1 | todo | docs/OPS-01-SHIP-GATE.md | | Part 1 item 7's browser-level netlog sub-item needs a --user-agent override (or opt_out_useragent_filter:true in posthog.init) - posthog-js's built-in bot filter silently drops all captures for any UA containing HeadlessChrome, producing a false negative regardless of app correctness (found 02.1-03). | open | | 2026-09-14T22:02:16.645Z | | +| 8 | 03 | deviation | scripts/protected-elements.mjs | | protected-elements has no bypass-header/query-param support, so its live route checks cannot verify an SSO-protected preview deploy (302s to Vercel login instead); direct curl with x-vercel-protection-bypass is the workaround used in 03-06 Part 5 | open | | 2026-09-16T08:59:20.886Z | | +| 9 | 03 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 03-06 Task 2 developer-only backstops not performed by the executor: D-04 award-pool tone review, real Discord unfurl of preview awards+player links (longest-name ellipsis check), D-13 mobile reachability of both share buttons — exact tests and preview URLs recorded in Part 5 Developer review (preview) | fixed | | 2026-09-16T09:02:42.606Z | 2026-09-19T20:19:49.601Z | +| 10 | 03 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 03-07 item-7 re-measure needed: dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx's 09:14:57Z-10:14:57Z window scored threshold 1 FAIL (7 pageviews), threshold 2 PASS (2 countries), threshold 3 NOT EVALUABLE (no Vercel Web Analytics endpoint on the personal token). Re-run the three HogQL queries against a full 60-minute window on a busier UTC hour (candidate ~22:00Z, 24 pageviews on 2026-09-14) plus a window-granularity Vercel Web Analytics dashboard read. | fixed | | 2026-09-16T16:56:13.409Z | 2026-09-19T01:10:28.150Z | +| 11 | 03 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 03-07 share-rate (D-14) re-run needed: 7-day trailing HogQL read 0.0% (0 share_action sessions / 19 analysis_complete sessions) as a first reading with only ~7.5h of live share_action exposure. Re-run the same HogQL on/after 2026-09-23T09:15Z for the first meaningful comparison against the ~2.8% baseline. | open | | 2026-09-16T16:56:13.555Z | | +| 12 | 04 | deviation | app/components/AdSlot.tsx | 162 | Reserved ad box
carries an inline style={{width:base.width,height:base.height}} that outranks the responsive md: Tailwind classes at every viewport, so no slot (tbc-audit-mid/-end, analyze-mid/-end) ever grows to its declared desktop size; verified live via CDP getComputedStyle on the preview (04-06 Task 2). Blocking finding for 04-07. | fixed | | 2026-09-21T20:15:46.704Z | 2026-09-22T05:01:23.318Z | +| 13 | 04 | deviation | app/components/ReportUrlForm.tsx | 95 | Pre-existing, non-ad-related: the example-URL string has no wrap class, forcing the layout viewport wider than 384px on / and /tbc-audit (not /guides, not /analyze) at phone width; found while measuring ad-box adjacency in 04-06 Task 3. Out of Phase 4 scope to fix. | open | | 2026-09-21T20:15:47.035Z | | +| 14 | 04 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 04-06 Task 2 preview netlog/measured-box/CSP-harvest procedure could not be re-run against the second preview (dpl_9SxD4PEAPUHv8aghVucyjqJ3LQoV) after the box-size/dark-mode fixes: the one working method to source the Vercel SSO bypass secret was denied by the session's own Bash-permission auto-classifier (Credential Materialization). Closing test: re-run 04-06 Task 2's procedure against this preview once the bypass secret can be sourced in a session with permission. | open | | 2026-09-21T22:32:39.303Z | | +| 15 | 04 | deviation | app/components/AdSlot.tsx | 190 | Round-2's visibility:hidden fix for the white unfilled-frame defect (WINDOWS #12's sibling Defect B) did not hold on the second preview: adsbygoogle.js injects a child div (#aswift_N_host) inside the with its own explicit visibility:visible, which CSS lets override an ancestor's visibility:hidden regardless of tree depth. Round 3 replaced it with a wrapper-level occluding cover (a later sibling of the , absolute/inset-0/z-10/bg-background) verified via pixel-sampled local screenshots in both themes, both viewports (0% white-pixel deviation, exact page-background color match). Preview-level (SSO-gated) re-verification is still blocked by the same bypass-secret gap #14 records; closing test is the developer's first-hand review of the third preview (dpl_2CU68Zw3yFuxq7qqMjxNeTjT1VC1). | open | | 2026-09-22T05:01:37.732Z | | +| 16 | 04 | deviation | app/components/AdSlot.tsx | 228 | Round-3's flat bg-background occluding cover painted a textureless patch against the page's grainy .bg-noise background (developer caught it on the third preview as a subtle different-shade box). Confirmed numerically via pixel-sampled CDP screenshots on a production build (cover stddev 0 vs control ~0.5-0.8, mean ~1-2.8 RGB levels warmer, both themes/viewports); fixed with a local (position:absolute) grain-texture pseudo-element scoped to the cover, matching within ~0.1-0.2 RGB levels post-fix. Closing test: developer's first-hand review of the fourth preview (dpl_9hkgsvPz57brpYzdHdFf3x7GM56Z). | open | | 2026-09-22T05:35:19.731Z | | +| 17 | 04 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 04-07 Task 3: OPS-01 item 7 PostHog live-traffic thresholds and ad_slot breakdown NOT EVALUABLE - no PostHog MCP/API query channel available to this executor dispatch. Closing test: re-run Part 1 item 7's HogQL verbatim against PostHog project 337485 in a session with PostHog access. | open | | 2026-09-22T19:55:25.355Z | | +| 18 | 04 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 04-07 Task 3: Search Console rows for /tbc-audit, the demo analyze page, and /privacy recorded no-data - no gscServer MCP tool available to this executor dispatch. Closing test: URL-inspect all three routes via sc-domain:parseforge.gg in a session with GSC access. | open | | 2026-09-22T19:55:26.466Z | | +| 19 | 04 | unrun-verify | docs/OPS-01-SHIP-GATE.md | | 04-07 Task 3: day-2 (due 2026-09-24T09:23:24Z) and day-7 (due 2026-09-29T09:23:24Z) CWV re-reads against the D-09 pre-ad baseline are scheduled, not yet due; today's reading is an informal same-day readability check only, not the scored comparison. Closing test: re-run the three vercel metrics commands recorded in Part 6 and append a dated PASS/breach row against every D-10 trigger. | open | | 2026-09-22T19:55:26.755Z | | +| 20 | 05 | unrun-verify | docs/DISCORD-SEED.md | | 05-03 Task 2's seed-kit-ok gate's live-homepage liveness check (curl https://parseforge.gg) is flaky against production: two independent curls seconds apart return different subsets of recent report codes, most likely because FeaturedReports.tsx's per-candidate WCL enrichment call fails transiently and non-deterministically per request. All three backfill codes (CyhwP713Dqf8RWvZ, yGR3dVTwr71Hc68D, n6zb8WYBcf1LGKNa) were independently confirmed as real, live, public reports via direct GET https://parseforge.gg/api/report/ (HTTP 200, real WCL data) at doc-finalization time — the doc content is correct — but a literal re-run of the plan's automated verify script may intermittently report GATE-FAIL on the homepage-liveness assertion due to this pre-existing enrichment flakiness, not a defect in this plan's work. | open | | 2026-09-25T07:37:37.643Z | | +| 21 | 05 | deviation | docs/DISCORD.md | | 05-03 Task 1's first automated verify block (sister-repo-scope-ok) asserts grep -c 'parseforge\\.gg' on the LootList+ repo's LandingFooter.tsx equals exactly 1, but that anchor's own visible link text is literally 'parseforge.gg' on a separate source line from its href — proven via git show HEAD~1 on the sister repo that this line-count was already 2 in the baseline file before this task's edit, unrelated to the change made here. The task's own action instructions explicitly require keeping the visible anchor text byte-identical, making the literal grep-count assertion unsatisfiable while complying with the rest of the same task. Semantic intent (exactly one anchor whose href host is parseforge.gg) is independently confirmed true via grep -c 'href="https://parseforge\\.gg' = 1 in that file. Every other assertion in both automated verify blocks passed (sister-repo-gates-ok reached cleanly; the scope-ok block is blocked only by this one line). | open | | 2026-09-25T07:37:53.459Z | | +| 22 | 05 | deviation | docs/DISCORD.md | | 05-03 Task 1 discovery: the sister repo (/Users/alexander.mayes/Code/personal/loot-list-plus) is a working tree shared with at least one other live, concurrent GSD agent session doing unrelated Phase 6 (week-4-review-next-bet) work on LootList+'s own roadmap. After this plan's Task 1 commit (5d34b1f6, single file, verified clean) landed on the new growth/parseforge-cross-promo branch, an unrelated commit (b5ae3a5c, docs(06): capture phase context and week-4 data inputs, different author/co-author) appeared on top of it -- that concurrent session evidently commits onto whatever branch happens to be checked out rather than its own. This plan's commit content is verified intact and untouched; only the branch's current tip no longer satisfies 'touches exactly one path'. No destructive git operation was used to fix this (would risk colliding with the other session's in-progress work); the shared tree was returned to main via a plain checkout. Plan 05-04 (or whoever pushes/merges this branch) must cherry-pick 5d34b1f6 onto a clean branch, or coordinate with the concurrent Phase 6 session, before pushing. | fixed | | 2026-09-25T07:42:07.451Z | 2026-09-25T07:44:51.816Z | +| 23 | 05 | deviation | docs/OPS-01-SHIP-GATE.md | | 05-04 Task 1's local-gate-ok verify chain (npx tsc && npm run lint && npm test && npm run theme-parity && npm run token-audit && npm run protected-elements && echo local-gate-ok) never prints local-gate-ok because npm run token-audit exits 1 on lib/discord-announce.ts:26's pre-existing, already-logged non-allowlisted raw-hex finding (deferred-items.md, 05-02) — a plan-01 finding from commit 7655a8c, untouched by 05-02/05-03/05-04's files_modified. tsc, lint, test (323/323), theme-parity all pass; protected-elements (25/25) also passes when run standalone but never executes in the literal && chain because token-audit's non-zero exit short-circuits it first. Part 1 item 1's explicit hard-stop language names only npm test ('non-zero npm test exit blocks... no override'); it does not extend that hard-stop to token-audit, lint, or theme-parity. Recorded per-command exit codes individually in Part 7 rather than relying on the chained one-liner. | open | | 2026-09-25T07:50:44.787Z | | +| 24 | 05 | deviation | docs/OPS-01-SHIP-GATE.md | | 05-04 Task 1's part7-opened-ok verify block's zero-elsewhere assertion (grep -rl 'cross_promo_click' app lib \| grep -v Footer.tsx expects empty) is a literal-string grep, not a posthog.capture-call-site grep, so it also matches lib/cross-promo.ts:12's doc comment ('the cross_promo_click PostHog event') and would GATE-FAIL even though that line is prose, not a second call site. Semantic check used instead: grep -c "posthog.capture..'cross_promo_click'" across app/ and lib/ = 1 (app/components/Footer.tsx only). Same over-broad-string-grep defect class as WINDOWS #21. | open | | 2026-09-25T07:50:52.273Z | | +| 25 | 05 | deviation | | | 05-04 Task 2 discovery: pushing growth/05-03-parseforge-footer (based on LootList+ local main, 319+ commits ahead of origin/main) was rejected by GitHub push protection (GH013) for a live Stripe API key found in two pre-existing, unrelated local commits (68192bc1, 12de1845, dated 2026-09-21, path .planning/quick/260921-ud3-add-scripts-create-gift-code-ts-for-crea/, in the sister loot-list-plus repo). Not introduced by any ParseForge phase-05 plan; not viewed or printed by the assistant. Unblocked THIS plan's need only: verified the flagged commits are absent from origin/main and that app/components/landing/LandingFooter.tsx is byte-identical between origin/main and local main, then cherry-picked the single cross-promo commit (677899a7, was a6f917ec on the contaminated branch) onto a fresh branch growth/05-03-parseforge-footer-clean off origin/main -- zero conflicts, no secret in its ancestry, ready to push. The 319+ commit local/origin divergence and the live key itself are UNRESOLVED and out of this plan's scope -- developer should rotate the Stripe key regardless of push outcome, and separately decide how/whether to ever push or clean the rest of that local main backlog. | fixed | | 2026-09-25T22:52:10.986Z | 2026-09-25T23:28:28.295Z | +| 26 | 05 | todo | docs/DISCORD-SEED.md | | G-05-16 (05-UAT test 16): ParseForge Discord new-member refresh (#welcome read-only landing channel, welcome message and ground rules, channel topics, #feedback opener, @everyone permissions, invite re-created from #welcome, leftover default channels removed, #announcements checked before the post-deploy backfill) is owned by plan 05-07 (developer checkpoint:human-action over the docs/DISCORD-SEED.md checklist) and plan 05-08 (deploy, backfill, OPS-01 Part 7 and COMM-01 addenda). Mark fixed only in 05-08 Task 3, never on a partial checklist. | open | | 2026-09-28T01:28:27.238Z | | +| 27 | 05 | deviation | lib/discord-announce.test.ts | 29 | 05-06 Task 1's decisions-recorded-ok verify block's repo-wide grep for 'discord.com/api/webhooks/[0-9]' matches lib/discord-announce.test.ts:29's fabricated WEBHOOK_URL test fixture, not a real credential. Pre-existing from plan 05-01 (fc99f62), untouched by 05-06's files_modified. Same over-broad-literal-grep defect class as WINDOWS #21/#24. Confirmed via git diff that no file this plan touched contains a webhooks/ URL. | open | | 2026-09-28T01:33:01.656Z | | +| 28 | 05 | todo | docs/DISCORD-SEED.md | | 05-07 finding (G-05-16-adjacent, out of this plan's scope): #announcements holds 5,293 webhook-posted cards (~4,292 on 2026-09-26, ~794 on 2026-09-27, 14 by 03:10Z 2026-09-28; oldest scanned 2026-09-26T05:54Z with more before) -- far beyond real human report views. Likely driver: crawler/bot renders of public /analyze pages (the sitemap self-populates them), which plan 05-05's /analyze-only Discord announce trigger does not filter. Must be decided before plan 05-08's sign-off. | open | | 2026-09-28T03:30:32.428Z | | +| 29 | 05 | deviation | docs/DISCORD-SEED.md | | D-11 purge deferred to the developer (deploy proven, not deleted) and the drafted #changelog post blocked by Discord permission error 403/50013 (bot role has no channel-specific Send Messages overwrite in read-only #changelog) — both need developer action; see docs/DISCORD-SEED.md and OPS-01-SHIP-GATE.md G-05-16 addendum Sign-off. | open | | 2026-09-28T06:07:44.881Z | | ````json [ @@ -95,6 +118,282 @@ last_updated: 2026-09-08T02:21:39.073Z "reason": "", "recorded_at": "2026-09-08T02:21:39.073Z", "resolved_at": null + }, + { + "id": 7, + "kind": "todo", + "phase": "02.1", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "Part 1 item 7's browser-level netlog sub-item needs a --user-agent override (or opt_out_useragent_filter:true in posthog.init) - posthog-js's built-in bot filter silently drops all captures for any UA containing HeadlessChrome, producing a false negative regardless of app correctness (found 02.1-03).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-14T22:02:16.645Z", + "resolved_at": null + }, + { + "id": 8, + "kind": "deviation", + "phase": "03", + "file": "scripts/protected-elements.mjs", + "line": null, + "description": "protected-elements has no bypass-header/query-param support, so its live route checks cannot verify an SSO-protected preview deploy (302s to Vercel login instead); direct curl with x-vercel-protection-bypass is the workaround used in 03-06 Part 5", + "status": "open", + "reason": "", + "recorded_at": "2026-09-16T08:59:20.886Z", + "resolved_at": null + }, + { + "id": 9, + "kind": "unrun-verify", + "phase": "03", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "03-06 Task 2 developer-only backstops not performed by the executor: D-04 award-pool tone review, real Discord unfurl of preview awards+player links (longest-name ellipsis check), D-13 mobile reachability of both share buttons — exact tests and preview URLs recorded in Part 5 Developer review (preview)", + "status": "fixed", + "reason": "", + "recorded_at": "2026-09-16T09:02:42.606Z", + "resolved_at": "2026-09-19T20:19:49.601Z" + }, + { + "id": 10, + "kind": "unrun-verify", + "phase": "03", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "03-07 item-7 re-measure needed: dpl_6Pj5Lz5Q1tSJYSCtUu3YTvtRx3mx's 09:14:57Z-10:14:57Z window scored threshold 1 FAIL (7 pageviews), threshold 2 PASS (2 countries), threshold 3 NOT EVALUABLE (no Vercel Web Analytics endpoint on the personal token). Re-run the three HogQL queries against a full 60-minute window on a busier UTC hour (candidate ~22:00Z, 24 pageviews on 2026-09-14) plus a window-granularity Vercel Web Analytics dashboard read.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-09-16T16:56:13.409Z", + "resolved_at": "2026-09-19T01:10:28.150Z" + }, + { + "id": 11, + "kind": "unrun-verify", + "phase": "03", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "03-07 share-rate (D-14) re-run needed: 7-day trailing HogQL read 0.0% (0 share_action sessions / 19 analysis_complete sessions) as a first reading with only ~7.5h of live share_action exposure. Re-run the same HogQL on/after 2026-09-23T09:15Z for the first meaningful comparison against the ~2.8% baseline.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-16T16:56:13.555Z", + "resolved_at": null + }, + { + "id": 12, + "kind": "deviation", + "phase": "04", + "file": "app/components/AdSlot.tsx", + "line": 162, + "description": "Reserved ad box
carries an inline style={{width:base.width,height:base.height}} that outranks the responsive md: Tailwind classes at every viewport, so no slot (tbc-audit-mid/-end, analyze-mid/-end) ever grows to its declared desktop size; verified live via CDP getComputedStyle on the preview (04-06 Task 2). Blocking finding for 04-07.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-09-21T20:15:46.704Z", + "resolved_at": "2026-09-22T05:01:23.318Z" + }, + { + "id": 13, + "kind": "deviation", + "phase": "04", + "file": "app/components/ReportUrlForm.tsx", + "line": 95, + "description": "Pre-existing, non-ad-related: the example-URL string has no wrap class, forcing the layout viewport wider than 384px on / and /tbc-audit (not /guides, not /analyze) at phone width; found while measuring ad-box adjacency in 04-06 Task 3. Out of Phase 4 scope to fix.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-21T20:15:47.035Z", + "resolved_at": null + }, + { + "id": 14, + "kind": "unrun-verify", + "phase": "04", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "04-06 Task 2 preview netlog/measured-box/CSP-harvest procedure could not be re-run against the second preview (dpl_9SxD4PEAPUHv8aghVucyjqJ3LQoV) after the box-size/dark-mode fixes: the one working method to source the Vercel SSO bypass secret was denied by the session's own Bash-permission auto-classifier (Credential Materialization). Closing test: re-run 04-06 Task 2's procedure against this preview once the bypass secret can be sourced in a session with permission.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-21T22:32:39.303Z", + "resolved_at": null + }, + { + "id": 15, + "kind": "deviation", + "phase": "04", + "file": "app/components/AdSlot.tsx", + "line": 190, + "description": "Round-2's visibility:hidden fix for the white unfilled-frame defect (WINDOWS #12's sibling Defect B) did not hold on the second preview: adsbygoogle.js injects a child div (#aswift_N_host) inside the with its own explicit visibility:visible, which CSS lets override an ancestor's visibility:hidden regardless of tree depth. Round 3 replaced it with a wrapper-level occluding cover (a later sibling of the , absolute/inset-0/z-10/bg-background) verified via pixel-sampled local screenshots in both themes, both viewports (0% white-pixel deviation, exact page-background color match). Preview-level (SSO-gated) re-verification is still blocked by the same bypass-secret gap #14 records; closing test is the developer's first-hand review of the third preview (dpl_2CU68Zw3yFuxq7qqMjxNeTjT1VC1).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-22T05:01:37.732Z", + "resolved_at": null + }, + { + "id": 16, + "kind": "deviation", + "phase": "04", + "file": "app/components/AdSlot.tsx", + "line": 228, + "description": "Round-3's flat bg-background occluding cover painted a textureless patch against the page's grainy .bg-noise background (developer caught it on the third preview as a subtle different-shade box). Confirmed numerically via pixel-sampled CDP screenshots on a production build (cover stddev 0 vs control ~0.5-0.8, mean ~1-2.8 RGB levels warmer, both themes/viewports); fixed with a local (position:absolute) grain-texture pseudo-element scoped to the cover, matching within ~0.1-0.2 RGB levels post-fix. Closing test: developer's first-hand review of the fourth preview (dpl_9hkgsvPz57brpYzdHdFf3x7GM56Z).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-22T05:35:19.731Z", + "resolved_at": null + }, + { + "id": 17, + "kind": "unrun-verify", + "phase": "04", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "04-07 Task 3: OPS-01 item 7 PostHog live-traffic thresholds and ad_slot breakdown NOT EVALUABLE - no PostHog MCP/API query channel available to this executor dispatch. Closing test: re-run Part 1 item 7's HogQL verbatim against PostHog project 337485 in a session with PostHog access.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-22T19:55:25.355Z", + "resolved_at": null + }, + { + "id": 18, + "kind": "unrun-verify", + "phase": "04", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "04-07 Task 3: Search Console rows for /tbc-audit, the demo analyze page, and /privacy recorded no-data - no gscServer MCP tool available to this executor dispatch. Closing test: URL-inspect all three routes via sc-domain:parseforge.gg in a session with GSC access.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-22T19:55:26.466Z", + "resolved_at": null + }, + { + "id": 19, + "kind": "unrun-verify", + "phase": "04", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "04-07 Task 3: day-2 (due 2026-09-24T09:23:24Z) and day-7 (due 2026-09-29T09:23:24Z) CWV re-reads against the D-09 pre-ad baseline are scheduled, not yet due; today's reading is an informal same-day readability check only, not the scored comparison. Closing test: re-run the three vercel metrics commands recorded in Part 6 and append a dated PASS/breach row against every D-10 trigger.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-22T19:55:26.755Z", + "resolved_at": null + }, + { + "id": 20, + "kind": "unrun-verify", + "phase": "05", + "file": "docs/DISCORD-SEED.md", + "line": null, + "description": "05-03 Task 2's seed-kit-ok gate's live-homepage liveness check (curl https://parseforge.gg) is flaky against production: two independent curls seconds apart return different subsets of recent report codes, most likely because FeaturedReports.tsx's per-candidate WCL enrichment call fails transiently and non-deterministically per request. All three backfill codes (CyhwP713Dqf8RWvZ, yGR3dVTwr71Hc68D, n6zb8WYBcf1LGKNa) were independently confirmed as real, live, public reports via direct GET https://parseforge.gg/api/report/ (HTTP 200, real WCL data) at doc-finalization time — the doc content is correct — but a literal re-run of the plan's automated verify script may intermittently report GATE-FAIL on the homepage-liveness assertion due to this pre-existing enrichment flakiness, not a defect in this plan's work.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-25T07:37:37.643Z", + "resolved_at": null + }, + { + "id": 21, + "kind": "deviation", + "phase": "05", + "file": "docs/DISCORD.md", + "line": null, + "description": "05-03 Task 1's first automated verify block (sister-repo-scope-ok) asserts grep -c 'parseforge\\.gg' on the LootList+ repo's LandingFooter.tsx equals exactly 1, but that anchor's own visible link text is literally 'parseforge.gg' on a separate source line from its href — proven via git show HEAD~1 on the sister repo that this line-count was already 2 in the baseline file before this task's edit, unrelated to the change made here. The task's own action instructions explicitly require keeping the visible anchor text byte-identical, making the literal grep-count assertion unsatisfiable while complying with the rest of the same task. Semantic intent (exactly one anchor whose href host is parseforge.gg) is independently confirmed true via grep -c 'href=\"https://parseforge\\.gg' = 1 in that file. Every other assertion in both automated verify blocks passed (sister-repo-gates-ok reached cleanly; the scope-ok block is blocked only by this one line).", + "status": "open", + "reason": "", + "recorded_at": "2026-09-25T07:37:53.459Z", + "resolved_at": null + }, + { + "id": 22, + "kind": "deviation", + "phase": "05", + "file": "docs/DISCORD.md", + "line": null, + "description": "05-03 Task 1 discovery: the sister repo (/Users/alexander.mayes/Code/personal/loot-list-plus) is a working tree shared with at least one other live, concurrent GSD agent session doing unrelated Phase 6 (week-4-review-next-bet) work on LootList+'s own roadmap. After this plan's Task 1 commit (5d34b1f6, single file, verified clean) landed on the new growth/parseforge-cross-promo branch, an unrelated commit (b5ae3a5c, docs(06): capture phase context and week-4 data inputs, different author/co-author) appeared on top of it -- that concurrent session evidently commits onto whatever branch happens to be checked out rather than its own. This plan's commit content is verified intact and untouched; only the branch's current tip no longer satisfies 'touches exactly one path'. No destructive git operation was used to fix this (would risk colliding with the other session's in-progress work); the shared tree was returned to main via a plain checkout. Plan 05-04 (or whoever pushes/merges this branch) must cherry-pick 5d34b1f6 onto a clean branch, or coordinate with the concurrent Phase 6 session, before pushing.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-09-25T07:42:07.451Z", + "resolved_at": "2026-09-25T07:44:51.816Z" + }, + { + "id": 23, + "kind": "deviation", + "phase": "05", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "05-04 Task 1's local-gate-ok verify chain (npx tsc && npm run lint && npm test && npm run theme-parity && npm run token-audit && npm run protected-elements && echo local-gate-ok) never prints local-gate-ok because npm run token-audit exits 1 on lib/discord-announce.ts:26's pre-existing, already-logged non-allowlisted raw-hex finding (deferred-items.md, 05-02) — a plan-01 finding from commit 7655a8c, untouched by 05-02/05-03/05-04's files_modified. tsc, lint, test (323/323), theme-parity all pass; protected-elements (25/25) also passes when run standalone but never executes in the literal && chain because token-audit's non-zero exit short-circuits it first. Part 1 item 1's explicit hard-stop language names only npm test ('non-zero npm test exit blocks... no override'); it does not extend that hard-stop to token-audit, lint, or theme-parity. Recorded per-command exit codes individually in Part 7 rather than relying on the chained one-liner.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-25T07:50:44.787Z", + "resolved_at": null + }, + { + "id": 24, + "kind": "deviation", + "phase": "05", + "file": "docs/OPS-01-SHIP-GATE.md", + "line": null, + "description": "05-04 Task 1's part7-opened-ok verify block's zero-elsewhere assertion (grep -rl 'cross_promo_click' app lib | grep -v Footer.tsx expects empty) is a literal-string grep, not a posthog.capture-call-site grep, so it also matches lib/cross-promo.ts:12's doc comment ('the cross_promo_click PostHog event') and would GATE-FAIL even though that line is prose, not a second call site. Semantic check used instead: grep -c \"posthog.capture..'cross_promo_click'\" across app/ and lib/ = 1 (app/components/Footer.tsx only). Same over-broad-string-grep defect class as WINDOWS #21.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-25T07:50:52.273Z", + "resolved_at": null + }, + { + "id": 25, + "kind": "deviation", + "phase": "05", + "file": "", + "line": null, + "description": "05-04 Task 2 discovery: pushing growth/05-03-parseforge-footer (based on LootList+ local main, 319+ commits ahead of origin/main) was rejected by GitHub push protection (GH013) for a live Stripe API key found in two pre-existing, unrelated local commits (68192bc1, 12de1845, dated 2026-09-21, path .planning/quick/260921-ud3-add-scripts-create-gift-code-ts-for-crea/, in the sister loot-list-plus repo). Not introduced by any ParseForge phase-05 plan; not viewed or printed by the assistant. Unblocked THIS plan's need only: verified the flagged commits are absent from origin/main and that app/components/landing/LandingFooter.tsx is byte-identical between origin/main and local main, then cherry-picked the single cross-promo commit (677899a7, was a6f917ec on the contaminated branch) onto a fresh branch growth/05-03-parseforge-footer-clean off origin/main -- zero conflicts, no secret in its ancestry, ready to push. The 319+ commit local/origin divergence and the live key itself are UNRESOLVED and out of this plan's scope -- developer should rotate the Stripe key regardless of push outcome, and separately decide how/whether to ever push or clean the rest of that local main backlog.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-09-25T22:52:10.986Z", + "resolved_at": "2026-09-25T23:28:28.295Z" + }, + { + "id": 26, + "kind": "todo", + "phase": "05", + "file": "docs/DISCORD-SEED.md", + "line": null, + "description": "G-05-16 (05-UAT test 16): ParseForge Discord new-member refresh (#welcome read-only landing channel, welcome message and ground rules, channel topics, #feedback opener, @everyone permissions, invite re-created from #welcome, leftover default channels removed, #announcements checked before the post-deploy backfill) is owned by plan 05-07 (developer checkpoint:human-action over the docs/DISCORD-SEED.md checklist) and plan 05-08 (deploy, backfill, OPS-01 Part 7 and COMM-01 addenda). Mark fixed only in 05-08 Task 3, never on a partial checklist.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-28T01:28:27.238Z", + "resolved_at": null + }, + { + "id": 27, + "kind": "deviation", + "phase": "05", + "file": "lib/discord-announce.test.ts", + "line": 29, + "description": "05-06 Task 1's decisions-recorded-ok verify block's repo-wide grep for 'discord.com/api/webhooks/[0-9]' matches lib/discord-announce.test.ts:29's fabricated WEBHOOK_URL test fixture, not a real credential. Pre-existing from plan 05-01 (fc99f62), untouched by 05-06's files_modified. Same over-broad-literal-grep defect class as WINDOWS #21/#24. Confirmed via git diff that no file this plan touched contains a webhooks/ URL.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-28T01:33:01.656Z", + "resolved_at": null + }, + { + "id": 28, + "kind": "todo", + "phase": "05", + "file": "docs/DISCORD-SEED.md", + "line": null, + "description": "05-07 finding (G-05-16-adjacent, out of this plan's scope): #announcements holds 5,293 webhook-posted cards (~4,292 on 2026-09-26, ~794 on 2026-09-27, 14 by 03:10Z 2026-09-28; oldest scanned 2026-09-26T05:54Z with more before) -- far beyond real human report views. Likely driver: crawler/bot renders of public /analyze pages (the sitemap self-populates them), which plan 05-05's /analyze-only Discord announce trigger does not filter. Must be decided before plan 05-08's sign-off.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-28T03:30:32.428Z", + "resolved_at": null + }, + { + "id": 29, + "kind": "deviation", + "phase": "05", + "file": "docs/DISCORD-SEED.md", + "line": null, + "description": "D-11 purge deferred to the developer (deploy proven, not deleted) and the drafted #changelog post blocked by Discord permission error 403/50013 (bot role has no channel-specific Send Messages overwrite in read-only #changelog) — both need developer action; see docs/DISCORD-SEED.md and OPS-01-SHIP-GATE.md G-05-16 addendum Sign-off.", + "status": "open", + "reason": "", + "recorded_at": "2026-09-28T06:07:44.881Z", + "resolved_at": null } ] ```` diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index 148e3b7..7846f10 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -14,6 +14,19 @@ - Retry: 3 attempts with exponential backoff (1s, 2s, 4s) on 429/5xx - Timeout: 30 seconds per request +**Discord (HTTP Interactions, added 2026-09-28, plan 05-10):** +- Inbound: `app/api/discord/interactions/route.ts` — POST-only endpoint answering Discord's + application interactions (role-picker button taps in the ParseForge Discord's `#welcome` + channel) directly from parseforge.gg, no always-on bot process +- Verification: `lib/discord-interactions.ts` `verifyDiscordSignature` — Ed25519 via `node:crypto` + over `timestamp + rawBody` against `DISCORD_PUBLIC_KEY`; requests that fail get 401 before any + parsing +- Outbound: role add/remove via Discord REST v10 (`PUT`/`DELETE + /guilds/{guild}/members/{user}/roles/{role}`) using `Authorization: Bot ${DISCORD_BOT_TOKEN}` +- Allowlist: `lib/discord-roles.json` — the only roles the route will ever grant +- One-off ops tooling (not a standing server): `scripts/discord-bot.mjs` — endpoint URL switch, + command retirement, role-picker posting, all writes behind `--execute` + **Wowhead:** - External tooltips for WoW items, spells, gems - Script: `https://wow.zamimg.com/js/tooltips.js` (loaded after interactive) @@ -46,6 +59,10 @@ - Token cached in module scope + shared Redis (fastest → shared → mint new) - Automatic refresh if 401 on request - Cache location: `lib/kv-cache.ts` under key `wcl:token` +- Discord Bot token (added 2026-09-28, plan 05-10): `DISCORD_BOT_TOKEN`, read only in + `lib/discord-interactions.ts` `defaultInteractionDeps`; never logged. Interaction requests are + authenticated the other direction — Ed25519 signature verification of the inbound Discord + request against `DISCORD_PUBLIC_KEY`, not a bearer token. ## Monitoring & Observability @@ -82,6 +99,9 @@ - `KV_REST_API_TOKEN` - Upstash Redis token (or `UPSTASH_REDIS_REST_TOKEN`) - `NEXT_PUBLIC_POSTHOG_KEY` - PostHog project API key (public, safe to expose) - `NEXT_PUBLIC_POSTHOG_HOST` - PostHog ingestion host (public) +- `DISCORD_BOT_TOKEN` - ParseForge Discord bot token, sensitive (added 2026-09-28, plan 05-10) +- `DISCORD_PUBLIC_KEY` - ParseForge Discord application public key, not sensitive (added + 2026-09-28, plan 05-10) **Optional:** - Node environment variables for feature flags/debugging (e.g., `NODE_ENV`) diff --git a/.planning/config.json b/.planning/config.json index 5ab03f3..d428ec4 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -49,7 +49,8 @@ "security_block_on": "high", "_auto_chain_active": false, "build_command": "npx tsc --noEmit", - "test_command": "npm test" + "test_command": "npm test", + "use_worktrees": false }, "ship": { "pr_body_sections": [ diff --git a/.planning/debug/discord-onboarding-not-ready.md b/.planning/debug/discord-onboarding-not-ready.md new file mode 100644 index 0000000..d21039c --- /dev/null +++ b/.planning/debug/discord-onboarding-not-ready.md @@ -0,0 +1,148 @@ +--- +status: diagnosed +trigger: "G-05-16 — ParseForge Discord seed-kit welcome message is not pinned; developer wants a general refresh so the server is set up nicely for new members" +created: 2026-09-27T00:00:00Z +updated: 2026-09-27T22:30:00Z +mode: find_root_cause_only +bug_class: process/ops gap (deterministic — Bohrbug-analog; no runtime code path involved) +--- + +## Current Focus + +hypothesis: CONFIRMED (set of two contributing causes; AND-gate fired) — see Resolution.root_cause +test: done — document/plan/commit/code evidence + Discord platform docs +expecting: n/a +next_action: Return ROOT CAUSE FOUND to orchestrator (diagnose-only mode) + +reasoning_checkpoint: + hypothesis: "The room is not ready because (A) D-10's pin/topic/backfill steps had no owning task, checkpoint, or tracked todo — 05-VALIDATION's 'seed before flipping the footer' gate was dropped at planning, 05-04 explicitly moved seeding to after the promotion deploy (forced by the backfill-via-prod-auto-post design), and the open item then lived only as prose in the sign-off until UAT; AND (B) the seed kit's design is too narrow to produce a good new-member experience even if executed: it targets the welcome at #announcements while the 05-01 invite lands members in #feedback (which gets only a topic, no opener despite 05-03's must-have), it relies on a pin (hidden behind the pin icon, in a channel that fills with auto-posts), and nothing in the phase defines rules, onboarding, permissions, branding, or landing-channel settings — with D-03's two-channel limit blocking Discord's native Onboarding (needs >=7 default channels)." + confirming_evidence: + - "DISCORD-SEED.md:28 instructs pinning; OPS-01 Part 7 sign-off + 05-04-SUMMARY:141 + 05-VERIFICATION say hand-posting 'not yet confirmed done'; UAT 16: 'It's not pinned.'" + - "Only 2 human-action checkpoints exist in the phase (05-01 T1 server creation, 05-04 T2 deploy auth); none owns seeding; no WINDOWS/todo row tracks it" + - "05-VALIDATION.md:71 required seed content live 'before flipping the site-wide footer/invite link live'; 05-04-PLAN:58 moved seeding to 'after this deploy and outside any executable plan'; commits show promotion ~27h after server creation" + - "05-01-PLAN:32-33,222 invite created from #feedback; DISCORD-SEED.md posts welcome in #announcements and has no #feedback opener; 05-03-PLAN:24,34 promised one" + - "Discord docs: Onboarding needs >=7 default channels (>=5 @everyone-sendable); Welcome Screen/Server Guide need Community mode which needs rules + updates channels" + falsification_test: "If the developer reports a seeding checkpoint was executed and pinned-then-unpinned, or the invite actually targets #announcements with the welcome visible on landing, cause B's landing-channel mismatch is falsified; if any tracked task/todo for seeding existed, cause A is falsified. Neither was found in any phase artifact." + fix_rationale: "Diagnose-only. A fix must (1) give the Discord refresh an owned, checkpointed human-action task with a verifiable checklist (so it cannot silently lapse again), and (2) redefine what 'set up for new folks' means — decide landing channel, rules, Community mode/Server Guide vs D-03, permissions, branding — and rewrite the seed kit into that checklist + copy. Doing only (1) re-runs a design that still lands people in a near-empty #feedback." + blind_spots: "Cannot observe the live server: whether the welcome was posted at all (vs posted-but-unpinned), whether topics are set, which channel the invite actually targets, whether Discord's default #general/General voice channels were deleted, whether @everyone can post in #announcements, and whether Magtheridon/Archimonde (or excluded reports) already auto-posted via homepage renders. Server Guide may be enableable without full Onboarding — confirm in the Discord UI." + candidate_causes: + - "process: no owning task/checkpoint/todo for D-10 execution; validation gate dropped; seeding reordered after promotion" + - "spec/docs: seed kit scope too narrow; welcome targeted at wrong channel vs invite landing; no #feedback opener; DISCORD.md 'no human posts here' contradicts pinning a human welcome in #announcements" + - "environment/platform: Discord pins are low-visibility; Onboarding/Welcome Screen/Server Guide gated on Community mode and >=7 channels, incompatible with D-03" + - "data: #announcements content is uncurated (every public report, possibly homepage-triggered), burying the welcome" + and_gate: "yes — the reported symptom (not pinned) needs only cause A, but the developer's widened scope ('set up nicely for new folks') fails unless BOTH A (never executed/verified) and B (design insufficient/undefined) are addressed; closing A alone reproduces a room whose landing channel is #feedback with no message." + +## Symptoms + +expected: In the ParseForge Discord, the welcome message from docs/DISCORD-SEED.md is posted AND pinned, #announcements and #feedback have their channel topics set, and the two remaining backfill reports (Magtheridon yGR3dVTwr71Hc68D fight 18; Archimonde n6zb8WYBcf1LGKNa fight 103) have auto-posted their cards. A first-time visitor arriving from the site footer's "Join the Discord" link sees a real, active room — not two cards and two empty channel names. (Roadmap Phase 5 success criterion 1.) +actual: User reported "It's not pinned." Developer follow-up: "I think we should probably do a refresh of the Discord in general and make sure it's setup nicely for new folks." +errors: None reported +reproduction: Test 16 in .planning/phases/05-community-cross-promotion/05-UAT.md +started: Discovered during UAT (2026-09-26/27), after the 05-04 production ship gate; verifier had flagged D-10 seeding "infrastructure-met, seeding-pending" + +## Eliminated + +- hypothesis: The seed kit never told the developer to pin the welcome message (content gap) + evidence: docs/DISCORD-SEED.md line 28 says "Post and pin this in `#announcements`"; 05-03-PLAN Task 2 line 213 required it. + timestamp: 2026-09-27 + +- hypothesis: The site-side entry point is wrong (invite URL mismatch / wrong server / wrong rel) and causes the not-ready room + evidence: lib/cross-promo.ts PARSEFORGE_DISCORD_INVITE_URL == docs/DISCORD.md invite; Footer.tsx renders it site-wide; UAT tests 1-2 passed. The site cannot set pins/topics/landing channel. (Invite's live validity and landing channel remain developer-observable only.) + timestamp: 2026-09-27 + +- hypothesis: A Discord platform/permission limitation prevented pinning in the webhook channel + evidence: The server owner (sole admin, D-02) holds every permission including pin; nothing in the phase records an attempted-and-failed pin — the sign-off says the hand-posting "has not been confirmed as done". Reasoned from D-02 + docs, not directly observed in Discord. + timestamp: 2026-09-27 + +## Evidence + +- timestamp: 2026-09-27 + checked: .planning/debug/knowledge-base.md (Phase 0) + found: No knowledge base exists; no prior resolved sessions. MemPalace not available in this environment. + implication: No known-pattern candidate; proceed with open investigation. + +- timestamp: 2026-09-27 + checked: docs/DISCORD-SEED.md (full) + found: Seed kit DOES instruct pinning ("## Welcome message — Post and pin this in `#announcements`", line 28) and gives exact channel topics (lines 21-24) and a 3-row backfill table. It is written entirely as a to-do list addressed to the developer ("Nothing here is automated: the developer runs every step by hand"). Its "Before you post" checklist (lines 14-17) makes the 05-04 PRODUCTION deploy a precondition for the backfill. It contains NO #feedback opener message (topic only), no rules/guidelines, no onboarding/Server Guide, no roles, no server icon/banner/description, no invite-landing-channel guidance, no channel-permission guidance. + implication: Pinning is not missing from the instructions — the failure is that the instructions were never executed (and nothing verified them). The kit's scope is also much narrower than "set up nicely for new folks". + +- timestamp: 2026-09-27 + checked: docs/DISCORD.md + found: "#announcements — written by an incoming webhook — no human posts here directly" (line 13) CONTRADICTS DISCORD-SEED.md's "Post and pin this [welcome] in #announcements". DISCORD.md records the invite URL and "never expire, unlimited uses" but does NOT record which channel the invite lands on. No channel-permission record (whether @everyone can post in #announcements), no record of whether Discord's "Create My Own" default #general text / General voice channels were deleted. + implication: Internal contradiction about where human-posted welcome content lives; landing channel and permissions are unrecorded — the repo has no source of truth for what a new member actually sees. + +- timestamp: 2026-09-27 + checked: 05-01-PLAN.md user_setup + Task 1 instructions (lines 26-33, 213-224) + found: The invite was specified as "Create a permanent, never-expiring, unlimited-use invite link for #feedback" (step 4: "In #feedback → Invite People → Edit invite link"). Server creation step is "Add a Server → Create My Own" + "Create exactly two text channels" — no instruction to delete Discord's auto-created default channels. UAT test 9 ("exactly #announcements and #feedback") was source: automated, i.e. verified against docs/DISCORD.md text, not the live server. + implication: A new member following the footer invite lands in #feedback — which per the seed kit has only a topic and no message — while the welcome message was designed for #announcements (a different channel). Even if pinned as instructed, the welcome would not be in the landing channel, and pins are only visible via the pin icon. Whether default #general/voice channels still exist is unverified. + +- timestamp: 2026-09-27 + checked: 05-CONTEXT.md D-10, ROADMAP Phase 5 notes, REQUIREMENTS COMM-01, 05-VALIDATION.md line 71 + found: D-10: "The developer seeds the server manually ... Running the server day to day stays a human task outside the GSD-tracked plan; Claude's plan produces the server/channels/bot and the seed content, then hands off." ROADMAP sequencing: "COMM-01 → seed activity 1–2 weeks → site-wide promotion → SHARE-04"; ROADMAP resolution note: empty-room risk "mitigated by D-10's seeding before site-wide promotion". COMM-01: "seeded activity for 1–2 weeks, then promoted site-wide". 05-VALIDATION.md manual row: "Developer confirms ... welcome message + backfilled seed posts are live, before flipping the site-wide footer/invite link live". + implication: Every upstream artifact required seeding BEFORE promotion, with a developer confirmation gate. + +- timestamp: 2026-09-27 + checked: task types across 05-01..05-04 PLANs; 05-04-PLAN flagged_assumptions line 58; 05-04 Task 2 checkpoint + found: Only two checkpoint:human-action tasks exist in the phase (05-01 Task 1 server creation; 05-04 Task 2 deploy authorization). No task — checkpoint or auto — owns posting/pinning the welcome, setting topics, or opening backfill URLs. 05-04's flagged assumption explicitly moved seeding: "the 1–2 week seeding window the ROADMAP names runs after this deploy and outside any executable plan". The 05-04 Task 2 deploy checkpoint lists DISCORD-SEED.md "Before you post" only as "the checklist that becomes actionable the moment this deploy lands" — not as a precondition. 05-VALIDATION.md's "confirm seed content live before flipping the footer" row was never turned into a task. + implication: The validation contract's seeding gate was dropped between VALIDATION and PLAN; the pin/topic/backfill steps had no owner inside the tracked workflow and no verification until UAT. + +- timestamp: 2026-09-27 + checked: 05-03-PLAN.md lines 185, 206-208 vs DISCORD-SEED.md backfill design + found: 05-03 says the kit exists "so the server has real content in it before plan 05-04 promotes it site-wide", but also requires "the production deploy from plan 05-04 has gone out" before the backfill because the backfill works by opening URLs so the SHIPPED prod auto-post fires. The 05-04 deploy is the same deploy that ships the site-wide footer. + implication: Structural ordering contradiction — backfill-through-the-real-auto-post cannot precede promotion, because promotion and the auto-post go live in one deploy. Seeding-before-promotion was impossible as designed (unless the footer were feature-flagged or the welcome/topics done separately first). + +- timestamp: 2026-09-27 + checked: git commit timestamps + found: Server recorded 4c010d9 2026-09-24 12:15 PDT; seed kit bc35038 2026-09-25 00:38 PDT; ParseForge prod deploy recorded 52bad34 2026-09-25 15:49 PDT. + implication: Site-wide promotion went live ~27 hours after the server was created — not after a 1–2 week seeding window. + +- timestamp: 2026-09-27 + checked: 05-03-PLAN must_haves truth line 24 + artifact line 34 vs Task 2 structure and DISCORD-SEED.md; 05-VERIFICATION human item 1 + found: 05-03's must-have truth promises "a welcome message, a #feedback opener, and a concrete backfill list"; the Task 2 seven-heading structure omits a #feedback opener and the shipped kit has none. 05-VERIFICATION expects "a populated #feedback opener". + implication: Spec drift — the channel new members land in (#feedback, per the invite spec) was promised an opener and never got one drafted. + +- timestamp: 2026-09-27 + checked: docs/OPS-01-SHIP-GATE.md Part 7 Sign-off; 05-04-SUMMARY; 05-VERIFICATION; REQUIREMENTS.md + found: Sign-off 2026-09-25: COMM-01 "infrastructure-met, seeding-pending" — "the developer's own hand-posting of the welcome message and backfill (D-10) has not been confirmed as done". REQUIREMENTS.md nonetheless marks COMM-01 [x] Complete. 05-VERIFICATION truth 2 routed to human verification. + implication: The gap was known and honestly flagged but tracked only as prose; no todo/checkpoint forced closure, so it surfaced only at UAT. + +- timestamp: 2026-09-27 + checked: lib/cross-promo.ts, app/components/Footer.tsx, app/components/LandingHero.tsx + found: PARSEFORGE_DISCORD_INVITE_URL = "https://discord.gg/rYQB5ftUh9" (single source of truth, matches docs/DISCORD.md). Footer renders it site-wide, target=_blank, rel "noopener noreferrer nofollow". LandingHero keeps discord.gg/bigyikes (different community). Nothing site-side is wrong; the site cannot express which channel the invite lands on — that is a property of the invite object in Discord. + implication: Site entry point is correct and not the root cause; landing channel/expiry can only be confirmed in the Discord UI (Server Settings → Invites). + +- timestamp: 2026-09-27 + checked: .planning/WINDOWS.md, .planning/todos/pending/, phase deferred-items.md + found: No WINDOWS row, pending todo, or deferred item tracks executing D-10 seeding (pin/topics/backfill). The only Discord-seed WINDOWS row (#20) is about homepage-curl flakiness in the seed-kit gate. + implication: After the 2026-09-25 sign-off, the open seeding obligation lived only as prose in Part 7 / 05-04-SUMMARY / 05-VERIFICATION — nothing machine-tracked it to closure. + +- timestamp: 2026-09-27 + checked: Discord support docs (Community Onboarding FAQ, Server Guide FAQ, Enabling Your Community Server, Welcome Screen, Rules Screening, Invites 101) via web search + found: (1) Welcome Screen, Server Guide, Onboarding require Community mode; enabling Community requires a rules channel and a community-updates channel plus verification-level/explicit-content-filter settings. (2) Onboarding requires >=7 Default Channels with >=5 allowing @everyone View+Send. (3) Rules Screening makes members agree to rules before they can talk. (4) Without Onboarding, a new member lands in the channel the invite was created from ("send an invite from that channel"). + implication: "Set up nicely for new folks" in Discord's own terms (rules, Welcome Screen/Server Guide, onboarding) is structurally incompatible with D-03's exactly-two-channel decision — full Onboarding cannot be enabled on a 2-channel server, and Community mode itself needs a rules channel + updates channel. D-03 must be revisited (or a reduced checklist chosen) before a refresh can be specified. With no Onboarding, the landing channel is the invite's channel (#feedback per 05-01 spec). + +- timestamp: 2026-09-27 + checked: lib/report-meta.ts:56-86, app/components/FeaturedReports.tsx, app/page.tsx (revalidate=3600), Next.js after() docs + found: getReportMeta schedules after(() => maybeAnnounceReport(code, report)) on every successful public load. FeaturedReports (homepage) calls getReportMeta for the demo + up to 6 recent codes. Next.js docs: after() in a static page runs at build time or whenever the page is revalidated. 05-RESEARCH/05-REVIEW never mention the homepage as an announce trigger. Counter-evidence: 05-04's post-deploy homepage curls did NOT claim CyhwP713Dqf8RWvZ (its first /analyze fetch still announced), so at least the first post-deploy homepage render did not announce it (likely served the build-time prerender whose getRecentReports fails with "Dynamic server usage"). + implication: UNVERIFIED secondary risk: homepage renders/revalidations may already have auto-posted the Magtheridon/Archimonde backfill cards (making "open each URL once" a no-op) and possibly other homepage-listed reports the seed kit excluded on brand-fit grounds (e.g. q9dwBX6Z2vg1QKD4 retail M+). #announcements content/order is not curated by the seed kit at all. Developer must look at the channel before running backfill. Not the root cause of the pin gap. + +## Resolution + +root_cause: "(A) Process: Phase 5 never gave D-10 seeding an owner — no plan task, human-action checkpoint, WINDOWS row, or todo covers posting/pinning the welcome, setting topics, or running the backfill; 05-VALIDATION's 'seed before flipping the footer' manual gate was dropped at planning, 05-04 explicitly deferred seeding to after the promotion deploy (the seed kit's backfill-through-the-prod-auto-post design made seed-before-promote impossible), and the gap was recorded only as prose ('infrastructure-met, seeding-pending') while COMM-01 was marked Complete — so nothing forced it closed before UAT; (B) Spec: even if executed, the seed kit's design doesn't produce a good first visit and 'set up nicely for new folks' is undefined anywhere — the 05-01 invite lands members in #feedback, which the kit gives only a topic (the #feedback opener promised in 05-03's must-haves was never drafted), while the welcome goes to #announcements as a pin (hidden behind the pin icon, buried by uncurated auto-posts, and contradicting DISCORD.md's 'no human posts here'); there is no rules/guidelines, Community mode/Welcome Screen/Server Guide, permissions, branding, or invite-landing-channel spec, and D-03's exactly-two-channel decision blocks Discord's native Onboarding (needs >=7 default channels) and Community mode (needs rules + updates channels)." +fix: (diagnose-only mode — not applied) +verification: +files_changed: [] +fix_split: + repo_work: + - "Rewrite docs/DISCORD-SEED.md into a complete new-member setup checklist + copy: landing channel decision, channel list/order/topics, permissions per channel, pinned welcome placement, #feedback opener, short rules/guidelines text, Community/Server Guide/Welcome Screen settings (if D-03 is revised), server icon/banner/description, invite settings, backfill check-first step, and a per-item 'done' column the developer ticks" + - "Reconcile docs/DISCORD.md (landing channel, channel list, permissions, 'no human posts here' contradiction, record which channel the invite targets and the verified never-expire setting)" + - "Record a D-03 revision (or explicit reaffirmation) in 05-CONTEXT/phase decisions if more channels (#rules/#welcome/#start-here, private mod-updates) are added" + - "Plan a checkpoint:human-action task owning the refresh with a verifiable resume signal, plus a WINDOWS/todo row so it cannot lapse; update OPS-01 Part 7 / REQUIREMENTS COMM-01 addendum when closed" + - "Optional/separate: decide whether homepage (FeaturedReports -> getReportMeta -> after(maybeAnnounceReport)) should be an announce trigger — affects #announcements curation; out of G-05-16's direct scope" + developer_only_discord_ui: + - "Pin the welcome message (and post #feedback opener) where new members actually land" + - "Set channel topics; set per-channel permissions (e.g. @everyone read-only in #announcements)" + - "Check/re-target the invite (Server Settings -> Invites): landing channel, never-expire, unlimited — keep the same code rYQB5ftUh9 if possible, else update lib/cross-promo.ts" + - "Delete any leftover default #general / General voice channel (or decide to keep per revised D-03)" + - "Optionally enable Community mode, rules channel, Rules Screening, Welcome Screen/Server Guide; set server icon/banner/description; set System Messages channel" + - "Look at #announcements before opening the Magtheridon/Archimonde URLs — they (and possibly excluded reports) may already have auto-posted" diff --git a/.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md b/.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md index 55b738f..a153881 100644 --- a/.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md +++ b/.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md @@ -284,3 +284,41 @@ All 5 human-verification items passed in `01-UAT.md` (theme toggle, EEA/UK vs US ## Acknowledged Gaps - **Security review (ASVS L1) deferred.** `workflow.security_enforcement` is enabled, but this GSD install (standard profile) lacks the `gsd-secure-phase` skill and `gsd-security-auditor` agent, so `01-SECURITY.md` could not be produced. Developer chose to proceed and defer (2026-09-07). Mitigation already in place: every Phase 1 PLAN.md carries a STRIDE threat register (T-01-01 … T-01-30, T-01-SC) with dispositions; CSP remains report-only (pre-existing concern). To close: install the full gsd-core profile, run `/gsd-secure-phase 01`. + +## Addendum (2026-09-14) + +This phase's OPS-01 PostHog criterion, recorded above and in `docs/OPS-01-SHIP-GATE.md`'s Phase 1 +sign-off table as **pass (pre-deploy)** / **no-data (post-deploy)**, was not actually met. +Nothing already written above is being changed — this addendum is the correction the sign-off +did not have at the time. + +**What was actually true.** The Phase 1 production deploy of 2026-09-06 introduced +`cookieless_mode: "on_reject"` in `app/components/PostHogProvider.tsx`. In posthog-js 1.360, that +setting makes `isOptedOut()` return `true` whenever consent is `PENDING`, so every `capture()` call +is dropped — not sent cookieless, dropped outright. Opt-in depended on a `__tcfapi` callback that +Google's CMP never fires for a fresh visitor (`window.__tcfapi` is not present at `loaded` time), +so the 3-second `CMP_TIMEOUT_MS` always resolved to `"pending"` and capture never resumed. The +`no-data` row this sign-off recorded for `theme_changed` and `consent_resolved` was attributed to +the deploy being minutes old and an unstable MCP connection — both true, but neither was the real +cause, and the real cause did not go away once traffic arrived. + +**The volume evidence** (`.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md`): +daily event volume ran roughly 4,000–10,000/day in the days before this deploy, fell to +3–7/day within two days after it, and stayed there through 2026-09-13. `theme_changed` and +`consent_resolved` — the two Phase 1 custom events this sign-off's PostHog row concerned — along +with `consent_unavailable`, `timeline_viewed`, `timeline_filter_used` and `timeline_error` (the +latter three shipped in Phase 2) were never ingested at all in that window. + +**What was actually wrong with the sign-off.** The gate asked whether an event *definition* +existed in the PostHog project (Part 1 item 4, pre-hotfix wording) — and event definitions persist +once created, whether or not a single event has been ingested since. The checklist's own recording +rule ("a route Search Console has no data for is `no-data` — never a pass") already said the right +thing about `no-data`; the gate simply never counted events, so no row could ever have gone +`no-data` from a real outage. The gate asked the wrong question, not that this sign-off answered +it dishonestly. + +**Where it was actually met.** The PostHog half of Phase 1's OPS-01 criterion was met for real by +Phase 2.1 (`.planning/phases/02.1-posthog-consent-gate-hotfix/`), which fixes the capture path with +server-side geo consent classification and adds a mandatory post-deploy live-traffic check — +`docs/OPS-01-SHIP-GATE.md` Part 1 item 7 — that a gate whose events cannot be observed now fails +outright rather than recording `no-data` and passing anyway. diff --git a/.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md b/.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md index 2dd3a89..44b81b6 100644 --- a/.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md +++ b/.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md @@ -174,3 +174,35 @@ mechanism working as designed, not defects in it. _Verified: 2026-09-08_ _Verifier: Claude (gsd-verifier)_ + +## Addendum (2026-09-14) + +This report's "Human Verification Required" item 1 already routed criterion 5's PostHog half to +human verification rather than passing it — this addendum records that the follow-up it described +could not have succeeded, and corrects the record accordingly. Nothing already written above is +being changed. + +**What was actually true.** The Phase 1 production deploy of 2026-09-06 introduced +`cookieless_mode: "on_reject"` in `app/components/PostHogProvider.tsx`; in posthog-js 1.360 that +setting drops every `capture()` while consent is `PENDING`, and the opt-in path depended on a +`__tcfapi` callback Google's CMP never fires for a fresh visitor. That regression was already live +throughout Phase 2's development and its 2026-09-08 deploy — Phase 2 shipped on top of an already +broken capture path, it did not introduce a new one. + +**The volume evidence** (`.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md`): +`timeline_viewed`, `timeline_filter_used` and `timeline_error` — the three Phase 2 custom events +this report's human-verification item named — were never ingested, for the same reason. Daily +event volume across this whole period ran 3–7/day, against 4,000–10,000/day before the Phase 1 +deploy. + +**What is not retracted.** The code-side evidence in this report's Truths and prohibition checks — +the single capture call site per event, verified by grep, and the canonical/robots/structured-data +checks for `/analyze` — remains correct and is not retracted by this addendum. Only the ingestion +claim implicit in routing this to a "confirm the events are present" follow-up is corrected: that +follow-up could not have found the events present, because nothing was reaching PostHog to find. + +**Where it was actually met.** The PostHog half of Phase 2's OPS-01 criterion was met for real by +Phase 2.1 (`.planning/phases/02.1-posthog-consent-gate-hotfix/`), which fixes the capture path with +server-side geo consent classification and adds a mandatory post-deploy live-traffic check — +`docs/OPS-01-SHIP-GATE.md` Part 1 item 7 — so a gate whose events cannot be observed now fails +outright rather than recording `no-data` and passing anyway. diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-PLAN.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-PLAN.md new file mode 100644 index 0000000..264e84d --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-PLAN.md @@ -0,0 +1,431 @@ +--- +phase: 02.1-posthog-consent-gate-hotfix +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/geo.ts + - lib/geo.test.ts + - app/api/geo/route.ts + - app/api/geo/route.test.ts + - app/components/PostHogProvider.tsx + - lib/consent.ts + - lib/consent.test.ts +autonomous: false +requirements: [MONY-01, OPS-01] + +estimate: + tokens: 65000 + raw_tokens: 65000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "A fresh visitor whose country header is outside the consent-region set has their first $pageview actually sent to PostHog within seconds of load, with no __tcfapi involvement anywhere on that path (D-01, D-04, ROADMAP SC1)." + - "The first $pageview is not captured until the consent path has been applied - capture while consent is PENDING is dropped by the SDK, not queued, so PostHogPageView waits for a resolved gate rather than firing on mount (D-04)." + - "A visitor whose country is missing, empty, whitespace-only or unknown is treated as a consent-region visitor (fail closed), and on Vercel that case emits exactly one geo_header_missing log line (D-03)." + - "Country codes are compared case-insensitively after trimming, so a lower-case or padded header value classifies identically to its canonical upper-case form (D-03)." + - "The geo decision is applied exactly once per page load, and for a consent-region visitor the TCF path wins regardless of when or whether a __tcfapi event arrives - a non-consent-region visitor never starts a consent listener at all (D-04, D-05)." + - "An EEA/UK visitor's behaviour is unchanged: nothing is captured before the TCF dialog resolves, an explicit reject stays cookieless with no replay, and session replay starts only on a full opt-in (D-05, MONY-01, ROADMAP SC2)." + - "Every event carries a consent_gate_path super property whose value is one of geo-non-consent-region, tcf-accept, tcf-reject or tcf-timeout, registered before the opt-in call so the first $pageview already carries it (D-07, ROADMAP SC3)." + - "No route's rendering mode changes: / stays ISR and the static routes stay static, because the only reader of the geo header is the /api/geo Route Handler and the root layout gains no dynamic request API (D-02)." + - statement: "A country code inside CONSENT_REGIONS whose visitor is never shown a CMP dialog surfaces as a consent_gate_path value of tcf-timeout, or as tcf-accept carrying gdpr_applies false, rather than silently vanishing - the list/CMP mismatch stays countable instead of invisible." + verification: backstop + prohibitions: + - statement: "MUST NOT set any cookie for a consent-region visitor before they consent - the /api/geo response carries no Set-Cookie, and PostHog persistence stays memory-only until the TCF dialog resolves to a full opt-in." + status: unverified + verification: test + - statement: "MUST NOT read Vercel geolocation request headers anywhere except app/api/geo/route.ts - no layout, page, client component or other lib module may read them." + status: unverified + verification: test + - statement: "MUST NOT change any route's rendering mode - the root layout must not gain a dynamic request API, / must keep its hourly revalidate, and the guide, audit, privacy and terms routes must stay static." + status: unverified + verification: test + artifacts: + - path: "lib/geo.ts" + provides: "CONSENT_REGIONS and isConsentRegionCode - the single source of truth for which countries the CMP targets" + contains: "CONSENT_REGIONS" + - path: "lib/geo.test.ts" + provides: "Unit coverage for the region set, the fail-closed default and case normalisation" + contains: "isConsentRegionCode" + - path: "app/api/geo/route.ts" + provides: "The only server surface that reads the geo header; returns a single boolean with no cookie and no shared caching" + contains: "isConsentRegion" + - path: "app/api/geo/route.test.ts" + provides: "Route Handler coverage driven by a constructed NextRequest, no server start needed" + contains: "NextRequest" + - path: "lib/consent.ts" + provides: "deriveConsentGateOutcome - the pure merge of the geo boolean and the TCF action into the SDK calls the provider makes" + contains: "deriveConsentGateOutcome" + - path: "app/components/PostHogProvider.tsx" + provides: "Module-scope init, the /api/geo fetch, the consent-ready gate on the first pageview, and the thin switch over the pure outcome" + contains: "consent_gate_path" + key_links: + - from: "app/components/PostHogProvider.tsx" + to: "app/api/geo/route.ts" + via: "a single same-origin fetch of /api/geo made once per page load before the consent decision is applied" + pattern: "/api/geo" + - from: "app/components/PostHogProvider.tsx" + to: "lib/consent.ts" + via: "deriveConsentGateOutcome decides which SDK calls fire; the provider only executes them" + pattern: "deriveConsentGateOutcome" + - from: "app/api/geo/route.ts" + to: "lib/geo.ts" + via: "isConsentRegionCode is the only classifier; the route never re-implements membership" + pattern: "isConsentRegionCode" +--- + +## Phase Goal + +Every visitor is measured again - non-EEA/UK visitors are captured without depending on the Google +CMP, EEA/UK visitors keep the exact TCF gate, and the OPS-01 gate can never again pass on +pre-deploy data. + +> **Planner note (MVP mode):** the ROADMAP Goal line for this phase is quoted verbatim above. It is +> not written in "As a / I want to / so that" form, and no user story has been invented in its +> place. If the user-story framing is wanted, run `/gsd mvp-phase 2.1` before execution - it is a +> documentation shape, not a blocker for this hotfix. + + +Restore PostHog capture for every visitor the consent gate was never meant to stop, without +loosening the gate for anyone it was meant to stop. + +Today `cookieless_mode: "on_reject"` puts posthog-js into PENDING, and in 1.360 PENDING means every +`capture()` is dropped - not queued, not sent cookieless, dropped. The only escape is +`opt_in_capturing()`, which the provider calls solely from a `__tcfapi` callback that Google's CMP +never fires for a fresh visitor. Result: ~99.9% of capture has been gone since 2026-09-06. + +This plan moves the region decision server-side (D-01), delivers it through a single `/api/geo` +Route Handler that changes no page's rendering mode (D-02), pins the region set to exactly what the +CMP targets (D-03), opts non-consent-region visitors in with no CMP dependency (D-04), keeps the +EEA/UK TCF flow exactly as it is (D-05), extracts the merge of the two into one pure unit-tested +function (D-06), and tags every event with the path that admitted it (D-07). + +Purpose: make ROADMAP success criteria 1, 2 and 3 true in code, verified by unit tests, ready for +the preview netlog proof in 02.1-03. +Output: `lib/geo.ts`, `app/api/geo/route.ts`, their tests, a new pure function in `lib/consent.ts`, +and a rewired `app/components/PostHogProvider.tsx`. + + + +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/workflows/execute-plan.md +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-RESEARCH.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-PATTERNS.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/COVERAGE.md +@.planning/phases/01-foundation-themes-consent/01-CONTEXT.md +@./CLAUDE.md +@./.claude/CLAUDE.md + + +**Existing, unchanged - `lib/consent.ts`:** +- `export type ConsentAction = "opt-in-full" | "cookieless" | "opt-in-non-eea" | "pending"` +- `export interface TcfConsentData { gdprApplies?, eventStatus?, purpose?, listenerId? }` +- `export const CMP_TIMEOUT_MS = 3000` +- `export function deriveConsentAction(tcData): ConsentAction` +- `export interface ConsentState { action, gdprApplies, resolvedAt, timedOut }` +- `export function getConsentState(): ConsentState` +- `export function startConsentListener(onResolve: (action, state) => void, opts?: { timeoutMs?: number }): () => void` + +All 11 existing tests in `lib/consent.test.ts` must keep passing; everything this plan adds to that +file is additive. + +**Existing - `lib/observability.ts`:** `logEvent(event: string, props?: Record): void`. Never throws (own try/catch); callers need no try/catch of their own. + +**posthog-js 1.360.0 facts this plan depends on, all read from the installed SDK:** +- `isOptedOut()` returns true while consent is PENDING under `cookieless_mode: "on_reject"`, so every `capture()` in that state is dropped. +- `capture()` is a bare early return while `__loaded` is false - a capture racing ahead of `init()` is a silent no-op, never a deferred send. +- `opt_in_capturing({ captureEventName: false })` suppresses the default `$opt_in` event. +- `register()` writes to the in-memory props bag unconditionally, even while persistence is disabled - so a register call made immediately before the opt-in is carried by the first `$pageview`. +- `opt_in_capturing()` calls `reset(true)` only when the visitor was already explicitly DENIED; opting in from PENDING does not reset state. + +**Vitest:** `environment: "node"`, `include: ["lib/**/*.test.ts", "app/**/*.test.ts"]` - so +`app/api/geo/route.test.ts` is picked up automatically. A Route Handler is tested by importing `GET` +and calling it with `new NextRequest(url, { headers })`; this was confirmed working against the +installed next@16.1.6. + +**Baselines measured on this branch immediately before planning:** +- `npx vitest run` gives `Test Files 14 passed (14)`, `Tests 137 passed (137)` +- `npx tsc --noEmit` is clean +- `npx eslint app/components/PostHogProvider.tsx lib/consent.ts lib/consent.test.ts lib/observability.ts` exits 0 with no findings + +`npm run lint` across the whole repo reports ~1698 pre-existing problems - never gate on its exit +code; gate on the scoped eslint invocation above. + + + + + + + Task 1: End-to-end - a non-consent-region visitor's first $pageview actually reaches PostHog + lib/geo.ts, lib/geo.test.ts, app/api/geo/route.ts, app/api/geo/route.test.ts, app/components/PostHogProvider.tsx + `node_modules/` is installed in the repo root (run `npm ci` with `~/.local/node20/bin` on PATH if absent) - the vitest and tsc commands in this task's verify block cannot run without it. + + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md` - the confirmed mechanism; section 4 quotes the exact `isOptedOut()` source + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-RESEARCH.md` - Pattern 1 (the Route Handler body, authoritative for that file), Pattern 2 (module-scope init), Pitfall 1 (effect ordering), Pitfall 4 (reset on DENIED), and the `opt_in_capturing` / `register()` source quotes + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-PATTERNS.md` - the analog assignments for `lib/geo.ts`, `lib/geo.test.ts` and `app/api/geo/route.ts` + - `.planning/phases/02.1-posthog-consent-gate-hotfix/COVERAGE.md` - which geolocation headers and which SDK methods are in scope, and why the rest are not + - `app/components/PostHogProvider.tsx` - the file being rewired, in full + - `lib/consent.ts` - the file-header and pure-function style `lib/geo.ts` must match + - `lib/consent.test.ts` - the test-file shape `lib/geo.test.ts` must match + - `app/api/report/[code]/route.ts` - the repo's Route Handler import and response idiom + - `lib/observability.ts` - `logEvent`'s signature and no-throw contract + - `next.config.ts` - confirm the existing CSP connect-src needs no change for a same-origin fetch + + + `lib/geo.test.ts`: + - `CONSENT_REGIONS.size` is exactly 32. + - `isConsentRegionCode` returns true for "DE", "FR", "IS", "LI", "NO", "GB" and "CH". + - `isConsentRegionCode` returns false for "US", "AU", "CA" and "BR". + - `isConsentRegionCode` returns true for null, undefined, "" and " " - fail closed on an unknown region. + - `isConsentRegionCode("de")` and `isConsentRegionCode(" gb ")` are true; `isConsentRegionCode("us")` is false - trimmed, case-insensitive comparison. + - `isConsentRegionCode` returns false for "GI", "IM", "JE" and "GG", with the test name stating that these Crown dependencies and overseas territories are deliberately outside the set pending Task 2's confirmation. + + `app/api/geo/route.test.ts`: + - `GET` with a country header of "DE" resolves to a JSON body of `{ isConsentRegion: true }`. + - `GET` with a country header of "US" resolves to `{ isConsentRegion: false }`. + - `GET` with no country header at all resolves to `{ isConsentRegion: true }`. + - `GET` with a lower-case country header of "us" resolves to `{ isConsentRegion: false }`. + - Every response's Cache-Control header is exactly `private, no-store`. + - No response carries a Set-Cookie header. + - With no country header and `process.env.VERCEL` set, exactly one `geo_header_missing` line is written to stdout; with the header present, none is. + + +Build the whole non-consent-region path in one slice, production quality, no stubs. + +**1. `lib/geo.ts` (new).** Open with a file-header comment in the style of `lib/consent.ts`'s, naming Google's AdSense support page as the source of truth for the targeted-region list and stating the fail-closed rule. Export `CONSENT_REGIONS` as a `ReadonlySet` of 32 ISO 3166-1 alpha-2 codes, with a JSDoc line in the style of `CMP_TIMEOUT_MS`'s: the 27 EU member states (AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IE, IT, LV, LT, LU, MT, NL, PL, PT, RO, SK, SI, ES, SE), the three non-EU EEA states (IS, LI, NO), plus GB and CH. That is exactly the set Google Privacy and Messaging's GDPR message targets (D-03) - a mismatch in either direction is the bug class this phase exists to remove. Do not include GI, IM, JE or GG; note inline that they are deliberately out pending Task 2's confirmation. Export `isConsentRegionCode(code: string | null | undefined): boolean`, modelled on `deriveConsentAction`: typed input including the nullish case, narrow boolean return, no throwing. It trims and upper-cases the input, returns true for a nullish or blank value (fail closed, D-03), and otherwise returns the set membership result. Write `lib/geo.test.ts` against the behavior list above, matching `lib/consent.test.ts`'s one-`it`-per-branch shape. + +**2. `app/api/geo/route.ts` (new).** Take the body from RESEARCH.md Pattern 1, which is authoritative for this file. Import `NextRequest` and `NextResponse` from `next/server`, `isConsentRegionCode` from `@/lib/geo` and `logEvent` from `@/lib/observability`, all via the `@/` alias. Add `export const dynamic = "force-dynamic"` as explicit documentation of per-request intent. `GET` reads the single header `x-vercel-ip-country`, and when that value is absent and `process.env.VERCEL` is set it emits one `logEvent("geo_header_missing", ...)` call with a low-cardinality prop identifying the route (D-03). It then returns `NextResponse.json` with a body whose only key is `isConsentRegion`, set from `isConsentRegionCode(country)`, and a `Cache-Control` response header of `private, no-store`. No try/catch - there is no fallible I/O here, and `logEvent` already swallows its own errors. No cookie is ever set on this response (D-02), and no other header from the geolocation family is read (see COVERAGE.md). Write `app/api/geo/route.test.ts` against the behavior list: import `GET` directly, drive it with `new NextRequest("http://localhost/api/geo", { headers: ... })`, and assert on the parsed body plus the response headers. For the log assertion, spy on `console.log` with `vi.spyOn` and restore it in `afterEach`, and set then unset `process.env.VERCEL` around the case that needs it. + +**3. `app/components/PostHogProvider.tsx` (rewired).** Three changes, and nothing else. + +(a) Move the `posthog.init(POSTHOG_KEY, { ... })` call out of `PostHogProvider`'s `useEffect` and up to module scope, guarded by `typeof window !== "undefined" && POSTHOG_KEY`. Keep the config object byte-identical apart from the comment in (b) and the removal of the `loaded` callback's consent body, which moves into the effect described in (c); keep `loaded` itself only for the existing development-mode `ph.debug()` call. Rationale to record in a comment and in the SUMMARY: `capture()` is a bare early return until `init()` has set the loaded flag, and React runs child effects before parent effects, so `PostHogPageView`'s capture currently races ahead of the provider's own effect and is silently dropped on every load. Module scope removes the race entirely. This is chosen over a root `instrumentation-client.ts` deliberately: it keeps the init config, the consent wiring and the readiness gate in one file, which is what this hotfix's six-source-file budget allows, and the `typeof window` guard already prevents the call from evaluating during server-side module evaluation. `PHProvider client={posthog}` keeps working unchanged. Cite the SDK source for the module-scope justification, not PostHog's documentation - RESEARCH assumption A1 flags that the documentation citation was not independently verified. + +(b) Directly above `cookieless_mode: "on_reject"`, add a comment recording the exact SDK semantics: under this setting a PENDING consent state makes `isOptedOut()` true, and while opted out `capture()` discards events rather than sending them cookieless or queueing them. Name the regression this defends against (diagnosed 2026-09-14) so the next reader cannot reintroduce it. + +(c) Add the consent-readiness gate. In `PostHogProvider`, hold a `consentReady` boolean in `useState` plus a `useRef(false)` guard named for applying the decision once. In a `useEffect` with an empty dependency list, return early when `POSTHOG_KEY` is empty; otherwise `fetch("/api/geo")` once, parse `isConsentRegion` from the body, and - under the once-guard, and only while the component is still mounted - apply the decision. When `isConsentRegion` is false: call `posthog.register` with a `consent_gate_path` of `geo-non-consent-region` first, so the property is already in the in-memory props bag; then `posthog.opt_in_capturing({ captureEventName: false })`; then `posthog.startSessionRecording()` (D-04, restoring Phase 1 D-07 behaviour); then flip `consentReady` true. Do not call `startConsentListener` on this path at all - no `__tcfapi` involvement whatsoever (D-04). When `isConsentRegion` is true: call `startConsentListener` with the existing four-branch switch body kept verbatim from today's code (D-05 - Task 3 is what replaces that switch with the pure function; do not pre-empt it here), and flip `consentReady` true at the end of every branch so the gate always resolves. If the fetch rejects or the body cannot be parsed, fail closed to the consent-region branch - a network or CSP failure must never opt a European visitor in. Return a cleanup that disposes any consent listener and clears the mounted flag. + +Then gate the page view: give `PostHogPageView` a `ready` boolean prop, pass `consentReady` into it, have its effect return early while `ready` is false, and add `ready` to its dependency array so the deferred first `$pageview` fires the moment the gate resolves. This is what makes SC1 true - an early `init()` alone does not help, because a capture made while consent is PENDING is dropped rather than queued. + + + - `lib/geo.ts` exports `CONSENT_REGIONS` with exactly 32 members and `isConsentRegionCode`, and `lib/geo.test.ts` asserts every case listed in the behavior block. + - The codes GI, IM, JE and GG are absent from the set itself, proven by passing assertions that `isConsentRegionCode` returns false for each. + - `app/api/geo/route.ts` contains `export const dynamic = "force-dynamic"`, returns a body whose only key is `isConsentRegion`, and sets Cache-Control to the literal value `private, no-store`. + - `app/api/geo/route.ts` is the only file under `app/` or `lib/` that names a Vercel geolocation request header. + - `app/layout.tsx` is not modified by this task and gains no dynamic request API, so `/` keeps `revalidate = 3600` and the guide, audit, privacy and terms routes stay static (D-02). + - `app/components/PostHogProvider.tsx` calls `posthog.init` at module scope inside a `typeof window !== "undefined"` guard, and `posthog.init` no longer appears inside any `useEffect` body. + - The config keys `api_host`, `ui_host`, `person_profiles`, `capture_pageview`, `capture_pageleave`, `cookieless_mode`, `disable_session_recording`, `session_recording.maskAllInputs`, `enable_recording_console_log` and `autocapture` all retain the exact values they have today. + - `PostHogPageView` accepts a `ready` prop, returns early while it is false, and lists `ready` in its effect dependency array. + - The non-consent-region branch calls `posthog.register` before `posthog.opt_in_capturing`, passes `{ captureEventName: false }`, then calls `posthog.startSessionRecording`, and contains no call to `startConsentListener`. + - A rejected `/api/geo` fetch routes to the consent-region branch. + - `npx vitest run` reports at least 137 tests passing with zero failures, the 11 existing `lib/consent.test.ts` tests still among them. + - `npx tsc --noEmit` is clean and a scoped `npx eslint` over the five files in this task exits 0. + + + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx vitest run lib/geo.test.ts app/api/geo/route.test.ts + non-zero exit, or the summary contains "failed", or vitest reports "No test files found" for either path, or the Tests line shows fewer than 10 passed + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx vitest run + non-zero exit, or the Tests summary line reports any failed count, or fewer than 137 tests passed (the pre-change baseline) + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx tsc --noEmit && echo typecheck-ok + the line typecheck-ok is absent from the output, or any line containing "error TS" appears + cd /Users/alexander.mayes/Code/personal/parseforge && if grep -v '^[[:space:]]*//' app/layout.tsx | grep -q 'next/headers'; then echo "GATE-FAIL: root layout pulled in a dynamic request API"; exit 1; fi; echo layout-static-ok + the line layout-static-ok is absent, or a line beginning "GATE-FAIL" is present, or non-zero exit + + cd /Users/alexander.mayes/Code/personal/parseforge && FOUND=$(grep -rlE 'headers[[:space:]]*\.[[:space:]]*get\([^)]*x-vercel-ip' app lib --include='*.ts' --include='*.tsx' | grep -v '^app/api/geo/' || true); if [ -n "$FOUND" ]; then echo "GATE-FAIL: geo header read outside the Route Handler: $FOUND"; exit 1; fi; echo geo-header-scope-ok + the line geo-header-scope-ok is absent, or a line beginning "GATE-FAIL" names any file outside app/api/geo/ that actually reads a Vercel geolocation header (a mention in a comment does not trip this gate; an actual read does) + + `npx vitest run lib/geo.test.ts app/api/geo/route.test.ts` is green, the full suite is still at or above 137 passing, typecheck is clean, the root layout is untouched, and `app/api/geo/route.ts` is the only reader of a Vercel geolocation header. The non-consent-region path is wired end to end from header to opt-in to first `$pageview`, with no `__tcfapi` on it. + D-02's transport shape (a client fetch of `/api/geo` rather than middleware or a cookie) is what Phase 4's ad loader will also read, so changing it later means changing two consumers. It is not one-way: the Route Handler can be replaced without a data migration or an external-service change. + + + + Task 2: Confirm the four ambiguous territories stay outside CONSENT_REGIONS + + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-RESEARCH.md` - Open Question 2 and assumption A4 + - `lib/geo.ts` as written in Task 1 + + Should Gibraltar (GI), Isle of Man (IM), Jersey (JE) and Guernsey (GG) be inside `CONSENT_REGIONS`? They are currently OUT, which is the default Task 1 implemented (D-03). + +Google's AdSense support page defines the targeted region as "the European Economic Area (EEA), the +UK, and Switzerland" and does not enumerate these four. Vercel returns them as their own ISO codes, +distinct from GB, so they do not inherit GB's classification. D-03's fail-closed rule covers a +*missing* header, not a *present but ambiguous* one, so this needs a human answer rather than a +silent guess (RESEARCH Open Question 2, assumption A4). + +Both ways of being wrong are versions of the bug this phase is fixing. Out, when Google does show +them a dialog: we capture before consent - the failure MONY-01 exists to prevent. In, when Google +does not: the visitor stays PENDING forever and is never measured - this outage in miniature. + +Traffic reality: the last seven days of surviving capture showed PL, DE, SE, UA and AU. None of +these four has appeared. Whichever way this goes, the blast radius is small. + +This is non-blocking. If it goes unanswered in the execution session, proceed with option A and +record the unanswered checkpoint verbatim in the SUMMARY - it must not stall the hotfix. + + + + + + + + - The chosen option is recorded in the SUMMARY, in the developer's own words where given, or explicitly marked as unanswered-defaulted-to-A. + - `lib/geo.ts` and `lib/geo.test.ts` agree with the choice: the set size assertion and the four membership assertions both match it. + - `npx vitest run lib/geo.test.ts` is green after any change. + + Reply "a", "b" or "c" (or say "leave it" for the default). Silence proceeds with option A and is recorded as unanswered. + The choice is recorded in the SUMMARY with the developer's own words where given, `lib/geo.ts` and `lib/geo.test.ts` agree with it, and `npx vitest run lib/geo.test.ts` is green. + + + + Task 3: One pure function decides both paths, and every event carries the path that admitted it + lib/consent.ts, lib/consent.test.ts, app/components/PostHogProvider.tsx + + - `lib/consent.ts` in full - especially `deriveConsentAction`'s doc-comment style and the `ConsentAction` union + - `lib/consent.test.ts` in full - the 11 existing tests that must keep passing + - `app/components/PostHogProvider.tsx` as rewired by Task 1 - the four-branch switch this task replaces + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md` - D-05, D-06 and D-07 in full + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-PATTERNS.md` - the warning not to conflate the `ConsentAction` union with the new gate-path union + + + New tests in `lib/consent.test.ts`, in a `describe("deriveConsentGateOutcome", ...)` block: + - `deriveConsentGateOutcome(false, null)` returns gatePath "geo-non-consent-region", optIn true, startReplay true, event null. + - `deriveConsentGateOutcome(false, "pending")` and `deriveConsentGateOutcome(false, "cookieless")` also return gatePath "geo-non-consent-region" - a TCF action arriving on a non-consent-region visitor never changes the geo decision, whatever its arrival order. + - `deriveConsentGateOutcome(true, null)` returns null - the consent-region path has nothing to apply until the TCF listener resolves, so the caller keeps waiting. + - `deriveConsentGateOutcome(true, "opt-in-full")` returns gatePath "tcf-accept", optIn true, startReplay true, event "consent_resolved" with props accepted true and gdpr_applies true. + - `deriveConsentGateOutcome(true, "cookieless")` returns gatePath "tcf-reject", optIn false, startReplay false, event "consent_resolved" with props accepted false and gdpr_applies true. + - `deriveConsentGateOutcome(true, "pending")` returns gatePath "tcf-timeout", optIn false, startReplay false, event "consent_unavailable" with a reason prop of "tcfapi_timeout". + - `deriveConsentGateOutcome(true, "opt-in-non-eea")` returns gatePath "tcf-accept", optIn true, startReplay true, event "consent_resolved" with props accepted true and gdpr_applies false - the list/CMP mismatch backstop, which keeps today's SDK calls identical while making the mismatch countable. + - Every non-null return's gatePath is one of the four literal values in the `ConsentGatePath` union, asserted against an inline array of those four strings. + - No input combination returns optIn false together with startReplay true - replay never starts without an opt-in. + + +Extract the decision, then make the provider a thin executor of it (D-06). + +**`lib/consent.ts`.** Add - do not modify anything that exists - a `ConsentGatePath` type union of exactly the four literals `geo-non-consent-region`, `tcf-accept`, `tcf-reject` and `tcf-timeout` (D-07). Keep it visibly distinct from `ConsentAction`: they have four members each but they are not the same four, and PATTERNS.md flags conflating them as the trap here. Add an exported `ConsentGateOutcome` interface with the fields `gatePath: ConsentGatePath`, `optIn: boolean`, `startReplay: boolean`, `event: "consent_resolved" | "consent_unavailable" | null` and `eventProps: Record`. Add the pure function `deriveConsentGateOutcome(isConsentRegion: boolean, tcfAction: ConsentAction | null): ConsentGateOutcome | null`, mapping exactly the table in the behavior block. Give it a multi-paragraph JSDoc in `deriveConsentAction`'s style that names D-04, D-05, D-06 and D-07 and explains three things: why a non-consent-region visitor's outcome ignores `tcfAction` entirely (there is no listener on that path, and geo is the authority - D-04); why the consent-region cases reproduce today's SDK calls exactly rather than improving them (D-05 keeps EEA/UK behaviour unchanged, and the pre-choice cookieless idea is explicitly deferred); and why the `opt-in-non-eea` case reached with `isConsentRegion` true is a region-list-versus-CMP mismatch worth an event carrying `gdpr_applies` false - additive observability only, with the SDK calls identical to today's. No throwing, no side effects, no `window` access. + +Add the new `describe` block to `lib/consent.test.ts` covering every row of the behavior list. Leave the three existing `describe` blocks untouched. + +**`app/components/PostHogProvider.tsx`.** Replace the inline four-branch switch that Task 1 carried over with a thin executor. On the consent-region path, `startConsentListener`'s callback now calls `deriveConsentGateOutcome(true, action)`; when the result is null it returns without touching the SDK, otherwise it applies the result in a fixed order: `posthog.register` with the outcome's `gatePath` under the key `consent_gate_path` first, so the property is in the props bag before anything is captured; then `posthog.opt_in_capturing({ captureEventName: false })` when `optIn` is true; then `posthog.startSessionRecording()` when `startReplay` is true; then `posthog.capture(outcome.event, outcome.eventProps)` when `event` is non-null; then flip the readiness state true. Route the non-consent-region path through the same executor by calling `deriveConsentGateOutcome(false, null)` instead of hand-writing those SDK calls, so there is exactly one place in the file that turns an outcome into SDK calls. Keep the once-guard from Task 1 so the decision is applied exactly once per page load even under React's development double-invoke. Note in a comment that `consent_resolved` and `consent_unavailable` on the reject and timeout paths are dropped by the SDK while the visitor is opted out - that is accepted (D-07), and the observability for that gap is the server-side `geo_header_missing` log plus the Vercel-Analytics-versus-PostHog ratio recorded in the OPS-01 gate. + + + - `lib/consent.ts` exports `ConsentGatePath`, `ConsentGateOutcome` and `deriveConsentGateOutcome`, and the pre-existing exports `ConsentAction`, `TcfConsentData`, `CMP_TIMEOUT_MS`, `deriveConsentAction`, `ConsentState`, `getConsentState` and `startConsentListener` are unchanged in name and signature. + - `ConsentGatePath` has exactly the four members `geo-non-consent-region`, `tcf-accept`, `tcf-reject` and `tcf-timeout`, and no other string literal reaches `consent_gate_path`. + - All 11 pre-existing tests in `lib/consent.test.ts` still pass, and the new `describe` block covers all nine behavior rows. + - `deriveConsentGateOutcome` contains no `window` reference, no `posthog` import and no `try`. + - `app/components/PostHogProvider.tsx` contains exactly one call site each for `posthog.opt_in_capturing`, `posthog.startSessionRecording` and `posthog.register`. + - The register call precedes the opt-in call in that single executor, so the first `$pageview` carries `consent_gate_path`. + - `posthog.startSessionRecording` is reachable only when the outcome's `startReplay` is true, which is never true while `optIn` is false. + - `npx vitest run` reports at least 146 tests passing with zero failures. + - `npx tsc --noEmit` is clean and a scoped `npx eslint` over `lib/geo.ts`, `lib/geo.test.ts`, `lib/consent.ts`, `lib/consent.test.ts`, `app/api/geo/route.ts`, `app/api/geo/route.test.ts` and `app/components/PostHogProvider.tsx` exits 0. + + + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx vitest run lib/consent.test.ts + non-zero exit, or the summary contains "failed", or fewer than 20 tests are reported passing for this file (11 pre-existing plus the new block) + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx vitest run + non-zero exit, or the Tests summary line reports any failed count, or fewer than 146 tests passed + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && npx tsc --noEmit && npx eslint lib/geo.ts lib/geo.test.ts lib/consent.ts lib/consent.test.ts app/api/geo/route.ts app/api/geo/route.test.ts app/components/PostHogProvider.tsx && echo gates-ok + the line gates-ok is absent from the output, or any line containing "error TS" appears, or eslint prints any problem for the seven named files + export PATH="$HOME/.local/node20/bin:$PATH"; cd /Users/alexander.mayes/Code/personal/parseforge && for FN in opt_in_capturing startSessionRecording register; do N=$(grep -c "posthog\.$FN(" app/components/PostHogProvider.tsx); echo "$FN=$N"; [ "$N" -eq 1 ] || { echo "GATE-FAIL: $FN has $N call sites, expected 1"; exit 1; }; done; echo single-call-sites-ok + the line single-call-sites-ok is absent, or a line beginning "GATE-FAIL" is present, or any printed count is other than 1 + + The decision for both regions lives in one pure, unit-tested function; the provider only executes its result; every event carries `consent_gate_path`; the full suite is green at 146 or more tests and typecheck plus scoped lint are clean. + + + + +## Artifacts this phase produces + +Created by this plan: + +| Symbol / artifact | File | What it is | +|---|---|---| +| `CONSENT_REGIONS` | `lib/geo.ts` | `ReadonlySet` of 32 ISO 3166-1 alpha-2 codes - EU-27 + IS, LI, NO + GB + CH | +| `isConsentRegionCode(code)` | `lib/geo.ts` | Pure classifier; trims and upper-cases; nullish or blank returns true (fail closed) | +| `GET` | `app/api/geo/route.ts` | Route Handler; the only reader of the geo request header; returns `{ isConsentRegion }` | +| `dynamic = "force-dynamic"` | `app/api/geo/route.ts` | Explicit per-request intent marker | +| `geo_header_missing` | emitted by `app/api/geo/route.ts` via `logEvent` | Server log event counting missing-header requests on Vercel | +| `ConsentGatePath` | `lib/consent.ts` | Union of `geo-non-consent-region`, `tcf-accept`, `tcf-reject`, `tcf-timeout` | +| `ConsentGateOutcome` | `lib/consent.ts` | `{ gatePath, optIn, startReplay, event, eventProps }` | +| `deriveConsentGateOutcome(isConsentRegion, tcfAction)` | `lib/consent.ts` | The pure merge of geo and TCF into SDK calls (D-06) | +| `consent_gate_path` | registered in `app/components/PostHogProvider.tsx` | PostHog super property carried by every event including the first `$pageview` (D-07) | +| `ready` prop on `PostHogPageView` | `app/components/PostHogProvider.tsx` | The consent-readiness gate that stops the first `$pageview` being dropped | +| `lib/geo.test.ts` | new test file | Region set, fail-closed default, case normalisation, ambiguous territories | +| `app/api/geo/route.test.ts` | new test file | First Route Handler test in this repo; `NextRequest`-driven, no server start | + +Produced by later plans in this phase: the `Post-deploy live-traffic check` section of +`docs/OPS-01-SHIP-GATE.md` and its `Part 4 - Phase 2.1 evidence` section (02.1-02, 02.1-03, +02.1-04), and the dated addenda in `01-VERIFICATION.md`, `02-VERIFICATION.md` and +`REQUIREMENTS.md` (02.1-02). + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| visitor browser -> `/api/geo` | An untrusted client can call the endpoint directly and can set arbitrary request headers before Vercel's edge overwrites the geolocation family. | +| Vercel edge -> Route Handler | The geolocation headers are attached by the platform; the handler trusts the platform, not the client. | +| `/api/geo` response -> shared caches | A per-visitor classification travelling back through any CDN or proxy on the path. | +| posthog-js -> PostHog ingest | Events leave the browser only once the consent gate has resolved; this is the boundary MONY-01 protects. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-02.1-01 | Information Disclosure | `lib/geo.ts` `isConsentRegionCode` / `app/api/geo/route.ts` | high | mitigate | Misclassifying a consent-region visitor as non-consent-region would capture and replay an EEA/UK visitor with no TCF consent. Mitigated by the fail-closed default for nullish, blank or unknown values, trimmed case-insensitive comparison, the 32-code set pinned to Google's published scope, and unit tests asserting each of those (Task 1 behavior block). ASVS V5: the header is read with a plain `.get()` and used only for `Set.has` membership - never parsed, split or interpolated. | +| T-02.1-02 | Tampering / Information Disclosure | `app/api/geo/route.ts` response | medium | mitigate | A shared cache serving one visitor's classification to another would break the gate for everyone behind that cache. Mitigated by `Cache-Control: private, no-store` on every response plus `export const dynamic = "force-dynamic"`, both asserted by `app/api/geo/route.test.ts`. | +| T-02.1-03 | Spoofing | `x-vercel-ip-country` on a direct request to `/api/geo` | low | accept | A client can send the header, but Vercel's edge overwrites the geolocation family for all traffic reaching the deployment (the same trust model as its `x-forwarded-for` handling). The only reachable consequence is that a forger changes their own classification, which grants no access and discloses no other visitor's data. Accepted with no additional control. | +| T-02.1-04 | Information Disclosure | `posthog.startSessionRecording` in `app/components/PostHogProvider.tsx` | high | mitigate | Session replay for a consent-region visitor before a full opt-in would record their screen without consent. Mitigated by keeping `disable_session_recording: true` at init, routing every start through the single executor, and the unit assertion that no outcome has `startReplay` true while `optIn` is false (Task 3 behavior block). | +| T-02.1-05 | Information Disclosure | `/api/geo` response body | low | mitigate | The endpoint must not become a geolocation oracle. The response body has exactly one boolean key and no city, region, coordinate or timezone field is read at all - recorded as explicit OPT-OUT rows in `COVERAGE.md`, asserted by the route test's body-shape assertions. | +| T-02.1-06 | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages. `posthog-js@1.360.0` and `next@16.1.6` are pre-existing pinned dependencies, so the Package Legitimacy Gate does not apply (RESEARCH "Package Legitimacy Audit": no `[SLOP]`, no `[SUS]`). If any task finds itself reaching for `npm install`, stop and re-plan - it is outside this hotfix's scope. | + + + +- `npx vitest run` green at 146 or more tests, zero failures (baseline before this plan: 137). +- `npx tsc --noEmit` clean. +- `npx eslint` over the seven files this plan touches exits 0 (the four pre-existing ones exit 0 today). +- `app/layout.tsx` unmodified; no route's rendering mode changed. +- `app/api/geo/route.ts` is the sole reader of a Vercel geolocation header across `app/` and `lib/`. +- Exactly one call site each for `posthog.opt_in_capturing`, `posthog.startSessionRecording` and `posthog.register`. + +Not verifiable here, by design: whether a real browser actually sends the first `$pageview`. This +project has no jsdom or React Testing Library and D-06 does not add one. That proof is 02.1-03's +headless-Chrome netlog against a deployed URL (D-10), and it is the phase's real gate. + + + +- ROADMAP SC1 is true in code: the non-consent-region path opts in from the server-side geo decision with no `__tcfapi` on it, and the first `$pageview` waits for that decision rather than racing it. +- ROADMAP SC2 is preserved in code: the consent-region path's SDK calls are byte-for-byte the behaviour that shipped in Phase 1. +- ROADMAP SC3 is half-true: `consent_gate_path` is registered before any capture on both paths. The other half - the events actually appearing in project 337485 - is 02.1-04's live-traffic gate. +- Every decision D-01 through D-07 has a task implementing it, with the decision ID cited in the action that implements it. + + + +Create `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-SUMMARY.md` when done. + +Record in it: the module-scope-init versus `instrumentation-client.ts` choice and why; the Task 2 +answer verbatim (or that it went unanswered and defaulted); the final `CONSENT_REGIONS` size; the +before and after test counts; and anything the SDK did that contradicted RESEARCH.md - assumptions +A1 (the PostHog docs citation) and A4 (the ambiguous territories) are the two most likely to move. + diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-SUMMARY.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-SUMMARY.md new file mode 100644 index 0000000..00d0a9e --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-SUMMARY.md @@ -0,0 +1,182 @@ +--- +phase: 02.1-posthog-consent-gate-hotfix +plan: 01 +subsystem: analytics +tags: [posthog, posthog-js, consent, tcf, geo, next-route-handler, vitest] + +# Dependency graph +requires: [] +provides: + - "lib/geo.ts — CONSENT_REGIONS (32 codes) + isConsentRegionCode, the single source of truth for which countries the CMP targets (D-03)" + - "app/api/geo/route.ts — the only server surface reading Vercel's geo header; returns { isConsentRegion } with Cache-Control: private, no-store and no cookie (D-01/D-02)" + - "app/components/PostHogProvider.tsx — module-scope posthog.init, a consent-readiness gate that defers the first $pageview, and a single applyOutcome executor for both the geo and TCF paths (D-04/D-05/D-06/D-07)" + - "lib/consent.ts — ConsentGatePath, ConsentGateOutcome, deriveConsentGateOutcome: the pure merge of the geo boolean and the TCF action into SDK calls (D-06/D-07)" + - "consent_gate_path super property registered before any capture on both the geo and TCF paths" +affects: [02.1-03, 02.1-04, phase-4-ad-loader] + +# Actuals (#2632) — chars/4 over the realized diff (Task 1 + Task 3 source changes), not a harness token count. +actuals: + tokens: 7374 + tasks: 3 + commits: 2 + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Module-scope posthog.init (guarded by typeof window !== 'undefined' && POSTHOG_KEY) instead of useEffect — capture() is a bare early return until init() sets the loaded flag, and React runs child effects before parent effects, so a child's capture used to race ahead of the provider's own effect and be silently dropped." + - "Consent-readiness gate: a ready boolean prop threaded into PostHogPageView, only flipped true once the geo/TCF decision is applied — capture while consent is PENDING is dropped by the SDK, not queued, so the first $pageview must wait for a resolved gate rather than firing on mount." + - "One pure function (deriveConsentGateOutcome) maps (isConsentRegion, ConsentAction | null) -> ConsentGateOutcome; the provider is a thin executor (applyOutcome) with exactly one call site each for posthog.register/opt_in_capturing/startSessionRecording — the decision and its execution are fully separated and unit-testable without a browser." + - "ConsentGatePath is a deliberately distinct union from ConsentAction (both four members, not the same four) — avoids conflating the raw TCF signal with the gate that decided whether to capture at all." + +key-files: + created: + - lib/geo.ts + - lib/geo.test.ts + - app/api/geo/route.ts + - app/api/geo/route.test.ts + modified: + - lib/consent.ts + - lib/consent.test.ts + - app/components/PostHogProvider.tsx + +key-decisions: + - "Module-scope init over instrumentation-client.ts: keeps init config, consent wiring and the readiness gate in one file (this hotfix's six-source-file budget), and the typeof window guard already prevents evaluation during SSR. Justification is cited from the installed SDK source (node_modules/posthog-js/dist/module.js), not PostHog's documentation — RESEARCH assumption A1 flagged that citation as unverified; it was not needed since the SDK source itself was read directly." + - "Task 2 (ambiguous territories) — user-selected option a at checkpoint: Gibraltar (GI), Isle of Man (IM), Jersey (JE) and Guernsey (GG) stay OUT of CONSENT_REGIONS. No code change was needed — Task 1 already implemented this default, and lib/geo.test.ts already asserted their absence with CONSENT_REGIONS.size === 32. The decision was not left unanswered/defaulted; the developer explicitly chose option a in the same session." + - "posthog.opt_in_capturing({ captureEventName: false }) is now called uniformly from the single executor for every gate path, including tcf-accept and the opt-in-non-eea mismatch case — previously the TCF opt-in-full/opt-in-non-eea branches called opt_in_capturing() with no argument (which captures a default $opt_in event). This is a deliberate, plan-directed consequence of D-06's 'exactly one call site' requirement, not an unplanned deviation: the plan's Task 3 action explicitly specifies the single executor calls opt_in_capturing with { captureEventName: false } for every optIn-true outcome. Net effect: one fewer event type (a redundant $opt_in) shows up for EEA/UK acceptors; consent_resolved already carries the same information with accepted/gdpr_applies props." + - "RESEARCH.md assumption A4 (ambiguous territories) resolved by direct developer decision rather than moving in either direction the research anticipated — traffic-based low-blast-radius reasoning from RESEARCH held, and the developer confirmed the conservative default." + +patterns-established: + - "Pure-function-plus-colocated-test extended a second time in lib/consent.ts (deriveConsentAction alongside the new deriveConsentGateOutcome) rather than forking into a new file — same file, same typed-input/narrow-return/no-throw discipline, one describe block per function." + - "Route Handler test pattern (app/api/geo/route.test.ts) is the first `app/api/*/route.test.ts` in this repo: import GET directly, drive it with `new NextRequest(url, { headers })`, no server start, no jsdom." + +requirements-completed: [MONY-01, OPS-01] + +coverage: + - id: D1 + description: "A non-consent-region visitor's first $pageview reaches PostHog with no __tcfapi involvement anywhere on that path — server-side geo classification (lib/geo.ts + app/api/geo/route.ts) feeds a consent-readiness gate that defers PostHogPageView's capture until the decision resolves." + requirement: "MONY-01" + verification: + - kind: unit + ref: "lib/geo.test.ts#isConsentRegionCode returns true/false/fail-closed/trim-case-normalize/Crown-dependency-exclusion (5 tests)" + status: pass + - kind: unit + ref: "app/api/geo/route.test.ts#GET header presence/absence, case, Cache-Control, no Set-Cookie, geo_header_missing log (7 tests)" + status: pass + - kind: other + ref: "grep gate: app/api/geo/route.ts is the sole reader of a Vercel geolocation header across app/ and lib/; root layout unmodified, no route rendering-mode change" + status: pass + human_judgment: true + rationale: "Whether a real browser actually sends the first $pageview end-to-end against a deployed URL is NOT verifiable by this plan's unit tests (no jsdom/RTL in this project) — that proof is 02.1-03's headless-Chrome netlog against a deployed preview (D-10), the phase's real gate for SC1." + - id: D2 + description: "EEA/UK consent semantics are byte-for-byte unchanged in the SDK calls each TCF outcome makes (opt-in-full opts in + starts replay, cookieless does neither, pending/timeout does neither), now expressed as deriveConsentGateOutcome's pure mapping instead of an inline switch." + requirement: "MONY-01" + verification: + - kind: unit + ref: "lib/consent.test.ts#deriveConsentGateOutcome — opt-in-full/cookieless/pending/opt-in-non-eea rows, null-tcfAction-on-consent-region returns null, non-consent-region ignores tcfAction, gatePath literal membership, optIn/startReplay invariant (10 tests)" + status: pass + - kind: other + ref: "grep gate: exactly one call site each for posthog.opt_in_capturing / posthog.startSessionRecording / posthog.register in app/components/PostHogProvider.tsx" + status: pass + human_judgment: false + - id: D3 + description: "Every event carries a consent_gate_path super property (geo-non-consent-region | tcf-accept | tcf-reject | tcf-timeout), registered before opt-in on both paths so the first $pageview already carries it." + requirement: "OPS-01" + verification: + - kind: unit + ref: "lib/consent.test.ts#deriveConsentGateOutcome — gatePath present and one of the four ConsentGatePath literals on every non-null outcome" + status: pass + - kind: other + ref: "code inspection: applyOutcome() in PostHogProvider.tsx calls posthog.register({ consent_gate_path }) before posthog.opt_in_capturing on every path" + status: pass + human_judgment: true + rationale: "Whether consent_gate_path actually appears on live events in PostHog project 337485 is 02.1-04's live-traffic gate (D-08), not provable from this plan's unit tests." + - id: D4 + description: "Task 2 checkpoint resolved: Gibraltar/Isle of Man/Jersey/Guernsey stay outside CONSENT_REGIONS (option a, user-selected)." + verification: + - kind: unit + ref: "lib/geo.test.ts#CONSENT_REGIONS has exactly 32 members; isConsentRegionCode excludes GI/IM/JE/GG" + status: pass + human_judgment: false + +# Metrics +duration: continuation session (Task 2 decision + Task 3 implementation) +completed: 2026-09-14 +status: complete +--- + +# Phase 2.1 Plan 01: PostHog Consent Gate Hotfix — Source Change Summary + +**Server-side geo classification opts non-EEA/UK visitors in with no `__tcfapi` dependency; a pure `deriveConsentGateOutcome` function now decides both the geo and TCF paths, with the provider reduced to a single SDK-call executor.** + +## Performance + +- **Duration:** continuation session (resumed at Task 2's decision checkpoint; Task 1 was completed in a prior session — see 3e8f472) +- **Tasks:** 3 (Task 1 completed prior to this session; Task 2 decision recorded this session; Task 3 implemented and committed this session) +- **Files modified:** 7 (4 created, 3 modified) + +## Accomplishments +- `lib/geo.ts` / `lib/geo.test.ts`: `CONSENT_REGIONS` (32 ISO codes: EU-27 + IS/LI/NO + GB + CH) and `isConsentRegionCode`, fail-closed on nullish/blank/unknown input, trimmed case-insensitive comparison. +- `app/api/geo/route.ts` / `app/api/geo/route.test.ts`: the sole Route Handler reading `x-vercel-ip-country`; returns `{ isConsentRegion }` with `Cache-Control: private, no-store`, no cookie, `dynamic = "force-dynamic"`, and a `geo_header_missing` log on Vercel when the header is absent. +- `app/components/PostHogProvider.tsx`: `posthog.init` moved to module scope (eliminates the child-effect-before-parent-effect first-`$pageview` race); a `consentReady` gate defers `PostHogPageView`'s capture until the geo/TCF decision resolves; both paths now route through a single `applyOutcome` executor built on `deriveConsentGateOutcome`. +- `lib/consent.ts` / `lib/consent.test.ts`: new `ConsentGatePath` union, `ConsentGateOutcome` interface, and `deriveConsentGateOutcome(isConsentRegion, tcfAction)` — the pure merge of the two signals into SDK calls, with 10 new tests covering every outcome row plus the gatePath-literal and optIn/startReplay invariants. All 11 pre-existing `deriveConsentAction`/`startConsentListener` tests untouched and still passing. +- Task 2 checkpoint resolved: the four ambiguous territories (GI, IM, JE, GG) stay outside `CONSENT_REGIONS` per the developer's explicit choice of option a — no code change required since Task 1 already implemented the default. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: End-to-end — a non-consent-region visitor's first $pageview actually reaches PostHog** — `3e8f472` (feat, tracer/tdd) — completed in a prior session; verified present and unmodified at the start of this continuation. +2. **Task 2: Confirm the four ambiguous territories stay outside CONSENT_REGIONS** — no commit (checkpoint:decision; user selected option a, matching the code Task 1 already shipped — no diff to commit). +3. **Task 3: One pure function decides both paths, and every event carries the path that admitted it** — `38b891a` (feat, tdd) + +**Plan metadata:** (this SUMMARY's commit, immediately following) + +## Files Created/Modified +- `lib/geo.ts` - `CONSENT_REGIONS` (32 codes) + `isConsentRegionCode`, fail-closed classifier (Task 1) +- `lib/geo.test.ts` - unit coverage for the region set, fail-closed default, case normalization, Crown-dependency exclusion (Task 1) +- `app/api/geo/route.ts` - Route Handler; sole reader of the Vercel geo header (Task 1) +- `app/api/geo/route.test.ts` - `NextRequest`-driven Route Handler tests, no server start (Task 1) +- `app/components/PostHogProvider.tsx` - module-scope init, consent-readiness gate, single `applyOutcome` executor (Task 1 + Task 3) +- `lib/consent.ts` - added `ConsentGatePath`, `ConsentGateOutcome`, `deriveConsentGateOutcome` (Task 3) +- `lib/consent.test.ts` - 10 new tests for `deriveConsentGateOutcome` (Task 3) + +## Decisions Made +- Module-scope `posthog.init` over a root `instrumentation-client.ts` (see key-decisions above for full rationale). +- Task 2: **user-selected option a** — Gibraltar (GI), Isle of Man (IM), Jersey (JE) and Guernsey (GG) stay OUT of `CONSENT_REGIONS`. Recorded verbatim from the resume instructions: *"Keep all four out (default, already implemented)."* `CONSENT_REGIONS.size` stays 32; the four exclusion assertions in `lib/geo.test.ts` are unchanged. +- Uniform `posthog.opt_in_capturing({ captureEventName: false })` across every `optIn: true` outcome (including the two TCF-accept branches, which previously called `opt_in_capturing()` with no argument) — a direct, plan-specified consequence of collapsing to one call site, not a scope-creep deviation. + +## Deviations from Plan + +None - plan executed exactly as written. Task 2's resolution required no code change because Task 1 had already implemented the chosen default (option a); this is the plan's own acceptance criterion ("no code change" was listed as a pro of option a), not a deviation. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- ROADMAP SC1 and SC2 are true in code; SC3 is half-true (property registered client-side; live-event visibility is 02.1-04's gate). +- All four D-01..D-07 consent-logic decisions have an implementing task, cited by ID in the code comments. +- Full suite green at 161 tests (baseline 137 before this plan, 151 after Task 1, 161 after Task 3); `npx tsc --noEmit` clean; scoped `npx eslint` over all seven touched files exits 0. +- Ready for 02.1-03 (preview deploy + headless-Chrome netlog proof against a real deployed URL) and 02.1-04 (prod deploy + live-traffic OPS-01 gate). +- Not yet proven: whether a real browser sends the first `$pageview` and whether `consent_gate_path` appears on live PostHog events — both are downstream plans' gates by design (D-10, D-08), not this plan's. + +## Self-Check: PASSED + +- `lib/geo.ts` exists: FOUND +- `lib/geo.test.ts` exists: FOUND +- `app/api/geo/route.ts` exists: FOUND +- `app/api/geo/route.test.ts` exists: FOUND +- `lib/consent.ts` exists: FOUND +- `lib/consent.test.ts` exists: FOUND +- `app/components/PostHogProvider.tsx` exists: FOUND +- Commit `3e8f472` present in `git log --oneline --all`: FOUND +- Commit `38b891a` present in `git log --oneline --all`: FOUND +- Re-ran plan-level ``: `npx vitest run` → 161 passed, 0 failed (≥146 required); `npx tsc --noEmit` → clean; scoped `npx eslint` over the seven files → exit 0; root-layout-untouched grep → pass; sole-geo-header-reader grep → pass; single-call-site grep (register/opt_in_capturing/startSessionRecording) → pass (1 each). + +--- +*Phase: 02.1-posthog-consent-gate-hotfix* +*Completed: 2026-09-14* diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-PLAN.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-PLAN.md new file mode 100644 index 0000000..0f9679a --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-PLAN.md @@ -0,0 +1,294 @@ +--- +phase: 02.1-posthog-consent-gate-hotfix +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - docs/OPS-01-SHIP-GATE.md + - .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md + - .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md + - .planning/REQUIREMENTS.md +autonomous: true +requirements: [OPS-01] + +estimate: + tokens: 35000 + raw_tokens: 35000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "docs/OPS-01-SHIP-GATE.md requires a post-deploy live-traffic check that must be run and pasted as evidence before any gate is signed, so no future phase can sign off on pre-deploy data (D-08, ROADMAP SC4)." + - "The live-traffic thresholds are stated as inclusive: exactly 20 $pageview events PASSES, exactly 2 distinct non-consent-region countries PASSES, and a PostHog-to-Vercel-Analytics ratio of exactly 50 percent PASSES - the doc says which side of equality passes so two readers cannot disagree (D-08)." + - "Zero $pageview events in the 60-minute window is an explicit FAIL, not a no-data row - the doc states that a phase whose events cannot be observed fails the gate and may no longer be recorded as recorded-not-assumed (D-08)." + - "The gate doc's existing ordering rule survives the edit: route rows stay ordered lexicographically by path and a re-check extends an existing row rather than adding a duplicate, so two runs of the gate remain diffable." + - "The pre-deploy PostHog step can no longer be satisfied on its own - item 4 explicitly states that an event definition existing is not evidence that any event was ingested (D-08)." + - "Phase 1 and Phase 2's verification records carry a dated, append-only addendum stating their OPS-01 PostHog criterion was not met at sign-off and was met by Phase 2.1; no existing text in either file is rewritten (D-09)." + - "REQUIREMENTS.md's OPS-01 row carries the same note, so the requirement index and the phase records agree (D-09)." + prohibitions: + - statement: "MUST NOT rewrite, reword or delete any existing sentence, table row or sign-off line in 01-VERIFICATION.md or 02-VERIFICATION.md - D-09 is append-only, and editing a past verification record to look better is exactly the dishonesty this phase exists to correct." + status: unverified + verification: test + - statement: "MUST NOT weaken or remove any existing row of the OPS-01 gate while adding the new one - the six existing steps and the three recording rules all survive the edit." + status: unverified + verification: test + - statement: "MUST NOT commit a PostHog personal API key, project token or any other credential into the repository as part of documenting the live-traffic query." + status: unverified + verification: judgment + artifacts: + - path: "docs/OPS-01-SHIP-GATE.md" + provides: "The hardened reusable gate: a mandatory post-deploy live-traffic check with exact HogQL, inclusive thresholds and a no-data-fails rule" + contains: "live-traffic" + - path: ".planning/phases/01-foundation-themes-consent/01-VERIFICATION.md" + provides: "Dated append-only addendum recording that Phase 1's OPS-01 PostHog criterion was not actually met" + contains: "Addendum" + - path: ".planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md" + provides: "Dated append-only addendum recording that Phase 2's OPS-01 PostHog criterion was not actually met" + contains: "Addendum" + - path: ".planning/REQUIREMENTS.md" + provides: "OPS-01 row annotated with the same correction so the index and the phase records agree" + contains: "OPS-01" + key_links: + - from: "docs/OPS-01-SHIP-GATE.md" + to: ".planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md" + via: "the new live-traffic item names the outage it exists to catch, so the rule is not a rule without a reason" + pattern: "2026-09-06" + - from: ".planning/REQUIREMENTS.md" + to: ".planning/phases/01-foundation-themes-consent/01-VERIFICATION.md" + via: "the OPS-01 row's note points at the two phase addenda rather than restating them" + pattern: "Phase 2.1" +--- + +## Phase Goal + +Every visitor is measured again - non-EEA/UK visitors are captured without depending on the Google +CMP, EEA/UK visitors keep the exact TCF gate, and the OPS-01 gate can never again pass on +pre-deploy data. + + +Make the gate a gate. + +The outage ran for eight days behind two signed OPS-01 gates. Both signed honestly against the +checklist as written: item 4 asked whether the event *definition* appeared in PostHog, and an event +definition created weeks earlier is still there whether or not a single event has been ingested +since. The checklist's own recording rules already say a `no-data` row is never a pass - but no row +would ever have gone `no-data`, because nothing in the gate counted events. + +This plan adds the row that counts events (D-08) and writes the honest correction into the two +verification records that signed without it (D-09). It touches no source code, so it runs in +parallel with the fix itself. + +Purpose: ROADMAP success criterion 4 - the durable half of this phase. +Output: a hardened `docs/OPS-01-SHIP-GATE.md` Part 1, and dated append-only addenda in +`01-VERIFICATION.md`, `02-VERIFICATION.md` and `REQUIREMENTS.md`. + + + +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/workflows/execute-plan.md +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-VALIDATION.md +@docs/OPS-01-SHIP-GATE.md +@./CLAUDE.md + + +**`docs/OPS-01-SHIP-GATE.md` structure as it stands (727 lines):** +- Part 1 - The reusable gate: numbered items 1 through 6 (1 local gate, 2 both-theme route sweep, + 3 SEO invariants, 4 PostHog instrumentation, 5 Search Console, 6 prod deploy is manual), then a + bolded "Recording rules (what makes this a gate, not a habit)" list of three rules. +- Part 2 - Phase 1 evidence (line ~118 onward). +- Part 3 - Phase 2 evidence (line ~322 onward), ending in "Sign-off - Phase 2 (2026-09-08)". + +Item 4 today reads, in part: "After deploy, confirm the event appears in the PostHog project's event +definitions (needs real traffic)." That clause is the hole. + +Item 6 is referenced by name from inside item 1's prose ("it stops the deploy in step 6"), so the +existing numbers 1 through 6 must not be renumbered. The new check is a *post*-deploy step, so it is +added as **item 7**, after the deploy - chronologically correct and numbering-stable. + +**The exact HogQL to record (from 02.1-VALIDATION.md, PostHog project 337485):** +- Country breakdown: `SELECT properties.$geoip_country_code AS country, count() AS pageviews FROM events WHERE event = '$pageview' AND timestamp >= now() - INTERVAL 60 MINUTE GROUP BY country ORDER BY pageviews DESC` +- Gate-path breakdown: `SELECT properties.consent_gate_path, count() FROM events WHERE timestamp >= now() - INTERVAL 60 MINUTE GROUP BY 1` + +**The bounded netlog command to record (D-10; GNU timeout and gtimeout are both absent on this +machine, `perl` is present at /usr/bin/perl):** +`perl -e 'alarm 45; exec @ARGV' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --no-first-run --user-data-dir= --virtual-time-budget=12000 --log-net-log= --dump-dom ` +then `grep -oE '"url":"[^"]*/ingest/(e|i/v0/e)/[^"]*"' ` must return at least one line. + +**PostHog MCP note:** the connector's default project is "LootList+ App", not ParseForge. Any session +running this check must `switch-project 337485` first. Fixing the connector default is a manual +follow-up outside this repo. + +**Verification-record tails (do not disturb):** `01-VERIFICATION.md` ends with `_Verified: 2026-09-06_`, +a `## UAT Result (2026-09-07)` section and a `## Acknowledged Gaps` section. `02-VERIFICATION.md` ends +with a `### Gaps Summary` section and `_Verified: 2026-09-08_`. + +**REQUIREMENTS.md:** OPS-01 appears twice - as a checklist line (~line 52) carrying an italic note +about the Phase 1 closure, and as a row in the requirement-to-phase table (~line 112). + + + + + + + Task 1: Add the mandatory post-deploy live-traffic check to the reusable gate + docs/OPS-01-SHIP-GATE.md + + - `docs/OPS-01-SHIP-GATE.md` lines 1 to 116 in full - Part 1's six items and the three recording rules, including the exact prose conventions to match + - `docs/OPS-01-SHIP-GATE.md` around its two sign-off sections - the shape an evidence section takes, so the new item tells a future reader what to paste and where + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md` - the event-volume cliff table, the concrete failure this item exists to catch + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-VALIDATION.md` - the Manual-Only Verifications table, source of the exact queries and the netlog command + + +Edit Part 1 only. Leave Parts 2 and 3 untouched - they are historical evidence, and Task 2 is where the correction to them goes. + +**Amend item 4 (PostHog instrumentation).** Keep its existing pre-deploy grep requirement exactly as it is. Replace only the clause about confirming the event appears in the project's event definitions with wording that says plainly what that clause failed to say: an event definition persists once created, so its presence is evidence that an event was ingested at some point in the past, not evidence that anything is being ingested now. Point the reader at the new item 7 as the step that actually counts events, and state that item 4 alone can no longer close the PostHog half of this gate (D-08). + +**Add item 7, titled so that the phrase live-traffic appears in the heading.** Its content, all mandatory: + +- *When:* run it against production, within 60 minutes of the prod deploy from item 6, querying the 60-minute window that follows that deploy. If fewer than 60 minutes have elapsed, wait - a partial window is never recorded as a pass. +- *Where:* PostHog project 337485. State the MCP connector caveat: the connector's default project is the wrong one, so `switch-project 337485` runs first in any session. +- *The three thresholds, each stated with the side of equality that passes:* at least 20 `$pageview` events in the window, where exactly 20 passes; from at least 2 distinct non-consent-region `$geoip_country_code` values, where exactly 2 passes; and a PostHog `$pageview` count at least 50 percent of Vercel Web Analytics page views for the same window, where exactly 50 percent passes. Explain in one line why Vercel Web Analytics is the reference signal: it is already mounted in `app/layout.tsx`, it is wholly independent of PostHog and of the consent gate, and comparing the two would have exposed this outage on its first day. +- *The exact HogQL,* both queries, in fenced blocks, copied verbatim from this plan's interfaces block. +- *The failure rule, stated as strongly as the existing test-suite rule in item 1:* zero `$pageview` events in the window is a FAIL, not a `no-data` row. A phase whose events cannot be observed fails this gate and may not be signed off as recorded-not-assumed. There is no override and no known-quiet-period allowance - if traffic is genuinely too low to reach 20 in an hour, widen the window and say so in the evidence rather than lowering the bar silently. +- *The browser-level proof,* as a sub-item: the bounded headless-Chrome netlog command from the interfaces block, noting that the alarm-based bound is used because GNU `timeout` and `gtimeout` are both absent on this machine and the bare Chrome command does not exit on its own. State the pass condition - at least one line matching the ingest-capture URL pattern - and the fresh-profile requirement, since a reused profile may carry an opt-in cookie and would prove nothing about a first-time visitor. +- *What to paste as evidence:* the query output verbatim (country rows and gate-path rows), the Vercel Analytics figure it was compared against, and the netlog grep output. + +**Extend the recording rules** with a fourth bullet in the same voice as the existing three: a gate row about ingested events is passing only when it carries counted events, and an absent count is a FAIL for that row, never a `no-data`. Leave the three existing bullets - including the lexicographic route-row ordering rule and the extend-don't-duplicate rule - exactly as written, so two runs of the gate stay diffable. + +Do not renumber items 1 through 6. Do not delete any existing item, sentence or rule. + + + - Part 1 contains a numbered item 7 whose heading contains the string `live-traffic`. + - Item 7 contains both HogQL queries verbatim, each including the substring `INTERVAL 60 MINUTE`. + - Item 7 states all three thresholds with the passing side of equality explicit: at least 20 `$pageview`, at least 2 distinct countries, at least 50 percent of Vercel Web Analytics. + - Item 7 states that zero events in the window is a FAIL and not a `no-data` row. + - Item 7 contains the bounded netlog command including the substrings `alarm 45`, `--headless=new`, `--virtual-time-budget=12000` and `--log-net-log`. + - Item 7 names PostHog project `337485` and the `switch-project` caveat. + - Item 4 still carries its pre-deploy grep requirement and now also states that an event definition is not evidence of current ingestion. + - Items 1 through 6 keep their existing numbers, so item 1's internal reference to step 6 is still correct. + - The Recording rules list has four bullets, the original three unchanged. + - No line is removed from Part 2 or Part 3. + + + cd /Users/alexander.mayes/Code/personal/parseforge && for S in "live-traffic" "INTERVAL 60 MINUTE" "337485" "alarm 45" "--virtual-time-budget=12000" "--log-net-log"; do N=$(grep -cF -- "$S" docs/OPS-01-SHIP-GATE.md); echo "[$S]=$N"; [ "$N" -ge 1 ] || { echo "GATE-FAIL: missing $S"; exit 1; }; done; echo gate-doc-strings-ok + the line gate-doc-strings-ok is absent, or a line beginning "GATE-FAIL" names a missing string, or any printed count is 0 + cd /Users/alexander.mayes/Code/personal/parseforge && for S in "Both-theme route sweep" "SEO invariants" "Search Console" "Prod deploy is manual" "Recording rules"; do grep -qF -- "$S" docs/OPS-01-SHIP-GATE.md || { echo "GATE-FAIL: pre-existing gate step lost: $S"; exit 1; }; done; echo gate-steps-intact-ok + the line gate-steps-intact-ok is absent, or a line beginning "GATE-FAIL" names a lost step + cd /Users/alexander.mayes/Code/personal/parseforge && REMOVED=$(git diff -U0 -- docs/OPS-01-SHIP-GATE.md | grep -cE '^-[^-]'); echo "removed-lines=$REMOVED"; git diff -U0 -- docs/OPS-01-SHIP-GATE.md | grep -E '^-[^-]' | head -20; [ "$REMOVED" -le 2 ] || { echo "GATE-FAIL: this edit is additive plus at most the two reworded clauses in item 4"; exit 1; }; echo gate-doc-additive-ok + the line gate-doc-additive-ok is absent, or removed-lines is greater than 2, or the printed removed lines include anything from Part 2 or Part 3 + + Item 7 exists with both queries, all three inclusive thresholds, the zero-events-is-a-FAIL rule and the bounded netlog command; item 4 no longer implies an event definition is proof of ingestion; items 1 to 6 and all three original recording rules are intact; the diff removes at most the two reworded clauses. + + + + Task 2: Write the honest addenda into the Phase 1 and Phase 2 records + .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md, .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md, .planning/REQUIREMENTS.md + + - `.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md` - the last 40 lines, to append after the existing UAT Result and Acknowledged Gaps sections without disturbing them + - `.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md` - the last 40 lines, especially the Gaps Summary that already routed criterion 5's PostHog half to human verification + - `.planning/REQUIREMENTS.md` - the OPS-01 checklist line and the OPS-01 row in the requirement-to-phase table + - `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md` - the dated evidence each addendum cites + + +Append only. Nothing already in these three files is reworded, and no sign-off line is touched (D-09). + +**`01-VERIFICATION.md`.** Append a new section headed as an addendum dated 2026-09-14. State, in the report's own plain register: Phase 1's OPS-01 PostHog criterion was recorded as met on 2026-09-06 on the strength of event definitions being present in project 337485, but no Phase 1 event was ever ingested. The Phase 1 production deploy of 2026-09-06 introduced `cookieless_mode: "on_reject"`, which in posthog-js 1.360 drops every capture while consent is PENDING, and the opt-in depended on a `__tcfapi` callback Google's CMP never fires for a fresh visitor. Give the volume figures from the diagnosis: roughly 4,000 to 10,000 events per day before, 3 to 7 per day after, and the six Phase 1 and Phase 2 custom events never ingested at all. Say what was actually wrong with the sign-off - the gate asked the wrong question, not that the verifier answered dishonestly - and state that the criterion was met for real by Phase 2.1, pointing at this phase's directory and at the new item 7 in the gate doc. Do not mark any existing row differently; the addendum is the correction. + +**`02-VERIFICATION.md`.** Append the equivalent addendum, same date and structure, with the Phase-2-specific facts: criterion 5's PostHog half was already routed to human verification as an outstanding follow-up rather than passed, so this addendum records that the follow-up could not have succeeded - `timeline_viewed`, `timeline_filter_used` and `timeline_error` were never ingested, for the same reason. Note that the code-side single-call-site evidence in that report remains correct and is not retracted; only the ingestion claim is corrected. + +**`REQUIREMENTS.md`.** Extend the OPS-01 checklist line's existing italic note - do not replace it - with a sentence recording that the Phase 1 and Phase 2 PostHog halves were not actually met at sign-off and were met by Phase 2.1 on the date this phase's live-traffic gate passes, and that the gate now requires a post-deploy live-traffic check. Update the OPS-01 row in the requirement-to-phase table the same way, keeping its existing text. Leave MONY-01's rows alone: that requirement is preserved unchanged by this phase, not corrected. + + + - Both verification files contain a new section whose heading includes the word `Addendum` and the date `2026-09-14`. + - `01-VERIFICATION.md` still contains the exact line `_Verified: 2026-09-06_` and its `## UAT Result (2026-09-07)` and `## Acknowledged Gaps` sections. + - `02-VERIFICATION.md` still contains the exact line `_Verified: 2026-09-08_` and its `### Gaps Summary` section. + - `git diff` for both verification files shows added lines only - zero removed lines. + - Each addendum names the mechanism (`cookieless_mode`, PENDING, `__tcfapi`), cites the 2026-09-06 deploy date, and states that Phase 2.1 is where the criterion was met. + - `REQUIREMENTS.md`'s OPS-01 checklist line and its table row both mention Phase 2.1; the pre-existing Phase 1 closure text is still present in the checklist line. + - No MONY-01 line in `REQUIREMENTS.md` is modified. + + + cd /Users/alexander.mayes/Code/personal/parseforge && for F in .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md; do R=$(git diff -U0 -- "$F" | grep -cE '^-[^-]'); echo "$F removed=$R"; [ "$R" -eq 0 ] || { echo "GATE-FAIL: $F is append-only but lost $R line(s)"; exit 1; }; done; echo addenda-append-only-ok + the line addenda-append-only-ok is absent, or any file reports a removed count above 0, or a line beginning "GATE-FAIL" is present + cd /Users/alexander.mayes/Code/personal/parseforge && grep -qF '_Verified: 2026-09-06_' .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md && grep -qF '_Verified: 2026-09-08_' .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md && grep -qF 'Addendum' .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md && grep -qF 'Addendum' .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md && grep -qF '2026-09-14' .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md && grep -qF '2026-09-14' .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md && echo addenda-content-ok + the line addenda-content-ok is absent, meaning a sign-off line was lost or an addendum heading or its date is missing from either file + cd /Users/alexander.mayes/Code/personal/parseforge && N=$(grep -c 'Phase 2.1' .planning/REQUIREMENTS.md); echo "requirements-phase-2.1-mentions=$N"; [ "$N" -ge 2 ] || { echo "GATE-FAIL: expected the OPS-01 checklist line and its table row to both cite Phase 2.1"; exit 1; }; grep -qF 'Phase 1 gate closed 2026-09-06' .planning/REQUIREMENTS.md || { echo "GATE-FAIL: pre-existing OPS-01 closure text was replaced instead of extended"; exit 1; }; echo requirements-note-ok + the line requirements-note-ok is absent, or the mention count is below 2, or a line beginning "GATE-FAIL" is present + + Both verification records carry a dated append-only addendum naming the mechanism and pointing at Phase 2.1, every pre-existing line in both files survives, and REQUIREMENTS.md's OPS-01 entries carry the same correction while keeping their original text. + + + + +## Artifacts this phase produces + +Created or amended by this plan: + +| Symbol / artifact | File | What it is | +|---|---|---| +| Part 1 item 7, heading contains `live-traffic` | `docs/OPS-01-SHIP-GATE.md` | The mandatory post-deploy check: two HogQL queries, three inclusive thresholds, the zero-events-is-a-FAIL rule, the bounded netlog command | +| Amended Part 1 item 4 | `docs/OPS-01-SHIP-GATE.md` | States that an event definition is not evidence of current ingestion | +| Fourth recording rule | `docs/OPS-01-SHIP-GATE.md` | An absent event count is a FAIL for that row, never a `no-data` | +| `Addendum (2026-09-14)` section | `.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md` | Phase 1's OPS-01 PostHog criterion was not met at sign-off | +| `Addendum (2026-09-14)` section | `.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md` | Phase 2's equivalent, with the code-side evidence explicitly not retracted | +| Extended OPS-01 note | `.planning/REQUIREMENTS.md` | Checklist line and table row both cite Phase 2.1 | + +Produced by sibling plans in this phase: `lib/geo.ts`, `lib/geo.test.ts`, `app/api/geo/route.ts`, +`app/api/geo/route.test.ts`, `CONSENT_REGIONS`, `isConsentRegionCode`, `deriveConsentGateOutcome`, +`ConsentGatePath`, the `consent_gate_path` super property and the `geo_header_missing` log event +(02.1-01); the `Part 4 - Phase 2.1 evidence` section of the gate doc (02.1-03 and 02.1-04). + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| repository -> PostHog API | The documented live-traffic query is run by a human or an MCP session against an external analytics service holding visitor data. | +| planning record -> future reader | A verification document is the only durable account of what was actually true at a sign-off; a reader trusts it without re-deriving it. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-02.1-07 | Repudiation | `docs/OPS-01-SHIP-GATE.md` Part 1 | high | mitigate | A gate that can be signed on unobservable data lets a real outage pass twice - which is exactly what happened. Mitigated by item 7's counted-events requirement, the explicit zero-events-is-a-FAIL rule, and the fourth recording rule making an absent count a failure rather than a `no-data` row. | +| T-02.1-08 | Tampering | `01-VERIFICATION.md`, `02-VERIFICATION.md` | medium | mitigate | Silently rewriting a past verification record to match what is now known destroys the audit trail and hides that the gate was wrong. Mitigated by D-09's append-only rule, enforced by the zero-removed-lines diff assertion in Task 2's verify block. | +| T-02.1-09 | Information Disclosure | live-traffic check tooling | medium | mitigate | A committed `scripts/posthog-live-check.mjs` would need a PostHog personal API key on whatever machine runs the gate, adding a credential surface to a hotfix. Mitigated by the discretion call recorded in `COVERAGE.md`: the check ships as a documented HogQL query run through the PostHog MCP, with no script and no key in the repo. `.env*` stays gitignored and no token is pasted into evidence. | +| T-02.1-10 | Information Disclosure | pasted gate evidence | low | mitigate | Query output pasted into the gate doc could carry more than counts. Mitigated by specifying exactly what to paste: country codes with counts, gate-path values with counts, and the netlog grep output - aggregates only, no per-person rows, no IP addresses, no URLs containing report codes tied to a visitor. | +| T-02.1-11 | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages and touches no source file. | + + + +- `docs/OPS-01-SHIP-GATE.md` contains item 7 with both queries, all three inclusive thresholds, the FAIL rule and the bounded netlog command. +- All six pre-existing gate steps and all three original recording rules survive; at most two lines are removed across the whole doc. +- Both verification files gained an addendum and lost zero lines. +- `REQUIREMENTS.md` cites Phase 2.1 in both OPS-01 entries while keeping its Phase 1 closure text. + +Not verifiable here, by design: whether the new item 7 actually catches a future outage. That is +proven once, for real, by 02.1-04 running it against live production traffic. + + + +- ROADMAP SC4's first half is true: the gate document now requires a post-deploy live-traffic check, with thresholds a reader cannot interpret two ways and a no-data rule that fails rather than shrugs. +- ROADMAP SC4's second half - Phase 1 and Phase 2 carrying an addendum - is true. +- D-08 and D-09 each have a task implementing them, with the decision ID cited in the action. + + + +Create `.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-SUMMARY.md` when done. + +Record in it: the final wording chosen for the zero-events FAIL rule; whether item 4's rewording +removed any line (and which); and the decision, already taken in `COVERAGE.md`, not to add +`scripts/posthog-live-check.mjs` - so a later phase that wants the script knows it was considered +and why it was declined. + + diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-SUMMARY.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-SUMMARY.md new file mode 100644 index 0000000..20128fa --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-02-SUMMARY.md @@ -0,0 +1,140 @@ +--- +phase: 02.1-posthog-consent-gate-hotfix +plan: 02 +subsystem: ops +tags: [posthog, hogql, ops-01, ship-gate, documentation, verification-addendum] + +# Dependency graph +requires: [] +provides: + - "docs/OPS-01-SHIP-GATE.md Part 1 item 7 — mandatory post-deploy live-traffic check (D-08)" + - "Reworded Part 1 item 4 — an event definition is no longer accepted as proof of ingestion" + - "Fourth recording rule — an absent event count is a FAIL, never a no-data row" + - "Dated append-only addenda in 01-VERIFICATION.md and 02-VERIFICATION.md (D-09)" + - "REQUIREMENTS.md OPS-01 entries extended to cite the Phase 2.1 correction" +affects: [02.1-03, 02.1-04, phase-3-and-later-ops-01-gate-runs] + +# Actuals (#2632) +actuals: + tokens: 3487 + tasks: 2 + commits: 2 + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Ship-gate hardening: a checklist item that only confirms configuration/definition existence is insufficient for a live-data claim — the gate must count events in a bounded post-deploy window with inclusive, unambiguous thresholds and an explicit zero-is-FAIL rule." + - "Append-only correction addenda on past verification records (dated, sourced, citing the correcting phase) rather than editing history — same discipline REQUIREMENTS.md and STATE.md already use for evolving status." + +key-files: + created: [] + modified: + - docs/OPS-01-SHIP-GATE.md + - .planning/phases/01-foundation-themes-consent/01-VERIFICATION.md + - .planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md + - .planning/REQUIREMENTS.md + +key-decisions: + - "Zero-events FAIL rule worded as: 'zero $pageview events in the window is a FAIL, not a no-data row. A phase whose events cannot be observed fails this gate and may not be signed off as recorded-not-assumed. There is no override and no known-quiet-period allowance — if traffic is genuinely too low to reach 20 in an hour, widen the window and say so explicitly in the evidence rather than lowering the bar silently.' Mirrored as the doc's fourth recording rule: 'A gate row about ingested events is passing only when it carries counted events — an absent count is a FAIL for that row, never a no-data.'" + - "Item 4's rewording removed exactly 2 lines (the diff-additive budget the plan set) — the two lines that said 'After deploy, confirm the event appears in the PostHog project's event definitions (needs real traffic). Evidence: the grep count pre-deploy, the event-definition screenshot/listing post-deploy.' Everything before that (the pre-deploy grep requirement) was left untouched; everything after is new text explaining why an event definition is not ingestion proof and pointing at item 7." + - "Declined scripts/posthog-live-check.mjs, per COVERAGE.md's discretion call already made at planning time: a committed script would need a PostHog personal API key on whatever machine runs the gate, adding a credential surface to a hotfix. The check ships as a documented HogQL query run through the PostHog MCP (switch-project 337485 first) instead. A later phase that wants the script has this reasoning on record; nothing here blocks adding it if the calculus changes (e.g. CI wants to run the gate unattended)." + +patterns-established: + - "Post-deploy live-traffic gate item shape: When/Where/Thresholds(inclusive, side-of-equality stated)/exact query/FAIL rule/browser-level proof/evidence-to-paste — reusable for any future phase that needs to prove a live-data claim rather than a config-presence claim." + +requirements-completed: [OPS-01] + +coverage: + - id: D1 + description: "docs/OPS-01-SHIP-GATE.md Part 1 gains a mandatory post-deploy live-traffic check (item 7): both exact HogQL queries, three inclusive thresholds with the passing side of equality stated, the zero-events-is-a-FAIL rule, and the bounded headless-Chrome netlog command — while items 1-6 and the three original recording rules survive untouched and item 4 no longer implies an event definition proves ingestion." + requirement: "OPS-01" + verification: + - kind: other + ref: "grep-based source assertion: live-traffic, INTERVAL 60 MINUTE (x2), 337485, alarm 45, --virtual-time-budget=12000, --log-net-log all present; Both-theme route sweep / SEO invariants / Search Console / Prod deploy is manual / Recording rules all still present; git diff -U0 shows exactly 2 removed lines, both inside Part 1's item 4" + status: pass + human_judgment: false + - id: D2 + description: "01-VERIFICATION.md and 02-VERIFICATION.md each carry a dated, append-only addendum (2026-09-14) stating their OPS-01 PostHog criterion was not met at sign-off and was met by Phase 2.1, naming the cookieless_mode/PENDING/__tcfapi mechanism and the 2026-09-06 deploy date — with zero pre-existing lines removed from either file, and REQUIREMENTS.md's OPS-01 checklist line and table row both extended (not replaced) to cite the same correction." + requirement: "OPS-01" + verification: + - kind: other + ref: "grep-based source assertion: git diff -U0 shows 0 removed lines in both verification files; both files retain their exact pre-existing sign-off lines (_Verified: 2026-09-06_ / _Verified: 2026-09-08_) and sections (## UAT Result, ## Acknowledged Gaps, ### Gaps Summary); each addendum contains 'Addendum' + '2026-09-14'; REQUIREMENTS.md contains 2 'Phase 2.1' mentions and retains the original 'Phase 1 gate closed 2026-09-06' text; MONY-01 lines confirmed unchanged in the diff" + status: pass + human_judgment: false + +duration: 25min +completed: 2026-09-14 +status: complete +--- + +# Phase 02.1 Plan 02: OPS-01 Ship Gate Hardening + Honest Retro Addenda Summary + +**Added a mandatory post-deploy live-traffic check (HogQL thresholds, zero-events-is-a-FAIL rule, bounded netlog proof) to the OPS-01 gate, and appended dated, append-only addenda correcting the Phase 1 and Phase 2 sign-offs that passed on pre-deploy PostHog data.** + +## Performance + +- **Duration:** 25 min +- **Started:** 2026-09-14T21:05:00Z (approx.) +- **Completed:** 2026-09-14T21:30:00Z (approx.) +- **Tasks:** 2 +- **Files modified:** 4 + +## Accomplishments + +- `docs/OPS-01-SHIP-GATE.md` Part 1 now has a numbered item 7 (heading contains "live-traffic") requiring, within 60 minutes of every prod deploy: ≥20 `$pageview` events, from ≥2 distinct non-consent-region countries, at ≥50% of the same-window Vercel Web Analytics page-view count — every threshold stated with the passing side of equality explicit — plus the exact HogQL for both the country and gate-path breakdowns, the alarm-bounded headless-Chrome netlog command, and an explicit "zero events is a FAIL, not a no-data row" rule with no override. +- Item 4 (PostHog instrumentation) still requires the pre-deploy grep, but no longer treats an event definition's presence as post-deploy proof — it now says plainly that a definition persists once created and points to item 7 as the only thing that actually proves current ingestion. +- The gate's three original recording rules are untouched; a fourth was added stating an absent event count is a FAIL for that row, never a `no-data`. +- `01-VERIFICATION.md` and `02-VERIFICATION.md` each gained a dated (2026-09-14), append-only "Addendum" section naming the `cookieless_mode: "on_reject"` / `PENDING` / `__tcfapi` mechanism, citing the 2026-09-06 deploy and the diagnosis's volume figures, and stating the criterion was actually met by Phase 2.1 — with every pre-existing line, including both sign-off dates and all named sections, still present and unmodified. +- `REQUIREMENTS.md`'s OPS-01 checklist line and its traceability-table row were both extended (not replaced) to point at the same correction; `MONY-01` was left untouched. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add the mandatory post-deploy live-traffic check to the reusable gate** - `683a14a` (docs) +2. **Task 2: Write the honest addenda into the Phase 1 and Phase 2 records** - `d1070e6` (docs) + +**Plan metadata:** committed alongside this SUMMARY (see final commit below) + +## Files Created/Modified + +- `docs/OPS-01-SHIP-GATE.md` - Part 1 item 7 (live-traffic check) added; item 4 reworded; fourth recording rule added +- `.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md` - append-only `## Addendum (2026-09-14)` section +- `.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md` - append-only `## Addendum (2026-09-14)` section +- `.planning/REQUIREMENTS.md` - OPS-01 checklist line and table row extended with the Phase 2.1 correction + +## Decisions Made + +- **Zero-events FAIL wording:** "zero `$pageview` events in the window is a FAIL, not a `no-data` row. A phase whose events cannot be observed fails this gate and may not be signed off as recorded-not-assumed. There is no override and no known-quiet-period allowance — if traffic is genuinely too low to reach 20 in an hour, widen the window and say so explicitly in the evidence rather than lowering the bar silently." Mirrored in the fourth recording rule: "A gate row about ingested events is passing only when it carries counted events — an absent count is a FAIL for that row, never a `no-data`." +- **Item 4's rewording removed exactly 2 lines** (the plan's additive-diff budget): the two lines reading "After deploy, confirm the event appears in the PostHog project's event definitions (needs real traffic). Evidence: the grep count pre-deploy, the event-definition screenshot/listing post-deploy." were replaced with new text explaining why a definition's presence proves past-not-current ingestion and pointing at item 7. The pre-deploy grep sentence immediately before it was left byte-for-byte unchanged. +- **`scripts/posthog-live-check.mjs` was not added**, per the discretion call already recorded in this phase's `COVERAGE.md`: a committed script would need a PostHog personal API key present on whatever machine runs the gate, adding a credential-handling surface to a hotfix whose whole point is to stay small. The live-traffic check instead ships as an exact, documented HogQL query run through the PostHog MCP (`switch-project 337485` first). A later phase that wants the script has this reasoning on record. + +## Deviations from Plan + +None - plan executed exactly as written. Both tasks' acceptance criteria and automated `` blocks passed on the first attempt; no Rule 1-4 deviations were needed. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. This plan is documentation-only. + +## Next Phase Readiness + +- The hardened gate (item 7) is ready to be exercised for real by sibling plan 02.1-04, which runs it against live production traffic after the prod deploy — that is the only thing this plan explicitly could not verify (a future outage being caught), by design (see this plan's `` block). +- Phase 1 and Phase 2's verification records now honestly reflect what was actually true about their PostHog criteria, closing the "recorded, not assumed" gap this phase exists to fix (D-09). +- No blockers for 02.1-03 / 02.1-04 — this plan touched no source code and has no file overlap with the sibling plans in this wave. + +## Self-Check: PASSED + +- `docs/OPS-01-SHIP-GATE.md` exists and contains item 7, the reworded item 4, and the fourth recording rule — confirmed via the task's own `` grep assertions (all passed, see commit `683a14a`). +- `.planning/phases/01-foundation-themes-consent/01-VERIFICATION.md` and `.planning/phases/02-accuracy-analysis-depth/02-VERIFICATION.md` each contain an `## Addendum (2026-09-14)` section and retain every pre-existing line (0 removed, confirmed via `git diff -U0`). +- `.planning/REQUIREMENTS.md` contains 2 "Phase 2.1" mentions and retains "Phase 1 gate closed 2026-09-06" — confirmed via grep. +- Commit hashes `683a14a` and `d1070e6` both found in `git log --oneline`. + +--- +*Phase: 02.1-posthog-consent-gate-hotfix* +*Completed: 2026-09-14* diff --git a/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-03-PLAN.md b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-03-PLAN.md new file mode 100644 index 0000000..9a1dd0d --- /dev/null +++ b/.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-03-PLAN.md @@ -0,0 +1,373 @@ +--- +phase: 02.1-posthog-consent-gate-hotfix +plan: 03 +type: execute +wave: 2 +depends_on: [02.1-01, 02.1-02] +files_modified: + - docs/OPS-01-SHIP-GATE.md +autonomous: false +requirements: [OPS-01, MONY-01] + +estimate: + tokens: 40000 + raw_tokens: 40000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "A fresh headless-Chrome profile loading the preview URL from a non-consent-region IP produces at least one request to an /ingest/e/ or /ingest/i/v0/e/ capture endpoint within the bounded run - where today's production produces none (D-10, ROADMAP SC1)." + - "The preview deployment's /api/geo response is read directly and recorded verbatim, which is what settles RESEARCH Open Question 1 - whether a preview deployment receives the same geo request headers as production is observed, never assumed." + - "The local gate from docs/OPS-01-SHIP-GATE.md items 1 to 3 is green before any deploy is proposed: typecheck, scoped lint, the full test suite, theme-parity, token-audit and seo-invariants (OPS-01)." + - "Every Vercel CLI invocation in this plan carries `--global-config ~/.vercel-personal`, and the personal login's access to team `loot-list-plus` is re-confirmed before any deploy is attempted; a failed deploy is never retried in a loop (D-11, CLAUDE.md)." + - "Preview ships before production, and this plan stops at the preview - the production deploy is a separate approval in 02.1-04 (D-11, CLAUDE.md)." + - "The preview sits behind Vercel SSO, so the D-10 netlog and the /api/geo read reach it through the developer-approved Protection Bypass for Automation secret, which is read from the scratchpad file into a shell variable and redacted out of every recorded line - the secret is never printed, pasted or committed." + - "The preview proves the non-consent-region path only: NEXT_PUBLIC_GOOGLE_CMP_PUB_ID is a Production-only environment variable, so no Google CMP script renders on a preview. The recorded evidence says so explicitly rather than implying the preview exercised the CMP." + - "If the bypass secret file is absent or the preview still answers 401 or 302 with the bypass applied, that is recorded verbatim as blocked and the D-10 proof moves to production - it is never recorded as a pass and never silently skipped." + prohibitions: + - statement: "MUST NOT run a production deploy in this plan - no invocation carrying the production flag, under any circumstance, including a green preview and a developer who already approved something else." + status: unverified + verification: judgment + - statement: "MUST NOT reuse an existing Chrome profile for the netlog run - a profile carrying a pre-existing PostHog opt-in cookie would produce a capture request that proves nothing about a first-time visitor." + status: unverified + verification: test + - statement: "MUST NOT print, paste or commit any Vercel token, PostHog personal API key or deployment protection bypass secret into the repository or into the recorded evidence." + status: unverified + verification: judgment + artifacts: + - path: "docs/OPS-01-SHIP-GATE.md" + provides: "Part 4 - Phase 2.1 evidence, preview half: local gate output, the preview deployment id, the /api/geo response and the netlog result" + contains: "Part 4" + key_links: + - from: "docs/OPS-01-SHIP-GATE.md" + to: "app/api/geo/route.ts" + via: "the recorded /api/geo response body is the first real-world confirmation that the Route Handler sees a geo header at all" + pattern: "isConsentRegion" + - from: "docs/OPS-01-SHIP-GATE.md" + to: "app/components/PostHogProvider.tsx" + via: "the netlog capture-request line is the end-to-end proof that the consent-ready gate releases the first pageview" + pattern: "ingest" +--- + +## Phase Goal + +Every visitor is measured again - non-EEA/UK visitors are captured without depending on the Google +CMP, EEA/UK visitors keep the exact TCF gate, and the OPS-01 gate can never again pass on +pre-deploy data. + + +Prove the fix in a real browser against a real deployment, before production. + +The bug was found by a netlog, not by reading code, and the fix is confirmed the same way (D-10). +Unit tests can show that `isConsentRegionCode("US")` is false and that the outcome table is right; +only a browser hitting a deployed URL can show that the first `$pageview` actually leaves the page. +This plan is the phase's real gate - everything before it is a hypothesis. + +It also settles the one genuinely undocumented thing in RESEARCH: whether a preview deployment +receives `x-vercel-ip-country` the way production does. That is observed here by reading the +deployment's own `/api/geo` response, not inferred from a docs page that is silent on it. + +Purpose: turn the phase's tracer slice from "green in tests" into "observed in a browser". +Output: a preview deployment, a recorded netlog capture line, and the preview half of Part 4 in the +OPS-01 gate document. + + + +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/workflows/execute-plan.md +@/Users/alexander.mayes/Code/personal/parseforge/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-CONTEXT.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-DIAGNOSIS.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-RESEARCH.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-VALIDATION.md +@.planning/phases/02.1-posthog-consent-gate-hotfix/02.1-01-SUMMARY.md +@.planning/phases/02-accuracy-analysis-depth/02-09-SUMMARY.md +@docs/OPS-01-SHIP-GATE.md +@./CLAUDE.md + + +**Deploy commands (CLAUDE.md).** Preview: +`vercel --global-config ~/.vercel-personal deploy --scope loot-list-plus --yes`. Production is the +same command plus the production flag - it does not belong in this plan at all. +Project `parseforge`, team `loot-list-plus`, aliased to parseforge.gg. + +**`--global-config ~/.vercel-personal` is mandatory on every `vercel` invocation (CLAUDE.md, confirmed +2026-09-14).** This machine's *default* Vercel login is the work account, whose teams do not include +`loot-list-plus`; without the flag every command fails with "The specified scope does not exist". The +personal login that owns this project lives in `~/.vercel-personal` and is already authenticated - +`vercel --global-config ~/.vercel-personal whoami` returns `alexandermayes`, `teams ls` lists +`loot-list-plus`, and `project ls --scope loot-list-plus` shows `parseforge`. The flag goes before the +subcommand, and applies to `deploy`, `whoami`, `teams ls`, `project ls`, `inspect` and `rollback` +alike. Phase 2's 02-09 also hit a harness classifier that denied `vercel deploy` until +`Bash(vercel deploy:*)` was granted in `.claude/settings.local.json`; that grant already exists. + +**Preview deployments are behind Vercel SSO, and the bypass is approved (confirmed 2026-09-14).** An +unauthenticated request to a preview URL answers `302` to `https://vercel.com/sso-api?...`. The +developer has enabled **Protection Bypass for Automation** for this project and explicitly approved +its use for this verification. The secret is available to the executor only at +`/bypass.env`, on a line keyed `VERCEL_AUTOMATION_BYPASS_SECRET` - it is not obtainable +through `vercel env pull`. Read it into a shell variable; never echo it. Two transports: +- `curl`: send it as the request header `x-vercel-protection-bypass`. +- headless Chrome (cannot set request headers): append the query parameters + `x-vercel-protection-bypass=` and `x-vercel-set-bypass-cookie=true` to the preview URL; + Vercel sets a bypass cookie on the first hit and the rest of the page load inherits it. +Because the query-parameter form puts the secret in a request URL, the netlog file itself will +contain it - redact it with `sed "s/$SECRET//g"` before any excerpt is pasted anywhere, and +keep the netlog in the scratchpad, never in the repository. + +**Preview environment variables are not production's (confirmed 2026-09-14).** +`NEXT_PUBLIC_POSTHOG_KEY` exists in Preview, but `NEXT_PUBLIC_GOOGLE_CMP_PUB_ID` and +`NEXT_PUBLIC_POSTHOG_HOST` are **Production-only**. A preview therefore loads PostHog but renders no +Google CMP ` Night" }); + + it("puts the fight name in title, sanitized report title in author.name, and brand gold in color", () => { + const embed = buildAnnouncementEmbed({ code: "ABC123XYZ0", report, fight: killFight }); + expect(embed.title).toBe("Gruul the Dragonkiller"); + expect(embed.author.name).toBe("T's Night"); + expect(embed.color).toBe(ANNOUNCE_EMBED_COLOR); + }); + + it("footer reads ' · Kill' for a kill and ' · Wipe' for a wipe", () => { + const killEmbed = buildAnnouncementEmbed({ code: "ABC123XYZ0", report, fight: killFight }); + expect(killEmbed.footer.text).toBe("Gruul's Lair · Kill"); + + const wipeEmbed = buildAnnouncementEmbed({ + code: "ABC123XYZ0", + report: makeReport([wipeFight]), + fight: wipeFight, + }); + expect(wipeEmbed.footer.text).toBe("Gruul's Lair · Wipe"); + }); + + it("falls back to 'Unknown zone' when the report carries no zone name", () => { + const noZoneReport = makeReport([killFight], { zone: undefined as unknown as WCLReportData["zone"] }); + const embed = buildAnnouncementEmbed({ code: "ABC123XYZ0", report: noZoneReport, fight: killFight }); + expect(embed.footer.text).toBe("Unknown zone · Kill"); + }); + + it("url ends with ref=discord and image.url contains view=awards, neither carrying source or tab", () => { + const embed = buildAnnouncementEmbed({ code: "ABC123XYZ0", report, fight: killFight }); + expect(embed.url).toMatch(/ref=discord$/); + expect(embed.image.url).toContain("view=awards"); + expect(embed.url).not.toContain("source="); + expect(embed.url).not.toContain("tab="); + expect(embed.image.url).not.toContain("source="); + expect(embed.image.url).not.toContain("tab="); + }); +}); + +describe("discordAnnounceEnabled", () => { + const ORIGINAL_WEBHOOK = process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL; + + afterEach(() => { + process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL = ORIGINAL_WEBHOOK; + kvCacheMock.usingSharedCache = true; + }); + + it("is false when the webhook env var is unset", () => { + delete process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL; + kvCacheMock.usingSharedCache = true; + expect(discordAnnounceEnabled()).toBe(false); + }); + + it("is false when the webhook env var is an empty string", () => { + process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL = ""; + kvCacheMock.usingSharedCache = true; + expect(discordAnnounceEnabled()).toBe(false); + }); + + it("is false when shared Redis is unconfigured, even with the webhook set", () => { + process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL = WEBHOOK_URL; + kvCacheMock.usingSharedCache = false; + expect(discordAnnounceEnabled()).toBe(false); + }); + + it("is true only when both the webhook is set and shared Redis is configured", () => { + process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL = WEBHOOK_URL; + kvCacheMock.usingSharedCache = true; + expect(discordAnnounceEnabled()).toBe(true); + }); +}); + +describe("maybeAnnounceReport", () => { + const killFight = makeFight({ id: 7, name: "Prince Malchezaar", encounterID: 10, kill: true, startTime: 100, endTime: 500 }); + + beforeEach(() => { + process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL = WEBHOOK_URL; + kvCacheMock.usingSharedCache = true; + kvCacheMock.markAnnouncedOnce.mockReset(); + logEventMock.mockReset(); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("performs exactly one fetch when the claim succeeds, and zero more on a later call whose claim fails", async () => { + kvCacheMock.markAnnouncedOnce.mockResolvedValueOnce(true); + (fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 204, + json: async () => ({}), + }); + await maybeAnnounceReport("CODE0000001", makeReport([killFight])); + expect(fetch).toHaveBeenCalledTimes(1); + + kvCacheMock.markAnnouncedOnce.mockResolvedValueOnce(false); + await maybeAnnounceReport("CODE0000001", makeReport([killFight])); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("never calls the claim helper when there is no featured fight — a fightless report must not burn its claim", async () => { + await maybeAnnounceReport("CODE0000002", makeReport([])); + expect(kvCacheMock.markAnnouncedOnce).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("never throws on a rejected fetch, a 500, or a 400, and logs ok:false for each", async () => { + kvCacheMock.markAnnouncedOnce.mockResolvedValue(true); + const fetchMock = fetch as unknown as ReturnType; + + fetchMock.mockRejectedValueOnce(new Error("network down")); + await expect(maybeAnnounceReport("CODE0000003", makeReport([killFight]))).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) }); + await expect(maybeAnnounceReport("CODE0000004", makeReport([killFight]))).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) }); + await expect(maybeAnnounceReport("CODE0000005", makeReport([killFight]))).resolves.toBeUndefined(); + + const okFalseCalls = logEventMock.mock.calls.filter( + ([event, props]) => event === "discord_report_announced" && (props as { ok?: boolean }).ok === false, + ); + expect(okFalseCalls.length).toBe(3); + + for (const call of logEventMock.mock.calls) { + expect(JSON.stringify(call)).not.toContain(WEBHOOK_URL); + } + }); + + it("retries exactly once on a 429 and succeeds on the retry", async () => { + kvCacheMock.markAnnouncedOnce.mockResolvedValue(true); + const fetchMock = fetch as unknown as ReturnType; + fetchMock + .mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({ retry_after: 0 }) }) + .mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + await maybeAnnounceReport("CODE0000006", makeReport([killFight])); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("gives up without throwing after a second consecutive 429", async () => { + kvCacheMock.markAnnouncedOnce.mockResolvedValue(true); + const fetchMock = fetch as unknown as ReturnType; + fetchMock + .mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({ retry_after: 0 }) }) + .mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({ retry_after: 0 }) }); + + await expect(maybeAnnounceReport("CODE0000007", makeReport([killFight]))).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(2); + + for (const call of logEventMock.mock.calls) { + expect(JSON.stringify(call)).not.toContain(WEBHOOK_URL); + } + }); + + it("never includes the webhook URL in any logEvent call on a successful post", async () => { + kvCacheMock.markAnnouncedOnce.mockResolvedValue(true); + (fetch as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 204, + json: async () => ({}), + }); + await maybeAnnounceReport("CODE0000008", makeReport([killFight])); + for (const call of logEventMock.mock.calls) { + expect(JSON.stringify(call)).not.toContain(WEBHOOK_URL); + } + }); +}); diff --git a/lib/discord-announce.ts b/lib/discord-announce.ts new file mode 100644 index 0000000..ea82344 --- /dev/null +++ b/lib/discord-announce.ts @@ -0,0 +1,190 @@ +/** + * Server-only Discord auto-post (SHARE-04). Turns "a public Warcraft Logs + * report just rendered for the first time" into one awards-card embed in the + * ParseForge Discord's #announcements channel. + * + * Never throws: `maybeAnnounceReport` wraps its entire body in one try/catch + * so a slow or failing Discord can never fail the `/analyze` render that + * scheduled it via `after()` (see lib/report-meta.ts). The webhook URL is + * read from `DISCORD_ANNOUNCEMENTS_WEBHOOK_URL` — a server-only secret — and + * is never logged or returned by any export here. The embed's image + * references the existing `/og?view=awards` route rather than recomputing + * awards, so the card is referenced, never rebuilt (see lib/awards-engine.ts). + */ + +import { usingSharedCache, markAnnouncedOnce } from "@/lib/kv-cache"; +import { logEvent } from "@/lib/observability"; +import { buildDiscordAnnounceUrl, buildAwardsOgPath } from "@/lib/share-links"; +import type { WCLFight, WCLReportData } from "@/lib/wcl-types"; + +// The embed always points at production even when this code runs on a +// preview deployment: a Discord reader must land on the canonical site, and +// `/og` only renders reliably where the WCL credentials and Redis live. +const PROD_ORIGIN = "https://parseforge.gg"; + +/** + * Decimal equivalent of the brand gold `#D4A843` that app/og/route.tsx's + * GOLD constant already uses. Keep these two paired — if the brand hex ever + * changes, update both. + */ +export const ANNOUNCE_EMBED_COLOR = 0xd4a843; + +export interface DiscordEmbed { + title: string; + url: string; + color: number; + author: { name: string }; + image: { url: string }; + footer: { text: string }; + timestamp: string; +} + +/** + * True only when the webhook secret is configured AND shared Redis is + * available. The Redis half is part of the gate, not an afterthought: + * without shared Redis there is no way to claim a report exactly once, and + * posting without that claim is the repost bug. Local dev therefore no-ops + * by construction, matching the graceful-degradation contract every other + * helper in lib/kv-cache.ts already honours. + */ +export function discordAnnounceEnabled(): boolean { + return Boolean(process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL) && usingSharedCache; +} + +/** + * Removes ASCII control characters (below 0x20, plus 0x7f), collapses every + * run of whitespace to one space, trims, then truncates to `max` code units. + * Pure; no throwing; a single character-class replace per step to avoid any + * regex backtracking hazard. + */ +export function sanitizeEmbedText(raw: string | null | undefined, max: number): string { + if (raw == null) return ""; + const noControlChars = raw.replace(/[\x00-\x1f\x7f]/g, ""); + const collapsed = noControlChars.replace(/\s+/g, " ").trim(); + return collapsed.slice(0, max); +} + +/** + * D-06's rule: the report's last recorded kill by `startTime`, falling back + * to its longest wipe when no kill exists. Ignores trash (`encounterID` + * `0`). Sorts explicitly on the numeric fields — never relies on the + * array's incoming order (05-RESEARCH.md Assumption A1: no fixture in this + * repo confirms WCL returns `report.fights` in chronological order). + */ +export function pickFeaturedFight(fights: WCLFight[]): WCLFight | null { + const real = fights.filter((f) => f.encounterID > 0); + const kills = real.filter((f) => f.kill).sort((a, b) => b.startTime - a.startTime); + if (kills.length > 0) return kills[0]; + const wipes = real + .filter((f) => !f.kill) + .sort((a, b) => b.endTime - b.startTime - (a.endTime - a.startTime)); + return wipes[0] ?? null; +} + +/** + * Builds the embed. Pure. The operator-typed report title goes only into + * `author.name`, never into `title` or a `description`: Discord renders + * markdown in the title/description fields but treats `author.name` and + * `footer.text` as inert plain text, so a report named with a markdown link + * or an `@everyone` string renders there as literal characters. + */ +export function buildAnnouncementEmbed({ + code, + report, + fight, +}: { + code: string; + report: WCLReportData; + fight: WCLFight; +}): DiscordEmbed { + const zone = report.zone?.name ?? "Unknown zone"; + return { + title: fight.name, + url: buildDiscordAnnounceUrl(PROD_ORIGIN, { reportCode: code, fightId: fight.id }), + color: ANNOUNCE_EMBED_COLOR, + author: { name: sanitizeEmbedText(report.title, 128) }, + image: { url: PROD_ORIGIN + buildAwardsOgPath({ reportCode: code, fightId: fight.id }) }, + footer: { text: sanitizeEmbedText(`${zone} · ${fight.kill ? "Kill" : "Wipe"}`, 128) }, + timestamp: new Date().toISOString(), + }; +} + +interface WebhookResponse { + ok: boolean; + status: number; + json: () => Promise; +} + +async function postWebhook(url: string, body: string): Promise { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: AbortSignal.timeout(5000), + }); +} + +async function readRetryAfterSeconds(res: WebhookResponse): Promise { + try { + const data = (await res.json()) as { retry_after?: number }; + return typeof data.retry_after === "number" ? data.retry_after : 5; + } catch { + return 5; + } +} + +function sleepSeconds(seconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +/** + * The orchestrator, and the only export with side effects. Never throws — + * the entire body is wrapped so nothing escapes into the caller (`after()` + * in lib/report-meta.ts). Order matters and is load-bearing: + * 1. the enabled gate + * 2. fight selection, before the claim — a report with no boss fights can + * still announce later once it has one + * 3. the permanent dedup claim + * 4. the POST, with exactly one retry on a 429 + */ +export async function maybeAnnounceReport(code: string, report: WCLReportData): Promise { + try { + if (!discordAnnounceEnabled()) return; + + const fight = pickFeaturedFight(report.fights); + if (!fight) return; + + const claimed = await markAnnouncedOnce(code); + if (!claimed) return; + + const embed = buildAnnouncementEmbed({ code, report, fight }); + const webhookUrl = process.env.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL!; + // allowed_mentions: { parse: [] } is a security control, not a nicety — + // it makes it structurally impossible for any report-derived text to + // trigger a role or @everyone/@here ping. + const body = JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } }); + + let res = await postWebhook(webhookUrl, body); + if (res.status === 429) { + const retryAfter = await readRetryAfterSeconds(res); + await sleepSeconds(Math.min(retryAfter, 5)); + res = await postWebhook(webhookUrl, body); + } + + logEvent("discord_report_announced", { + report_code: code, + fight_id: fight.id, + kill: fight.kill, + ok: res.ok, + status: res.status, + }); + } catch (err) { + // Never rethrow — matches app/og/route.tsx's "never fail an unfurl" + // contract. The webhook URL is never included here. + logEvent("discord_report_announced", { + report_code: code, + ok: false, + status: (err as Error).message, + }); + } +} diff --git a/lib/discord-roles.json b/lib/discord-roles.json new file mode 100644 index 0000000..909e01d --- /dev/null +++ b/lib/discord-roles.json @@ -0,0 +1,39 @@ +{ + "guildId": "1552413469301678122", + "customIdPrefix": "pf-role:", + "groups": [ + { + "id": "class", + "heading": "Pick every class you raid on. Tap to add a role; tap again to remove it.", + "roles": [ + { "name": "Warrior", "id": "1553974790753226763" }, + { "name": "Paladin", "id": "1553974791461933126" }, + { "name": "Hunter", "id": "1553974792351383572" }, + { "name": "Rogue", "id": "1553974793139920936" }, + { "name": "Priest", "id": "1553974794112868506" }, + { "name": "Shaman", "id": "1553974796264669226" }, + { "name": "Mage", "id": "1553974797736743034" }, + { "name": "Warlock", "id": "1553974799263334490" }, + { "name": "Druid", "id": "1553974800269975616" } + ] + }, + { + "id": "raid-role", + "heading": "Pick the roles you fill in a raid. Tap to add; tap again to remove.", + "roles": [ + { "name": "Tank", "id": "1553974801918591068" }, + { "name": "Healer", "id": "1553974804338581604" }, + { "name": "DPS", "id": "1553974804980178995" }, + { "name": "Raid Leader", "id": "1553974790052778075" } + ] + }, + { + "id": "version", + "heading": "Pick the game version(s) you play. Tap to add; tap again to remove.", + "roles": [ + { "name": "TBC", "id": "1553974806083538996" }, + { "name": "Classic", "id": "1553974807349952614" } + ] + } + ] +} diff --git a/lib/geo.test.ts b/lib/geo.test.ts new file mode 100644 index 0000000..c4947cb --- /dev/null +++ b/lib/geo.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { CONSENT_REGIONS, isConsentRegionCode } from "./geo"; + +describe("CONSENT_REGIONS", () => { + it("has exactly 32 members (EU-27 + IS, LI, NO + GB + CH)", () => { + expect(CONSENT_REGIONS.size).toBe(32); + }); +}); + +describe("isConsentRegionCode", () => { + it("returns true for EEA/UK/CH codes", () => { + expect(isConsentRegionCode("DE")).toBe(true); + expect(isConsentRegionCode("FR")).toBe(true); + expect(isConsentRegionCode("IS")).toBe(true); + expect(isConsentRegionCode("LI")).toBe(true); + expect(isConsentRegionCode("NO")).toBe(true); + expect(isConsentRegionCode("GB")).toBe(true); + expect(isConsentRegionCode("CH")).toBe(true); + }); + + it("returns false for non-consent-region codes", () => { + expect(isConsentRegionCode("US")).toBe(false); + expect(isConsentRegionCode("AU")).toBe(false); + expect(isConsentRegionCode("CA")).toBe(false); + expect(isConsentRegionCode("BR")).toBe(false); + }); + + it("fails closed to true for null, undefined, empty or blank input", () => { + expect(isConsentRegionCode(null)).toBe(true); + expect(isConsentRegionCode(undefined)).toBe(true); + expect(isConsentRegionCode("")).toBe(true); + expect(isConsentRegionCode(" ")).toBe(true); + }); + + it("trims and upper-cases before comparison", () => { + expect(isConsentRegionCode("de")).toBe(true); + expect(isConsentRegionCode(" gb ")).toBe(true); + expect(isConsentRegionCode("us")).toBe(false); + }); + + it("excludes Gibraltar, Isle of Man, Jersey and Guernsey — deliberately out pending Task 2's confirmation", () => { + expect(isConsentRegionCode("GI")).toBe(false); + expect(isConsentRegionCode("IM")).toBe(false); + expect(isConsentRegionCode("JE")).toBe(false); + expect(isConsentRegionCode("GG")).toBe(false); + }); +}); diff --git a/lib/geo.ts b/lib/geo.ts new file mode 100644 index 0000000..988f8a1 --- /dev/null +++ b/lib/geo.ts @@ -0,0 +1,44 @@ +// Server-side consent-region classification, in the style of lib/consent.ts: +// typed input, narrow return, no throwing. The source of truth is Google's own +// AdSense support page ("the European Economic Area (EEA), the UK, and +// Switzerland" — https://support.google.com/adsense/answer/10961068), NOT a +// hand-typed memory of "EU countries" — EEA adds Iceland, Liechtenstein and +// Norway, which "EU" does not include. A country present here without a real +// CMP dialog would be dropped forever (today's outage in miniature); a +// country with a dialog but absent here would be captured before consent. +// Unknown, missing, or blank input fails CLOSED to "this is a consent +// region" (D-03) — this project never assumes a visitor is safe to capture +// without consent just because we couldn't classify them. + +/** + * The exact set of ISO 3166-1 alpha-2 country codes Google Privacy & + * Messaging's GDPR message targets: the 27 EU member states, the three + * non-EU EEA states (Iceland, Liechtenstein, Norway), the UK, and + * Switzerland. Deliberately EXCLUDES Gibraltar (GI), Isle of Man (IM), + * Jersey (JE) and Guernsey (GG) — Google's support page does not enumerate + * them, and Task 2 of 02.1-01-PLAN.md records the developer's decision to + * leave them out pending further evidence. + */ +export const CONSENT_REGIONS: ReadonlySet = new Set([ + // 27 EU member states + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", + "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", + "SI", "ES", "SE", + // Non-EU EEA states + "IS", "LI", "NO", + // UK + Switzerland + "GB", "CH", +]); + +/** + * Classifies a country code as inside or outside the consent-region set. + * Trims and upper-cases before comparison so a lower-case or padded header + * value classifies identically to its canonical upper-case form. A nullish + * or blank value fails CLOSED to `true` (consent region) — an unknown + * visitor is never treated as safe to capture without consent (D-03). + */ +export function isConsentRegionCode(code: string | null | undefined): boolean { + const normalized = code?.trim().toUpperCase(); + if (!normalized) return true; + return CONSENT_REGIONS.has(normalized); +} diff --git a/lib/kv-cache.ts b/lib/kv-cache.ts index b446b26..bd2bb00 100644 --- a/lib/kv-cache.ts +++ b/lib/kv-cache.ts @@ -141,6 +141,29 @@ export async function cacheUnlock(key: string): Promise { } } +// ─── Discord auto-post dedup (permanent, fail-closed) ──────────────── +// Same atomic SET NX primitive as cacheLock, but adapted for a permanent +// "posted once, ever" marker rather than a short-lived lock — no EX ttl. +// +// The catch branch deliberately diverges from cacheLock's: cacheLock returns +// false on error meaning "not acquired, fall through and compute anyway" +// (fail-open, correct for a cache). Here false means "treat as already +// claimed, do not post" (fail-closed). The bias is chosen on purpose: a +// Redis blip that costs one missed announcement is recoverable; one that +// reposts the homepage demo report on every page view is not. + +/** Claim `pf:discord_posted:{code}` exactly once, ever. True only the first time. */ +export async function markAnnouncedOnce(code: string): Promise { + if (!usingSharedCache) return false; + try { + const { result } = await redisCmd(["SET", `pf:discord_posted:${code}`, "1", "NX"]); + return result === "OK"; + } catch (err) { + console.error(`[kv-cache] markAnnouncedOnce ${code} failed: ${(err as Error).message}`); + return false; + } +} + // ─── Recently-indexed reports (feeds the sitemap) ──────────────────── // A sorted set of report codes scored by last-seen timestamp, written each // time a public report is server-rendered. Only active when shared Redis is diff --git a/lib/rankings/budget.test.ts b/lib/rankings/budget.test.ts new file mode 100644 index 0000000..ba9d2b8 --- /dev/null +++ b/lib/rankings/budget.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import rankingsRatelimit from "../__fixtures__/rankings-ratelimit.json"; + +// Stubs the shared cache helpers so no network call ever leaves the machine +// (D-15 pattern already established in lib/wcl-client.test.ts). Each `it()` +// resets modules and re-imports "./budget" fresh so no test's mock state +// leaks into another. + +const cacheGetMock = vi.fn(); +const cacheSetMock = vi.fn(); + +vi.mock("../kv-cache", () => ({ + cacheGet: (...args: unknown[]) => cacheGetMock(...args), + cacheSet: (...args: unknown[]) => cacheSetMock(...args), +})); + +beforeEach(() => { + vi.resetModules(); + cacheGetMock.mockReset(); + cacheSetMock.mockReset(); + cacheSetMock.mockResolvedValue(undefined); + cacheGetMock.mockResolvedValue(null); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +async function loadBudget() { + return import("./budget"); +} + +describe("parseRateLimitData", () => { + it("Test 1: accepts every recorded fixture sample and returns the three numbers", async () => { + const { parseRateLimitData } = await loadBudget(); + for (const sample of rankingsRatelimit.samples) { + const parsed = parseRateLimitData(sample); + expect(parsed).toEqual({ + limitPerHour: sample.limitPerHour, + pointsSpentThisHour: sample.pointsSpentThisHour, + pointsResetIn: sample.pointsResetIn, + }); + } + }); + + it("Test 2: returns null when a value is missing", async () => { + const { parseRateLimitData } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + expect( + parseRateLimitData({ limitPerHour: sample.limitPerHour, pointsSpentThisHour: sample.pointsSpentThisHour }), + ).toBeNull(); + }); + + it("Test 3: returns null when a value is a string", async () => { + const { parseRateLimitData } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + expect( + parseRateLimitData({ ...sample, pointsSpentThisHour: String(sample.pointsSpentThisHour) }), + ).toBeNull(); + }); + + it("Test 4: returns null when a value is NaN", async () => { + const { parseRateLimitData } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + expect(parseRateLimitData({ ...sample, pointsResetIn: NaN })).toBeNull(); + }); + + it("Test 5: returns null when a value is negative", async () => { + const { parseRateLimitData } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + expect(parseRateLimitData({ ...sample, pointsSpentThisHour: -1 })).toBeNull(); + }); + + it("Test 6: returns null when the hourly limit is zero", async () => { + const { parseRateLimitData } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + expect(parseRateLimitData({ ...sample, limitPerHour: 0 })).toBeNull(); + }); + + it("Test 6b: returns null for a non-object payload", async () => { + const { parseRateLimitData } = await loadBudget(); + expect(parseRateLimitData(null)).toBeNull(); + expect(parseRateLimitData("nope")).toBeNull(); + }); +}); + +describe("recordRateLimit", () => { + it("Test 7: a valid payload writes state through the cache helper and resolves true", async () => { + const { recordRateLimit, BUDGET_KEY } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + const ok = await recordRateLimit(sample); + expect(ok).toBe(true); + expect(cacheSetMock).toHaveBeenCalledTimes(1); + expect(cacheSetMock.mock.calls[0][0]).toBe(BUDGET_KEY); + }); + + it("Test 8: an invalid payload writes nothing and resolves false", async () => { + const { recordRateLimit } = await loadBudget(); + const ok = await recordRateLimit({ limitPerHour: 0 }); + expect(ok).toBe(false); + expect(cacheSetMock).not.toHaveBeenCalled(); + }); + + it("Test 9: never throws when the cache helper rejects — resolves false", async () => { + cacheSetMock.mockRejectedValueOnce(new Error("redis down")); + const { recordRateLimit } = await loadBudget(); + const sample = rankingsRatelimit.samples[0]; + await expect(recordRateLimit(sample)).resolves.toBe(false); + }); +}); + +describe("hasBudget", () => { + it("Test 10: allowed true, reason ok when spend is below 90% of the limit", async () => { + cacheGetMock.mockResolvedValue({ + limitPerHour: 1000, + pointsSpentThisHour: 800, + pointsResetIn: 1800, + recordedAt: Date.now(), + }); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result).toEqual({ allowed: true, reason: "ok", spentFraction: 0.8 }); + }); + + it("Test 11: allowed false, reason at-gate at exactly 90% (inclusive boundary)", async () => { + cacheGetMock.mockResolvedValue({ + limitPerHour: 1000, + pointsSpentThisHour: 900, + pointsResetIn: 1800, + recordedAt: Date.now(), + }); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("at-gate"); + expect(result.spentFraction).toBe(0.9); + }); + + it("Test 11b: allowed false, reason at-gate above 90%", async () => { + cacheGetMock.mockResolvedValue({ + limitPerHour: 1000, + pointsSpentThisHour: 950, + pointsResetIn: 1800, + recordedAt: Date.now(), + }); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("at-gate"); + }); + + it("Test 12: allowed false, reason unknown when no state has been recorded", async () => { + cacheGetMock.mockResolvedValue(null); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result).toEqual({ allowed: false, reason: "unknown", spentFraction: null }); + }); + + it("Test 13: allowed false, reason unknown when the recorded state fails validation on read-back", async () => { + cacheGetMock.mockResolvedValue({ + limitPerHour: -5, + pointsSpentThisHour: 10, + pointsResetIn: 100, + recordedAt: Date.now(), + }); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result).toEqual({ allowed: false, reason: "unknown", spentFraction: null }); + }); + + it("Test 14: the hourly limit used in the comparison comes from the recorded state, never hard-coded — a different limit produces a different gate point", async () => { + const spentAmount = 1000; // same absolute spend... + cacheGetMock.mockResolvedValue({ + limitPerHour: 2000, // ...50% of this limit + pointsSpentThisHour: spentAmount, + pointsResetIn: 1800, + recordedAt: Date.now(), + }); + const { hasBudget: hasBudgetLowLimit } = await loadBudget(); + const lowLimitResult = await hasBudgetLowLimit(); + expect(lowLimitResult.allowed).toBe(true); + + vi.resetModules(); + cacheGetMock.mockResolvedValue({ + limitPerHour: 1000, // ...100% of this limit — same absolute spend, different limit + pointsSpentThisHour: spentAmount, + pointsResetIn: 1800, + recordedAt: Date.now(), + }); + const { hasBudget: hasBudgetHighUsage } = await loadBudget(); + const highUsageResult = await hasBudgetHighUsage(); + expect(highUsageResult.allowed).toBe(false); + expect(highUsageResult.spentFraction).not.toBe(lowLimitResult.spentFraction); + }); + + it("Test 15: a state whose reset window has elapsed is treated as unknown, not a stale allowance", async () => { + cacheGetMock.mockResolvedValue({ + limitPerHour: 1000, + pointsSpentThisHour: 10, + pointsResetIn: 60, // window was 60s from recordedAt + recordedAt: Date.now() - 120_000, // recorded 120s ago — window has elapsed + }); + const { hasBudget } = await loadBudget(); + const result = await hasBudget(); + expect(result).toEqual({ allowed: false, reason: "unknown", spentFraction: null }); + }); +}); diff --git a/lib/rankings/budget.ts b/lib/rankings/budget.ts new file mode 100644 index 0000000..c036cb5 --- /dev/null +++ b/lib/rankings/budget.ts @@ -0,0 +1,135 @@ +// WCL client-credentials rate-budget gate, on top of the project's existing +// shared cache (lib/kv-cache.ts) — same Redis-with-Map-fallback, "never +// throw, log and degrade" contract, no second transport. +// +// The hourly point limit is ALWAYS read from the WCL response at runtime and +// never hard-coded: PARSEFORGE-RANKINGS-SPEC.md §2.2 records it moving +// mid-session (9000 -> 18000 in one cited report), so any constant baked in +// here would silently go stale. `BUDGET_GATE_FRACTION` (90%) is a deliberate +// policy choice, not a measured threshold — a later phase may tune it +// knowingly, but it should never be loosened to make a gate pass. +// +// The gate fails CLOSED on an unknown state (no state recorded, a state that +// fails validation on read-back, or a state whose reset window has already +// elapsed): `hasBudget()` returns `allowed: false` with `reason: "unknown"` +// rather than guessing, exposing the reason so a caller with a genuinely +// cheap or already-cached request can make its own decision instead of being +// silently blocked. + +import { cacheGet, cacheSet } from "../kv-cache"; + +// ─── Types ───────────────────────────────────────────────────────────── + +export interface WclRateLimitData { + limitPerHour: number; + pointsSpentThisHour: number; + pointsResetIn: number; +} + +export interface WclBudgetState extends WclRateLimitData { + /** Epoch ms when this state was recorded (used to detect an elapsed window). */ + recordedAt: number; +} + +export type BudgetReason = "ok" | "at-gate" | "unknown"; + +export interface BudgetDecision { + allowed: boolean; + reason: BudgetReason; + spentFraction: number | null; +} + +export const BUDGET_KEY = "wcl:budget"; + +/** Refuse once spend reaches this fraction of the runtime-read hourly limit. Deliberate policy choice — see module header. */ +export const BUDGET_GATE_FRACTION = 0.9; + +// ─── Validation ────────────────────────────────────────────────────── + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** + * Runtime-validates a `rateLimitData` payload before trusting it: all three + * values must be finite non-negative numbers, and the hourly limit must be + * greater than zero (a zero denominator would make every spend look either + * infinitely safe or infinitely spent, depending on division order). Pure, + * no I/O. A malformed payload is never partially trusted — it returns null. + */ +export function parseRateLimitData(raw: unknown): WclRateLimitData | null { + if (!raw || typeof raw !== "object") return null; + const { limitPerHour, pointsSpentThisHour, pointsResetIn } = raw as Record; + if ( + !isFiniteNonNegative(limitPerHour) || + !isFiniteNonNegative(pointsSpentThisHour) || + !isFiniteNonNegative(pointsResetIn) + ) { + return null; + } + if (limitPerHour === 0) return null; + return { limitPerHour, pointsSpentThisHour, pointsResetIn }; +} + +// ─── Persistence (through the existing shared cache) ────────────────── + +/** + * Validates `raw`, then persists it through the project's existing cache + * helper with a TTL derived from `pointsResetIn` so an expired window can't + * masquerade as current state. Never throws — a cache failure or an invalid + * payload both resolve to `false`, they never reject. + */ +export async function recordRateLimit(raw: unknown): Promise { + const parsed = parseRateLimitData(raw); + if (!parsed) return false; + const state: WclBudgetState = { ...parsed, recordedAt: Date.now() }; + try { + const ttlMs = Math.max(1000, parsed.pointsResetIn * 1000); + await cacheSet(BUDGET_KEY, state, ttlMs); + return true; + } catch { + return false; + } +} + +/** + * Reads the persisted state back through the existing cache helper and + * re-validates it on the way out. A state that fails validation, or one + * whose reset window has already elapsed since it was recorded, is treated + * as absent — never as a stale-but-usable allowance. + */ +export async function readBudgetState(): Promise { + try { + const raw = await cacheGet(BUDGET_KEY); + if (!raw || typeof raw !== "object") return null; + const parsed = parseRateLimitData(raw); + if (!parsed) return null; + const recordedAt = (raw as { recordedAt?: unknown }).recordedAt; + if (!isFiniteNonNegative(recordedAt)) return null; + const elapsedSec = (Date.now() - recordedAt) / 1000; + if (elapsedSec > parsed.pointsResetIn) return null; + return { ...parsed, recordedAt }; + } catch { + return null; + } +} + +// ─── The single question callers ask ────────────────────────────────── + +/** + * May an expensive query run right now? Reads the persisted budget state + * and compares spend against `BUDGET_GATE_FRACTION` of the state's own + * `limitPerHour` — never a hard-coded number. Inclusive at the gate + * fraction (exactly 90% refuses, not just above it). Fails closed with + * `reason: "unknown"` when no valid, current state exists. + */ +export async function hasBudget(): Promise { + const state = await readBudgetState(); + if (!state) return { allowed: false, reason: "unknown", spentFraction: null }; + + const spentFraction = state.pointsSpentThisHour / state.limitPerHour; + if (spentFraction >= BUDGET_GATE_FRACTION) { + return { allowed: false, reason: "at-gate", spentFraction }; + } + return { allowed: true, reason: "ok", spentFraction }; +} diff --git a/lib/rankings/parse-lens.test.ts b/lib/rankings/parse-lens.test.ts new file mode 100644 index 0000000..0c9ed01 --- /dev/null +++ b/lib/rankings/parse-lens.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import rankingsReportFixture from "../__fixtures__/rankings-report.json"; +import type { ReportRankingsBlob } from "../wcl-types"; +import { toParseRows, toFightBadges, visibleParseRows } from "./parse-lens"; + +// Fixture-driven: every assertion below reads field names from the real +// recorded response in lib/__fixtures__/rankings-report.json (04-02), never +// from PARSEFORGE-RANKINGS-SPEC.md alone (04-RESEARCH.md Pitfall 1). `hidden` +// and a non-empty character blacklist are absent from that recording +// (README.md), so those two behaviors are exercised against a structurally +// cloned, minimally mutated copy of a real recorded character — never an +// invented field name. + +const blob = rankingsReportFixture.reportData.report.rankings as unknown as ReportRankingsBlob; + +function countFixtureCharacters(): number { + const entry = blob.data[0]; + return ( + entry.roles.tanks.characters.length + + entry.roles.healers.characters.length + + entry.roles.dps.characters.length + ); +} + +describe("toParseRows", () => { + it("Test 1: returns one row per character across all three role groups", () => { + const rows = toParseRows(blob); + expect(rows.length).toBe(countFixtureCharacters()); + }); + + it("Test 2: each row carries recorded fields exactly, none computed", () => { + const rows = toParseRows(blob); + const raw = blob.data[0].roles.tanks.characters[0]; + const row = rows.find((r) => r.id === raw.id); + expect(row).toBeTruthy(); + expect(row?.name).toBe(raw.name); + expect(row?.serverName).toBe(raw.server.name); + expect(row?.region).toBe(raw.server.region); + expect(row?.className).toBe(raw.class); + expect(row?.spec).toBe(raw.spec); + expect(row?.role).toBe("tank"); + expect(row?.amount).toBe(raw.amount); + expect(row?.rankPercent).toBe(raw.rankPercent); + }); + + it("Test 3: rankPercent is preserved as null when the recording has no value for it, never coerced to 0", () => { + const clone = structuredClone(blob); + clone.data[0].roles.dps.characters[0].rankPercent = null; + const rows = toParseRows(clone); + const row = rows.find((r) => r.id === clone.data[0].roles.dps.characters[0].id); + expect(row?.rankPercent).toBeNull(); + }); + + it("Test 4: a character marked hidden is returned with hidden: true, and excluded from the visible ranking", () => { + const clone = structuredClone(blob); + const target = clone.data[0].roles.healers.characters[0]; + (target as { hidden?: boolean }).hidden = true; + const rows = toParseRows(clone); + const row = rows.find((r) => r.id === target.id); + expect(row?.hidden).toBe(true); + const visible = visibleParseRows(rows); + expect(visible.some((r) => r.id === target.id)).toBe(false); + // present in the data, absent from the ranking + expect(rows.some((r) => r.id === target.id)).toBe(true); + }); + + it("Test 5: a character id in the entry's blacklist is returned with a blacklisted marker, excluded from the visible ranking", () => { + const clone = structuredClone(blob); + const target = clone.data[0].roles.dps.characters[1]; + clone.data[0].reportsBlacklistForCharacters = [target.id]; + const rows = toParseRows(clone); + const row = rows.find((r) => r.id === target.id); + expect(row?.blacklisted).toBe(true); + const visible = visibleParseRows(rows); + expect(visible.some((r) => r.id === target.id)).toBe(false); + expect(rows.some((r) => r.id === target.id)).toBe(true); + }); + + it("Test 6: the lens is pure — calling it twice returns equal output and never mutates its argument", () => { + const before = structuredClone(blob); + const first = toParseRows(blob); + const second = toParseRows(blob); + expect(second).toEqual(first); + expect(blob).toEqual(before); + }); + + it("Test 7: an empty data array returns an empty row list, never throws", () => { + expect(() => toParseRows({ data: [] })).not.toThrow(); + expect(toParseRows({ data: [] })).toEqual([]); + }); + + it("Test 8: an entry missing the roles object entirely returns an empty row list, never throws", () => { + const malformed = { data: [{ ...blob.data[0], roles: undefined }] } as unknown as ReportRankingsBlob; + expect(() => toParseRows(malformed)).not.toThrow(); + expect(toParseRows(malformed)).toEqual([]); + }); + + it("Test 9: a role group present with an empty characters array contributes zero rows without error", () => { + const clone = structuredClone(blob); + clone.data[0].roles.tanks.characters = []; + expect(() => toParseRows(clone)).not.toThrow(); + const rows = toParseRows(clone); + expect(rows.some((r) => r.role === "tank")).toBe(false); + expect(rows.length).toBe( + clone.data[0].roles.healers.characters.length + clone.data[0].roles.dps.characters.length + ); + }); + + it("Test 10: never invents a bracket percentile — exposes only the recorded bracket/bracketData fields", () => { + const rows = toParseRows(blob); + for (const row of rows) { + const keys = Object.keys(row); + expect(keys.some((k) => /bracketpercent/i.test(k))).toBe(false); + expect(row).toHaveProperty("bracket"); + expect(row).toHaveProperty("bracketData"); + } + }); +}); + +describe("toFightBadges", () => { + it("Test 11: returns the fight's kill flag, duration, death count, and speed/execution rankPercent values, all read from the recording", () => { + const badges = toFightBadges(blob); + const entry = blob.data[0]; + expect(badges).not.toBeNull(); + expect(badges?.kill).toBe(Boolean(entry.kill)); + expect(badges?.duration).toBe(entry.duration); + expect(badges?.deaths).toBe(entry.deaths); + expect(badges?.speedRankPercent).toBe(entry.speed.rankPercent); + expect(badges?.executionRankPercent).toBe(entry.execution.rankPercent); + }); + + it("Test 12: an empty data array returns a null badge object, never throws", () => { + expect(() => toFightBadges({ data: [] })).not.toThrow(); + expect(toFightBadges({ data: [] })).toBeNull(); + }); +}); + +describe("visibleParseRows", () => { + it("Test 13: sorts by rankPercent descending with a stable tiebreak on name", () => { + const rows = toParseRows(blob); + const visible = visibleParseRows(rows); + for (let i = 1; i < visible.length; i++) { + const prev = visible[i - 1].rankPercent ?? -Infinity; + const curr = visible[i].rankPercent ?? -Infinity; + if (prev === curr) { + expect(visible[i - 1].name.localeCompare(visible[i].name)).toBeLessThanOrEqual(0); + } else { + expect(prev).toBeGreaterThan(curr); + } + } + }); +}); diff --git a/lib/rankings/parse-lens.ts b/lib/rankings/parse-lens.ts new file mode 100644 index 0000000..5b10a0a --- /dev/null +++ b/lib/rankings/parse-lens.ts @@ -0,0 +1,151 @@ +// Pure lens over the WCL per-report rankings blob (`reportData.report.rankings`), +// in the style of lib/analysis-engine.ts: typed input, pure functions, never +// throws. Field names below are verified against the recorded response in +// lib/__fixtures__/rankings-report.json (2026-09-20), never against +// PARSEFORGE-RANKINGS-SPEC.md alone (04-RESEARCH.md Pitfall 1). +// +// The blob crosses a trust boundary (a third-party GraphQL response), so +// every nested object is read defensively — optional chaining and +// `Array.isArray` guards at every level — the same discipline lib/consent.ts +// applies to the CMP payload. A shape surprise returns an empty result, it +// never throws. +// +// D-13: this module is not imported by any route, page, component or API +// handler yet — it ships as tested, unwired engine code until R1 approval. + +import { + ReportRankingsBlob, + ReportRankingEntry, + RankingsCharacterEntry, +} from "../wcl-types"; + +// ─── Types ───────────────────────────────────────────────────────────── + +export type ParseRole = "tank" | "healer" | "dps"; + +export interface ParseRow { + id: number; + name: string; + serverName: string; + region: string; + className: string; + spec: string; + role: ParseRole; + /** Raw metric amount (DPS/HPS-shaped), exactly as recorded. */ + amount: number; + /** WCL's own percentile. Null means "the recording had no value" — never coerced to 0. */ + rankPercent: number | null; + bracket: number; + bracketData: number; + /** Set only when the recorded character carried `hidden: true`. */ + hidden?: boolean; + /** Set only when the character's id is in this entry's report blacklist. */ + blacklisted?: boolean; +} + +export interface FightBadges { + kill: boolean; + duration: number; + deaths: number; + speedRankPercent: number | null; + executionRankPercent: number | null; +} + +// ─── Role flattening ───────────────────────────────────────────────── + +const ROLE_KEYS: Array<{ key: "tanks" | "healers" | "dps"; role: ParseRole }> = [ + { key: "tanks", role: "tank" }, + { key: "healers", role: "healer" }, + { key: "dps", role: "dps" }, +]; + +function toRowsForEntry(entry: ReportRankingEntry): ParseRow[] { + const roles = entry?.roles; + if (!roles || typeof roles !== "object") return []; + + const blacklist = new Set( + Array.isArray(entry.reportsBlacklistForCharacters) + ? entry.reportsBlacklistForCharacters + : [], + ); + + const rows: ParseRow[] = []; + for (const { key, role } of ROLE_KEYS) { + const group = roles[key]; + const characters: RankingsCharacterEntry[] = Array.isArray(group?.characters) + ? group.characters + : []; + + for (const character of characters) { + if (!character || typeof character !== "object") continue; + const row: ParseRow = { + id: character.id, + name: character.name, + serverName: character.server?.name ?? "", + region: character.server?.region ?? "", + className: character.class, + spec: character.spec, + role, + amount: character.amount, + rankPercent: character.rankPercent ?? null, + bracket: character.bracket, + bracketData: character.bracketData, + }; + if (character.hidden === true) row.hidden = true; + if (blacklist.has(character.id)) row.blacklisted = true; + rows.push(row); + } + } + return rows; +} + +/** + * Flattens a per-report rankings blob into one row per character, across + * every fight entry and every role group. Never throws; a malformed or + * empty blob returns an empty array. Never mutates `blob`. + */ +export function toParseRows(blob: ReportRankingsBlob): ParseRow[] { + const entries: ReportRankingEntry[] = Array.isArray(blob?.data) ? blob.data : []; + const rows: ParseRow[] = []; + for (const entry of entries) { + if (!entry) continue; + rows.push(...toRowsForEntry(entry)); + } + return rows; +} + +/** + * The badge object for the blob's first (and normally only) fight entry — + * kill flag, duration, death count, and the speed/execution rankPercent + * values, all read verbatim from the recording. Null when the blob carries + * no entry at all. Never throws. + */ +export function toFightBadges(blob: ReportRankingsBlob): FightBadges | null { + const entries: ReportRankingEntry[] = Array.isArray(blob?.data) ? blob.data : []; + const entry = entries[0]; + if (!entry) return null; + return { + kill: Boolean(entry.kill), + duration: entry.duration, + deaths: entry.deaths, + speedRankPercent: entry.speed?.rankPercent ?? null, + executionRankPercent: entry.execution?.rankPercent ?? null, + }; +} + +/** + * The ranked view: rows that are neither hidden nor blacklisted, sorted by + * `rankPercent` descending with a stable tiebreak on name so the order is + * deterministic across calls. Does not mutate its argument. + */ +export function visibleParseRows(rows: ParseRow[]): ParseRow[] { + return rows + .filter((row) => !row.hidden && !row.blacklisted) + .slice() + .sort((a, b) => { + const aPct = a.rankPercent ?? -Infinity; + const bPct = b.rankPercent ?? -Infinity; + if (bPct !== aPct) return bPct - aPct; + return a.name.localeCompare(b.name); + }); +} diff --git a/lib/rate-limit.test.ts b/lib/rate-limit.test.ts new file mode 100644 index 0000000..98140d6 --- /dev/null +++ b/lib/rate-limit.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// D-16: `isAuthenticatedBotRequest` / `checkCallerRateLimit` give the Discord +// bot its own authenticated rate-limit lane, so the bot's one shared Railway +// egress IP no longer forces every member's `/analyze` call through the same +// per-IP bucket. These tests drive the real sliding-window logic through a +// mocked @upstash/ratelimit — the class records which bucket (constructor +// `prefix`) and which key each `.limit()` call used, so a test can assert +// "never keyed by IP" and "exactly these two keys" without a live Redis. +// Same hoisted-mock-class pattern as lib/discord-interactions.test.ts (mocks +// @/lib/observability) and lib/wcl-client.test.ts / lib/rankings/budget.test.ts +// (vi.resetModules() per test + a fresh dynamic import, since rate-limit.ts +// computes `enabled` and caches limiter instances at module load). + +const { limitCalls, setLimitResult, resetLimitState, RatelimitMock, logEventMock } = + vi.hoisted(() => { + const limitCalls: Array<{ prefix: string; key: string }> = []; + const limitResults = new Map< + string, + { success: boolean; reset: number } | "throw" + >(); + + function setLimitResult( + prefix: string, + result: { success: boolean; reset?: number } | "throw", + ) { + if (result === "throw") { + limitResults.set(prefix, "throw"); + return; + } + limitResults.set(prefix, { + success: result.success, + reset: result.reset ?? Date.now() + 60_000, + }); + } + + function resetLimitState() { + limitCalls.length = 0; + limitResults.clear(); + } + + class RatelimitMock { + static slidingWindow = vi.fn((limit: number, window: string) => ({ limit, window })); + prefix: string; + limit: (key: string) => Promise<{ success: boolean; reset: number }>; + constructor(opts: { prefix: string }) { + this.prefix = opts.prefix; + this.limit = vi.fn(async (key: string) => { + limitCalls.push({ prefix: this.prefix, key }); + const result = limitResults.get(this.prefix); + if (result === "throw") throw new Error("redis down"); + return result ?? { success: true, reset: Date.now() + 60_000 }; + }); + } + } + + const logEventMock = vi.fn(); + + return { limitCalls, setLimitResult, resetLimitState, RatelimitMock, logEventMock }; + }); + +vi.mock("@upstash/ratelimit", () => ({ Ratelimit: RatelimitMock })); +vi.mock("@upstash/redis", () => ({ Redis: vi.fn() })); +vi.mock("@/lib/observability", () => ({ logEvent: logEventMock })); + +const ORIGINAL_ENV = { ...process.env }; +const BOT_KEY = "a".repeat(64); // 64-char shared secret used across tests +const CALLER_ID = "123456789012345678"; // 18-digit snowflake + +async function loadRateLimit() { + return import("./rate-limit"); +} + +function makeRequest(headers: Record = {}): Request { + return new Request("https://parseforge.gg/api/analyze", { headers }); +} + +beforeEach(() => { + vi.resetModules(); + resetLimitState(); + logEventMock.mockClear(); + vi.stubEnv("KV_REST_API_URL", "https://example.upstash.io"); + vi.stubEnv("KV_REST_API_TOKEN", "test-token"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); +}); + +// ─── checkCallerRateLimit ──────────────────────────────────────────────── + +describe("checkCallerRateLimit", () => { + it("Test 1: an authenticated request checks bot-aggregate:discord-bot and bot-member:discord:{id}, never the client IP, and returns null when both allow", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ + [BOT_KEY_HEADER]: BOT_KEY, + [BOT_CALLER_HEADER]: CALLER_ID, + "x-forwarded-for": "203.0.113.5", + }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:bot-aggregate", key: "discord-bot" }); + expect(limitCalls).toContainEqual({ prefix: "rl:bot-member", key: `discord:${CALLER_ID}` }); + expect(limitCalls.some((c) => c.key === "203.0.113.5")).toBe(false); + expect(limitCalls.some((c) => c.prefix === "rl:analyze")).toBe(false); + }); + + it("Test 2: bot-member over its limit returns 429 with the per-IP path's error text and a Retry-After header", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + setLimitResult("rl:bot-member", { success: false }); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, [BOT_CALLER_HEADER]: CALLER_ID }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(429); + expect(result!.headers.get("Retry-After")).toBeTruthy(); + const body = await result!.json(); + expect(body).toEqual({ error: "Too many requests — please wait a moment and try again." }); + }); + + it("Test 3: no header (BOT_INTERNAL_API_KEY set) falls through to the per-IP bucket keyed by the first x-forwarded-for entry, exactly as before", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit } = await loadRateLimit(); + const request = makeRequest({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:analyze", key: "203.0.113.9" }); + expect( + limitCalls.some((c) => c.prefix === "rl:bot-member" || c.prefix === "rl:bot-aggregate"), + ).toBe(false); + }); + + it("Test 4: BOT_INTERNAL_API_KEY unset falls through to the per-IP bucket even with the header present", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", ""); + const { checkCallerRateLimit, BOT_KEY_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, "x-forwarded-for": "203.0.113.9" }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:analyze", key: "203.0.113.9" }); + }); + + it("Test 5: a 31-character BOT_INTERNAL_API_KEY falls through to the per-IP bucket (fail closed, just under the 32-char floor)", async () => { + const shortKey = "a".repeat(31); + vi.stubEnv("BOT_INTERNAL_API_KEY", shortKey); + const { checkCallerRateLimit, BOT_KEY_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: shortKey, "x-forwarded-for": "203.0.113.9" }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:analyze", key: "203.0.113.9" }); + }); + + it("Test 6: a same-length but different-content header falls through to the per-IP bucket, never throws", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER } = await loadRateLimit(); + const wrongKey = "b".repeat(BOT_KEY.length); + const request = makeRequest({ [BOT_KEY_HEADER]: wrongKey, "x-forwarded-for": "203.0.113.9" }); + + await expect(checkCallerRateLimit(request, "analyze")).resolves.toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:analyze", key: "203.0.113.9" }); + }); + + it("Test 7: a different-length header falls through to the per-IP bucket, never throws", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER } = await loadRateLimit(); + const request = makeRequest({ + [BOT_KEY_HEADER]: BOT_KEY + "x", + "x-forwarded-for": "203.0.113.9", + }); + + await expect(checkCallerRateLimit(request, "analyze")).resolves.toBeNull(); + expect(limitCalls).toContainEqual({ prefix: "rl:analyze", key: "203.0.113.9" }); + }); + + it("Test 8: a valid key with no x-discord-user-id returns 400 and makes no limiter call", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(400); + expect(await result!.json()).toEqual({ error: "Missing or invalid caller id." }); + expect(limitCalls).toHaveLength(0); + }); + + it.each(["abc", "123", "1".repeat(21)])( + "Test 9: a valid key with an invalid caller id (%s) returns 400 and makes no limiter call", + async (badId) => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, [BOT_CALLER_HEADER]: badId }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(400); + expect(limitCalls).toHaveLength(0); + }, + ); + + it("Test 10: bot-aggregate over its limit returns 429 even when bot-member would allow", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + setLimitResult("rl:bot-aggregate", { success: false }); + setLimitResult("rl:bot-member", { success: true }); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, [BOT_CALLER_HEADER]: CALLER_ID }); + + const result = await checkCallerRateLimit(request, "analyze"); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(429); + }); + + it("Test 11: a limiter that throws in the bot path fails open (returns null), matching isRateLimitedKey's contract", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + setLimitResult("rl:bot-member", "throw"); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, [BOT_CALLER_HEADER]: CALLER_ID }); + + await expect(checkCallerRateLimit(request, "analyze")).resolves.toBeNull(); + }); + + it("Test 12: never logs or echoes the secret, header value, or Discord id — including on the 429 response body", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + setLimitResult("rl:bot-member", { success: false }); // exercise the 429 body path too + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ [BOT_KEY_HEADER]: BOT_KEY, [BOT_CALLER_HEADER]: CALLER_ID }); + + const result = await checkCallerRateLimit(request, "analyze"); + const bodyText = result ? JSON.stringify(await result.json()) : ""; + + expect(bodyText).not.toContain(BOT_KEY); + expect(bodyText).not.toContain(CALLER_ID); + for (const call of logEventMock.mock.calls) { + const serialized = JSON.stringify(call); + expect(serialized).not.toContain(BOT_KEY); + expect(serialized).not.toContain(CALLER_ID); + } + }); + + it("Test 13: works identically for the raid-overview and report buckets — never keyed by IP for an authenticated request", async () => { + vi.stubEnv("BOT_INTERNAL_API_KEY", BOT_KEY); + const { checkCallerRateLimit, BOT_KEY_HEADER, BOT_CALLER_HEADER } = await loadRateLimit(); + const request = makeRequest({ + [BOT_KEY_HEADER]: BOT_KEY, + [BOT_CALLER_HEADER]: CALLER_ID, + "x-forwarded-for": "203.0.113.5", + }); + + const raidResult = await checkCallerRateLimit(request, "raid-overview"); + const reportResult = await checkCallerRateLimit(request, "report"); + + expect(raidResult).toBeNull(); + expect(reportResult).toBeNull(); + expect(limitCalls.some((c) => c.prefix === "rl:raid-overview")).toBe(false); + expect(limitCalls.some((c) => c.prefix === "rl:report")).toBe(false); + expect(limitCalls.filter((c) => c.prefix === "rl:bot-aggregate")).toHaveLength(2); + expect(limitCalls.filter((c) => c.prefix === "rl:bot-member")).toHaveLength(2); + }); +}); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index ea050d0..6bb6f0d 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; import { RATE_LIMITS, RATE_LIMIT_WINDOW } from "./constants"; @@ -85,3 +86,120 @@ export async function checkRateLimit( return null; } } + +/** + * Keyed rate-limit check — the same sliding-window limiter as + * `checkRateLimit`, but keyed by a caller-supplied string (e.g. a Discord + * member id, used only as an opaque rate-limit key — never logged) instead + * of the request's client IP. Returns `true` when `key` is over the limit + * for `bucket`; `false` when allowed, when Redis is not configured, or on + * any Redis error (fails open, same contract as `checkRateLimit`). + */ +export async function isRateLimitedKey(bucket: string, key: string): Promise { + if (!enabled || !redis) return false; + + try { + const { success } = await limiterFor(bucket).limit(key); + if (success) return false; + + logEvent("rate_limited", { route: bucket }); + return true; + } catch (err) { + // Fail open: a rate-limiter outage must not break the caller. + console.error( + `[rate-limit] ${bucket} keyed check failed, allowing request: ${(err as Error).message}`, + ); + return false; + } +} + +// ─── D-16: authenticated bot rate-limit lane ───────────────────────── +// +// The Discord bot calls /api/analyze, /api/raid-overview and +// /api/report/{code} from one shared Railway egress IP, which would +// otherwise force every server member's use through the same per-IP +// bucket (defeating the bot's own per-member design). This gives the +// bot's authenticated traffic its own buckets — per Discord member +// (bot-member) plus one ceiling for all bot traffic (bot-aggregate) — +// instead of the IP bucket. This is NEVER an unauthenticated bypass: +// an unset, empty, or short (<32 char) BOT_INTERNAL_API_KEY, or any +// header mismatch, falls straight through to the unchanged per-IP path +// via checkCallerRateLimit below. No path skips rate limiting. + +/** Header carrying the bot's shared secret (D-16). Never logged or echoed. */ +export const BOT_KEY_HEADER = "x-parseforge-bot-key"; +/** Header carrying the calling Discord member's snowflake id (D-16). */ +export const BOT_CALLER_HEADER = "x-discord-user-id"; + +const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/; + +/** + * True when `request` carries a valid bot shared secret in `BOT_KEY_HEADER`. + * Fails closed: returns false (never throws) when `BOT_INTERNAL_API_KEY` is + * unset or shorter than 32 characters, when the header is missing, or when + * the header's length differs from the secret's. Otherwise compares the two + * UTF-8 buffers with `timingSafeEqual` so response timing can't leak the + * secret byte-by-byte. On its own this only identifies the caller as the + * bot — it is never treated as exempt from rate limiting. + */ +export function isAuthenticatedBotRequest(request: Request): boolean { + try { + const secret = process.env.BOT_INTERNAL_API_KEY ?? ""; + if (secret.length < 32) return false; + + const provided = request.headers.get(BOT_KEY_HEADER); + if (!provided) return false; + + const secretBuf = Buffer.from(secret, "utf8"); + const providedBuf = Buffer.from(provided, "utf8"); + if (secretBuf.length !== providedBuf.length) return false; + + return timingSafeEqual(secretBuf, providedBuf); + } catch { + return false; + } +} + +/** + * Rate-limit gate for the three bot-called routes. When the request is not + * an authenticated bot request (see `isAuthenticatedBotRequest`), this is + * exactly `checkRateLimit(request, bucket)` — the unchanged per-IP path. + * + * For an authenticated bot request, the caller id in `BOT_CALLER_HEADER` + * must be a Discord snowflake (17-20 digits); anything else returns 400 + * without ever calling a limiter. Otherwise checks `bot-aggregate` (keyed + * `discord-bot`, one ceiling across all bot traffic) and `bot-member` + * (keyed `discord:{id}`, per Discord member); either being over its limit + * returns the same 429 shape `checkRateLimit` returns. Header values and + * the caller id are used only as limiter keys — never logged or echoed. + */ +export async function checkCallerRateLimit( + request: Request, + bucket: string, +): Promise { + if (!isAuthenticatedBotRequest(request)) { + return checkRateLimit(request, bucket); + } + + const callerId = request.headers.get(BOT_CALLER_HEADER); + if (!callerId || !DISCORD_SNOWFLAKE_RE.test(callerId)) { + return NextResponse.json( + { error: "Missing or invalid caller id." }, + { status: 400 }, + ); + } + + const [aggregateLimited, memberLimited] = await Promise.all([ + isRateLimitedKey("bot-aggregate", "discord-bot"), + isRateLimitedKey("bot-member", `discord:${callerId}`), + ]); + + if (aggregateLimited || memberLimited) { + return NextResponse.json( + { error: "Too many requests — please wait a moment and try again." }, + { status: 429, headers: { "Retry-After": "60" } }, + ); + } + + return null; +} diff --git a/lib/report-meta.test.ts b/lib/report-meta.test.ts new file mode 100644 index 0000000..bc9a7a9 --- /dev/null +++ b/lib/report-meta.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join, sep } from "node:path"; +import type { WCLFight, WCLReportData } from "./wcl-types"; + +// Hoisted mocks: lib/report-meta.ts imports `after`/`connection` from +// next/server, `recordRecentReport` from lib/kv-cache, `maybeAnnounceReport` +// from lib/discord-announce, and `wclQuery`/`WCLError` from lib/wcl-client. +// Mocking all four lets every test control the WCL response, assert exactly +// what gets scheduled, and never touch a real Redis/WCL/Discord call. +// `WCLError` is declared here (not spread via importOriginal) so the mock +// stays a minimal, self-contained factory; report-meta.ts's own +// `err instanceof WCLError` check works because both this test file and the +// module under test resolve "@/lib/wcl-client" to this same mocked module. +const { serverMock, kvCacheMock, discordMock, wclClientMock, headersMock, observabilityMock } = + vi.hoisted(() => { + class MockWCLError extends Error { + kind: string; + constructor(kind: string, detail?: string) { + super(detail ?? kind); + this.name = "WCLError"; + this.kind = kind; + } + } + return { + serverMock: { + after: vi.fn<(cb: () => void | Promise) => void>(), + connection: vi.fn<() => Promise>(), + }, + kvCacheMock: { + recordRecentReport: vi.fn<(code: string, timestamp: number) => Promise>(), + }, + discordMock: { + maybeAnnounceReport: vi.fn<(code: string, report: WCLReportData) => Promise>(), + }, + wclClientMock: { + wclQuery: vi.fn<(query: string, vars: Record) => Promise>(), + WCLError: MockWCLError, + }, + // `announceAnalyzeRender` reads the request's user-agent header after + // `connection()` resolves (D-11 crawler filter). Default set in + // `beforeEach` to a desktop Chrome UA so every pre-existing test here + // keeps modelling a person and keeps passing without change. + headersMock: { + headers: vi.fn<() => Promise>(), + }, + observabilityMock: { + logEvent: vi.fn<(event: string, props?: Record) => void>(), + }, + }; + }); + +vi.mock("next/server", () => serverMock); +vi.mock("@/lib/kv-cache", () => kvCacheMock); +vi.mock("@/lib/discord-announce", () => discordMock); +vi.mock("@/lib/wcl-client", () => wclClientMock); +vi.mock("next/headers", () => headersMock); +vi.mock("@/lib/observability", () => observabilityMock); + +import { getReportMeta, mapReportMeta, announceAnalyzeRender } from "./report-meta"; +import { WCLError } from "@/lib/wcl-client"; + +const CHROME_DESKTOP_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + +function makeHeaders(userAgent: string | undefined | null): Headers { + const h = new Headers(); + if (userAgent !== undefined && userAgent !== null) { + h.set("user-agent", userAgent); + } + return h; +} + +function makeFight(overrides: Partial = {}): WCLFight { + return { + id: 1, + name: "Trash", + encounterID: 0, + kill: false, + startTime: 0, + endTime: 1000, + difficulty: 5, + bossPercentage: 0, + fightPercentage: 0, + ...overrides, + }; +} + +// One boss kill, one trash fight, one player actor — enough shape for +// mapReportMeta and the announce trigger's fight selection. +function makeReport(overrides: Partial = {}): WCLReportData { + return { + title: "Test Report", + owner: { name: "TestOwner" }, + startTime: 0, + endTime: 100_000, + zone: { id: 1, name: "Test Zone" }, + fights: [ + makeFight({ id: 1, name: "Trash", encounterID: 0, kill: false }), + makeFight({ + id: 2, + name: "Test Boss", + encounterID: 100, + kill: true, + startTime: 5000, + endTime: 10_000, + }), + ], + masterData: { + actors: [{ id: 1, name: "Player One", type: "Player", subType: "Warrior" }], + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + headersMock.headers.mockResolvedValue(makeHeaders(CHROME_DESKTOP_UA)); +}); + +describe("getReportMeta", () => { + it("returns ok with meta equal to mapReportMeta(report), the same raw report reference, records the recent report once, and never schedules an announce (the homepage/ISR path)", async () => { + const report = makeReport(); + wclClientMock.wclQuery.mockResolvedValueOnce({ reportData: { report } }); + + const result = await getReportMeta("VALIDCODE0001"); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") throw new Error("expected ok result"); + expect(result.meta).toEqual(mapReportMeta(report)); + expect(result.report).toBe(report); + expect(kvCacheMock.recordRecentReport).toHaveBeenCalledTimes(1); + expect(kvCacheMock.recordRecentReport).toHaveBeenCalledWith( + "VALIDCODE0001", + expect.any(Number), + ); + expect(serverMock.after).not.toHaveBeenCalled(); + expect(discordMock.maybeAnnounceReport).not.toHaveBeenCalled(); + }); + + it("returns not_found when wclQuery resolves a null report, and never records or schedules anything", async () => { + wclClientMock.wclQuery.mockResolvedValueOnce({ reportData: { report: null } }); + + const result = await getReportMeta("NOTFOUNDCODE1"); + + expect(result.status).toBe("not_found"); + expect(kvCacheMock.recordRecentReport).not.toHaveBeenCalled(); + expect(serverMock.after).not.toHaveBeenCalled(); + }); + + it("returns private for a WCLError of kind private, and error for kind rate_limited; after is never called in either case", async () => { + wclClientMock.wclQuery.mockRejectedValueOnce(new WCLError("private")); + const privateResult = await getReportMeta("PRIVATECODE01"); + expect(privateResult.status).toBe("private"); + expect(serverMock.after).not.toHaveBeenCalled(); + + wclClientMock.wclQuery.mockRejectedValueOnce(new WCLError("rate_limited")); + const rateLimitedResult = await getReportMeta("RATELIMITCODE"); + expect(rateLimitedResult.status).toBe("error"); + expect(serverMock.after).not.toHaveBeenCalled(); + }); + + it("returns not_found for a malformed code without calling wclQuery", async () => { + const result = await getReportMeta("bad code!"); + + expect(result.status).toBe("not_found"); + expect(wclClientMock.wclQuery).not.toHaveBeenCalled(); + }); +}); + +describe("announceAnalyzeRender", () => { + it("does not register an after() callback until connection() resolves; the registered callback calls maybeAnnounceReport exactly once with the same report reference", async () => { + let resolveConnection!: () => void; + const deferred = new Promise((resolve) => { + resolveConnection = resolve; + }); + serverMock.connection.mockReturnValueOnce(deferred); + + const report = makeReport(); + const pending = announceAnalyzeRender("ANNOUNCECODE1", report); + + // Flush microtasks without resolving connection() — after() must not + // have been scheduled yet, and neither must the header read or the + // gate log (D-11: nothing runs before connection() settles). + await Promise.resolve(); + await Promise.resolve(); + expect(serverMock.after).not.toHaveBeenCalled(); + expect(headersMock.headers).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).not.toHaveBeenCalled(); + + resolveConnection(); + await pending; + + expect(serverMock.after).toHaveBeenCalledTimes(1); + const scheduled = serverMock.after.mock.calls[0][0]; + await scheduled(); + + expect(discordMock.maybeAnnounceReport).toHaveBeenCalledTimes(1); + expect(discordMock.maybeAnnounceReport).toHaveBeenCalledWith("ANNOUNCECODE1", report); + }); + + it("rejects with connection()'s own error and never registers after() when connection() rejects (the build/prerender case)", async () => { + const connectionError = new Error("prerender bailout"); + serverMock.connection.mockRejectedValueOnce(connectionError); + const report = makeReport(); + + await expect(announceAnalyzeRender("REJECTCODE001", report)).rejects.toBe(connectionError); + expect(serverMock.after).not.toHaveBeenCalled(); + expect(headersMock.headers).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).not.toHaveBeenCalled(); + }); +}); + +describe("announceAnalyzeRender crawler filter (D-11)", () => { + it("a Googlebot render logs a bot gate line and never schedules an announce", async () => { + headersMock.headers.mockResolvedValue( + makeHeaders( + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + ), + ); + const report = makeReport(); + + await announceAnalyzeRender("GOOGLEBOTCODE", report); + + expect(serverMock.after).not.toHaveBeenCalled(); + expect(discordMock.maybeAnnounceReport).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).toHaveBeenCalledTimes(1); + expect(observabilityMock.logEvent).toHaveBeenCalledWith("discord_announce_gate", { + report_code: "GOOGLEBOTCODE", + ua_class: "bot", + ua_bot: "google", + }); + }); + + it("a Discordbot render (link unfurl, not a person) logs a bot gate line and never schedules an announce", async () => { + headersMock.headers.mockResolvedValue( + makeHeaders( + "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)", + ), + ); + const report = makeReport(); + + await announceAnalyzeRender("DISCORDBOTCODE", report); + + expect(serverMock.after).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).toHaveBeenCalledWith("discord_announce_gate", { + report_code: "DISCORDBOTCODE", + ua_class: "bot", + ua_bot: "discord", + }); + }); + + it("a missing user-agent header logs a bot gate line with label empty and never schedules an announce", async () => { + headersMock.headers.mockResolvedValue(makeHeaders(undefined)); + const report = makeReport(); + + await announceAnalyzeRender("NOUACODE0001", report); + + expect(serverMock.after).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).toHaveBeenCalledWith("discord_announce_gate", { + report_code: "NOUACODE0001", + ua_class: "bot", + ua_bot: "empty", + }); + }); + + it("a whitespace-only user-agent header logs a bot gate line with label empty and never schedules an announce", async () => { + headersMock.headers.mockResolvedValue(makeHeaders(" ")); + const report = makeReport(); + + await announceAnalyzeRender("BLANKUACODE1", report); + + expect(serverMock.after).not.toHaveBeenCalled(); + expect(observabilityMock.logEvent).toHaveBeenCalledWith("discord_announce_gate", { + report_code: "BLANKUACODE1", + ua_class: "bot", + ua_bot: "empty", + }); + }); + + it("a current desktop Chrome render schedules exactly one announce and logs a human gate line with no ua_bot", async () => { + headersMock.headers.mockResolvedValue(makeHeaders(CHROME_DESKTOP_UA)); + const report = makeReport(); + + await announceAnalyzeRender("HUMANCODE001", report); + + expect(serverMock.after).toHaveBeenCalledTimes(1); + const scheduled = serverMock.after.mock.calls[0][0]; + await scheduled(); + expect(discordMock.maybeAnnounceReport).toHaveBeenCalledTimes(1); + expect(discordMock.maybeAnnounceReport).toHaveBeenCalledWith("HUMANCODE001", report); + + expect(observabilityMock.logEvent).toHaveBeenCalledWith("discord_announce_gate", { + report_code: "HUMANCODE001", + ua_class: "human", + ua_bot: undefined, + }); + }); +}); + +// Repo root: this file lives at lib/report-meta.test.ts, so ".." is the root. +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +// Walks `dir` (repo-relative, e.g. "app" or "lib") for non-test .ts/.tsx +// source files, returning repo-relative, "/"-separated paths so a failure +// prints the exact unexpected file. +function listSourceFiles(dir: string): string[] { + const entries = readdirSync(join(REPO_ROOT, dir), { recursive: true }) as string[]; + return entries + .filter((p) => p.endsWith(".ts") || p.endsWith(".tsx")) + .filter((p) => !p.includes(".test.") && !p.includes("node_modules")) + .map((p) => `${dir}/${p.split(sep).join("/")}`) + .sort(); +} + +function filesContaining(identifier: string): string[] { + const candidates = [...listSourceFiles("app"), ...listSourceFiles("lib")]; + return candidates + .filter((relPath) => readFileSync(join(REPO_ROOT, relPath), "utf8").includes(identifier)) + .sort(); +} + +describe("announce trigger call-site invariants (G-05-16)", () => { + it("only lib/report-meta.ts and the /analyze page reference announceAnalyzeRender", () => { + expect(filesContaining("announceAnalyzeRender")).toEqual( + ["app/analyze/[reportCode]/page.tsx", "lib/report-meta.ts"].sort(), + ); + }); + + it("only lib/discord-announce.ts and lib/report-meta.ts reference maybeAnnounceReport", () => { + expect(filesContaining("maybeAnnounceReport")).toEqual( + ["lib/discord-announce.ts", "lib/report-meta.ts"].sort(), + ); + }); + + it("the homepage can post again if this fails: app/page.tsx and FeaturedReports.tsx contain neither identifier", () => { + for (const relPath of ["app/page.tsx", "app/components/FeaturedReports.tsx"]) { + const text = readFileSync(join(REPO_ROOT, relPath), "utf8"); + expect(text).not.toContain("announceAnalyzeRender"); + expect(text).not.toContain("maybeAnnounceReport"); + } + }); + + it("the /analyze page stays request-rendered: no revalidate, dynamic, or generateStaticParams export", () => { + const text = readFileSync( + join(REPO_ROOT, "app/analyze/[reportCode]/page.tsx"), + "utf8", + ); + expect(text).not.toMatch(/export const revalidate\b/); + expect(text).not.toMatch(/export const dynamic\b/); + expect(text).not.toContain("generateStaticParams"); + }); + + it("announceAnalyzeRender has no try block, and runs connection() -> headers() -> classifyUserAgent( -> after( in that order (D-11)", () => { + const fullText = readFileSync(join(REPO_ROOT, "lib/report-meta.ts"), "utf8"); + const marker = "export async function announceAnalyzeRender"; + const startIndex = fullText.indexOf(marker); + expect(startIndex).toBeGreaterThanOrEqual(0); + const body = fullText.slice(startIndex); + + expect(body).not.toMatch(/\btry\b/); + + const connectionIndex = body.indexOf("await connection()"); + const headersIndex = body.indexOf("headers()"); + const classifyIndex = body.indexOf("classifyUserAgent("); + const afterIndex = body.indexOf("after("); + + expect(connectionIndex).toBeGreaterThanOrEqual(0); + expect(headersIndex).toBeGreaterThan(connectionIndex); + expect(classifyIndex).toBeGreaterThanOrEqual(headersIndex); + expect(afterIndex).toBeGreaterThan(classifyIndex); + }); +}); diff --git a/lib/report-meta.ts b/lib/report-meta.ts index 1798a80..e874f28 100644 --- a/lib/report-meta.ts +++ b/lib/report-meta.ts @@ -1,7 +1,12 @@ import { cache } from "react"; +import { after, connection } from "next/server"; +import { headers } from "next/headers"; import { wclQuery, WCLError } from "@/lib/wcl-client"; import { REPORT_META_QUERY } from "@/lib/wcl-queries"; import { recordRecentReport } from "@/lib/kv-cache"; +import { maybeAnnounceReport } from "@/lib/discord-announce"; +import { classifyUserAgent } from "@/lib/crawler-ua"; +import { logEvent } from "@/lib/observability"; import type { WCLReportData, ReportMeta } from "@/lib/wcl-types"; /** @@ -39,10 +44,13 @@ export function mapReportMeta(report: WCLReportData): ReportMeta { } export type ReportMetaResult = - | { status: "ok"; meta: ReportMeta } + | { status: "ok"; meta: ReportMeta; report: WCLReportData } | { status: "not_found" } | { status: "private" } | { status: "error" }; +// `report` is the raw server-only WCL payload, kept alongside `meta` because +// `mapReportMeta` drops each fight's `startTime` — the announce trigger's +// fight selection (lib/discord-announce.ts pickFeaturedFight) needs it. /** * Server-side report fetch for rendering + metadata, returning a discriminated @@ -50,6 +58,11 @@ export type ReportMetaResult = * * Wrapped in React `cache()` so `generateMetadata` and the page component share * a single call per request; the underlying `wclQuery` also caches for 5 min. + * + * Has no Discord side effect by design (G-05-16): the ISR homepage + * (`app/components/FeaturedReports.tsx`) calls this for every featured report + * on each hourly revalidation, which is exactly why it must never announce — + * see `announceAnalyzeRender` below for the one and only trigger. */ export const getReportMeta = cache( async (code: string): Promise => { @@ -64,7 +77,7 @@ export const getReportMeta = cache( if (!report) return { status: "not_found" }; // Record this public report so the sitemap can surface it for crawling. await recordRecentReport(code, Date.now()); - return { status: "ok", meta: mapReportMeta(report) }; + return { status: "ok", meta: mapReportMeta(report), report }; } catch (err) { if (err instanceof WCLError) { if (err.kind === "not_found") return { status: "not_found" }; @@ -76,3 +89,50 @@ export const getReportMeta = cache( } }, ); + +/** + * The one and only Discord auto-post trigger in the app (G-05-16). Called + * only by the `/analyze/[reportCode]` page component — never from + * `generateMetadata`, a layout, a route handler, or any other component — + * so exactly one `after()` callback is ever scheduled per real report render. + * + * Awaits `connection()` before scheduling anything: `connection()` resolves + * immediately during a real incoming request, but throws (legacy prerender) + * or hangs (other prerenders) during a build or ISR revalidation render, + * which is Next's own signal that the route is dynamic. That guarantees a + * build/revalidation render can never reach the `after()` call below — no + * try/catch here, because swallowing that throw would defeat the guarantee. + * + * Only a person's browser may start an announcement (D-11): once + * `connection()` confirms this is a real request, the `user-agent` header is + * read and classified (`@/lib/crawler-ua`). Search/AI/SEO crawlers, link + * unfurlers (Discordbot included — an unfurl is not a person viewing the + * report), headless browsers, command-line HTTP clients, and a missing user + * agent all classify as `bot` and schedule nothing. Every decision — bot or + * human — logs one `discord_announce_gate` line so the crawler share of + * `/analyze` renders is visible in production logs (WINDOWS #28); the header + * read happens after `connection()` so a build or ISR render still stops at + * `connection()` first, before anything UA-related runs. + * + * Uses `after()` rather than a dangling promise so the serverless invocation + * stays alive until the webhook POST settles, since the platform may + * otherwise kill an un-awaited promise once the response has streamed. Takes + * the raw report (not `ReportMeta`) because `pickFeaturedFight` needs each + * fight's `startTime`, which `mapReportMeta` drops. + * + * `maybeAnnounceReport` never throws, so this can only reject when + * `connection()` itself signals a non-request render. + */ +export async function announceAnalyzeRender( + code: string, + report: WCLReportData, +): Promise { + await connection(); + + const requestHeaders = await headers(); + const { uaClass, bot } = classifyUserAgent(requestHeaders.get("user-agent")); + logEvent("discord_announce_gate", { report_code: code, ua_class: uaClass, ua_bot: bot }); + if (uaClass === "bot") return; + + after(() => maybeAnnounceReport(code, report)); +} diff --git a/lib/share-links.test.ts b/lib/share-links.test.ts new file mode 100644 index 0000000..81c700f --- /dev/null +++ b/lib/share-links.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { + parseShareRef, + buildReportShareUrl, + buildPlayerShareUrl, + buildAwardsShareUrl, + buildAwardsOgPath, + buildDiscordAnnounceUrl, +} from "./share-links"; + +const ORIGIN = "https://parseforge.gg"; + +describe("buildReportShareUrl", () => { + it("appends fight and ref=share", () => { + expect(buildReportShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23 })).toBe( + "https://parseforge.gg/analyze/ABC?fight=23&ref=share", + ); + }); + + it("omits fight when fightId is null but still carries ref=share", () => { + expect(buildReportShareUrl(ORIGIN, { reportCode: "ABC", fightId: null })).toBe( + "https://parseforge.gg/analyze/ABC?ref=share", + ); + }); + + it("never emits a tab param", () => { + const url = buildReportShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23 }); + expect(url).not.toContain("tab="); + }); +}); + +describe("buildPlayerShareUrl", () => { + it("appends fight, source and ref=parse — source always present", () => { + expect( + buildPlayerShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23, sourceId: 12 }), + ).toBe("https://parseforge.gg/analyze/ABC?fight=23&source=12&ref=parse"); + }); + + it("never emits a tab param", () => { + const url = buildPlayerShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23, sourceId: 12 }); + expect(url).not.toContain("tab="); + }); +}); + +describe("buildAwardsShareUrl", () => { + it("appends fight, view=awards and ref=awards", () => { + expect(buildAwardsShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23 })).toBe( + "https://parseforge.gg/analyze/ABC?fight=23&view=awards&ref=awards", + ); + }); + + it("never emits a tab param", () => { + const url = buildAwardsShareUrl(ORIGIN, { reportCode: "ABC", fightId: 23 }); + expect(url).not.toContain("tab="); + }); +}); + +describe("buildAwardsOgPath", () => { + it("returns the relative /og path with no ref", () => { + expect(buildAwardsOgPath({ reportCode: "ABC", fightId: 23 })).toBe( + "/og?report=ABC&fight=23&view=awards", + ); + }); + + it("never emits a tab param", () => { + expect(buildAwardsOgPath({ reportCode: "ABC", fightId: 23 })).not.toContain("tab="); + }); +}); + +describe("buildDiscordAnnounceUrl", () => { + it("appends fight, view=awards and ref=discord", () => { + expect(buildDiscordAnnounceUrl(ORIGIN, { reportCode: "ABC", fightId: 23 })).toBe( + "https://parseforge.gg/analyze/ABC?fight=23&view=awards&ref=discord", + ); + }); + + it("never emits a source or tab param", () => { + const url = buildDiscordAnnounceUrl(ORIGIN, { reportCode: "ABC", fightId: 23 }); + expect(url).not.toContain("source="); + expect(url).not.toContain("tab="); + }); +}); + +describe("parseShareRef", () => { + it.each(["share", "parse", "awards", "discord"] as const)("returns %s for an exact match", (ref) => { + expect(parseShareRef(ref)).toBe(ref); + }); + + it.each([ + ["empty string", ""], + ["null", null], + ["undefined", undefined], + ["unknown word", "banana"], + ["case variant", "Share"], + ["a URL", "https://evil.example/x"], + ["a script tag", ""], + ])("returns null for %s", (_label, value) => { + expect(parseShareRef(value)).toBeNull(); + }); +}); diff --git a/lib/share-links.ts b/lib/share-links.ts new file mode 100644 index 0000000..d0a1d27 --- /dev/null +++ b/lib/share-links.ts @@ -0,0 +1,112 @@ +/** + * The single source of truth for every URL this phase copies to a clipboard, + * and for the `ref` allowlist. No share surface should ever read the + * browser's current URL again (D-09) — every caller passes an explicit + * `origin` so the caller decides between production and a preview host, and + * a test can pin the exact output. Pure module: no browser globals, no + * runtime imports. + */ + +/** The four allowed inbound-attribution values (D-16). */ +export type ShareRefKind = "share" | "parse" | "awards" | "discord"; + +export const SHARE_REF_KINDS: readonly ShareRefKind[] = [ + "share", + "parse", + "awards", + "discord", +] as const; + +/** + * Validates an untrusted `ref` query-string value against the fixed + * allowlist. Returns the matching kind only on an exact, case-sensitive + * match; everything else (including an unknown word, a case variant, or a + * URL/script-tag payload) returns null rather than being passed through to a + * PostHog event property (RESEARCH Pitfall 5, ASVS V5). + */ +export function parseShareRef(raw: string | null | undefined): ShareRefKind | null { + if (raw == null) return null; + return (SHARE_REF_KINDS as readonly string[]).includes(raw) ? (raw as ShareRefKind) : null; +} + +/** + * Report-level permalink: `/analyze/{code}?fight={id}&ref=share`. Appends + * `fight` only when `fightId` is a number; never appends `source` or `tab`. + */ +export function buildReportShareUrl( + origin: string, + { reportCode, fightId }: { reportCode: string; fightId: number | null }, +): string { + const params = new URLSearchParams(); + if (typeof fightId === "number") params.set("fight", String(fightId)); + params.set("ref", "share"); + return `${origin}/analyze/${reportCode}?${params.toString()}`; +} + +/** + * Player-level permalink: `/analyze/{code}?fight={id}&source={id}&ref=parse`. + * `source` is always present; never appends `tab`. + */ +export function buildPlayerShareUrl( + origin: string, + { reportCode, fightId, sourceId }: { reportCode: string; fightId: number; sourceId: number }, +): string { + const params = new URLSearchParams(); + params.set("fight", String(fightId)); + params.set("source", String(sourceId)); + params.set("ref", "parse"); + return `${origin}/analyze/${reportCode}?${params.toString()}`; +} + +/** + * Awards permalink: `/analyze/{code}?fight={id}&view=awards&ref=awards`. + * Never appends `source` or `tab`. + */ +export function buildAwardsShareUrl( + origin: string, + { reportCode, fightId }: { reportCode: string; fightId: number }, +): string { + const params = new URLSearchParams(); + params.set("fight", String(fightId)); + params.set("view", "awards"); + params.set("ref", "awards"); + return `${origin}/analyze/${reportCode}?${params.toString()}`; +} + +/** + * Discord auto-post permalink: `/analyze/{code}?fight={id}&view=awards&ref=discord`. + * This is the permalink the Discord embed's `url` field points at (plan + * 05-01) — the `discord` ref is what makes Discord-sourced traffic show up + * as a `share_landing` event with `ref: "discord"` in PostHog. No change to + * `AnalyzeClient.tsx` is needed or permitted: it already captures whatever + * `parseShareRef` admits. Never appends `source`. + */ +export function buildDiscordAnnounceUrl( + origin: string, + { reportCode, fightId }: { reportCode: string; fightId: number }, +): string { + const params = new URLSearchParams(); + params.set("fight", String(fightId)); + params.set("view", "awards"); + params.set("ref", "discord"); + return `${origin}/analyze/${reportCode}?${params.toString()}`; +} + +/** + * Relative `/og` path for the awards card's in-app preview `` (plan + * 03-04). Carries `report`, `fight` and `view=awards`; no `ref` — the OG + * route ignores it. + */ +export function buildAwardsOgPath({ + reportCode, + fightId, +}: { + reportCode: string; + fightId: number; +}): string { + const params = new URLSearchParams(); + params.set("report", reportCode); + params.set("fight", String(fightId)); + params.set("view", "awards"); + return `/og?${params.toString()}`; +} diff --git a/lib/wcl-types.ts b/lib/wcl-types.ts index 2483ea6..d3764f9 100644 --- a/lib/wcl-types.ts +++ b/lib/wcl-types.ts @@ -509,6 +509,37 @@ export interface RaidOverviewResult { healerMetrics: HealerMetrics[]; } +// ─── Awards Types (lib/awards-engine.ts) ───────────────────────────── + +export interface AwardWinner { + name: string; + className: string; + sourceId: number; +} + +/** `praise` for a positive award, `jab` for a gentle roast (D-01). */ +export type AwardTone = "praise" | "jab"; + +export interface AwardRow { + id: string; + title: string; + icon: string; + priority: number; + tone: AwardTone; + winners: AwardWinner[]; + /** Winners beyond MAX_WINNER_NAMES, truncated from the `winners` list. */ + extraWinnerCount: number; + /** The stat that earned the award, e.g. "0:42 in" or "no flask". */ + stat: string; +} + +export interface AwardsResult { + encounterName: string; + /** Null when the caller has no fight outcome — the card renders no Kill/Wipe pill. */ + outcome: { kill: boolean; bossPercentage: number } | null; + awards: AwardRow[]; +} + // ─── Healer Metrics Types ───────────────────────────────────────────── // HealerTableRow narrows the un-scoped per-player Healing row (`healingByPlayer` // in lib/wcl-queries.ts) to what the shared healer helper reads — the scoped @@ -581,3 +612,82 @@ export interface ReportMeta { icon: string; }>; } + +// ─── Report Rankings Blob Types (R0-3) ─────────────────────────────── +// Describes `reportData.report.rankings(fightIDs:)` — the per-report parse +// blob returned for one report/fight combination. This is deliberately NOT +// an extension of `WCLRanking`/`WCLRankingsData` above: those describe the +// `characterRankings` boss-leaderboard query, a different WCL query that +// shares no fields with this one. Every field name below was verified +// against the recorded response in lib/__fixtures__/rankings-report.json +// (recorded 2026-09-20) — not against PARSEFORGE-RANKINGS-SPEC.md alone — +// see lib/rankings/parse-lens.ts, the sole consumer of this type. + +export interface RankingsCharacterServer { + id: number; + name: string; + region: string; +} + +export interface RankingsCharacterEntry { + id: number; + name: string; + server: RankingsCharacterServer; + class: string; + spec: string; + amount: number; + bracketData: number; + bracket: number; + rank: string; + best: string; + totalParses: number; + rankPercent: number | null; + /** Absent from every character row in the 2026-09-20 recording + * (lib/__fixtures__/README.md) — honour it when a future recording + * includes it, never assume it is always present or always absent. */ + hidden?: boolean; +} + +export interface RankingsRoleGroup { + name: string; + characters: RankingsCharacterEntry[]; +} + +export interface RankingsRoles { + tanks: RankingsRoleGroup; + healers: RankingsRoleGroup; + dps: RankingsRoleGroup; +} + +export interface RankingsSpeedExecution { + rank: string; + best: string; + totalParses: number; + rankPercent: number | null; +} + +export interface ReportRankingEntry { + fightID: number; + partition: number; + zone: number; + encounter: { id: number; name: string }; + difficulty: number; + size: number; + /** WCL returns this as 0/1, not a boolean — see the recorded fixture. */ + kill: number; + duration: number; + bracketData: number; + deaths: number; + damageTakenExcludingTanks: number; + roles: RankingsRoles; + bracket: number; + /** Character ids blacklisted from this entry's rankings (e.g. "gaming" the parse — RANKINGS-SPEC.md §4.3). */ + reportsBlacklistForCharacters: number[]; + speed: RankingsSpeedExecution; + execution: RankingsSpeedExecution; +} + +/** The top-level shape of `reportData.report.rankings`. */ +export interface ReportRankingsBlob { + data: ReportRankingEntry[]; +} diff --git a/next.config.ts b/next.config.ts index aed2c85..830705a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,14 +14,35 @@ import type { NextConfig } from "next"; // declines to publish a fixed CMP domain list (01-RESEARCH.md Pitfall 2), so // any further hosts the dialog needs are to be discovered from report-only // violation reports post-deploy, not guessed at in advance. +// +// pagead2.googlesyndication.com serves the AdSense SDK itself (lib/ads.ts's +// `loadAdSenseScript`) and opens the ad-request connection; googleads.g. +// doubleclick.net is the ad-request/auction host the SDK calls; tpc. +// googlesyndication.com serves the filled creative's iframe (Phase 4, +// 04-CONTEXT.md D-06/D-07). `img-src`'s existing `https:` wildcard already +// covers creative images, so it needs no addition. As with the CMP hosts +// above, the remaining hosts a filled creative opens (e.g. a third-party +// tracker inside the creative) are to be discovered from report-only +// violation reports after deploy, not guessed exhaustively; promoting this +// policy to enforcing is Phase 7, out of scope here. +// +// ep1/ep2.adtrafficquality.google are AdSense's "sodar" ad-viewability/ +// anti-fraud beacon network — harvested from the Phase 4 preview's +// report-only CSP violations (04-06 Task 2/3, docs/OPS-01-SHIP-GATE.md Part +// 6 "CSP violation harvest"): ep1 was observed under connect-src, ep2 under +// script-src and frame-src, both only on the admitted (ads-loading) run. +// `www.google.com` was also observed on that run but its violated directive +// was not captured in the harvest sample, so per that harvest's own +// disposition it is deliberately NOT added here — added only once a report +// names its directive. const CSP_REPORT_ONLY = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://wow.zamimg.com https://fundingchoicesmessages.google.com", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://wow.zamimg.com https://fundingchoicesmessages.google.com https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net https://ep2.adtrafficquality.google", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", - "connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com https://fundingchoicesmessages.google.com", - "frame-src 'self' https://fundingchoicesmessages.google.com", + "connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com https://fundingchoicesmessages.google.com https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net https://ep1.adtrafficquality.google", + "frame-src 'self' https://fundingchoicesmessages.google.com https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://ep2.adtrafficquality.google", "worker-src 'self' blob:", "frame-ancestors 'self'", "base-uri 'self'", diff --git a/package.json b/package.json index ba3c9d7..e8f2104 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "theme-parity": "node scripts/theme-parity.mjs", "token-audit": "node scripts/token-audit.mjs", "seo-invariants": "node scripts/seo-invariants.mjs", + "protected-elements": "node scripts/protected-elements.mjs", "record-fixtures": "node scripts/record-wcl-fixtures.mjs", "regen-game-data": "node scripts/regen-game-data.mjs" }, diff --git a/scripts/build-bot-data.mjs b/scripts/build-bot-data.mjs new file mode 100644 index 0000000..13aa6d3 --- /dev/null +++ b/scripts/build-bot-data.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node +// scripts/build-bot-data.mjs +// +// Why this exists: bot/Dockerfile only COPYs bot/src into the Docker image +// (D-02 forbids the bot importing the root app's lib/ at all), but D-05's +// gateway-native role picker needs the EXACT role data that lives in +// lib/discord-roles.json. This script is the one bridge: it reads root-side +// sources and writes generated TypeScript modules into bot/src/generated/ +// that the bot imports like any other local file — no cross-boundary import, +// no hand-copied data to drift out of sync. +// +// Run with --check (e.g. in the root npm test suite, see lib/bot-data.test.ts) +// to assert every generated file is still byte-identical to what its source +// would produce right now; it exits non-zero and names the stale file instead +// of silently letting the bot's copy drift from lib/discord-roles.json. +// +// Structured as a list of generators so a later plan (05.1-07's `/help` seed) +// can add a second entry here without touching this file's control flow. +// +// Usage: +// node scripts/build-bot-data.mjs # (re)generate every output +// node scripts/build-bot-data.mjs --check # verify only; exit 1 on drift + +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, ".."); + +// ─── /help seed extraction (plan 05.1-07) ────────────────────────────── +// Dependency-free HTML/JSX -> plain-text extraction for the site's own +// guides, the tbc-audit FAQ, and the Discord setup copy. Pure functions of +// the source files on disk, like every generator here — no timestamps, no +// random ordering, so `--check` stays byte-for-byte comparable. + +const HELP_SEED_MIN_CHARS = 200; +const HELP_SEED_MAX_CHARS = 8000; + +const HTML_ENTITIES = { + "'": "'", + """: '"', + "&": "&", + "’": "\u2019", + "‘": "\u2018", + "“": "\u201c", + "”": "\u201d", + "—": "\u2014", + "–": "\u2013", + "←": "\u2190", + "→": "\u2192", + "…": "\u2026", + " ": " ", +}; + +function decodeHtmlEntities(text) { + return text.replace(/&[a-z]+;/g, (m) => HTML_ENTITIES[m] ?? m); +} + +/** First `field: "..."` string-literal value in a Next.js `metadata` object. */ +function extractMetaField(source, field) { + const re = new RegExp(`${field}:\\s*"([^"]*)"`); + const m = source.match(re); + return m ? m[1] : ""; +} + +/** The raw markup between the first ``. */ +function extractArticleHtml(source) { + const start = source.indexOf("", start); + const end = source.indexOf("", openEnd); + if (openEnd < 0 || end < 0) return ""; + return source.slice(openEnd + 1, end); +} + +/** + * Re-flows one blank-line-separated block of extracted text: a prettified + * JSX source wraps a single paragraph's inline elements (``, ``, + * ``...) across several source lines, which would otherwise survive + * as spurious single-newline breaks. Lines that open a markdown heading or + * list marker stay on their own line; every other line in the block is + * joined into one flowing line. + */ +const MARKER_RE = /^(-|##|###)(\s|$)/; + +function reflowBlock(block) { + const lines = block + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const out = []; + let buffer = []; + const flush = () => { + if (buffer.length > 0) { + out.push(buffer.join(" ").replace(/\s+/g, " ").trim()); + buffer = []; + } + }; + for (const line of lines) { + // A marker line (`-`/`##`/`###`, possibly with its text already on the + // same line) starts a NEW buffer — never gets appended to the previous + // one — so each list item or heading becomes its own output line even + // when the source wrapped its text onto a following line. + if (MARKER_RE.test(line)) { + flush(); + } + buffer.push(line); + } + flush(); + return out.join("\n"); +} + +// A single rare control character marks a genuine block boundary (paragraph, +// heading, or blank line) captured from the ORIGINAL markup before any tag +// is stripped. Stripping a tag to a single space (e.g. an inline ``/ +// `` wrapping its own text on separate source lines) can otherwise +// recreate a "\n \n"-shaped run that looks exactly like a blank line, even +// though the source never had one — marking real boundaries first and +// splitting on the sentinel (not on `/\n\s*\n/`) avoids that false split. +const BLOCK_BREAK = "\u0002"; + +/** + * Turns a guide page's JSX body into plain text: genuine blank lines are + * marked as block breaks first (before any tag touches the whitespace), + * `{" "}` spacers become a space, `

`/`

`/`

` become their own + * block, `

  • ` becomes `- ` lines, every other tag (and its attributes, so + * no `className=` ever survives) is dropped, HTML entities are decoded, any + * leftover `{...}` JSX expression is stripped, and each block's + * source-wrapped inline elements are re-flowed into running prose + * (`reflowBlock`). + */ +function htmlToPlainText(html) { + let text = html; + text = text.replace(/\n[ \t]*\n/g, BLOCK_BREAK); + text = text.replace(/\{"\s*"\}/g, " "); + text = text.replace(/]*>\s*/g, `${BLOCK_BREAK}## `).replace(/\s*<\/h2>/g, BLOCK_BREAK); + text = text.replace(/]*>\s*/g, `${BLOCK_BREAK}### `).replace(/\s*<\/h3>/g, BLOCK_BREAK); + text = text.replace(/]*>\s*/g, "\n- ").replace(/\s*<\/li>/g, "\n"); + text = text.replace(/]*>\s*/g, BLOCK_BREAK).replace(/\s*<\/p>/g, BLOCK_BREAK); + text = text.replace(/<[^>]+>/g, " "); + text = decodeHtmlEntities(text); + text = text.replace(/\{[^{}]*\}/g, " "); + const blocks = text + .split(BLOCK_BREAK) + .map(reflowBlock) + .filter((block) => block.length > 0); + return blocks.join("\n\n").trim(); +} + +function buildGuideArticle(dirName) { + const pagePath = path.join(REPO_ROOT, "app", "guides", dirName, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + const title = extractMetaField(source, "title"); + const description = extractMetaField(source, "description"); + const content = htmlToPlainText(extractArticleHtml(source)).slice(0, HELP_SEED_MAX_CHARS); + if (content.length < HELP_SEED_MIN_CHARS) { + throw new Error( + `bot-data: guide "${dirName}" extracted only ${content.length} characters (need >= ${HELP_SEED_MIN_CHARS})`, + ); + } + return { + slug: `guide-${dirName}`, + title, + description, + content, + sourceUrl: `https://parseforge.gg/guides/${dirName}`, + }; +} + +function unescapeJsString(raw) { + return raw.replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\\\/g, "\\"); +} + +/** Reads the `q`/`a` string pairs out of `app/tbc-audit/page.tsx`'s `const FAQ = [...]`. */ +function extractFaqItems(source) { + const start = source.indexOf("const FAQ = ["); + if (start < 0) throw new Error("bot-data: app/tbc-audit/page.tsx has no `const FAQ = [` array"); + const open = source.indexOf("[", start); + let depth = 0; + let end = -1; + for (let i = open; i < source.length; i++) { + if (source[i] === "[") depth++; + else if (source[i] === "]") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end < 0) throw new Error("bot-data: could not find the end of app/tbc-audit/page.tsx's FAQ array"); + const body = source.slice(open + 1, end); + const re = /q:\s*"((?:\\.|[^"\\])*)",\s*a:\s*"((?:\\.|[^"\\])*)"/g; + const items = []; + let m; + while ((m = re.exec(body))) { + items.push({ q: unescapeJsString(m[1]), a: unescapeJsString(m[2]) }); + } + if (items.length === 0) throw new Error("bot-data: app/tbc-audit/page.tsx's FAQ array parsed to zero items"); + return items; +} + +function buildFaqArticles() { + const tbcAuditPath = path.join(REPO_ROOT, "app", "tbc-audit", "page.tsx"); + const source = readFileSync(tbcAuditPath, "utf8"); + return extractFaqItems(source).map((item, i) => { + const content = `${item.q}\n\n${item.a}\n\nThis is one answer from ParseForge's TBC raid audit FAQ at https://parseforge.gg/tbc-audit, which checks gear, consumables, raid buffs and talent builds for the whole raid at once.`.slice( + 0, + HELP_SEED_MAX_CHARS, + ); + if (content.length < HELP_SEED_MIN_CHARS) { + throw new Error( + `bot-data: tbc-audit FAQ item ${i + 1} extracted only ${content.length} characters (need >= ${HELP_SEED_MIN_CHARS})`, + ); + } + return { + slug: `faq-tbc-audit-${i + 1}`, + title: item.q, + description: "", + content, + sourceUrl: "https://parseforge.gg/tbc-audit", + }; + }); +} + +/** The text between a `### Copy: ...` heading and the next `##`/`###` heading. */ +function extractNamedSection(source, heading) { + const idx = source.indexOf(heading); + if (idx < 0) throw new Error(`bot-data: docs/DISCORD-SEED.md is missing heading "${heading}"`); + const from = idx + heading.length; + const nextH2 = source.indexOf("\n## ", from); + const nextH3 = source.indexOf("\n### ", from); + let end = source.length; + if (nextH2 >= 0) end = Math.min(end, nextH2); + if (nextH3 >= 0) end = Math.min(end, nextH3); + return source.slice(from, end); +} + +function extractFencedBlocks(section) { + const re = /```\n([\s\S]*?)```/g; + const blocks = []; + let m; + while ((m = re.exec(section))) { + blocks.push(m[1].trim()); + } + return blocks; +} + +const DISCORD_SEED_SECTIONS = [ + { title: "Channel topics", heading: "### Copy: channel topics" }, + { title: "The #welcome message", heading: "### Copy: the #welcome message" }, + { title: "#feedback forum guidelines", heading: "### Copy: #feedback forum guidelines" }, + { title: "Role picker", heading: "### Copy: role picker" }, +]; + +function buildDiscordServerArticle() { + const seedPath = path.join(REPO_ROOT, "docs", "DISCORD-SEED.md"); + const source = readFileSync(seedPath, "utf8"); + const parts = DISCORD_SEED_SECTIONS.map(({ title, heading }) => { + const blocks = extractFencedBlocks(extractNamedSection(source, heading)); + if (blocks.length === 0) { + throw new Error(`bot-data: docs/DISCORD-SEED.md's "${heading}" section has no fenced code block`); + } + return `## ${title}\n${blocks.join("\n\n")}`; + }); + const content = parts.join("\n\n").slice(0, HELP_SEED_MAX_CHARS); + if (content.length < HELP_SEED_MIN_CHARS) { + throw new Error(`bot-data: discord-server article extracted only ${content.length} characters`); + } + return { + slug: "discord-server", + title: "The ParseForge Discord", + description: + "Channel topics, the #welcome message, #feedback forum guidelines, and the #welcome role picker.", + content, + sourceUrl: "", + }; +} + +function buildHelpSeedArticles() { + const guidesDir = path.join(REPO_ROOT, "app", "guides"); + const guideDirs = readdirSync(guidesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const articles = [ + ...guideDirs.map(buildGuideArticle), + ...buildFaqArticles(), + buildDiscordServerArticle(), + ]; + articles.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0)); + return articles; +} + +/** + * Each generator reads its own root-side source(s) and returns the exact + * file text that should live at `outPath` (relative to the repo root). + * `build()` must be a pure function of the source file(s) on disk — no + * timestamps, no random ordering — so `--check` can compare it byte-for-byte + * against what's already written. + */ +const GENERATORS = [ + { + name: "discord-roles", + outPath: path.join("bot", "src", "generated", "discord-roles.ts"), + build() { + const raw = readFileSync(path.join(REPO_ROOT, "lib", "discord-roles.json"), "utf8"); + const roles = JSON.parse(raw); + const json = JSON.stringify(roles, null, 2); + return ( + "// GENERATED by scripts/build-bot-data.mjs from lib/discord-roles.json — do not edit;\n" + + "// run `node scripts/build-bot-data.mjs` to regenerate after editing the source file.\n" + + `export const DISCORD_ROLES = ${json} as const;\n` + ); + }, + }, + { + name: "help-seed", + outPath: path.join("bot", "src", "generated", "help-seed.ts"), + build() { + const json = JSON.stringify(buildHelpSeedArticles(), null, 2); + return ( + "// GENERATED by scripts/build-bot-data.mjs from app/guides/*/page.tsx,\n" + + "// app/tbc-audit/page.tsx and docs/DISCORD-SEED.md — do not edit; run\n" + + "// `node scripts/build-bot-data.mjs` to regenerate after editing a source file.\n" + + `export const HELP_SEED_ARTICLES = ${json} as const;\n` + ); + }, + }, +]; + +function generate() { + for (const gen of GENERATORS) { + const outAbs = path.join(REPO_ROOT, gen.outPath); + mkdirSync(path.dirname(outAbs), { recursive: true }); + writeFileSync(outAbs, gen.build(), "utf8"); + console.log(`bot-data: wrote ${gen.outPath}`); + } +} + +function check() { + let inSync = true; + for (const gen of GENERATORS) { + const outAbs = path.join(REPO_ROOT, gen.outPath); + const expected = gen.build(); + const actual = existsSync(outAbs) ? readFileSync(outAbs, "utf8") : null; + if (actual !== expected) { + console.error( + `bot-data: ${gen.outPath} is stale or missing — run \`node scripts/build-bot-data.mjs\``, + ); + inSync = false; + } + } + if (inSync) { + console.log("bot-data: in sync"); + } + return inSync; +} + +const args = process.argv.slice(2); +if (args.includes("--check")) { + process.exit(check() ? 0 : 1); +} else { + generate(); +} diff --git a/scripts/discord-bot.mjs b/scripts/discord-bot.mjs new file mode 100644 index 0000000..9be9cfb --- /dev/null +++ b/scripts/discord-bot.mjs @@ -0,0 +1,1267 @@ +#!/usr/bin/env node +// ───────────────────────────────────────────────────────────────────────── +// discord-bot: a one-off operations tool for the ParseForge Discord bot +// (application 1481197331482153031, guild 1552413469301678122). Built for +// plan 05-08's G-05-16 gap closure (D-11): proving the crawler filter on +// production and clearing the crawler-posted #announcements backlog. +// +// Every write this script can perform (a bulk delete, a single delete, a +// channel post) requires an explicit `--execute` flag. Without it, every +// subcommand is read-only and prints what it WOULD do. +// +// The Interactions Endpoint URL is cleared and the gateway bot (bot/, on +// Railway) answers every interaction; parseforge.gg no longer has an +// interactions route. The `set-interactions-endpoint` subcommand stays for +// emergencies only. +// +// The bot token lives only in the gitignored bot/.env, loaded here with +// Node's own `process.loadEnvFile` so the value never reaches the shell or +// a command line. It is never printed, logged, or written to a file by this +// script; a non-2xx Discord response is reported by HTTP status and +// Discord's own numeric error code only — never a header, never the token. +// +// Subcommands: +// roles +// Read-only. Lists guild 1552413469301678122's roles (id, name, managed, +// permissions, position) and the bot's own highest role position, marking +// each role grantable (unmanaged and positioned below the bot) or not. +// Built for plan 05-10's role picker (D-12): sourcing real role ids for +// lib/discord-roles.json and confirming the bot can actually manage them. +// +// scan-announcements [--codes-out FILE] +// Read-only. Pages through the whole #announcements history (100 at a +// time, newest first) and prints total/webhook-authored/pinned counts, +// a per-UTC-day count, the oldest and newest message times, and how +// many of the four known report-card codes it found. --codes-out writes +// every report code found in a card embed URL to FILE, one per line. +// +// purge-announcements --keep-after ISO [--execute --expect-delete N] +// Buckets every #announcements message into exactly one of +// keep_not_webhook / keep_pinned / keep_known / keep_after / too_old / +// delete (first match wins, in that order) and prints every bucket's +// count. Without --execute, nothing is deleted. With --execute, refuses +// (exit 1, nothing deleted) unless too_old is 0, keep_known covers +// exactly the four known codes once each, and the recomputed delete +// count equals --expect-delete; then deletes the delete bucket in +// batches of at most 100 via bulk-delete (a final batch of exactly one +// id uses the single-message delete endpoint instead, since bulk +// delete needs at least two), stopping at the first failed batch, and +// finally re-scans and prints the remaining bucket counts. +// +// post --channel NAME --file PATH [--execute] +// Without --execute, prints the target channel and the file's text. +// With --execute, posts the text as one message (allowed_mentions set +// to parse nothing) and prints the new message id. +// +// commands list +// Read-only. Prints `global: N` and `guild: N` count lines, then every +// command's scope, name and id. +// +// commands retire [--execute] +// Prints what it would remove (same shape as `commands list`). With +// --execute, bulk-overwrites both the global and guild command lists +// with an empty list (retiring /raid and /analyze — D-12), then lists +// them again to confirm both scopes are empty. +// +// set-interactions-endpoint --url URL [--execute] +// Prints `current: VALUE` (the application's interactions_endpoint_url +// read back from Discord, or "none") and `requested: URL`. With +// --execute, PATCHes the new URL — Discord validates it by sending a +// signed PING and a badly signed request to it before accepting — and +// prints the result (success echoes the URL; a failed validation +// returns HTTP 400 with Discord's own message). +// +// set-interactions-endpoint --clear [--execute] +// Plan 05.1-09 (D-05/D-08 relay). Same read-back as --url, but prints +// `requested: none` and changes nothing without --execute. With +// --execute, sends an empty string for interactions_endpoint_url (the +// documented way to clear it); if Discord answers that with HTTP 400, +// falls back to sending `null` instead and reports which of the two +// forms Discord actually accepted. Prints `Updated. +// interactions_endpoint_url: none` either way. Clearing this URL hands +// every interaction for this application to the gateway bot instead of +// parseforge.gg's HTTP route — only run --execute once the bot is +// already live and ready to answer. +// +// role-picker [--execute] +// Reads lib/discord-roles.json, resolves #welcome, and checks the +// channel for an existing message from this bot whose buttons use the +// picker's custom-id prefix; if one exists, prints its id and refuses +// (exit 1) rather than posting a second picker. Otherwise prints the +// three planned messages (group heading as content; up to five +// secondary buttons per row, custom ids computed the same way as +// bot/src/roles.ts's custom ids). With --execute, posts +// them in channel order and prints each new message id. +// +// preflight +// Read-only. Built for plan 05.1-03's production tracer. Prints +// message_content_intent (on/off, from GET /applications/@me's flags +// bit 18), interactions_endpoint_url (or "none"), the bot's effective +// guild permissions — manage_threads, add_reactions, send_in_threads, +// manage_channels, manage_roles, each yes/no, from the union of the +// bot's own roles and @everyone — and one line each for the ops, +// feedback_forum and feedback_text channels: id or "missing", plus +// whether @everyone can view (ops, feedback_forum) or send +// (feedback_text). Resolves by name AND channel type (forum = 15) so +// the two channels named "feedback" are told apart. Plan 05.1-08 added +// two more lines: feedback_forum also reports public: yes|no (view AND +// send both allowed for @everyone), and feedback_archive reports the +// id (or "missing") and read-only: yes|no for a text channel literally +// named "feedback-archive". +// +// feedback-forum --id ID --open [--execute] +// Plan 05.1-08 (D-11). Refuses (exit 1) unless ID resolves to a type-15 +// (forum) channel. Prints the planned PATCH: topic re-read from +// "### Copy: #feedback forum guidelines" in docs/DISCORD-SEED.md, +// available_tags (the forum's existing tags kept by id, Bug 🐛 / +// Feature 💡 added only if missing by name), the full replacement +// permission_overwrites list (the @everyone overwrite changed to allow +// VIEW_CHANNEL, SEND_MESSAGES, SEND_MESSAGES_IN_THREADS, +// READ_MESSAGE_HISTORY and ADD_REACTIONS; every other existing +// overwrite — Developer role, the bot's own member overwrite — kept +// unchanged), and position (set to the old #feedback TEXT channel's +// current position, so the forum takes its place in the channel list). +// With --execute, PATCHes the forum. There is no subcommand that +// deletes a channel. +// +// archive-channel --id ID --name NAME [--execute] +// Plan 05.1-08 (D-11). Refuses (exit 1) unless ID resolves to a type-0 +// (text) channel. Prints the planned PATCH: name NAME, topic re-read +// from "### Copy: #feedback-archive topic" in docs/DISCORD-SEED.md, and +// the full replacement permission_overwrites list (the @everyone +// overwrite changed to allow VIEW_CHANNEL and READ_MESSAGE_HISTORY and +// deny SEND_MESSAGES, SEND_MESSAGES_IN_THREADS, CREATE_PUBLIC_THREADS +// and ADD_REACTIONS; every other existing overwrite kept unchanged). +// With --execute, PATCHes the channel. Deliberately has no delete +// subcommand — archiving, never deleting, is the only path this script +// offers for the old #feedback channel. +// +// bot-channels [--execute] +// Refuses (exit 1) if a text channel #ops or a forum channel named +// feedback already exists. Otherwise prints the two planned +// channel-creation payloads and, with --execute, creates #ops (text; +// @everyone denied View Channel; the Developer role and the bot's own +// member allowed to view/post/react) and the feedback forum (forum; +// @everyone denied View Channel; Developer and the bot allowed to +// view/post-in-threads/react; topic set to the fenced block under +// "### Copy: #feedback forum guidelines" in docs/DISCORD-SEED.md; +// available_tags Bug 🐛 and Feature 💡). Prints both new channel ids. +// +// thread-status --id ID +// Read-only. Prints a forum thread's name, its archived flag and +// message_count, how many of its latest 20 messages were authored by +// the bot user, and the first 120 characters of the newest +// bot-authored message. Never prints a member's message text or any +// user id. +// +// Usage: +// node scripts/discord-bot.mjs scan-announcements --codes-out /tmp/codes.txt +// node scripts/discord-bot.mjs purge-announcements --keep-after 2026-09-28T05:16:39Z +// node scripts/discord-bot.mjs purge-announcements --keep-after ... --execute --expect-delete 5289 +// node scripts/discord-bot.mjs post --channel changelog --file /tmp/changelog.txt +// node scripts/discord-bot.mjs post --channel changelog --file /tmp/changelog.txt --execute +// node scripts/discord-bot.mjs commands list +// node scripts/discord-bot.mjs commands retire --execute +// node scripts/discord-bot.mjs set-interactions-endpoint --url https://parseforge.gg/api/discord/interactions --execute +// node scripts/discord-bot.mjs set-interactions-endpoint --clear +// node scripts/discord-bot.mjs set-interactions-endpoint --clear --execute +// node scripts/discord-bot.mjs role-picker --execute +// node scripts/discord-bot.mjs preflight +// node scripts/discord-bot.mjs bot-channels +// node scripts/discord-bot.mjs bot-channels --execute +// node scripts/discord-bot.mjs thread-status --id 1400000000000000000 +// node scripts/discord-bot.mjs feedback-forum --id 1554569518049923153 --open +// node scripts/discord-bot.mjs feedback-forum --id 1554569518049923153 --open --execute +// node scripts/discord-bot.mjs archive-channel --id 1552413587094376620 --name feedback-archive +// node scripts/discord-bot.mjs archive-channel --id 1552413587094376620 --name feedback-archive --execute +// +// Exit codes: +// 0 -> command completed (read-only report, or a write that succeeded) +// 1 -> a write refused its safety checks, or a batch/delete/post failed +// 2 -> usage error or missing DISCORD_TOKEN +// ───────────────────────────────────────────────────────────────────────── + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import process from "node:process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); + +const DISCORD_API_BASE = "https://discord.com/api/v10"; +const USER_AGENT = "DiscordBot (https://parseforge.gg, 1.0)"; +const APPLICATION_ID = "1481197331482153031"; +const GUILD_ID = "1552413469301678122"; +const ANNOUNCEMENTS_CHANNEL_NAME = "announcements"; +const WELCOME_CHANNEL_NAME = "welcome"; +const OPS_CHANNEL_NAME = "ops"; +const FEEDBACK_CHANNEL_NAME = "feedback"; +const FEEDBACK_ARCHIVE_CHANNEL_NAME = "feedback-archive"; +const REQUEST_TIMEOUT_MS = 15000; +const MAX_429_RETRIES = 5; + +// Plan 05.1-03's block. Developer role id (used to gate #ops and the +// feedback forum to Zev only) and the two Discord channel type constants this plan +// tells apart by type, not just name. +const DEVELOPER_ROLE_ID = "1553974788224192533"; +const CHANNEL_TYPE_TEXT = 0; +const CHANNEL_TYPE_FORUM = 15; + +// Discord permission bits this plan reads or writes (BigInt — some exceed 2^32). +// Values confirmed against plan 05.1-03's block and Discord's own bit +// ordering (1 << n for each named permission). +const PERM = { + VIEW_CHANNEL: 1024n, + SEND_MESSAGES: 2048n, + MANAGE_CHANNELS: 16n, + EMBED_LINKS: 16384n, + READ_MESSAGE_HISTORY: 65536n, + ADD_REACTIONS: 64n, + MANAGE_ROLES: 268435456n, + MANAGE_THREADS: 17179869184n, + SEND_MESSAGES_IN_THREADS: 274877906944n, + CREATE_PUBLIC_THREADS: 34359738368n, +}; + +// ApplicationFlags bits (GET /applications/@me's `flags`): GATEWAY_MESSAGE_CONTENT is +// bit 18, the limited variant is bit 19 (plan 05.1-03's block). An unverified +// app (under 100 servers, like ParseForge) only ever gets the limited bit when the toggle is +// on, so either bit means the gateway will accept the MessageContent intent. +const APP_FLAG_MESSAGE_CONTENT = 1 << 18; +const APP_FLAG_MESSAGE_CONTENT_LIMITED = 1 << 19; + +// D-11 (b): the only four #announcements cards this purge must never touch. +const KNOWN_CODES = ["ZjKgNYxVcAqR8pGJ", "CyhwP713Dqf8RWvZ", "yGR3dVTwr71Hc68D", "n6zb8WYBcf1LGKNa"]; + +// A message id's creation time (this plan's block). +const DISCORD_EPOCH_MS = 1420070400000n; + +// Discord only bulk-deletes messages younger than 14 days; stay one hour +// inside that boundary so a message does not age out mid-run. +const TOO_OLD_MS = 14 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Loads DISCORD_TOKEN from bot/.env inside this process. The value is + * returned to the caller and used only as an in-memory fetch header — never + * printed, logged, or passed on a command line. + */ +function loadToken() { + const envPath = path.join(REPO_ROOT, "bot", ".env"); + try { + process.loadEnvFile(envPath); + } catch (err) { + if (err?.code !== "ENOENT") { + console.error(`Failed to read bot/.env: ${err?.code ?? "unknown error"}`); + process.exit(2); + } + } + const token = process.env.DISCORD_TOKEN; + if (!token) { + console.error("Missing required environment variable: DISCORD_TOKEN (expected in bot/.env)"); + process.exit(2); + } + return token; +} + +/** + * One REST call against Discord's v10 API as the ParseForge bot. Waits out + * a 429 using the body's retry_after (at most MAX_429_RETRIES times), waits + * when X-RateLimit-Remaining hits 0, and on any non-2xx throws an Error + * whose message carries only the method, path, HTTP status, and Discord's + * numeric error code. + */ +async function discordRequest(token, method, apiPath, body) { + for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let res; + try { + res = await fetch(`${DISCORD_API_BASE}${apiPath}`, { + method, + headers: { + Authorization: `Bot ${token}`, + "User-Agent": USER_AGENT, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + + if (res.status === 429 && attempt < MAX_429_RETRIES) { + const retryBody = await res.json().catch(() => ({})); + const retryAfterSec = typeof retryBody.retry_after === "number" ? retryBody.retry_after : 1; + await sleep(retryAfterSec * 1000 + 50); + continue; + } + + const remaining = res.headers.get("x-ratelimit-remaining"); + const resetAfter = res.headers.get("x-ratelimit-reset-after"); + if (remaining === "0" && resetAfter) { + await sleep(Number(resetAfter) * 1000 + 50); + } + + if (!res.ok) { + const errJson = await res.json().catch(() => ({})); + throw new Error(`Discord ${method} ${apiPath} -> HTTP ${res.status}, code ${errJson.code ?? "unknown"}`); + } + + if (res.status === 204) return null; + return res.json(); + } + throw new Error(`Discord ${method} ${apiPath} exceeded ${MAX_429_RETRIES} rate-limit retries`); +} + +async function resolveChannelId(token, guildId, name) { + const channels = await discordRequest(token, "GET", `/guilds/${guildId}/channels`); + const channel = channels.find((c) => c.name === name); + if (!channel) { + console.error(`Channel #${name} not found in guild ${guildId}`); + process.exit(2); + } + return channel.id; +} + +/** Pages the whole channel history, newest first, 100 at a time. */ +async function fetchAllMessages(token, channelId) { + const messages = []; + let before; + for (;;) { + const qs = new URLSearchParams({ limit: "100" }); + if (before) qs.set("before", before); + const page = await discordRequest(token, "GET", `/channels/${channelId}/messages?${qs}`); + if (!page || page.length === 0) break; + messages.push(...page); + before = page[page.length - 1].id; + if (page.length < 100) break; + } + return messages; +} + +function snowflakeToDate(id) { + const ms = (BigInt(id) >> 22n) + DISCORD_EPOCH_MS; + return new Date(Number(ms)); +} + +/** A card carries its report code in embeds[0].url (e.g. .../analyze/CODE). */ +function extractCodeFromMessage(message) { + const url = message.embeds?.[0]?.url; + if (!url) return null; + const match = url.match(/\/analyze\/([A-Za-z0-9]{10,20})/); + return match ? match[1] : null; +} + +function utcDay(date) { + return date.toISOString().slice(0, 10); +} + +function perDayCounts(dates) { + const counts = new Map(); + for (const d of dates) { + const key = utcDay(d); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts.entries()].sort(([a], [b]) => a.localeCompare(b)); +} + +/** + * True when role `a` sits strictly below role `b` in the guild's real role + * hierarchy. Discord's `position` field is not guaranteed unique — when two + * roles it creates in quick succession land on the same `position` value + * (observed for every role plan 05-07's bot created here), the tiebreak is + * the role id: the role created FIRST (the smaller snowflake) sits higher. + * This is the same rule discord.py's `Role.__lt__` and discord.js's + * `RoleManager.comparePositions` implement — not a ParseForge invention. + */ +function roleBelow(a, b) { + if (a.position !== b.position) return a.position < b.position; + return BigInt(a.id) > BigInt(b.id); +} + +/** + * Read-only. Lists every role in the ParseForge guild with its id, name, + * managed flag, permissions and position, and whether the bot can currently + * manage it (unmanaged and positioned below the bot's own highest role, per + * `roleBelow`'s id-tiebreak, not the raw `position` field alone). + */ +async function cmdRoles(token) { + const me = await discordRequest(token, "GET", "/users/@me"); + const member = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/members/${me.id}`); + const roles = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/roles`); + const byId = new Map(roles.map((r) => [r.id, r])); + const botRoles = member.roles.map((id) => byId.get(id)).filter(Boolean); + const botTopRole = botRoles.reduce( + (top, r) => (top === null || roleBelow(top, r) ? r : top), + null, + ); + + console.log(`guild: ${GUILD_ID}`); + console.log(`bot user: ${me.id} (${me.username})`); + console.log( + `bot's own roles: ${botRoles.map((r) => `${r.name} (id=${r.id}, pos=${r.position})`).join(", ") || "none"}`, + ); + console.log( + `bot's highest role: ${botTopRole ? `${botTopRole.name} (id=${botTopRole.id}, pos=${botTopRole.position})` : "none — @everyone only"}`, + ); + console.log("roles (sorted highest first, by real hierarchy order):"); + const sorted = [...roles].sort((a, b) => (roleBelow(a, b) ? 1 : roleBelow(b, a) ? -1 : 0)); + for (const r of sorted) { + const grantable = !r.managed && botTopRole !== null && roleBelow(r, botTopRole) && r.id !== botTopRole.id; + console.log( + ` id=${r.id} pos=${r.position} managed=${r.managed} permissions=${r.permissions} grantable=${grantable} name=${r.name}`, + ); + } +} + +async function cmdScanAnnouncements(token, args) { + const channelId = await resolveChannelId(token, GUILD_ID, ANNOUNCEMENTS_CHANNEL_NAME); + const messages = await fetchAllMessages(token, channelId); + + const dates = messages.map((m) => snowflakeToDate(m.id)); + const webhookCount = messages.filter((m) => m.webhook_id).length; + const pinnedCount = messages.filter((m) => m.pinned).length; + const codesFound = new Set(messages.map(extractCodeFromMessage).filter(Boolean)); + const knownFound = KNOWN_CODES.filter((c) => codesFound.has(c)); + + console.log("#announcements scan"); + console.log(` total: ${messages.length}`); + console.log(` webhook-authored: ${webhookCount}`); + console.log(` pinned: ${pinnedCount}`); + console.log(` oldest: ${dates.length ? new Date(Math.min(...dates.map((d) => d.getTime()))).toISOString() : "n/a"}`); + console.log(` newest: ${dates.length ? new Date(Math.max(...dates.map((d) => d.getTime()))).toISOString() : "n/a"}`); + console.log(` known codes found: ${knownFound.length}/${KNOWN_CODES.length} (${knownFound.join(", ") || "none"})`); + console.log(" per-UTC-day counts:"); + for (const [day, count] of perDayCounts(dates)) { + console.log(` ${day}: ${count}`); + } + + const codesOutIdx = args.indexOf("--codes-out"); + if (codesOutIdx !== -1) { + const outPath = args[codesOutIdx + 1]; + if (!outPath) { + console.error("--codes-out requires a file path"); + process.exit(2); + } + const sorted = [...codesFound].sort(); + writeFileSync(outPath, sorted.length ? sorted.join("\n") + "\n" : "", "utf8"); + console.log(`Wrote ${sorted.length} report code(s) to ${outPath}`); + } +} + +/** + * Buckets every message into exactly one of the six D-11 (b) buckets, + * first match wins. Shared by the dry run, the pre-execute recompute, and + * the post-delete re-scan so all three report identically. + */ +function bucketMessages(messages, keepAfterMs, nowMs) { + const buckets = { + keep_not_webhook: [], + keep_pinned: [], + keep_known: [], + keep_after: [], + too_old: [], + delete: [], + }; + for (const m of messages) { + const created = snowflakeToDate(m.id); + if (!m.webhook_id) { + buckets.keep_not_webhook.push(m); + continue; + } + if (m.pinned) { + buckets.keep_pinned.push(m); + continue; + } + const code = extractCodeFromMessage(m); + if (code && KNOWN_CODES.includes(code)) { + buckets.keep_known.push({ message: m, code, created }); + continue; + } + if (created.getTime() >= keepAfterMs) { + buckets.keep_after.push(m); + continue; + } + if (nowMs - created.getTime() > TOO_OLD_MS) { + buckets.too_old.push(m); + continue; + } + buckets.delete.push(m); + } + return buckets; +} + +function printBuckets(buckets, label) { + console.log(label); + console.log(` total: ${Object.values(buckets).reduce((n, b) => n + b.length, 0)}`); + console.log(` keep_not_webhook: ${buckets.keep_not_webhook.length}`); + console.log(` keep_pinned: ${buckets.keep_pinned.length}`); + console.log( + ` keep_known: ${buckets.keep_known.length} (${buckets.keep_known.map((k) => k.code).join(", ") || "none"})`, + ); + console.log(` keep_after: ${buckets.keep_after.length}`); + console.log(` too_old: ${buckets.too_old.length}`); + console.log(` delete: ${buckets.delete.length}`); + if (buckets.delete.length > 0) { + const dates = buckets.delete.map((m) => snowflakeToDate(m.id)); + console.log(` delete bucket oldest: ${new Date(Math.min(...dates.map((d) => d.getTime()))).toISOString()}`); + console.log(` delete bucket newest: ${new Date(Math.max(...dates.map((d) => d.getTime()))).toISOString()}`); + console.log(" delete bucket per-UTC-day counts:"); + for (const [day, count] of perDayCounts(dates)) { + console.log(` ${day}: ${count}`); + } + } +} + +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +async function cmdPurgeAnnouncements(token, args) { + const keepAfterIdx = args.indexOf("--keep-after"); + if (keepAfterIdx === -1 || !args[keepAfterIdx + 1]) { + console.error("purge-announcements requires --keep-after ISO"); + process.exit(2); + } + const keepAfter = new Date(args[keepAfterIdx + 1]); + if (Number.isNaN(keepAfter.getTime())) { + console.error(`--keep-after value is not a valid date: ${args[keepAfterIdx + 1]}`); + process.exit(2); + } + const execute = args.includes("--execute"); + const expectDeleteIdx = args.indexOf("--expect-delete"); + const expectDelete = expectDeleteIdx !== -1 ? Number(args[expectDeleteIdx + 1]) : null; + if (execute && (expectDelete === null || Number.isNaN(expectDelete))) { + console.error("--execute requires --expect-delete N"); + process.exit(2); + } + + const channelId = await resolveChannelId(token, GUILD_ID, ANNOUNCEMENTS_CHANNEL_NAME); + const messages = await fetchAllMessages(token, channelId); + const buckets = bucketMessages(messages, keepAfter.getTime(), Date.now()); + printBuckets(buckets, `purge-announcements dry run (keep-after ${keepAfter.toISOString()})`); + + if (!execute) { + console.log("Dry run only — nothing deleted. Pass --execute --expect-delete N to delete."); + return; + } + + const knownCodesPresent = new Set(buckets.keep_known.map((k) => k.code)); + const duplicateKnown = buckets.keep_known.length !== knownCodesPresent.size; + if (buckets.too_old.length !== 0) { + console.error(`Refusing to execute: ${buckets.too_old.length} candidate(s) are already too_old.`); + process.exit(1); + } + if (knownCodesPresent.size !== KNOWN_CODES.length || duplicateKnown) { + console.error( + `Refusing to execute: expected exactly the ${KNOWN_CODES.length} known codes each once, ` + + `found ${buckets.keep_known.length} keep_known message(s) covering ${knownCodesPresent.size} distinct code(s).`, + ); + process.exit(1); + } + if (buckets.delete.length !== expectDelete) { + console.error( + `Refusing to execute: recomputed delete count ${buckets.delete.length} does not match --expect-delete ${expectDelete}.`, + ); + process.exit(1); + } + + console.log(`Deleting ${buckets.delete.length} message(s)...`); + const ids = buckets.delete.map((m) => m.id); + const batches = chunk(ids, 100); + let deletedCount = 0; + let stoppedEarly = false; + for (const batch of batches) { + try { + if (batch.length === 1) { + await discordRequest(token, "DELETE", `/channels/${channelId}/messages/${batch[0]}`); + } else { + await discordRequest(token, "POST", `/channels/${channelId}/messages/bulk-delete`, { + messages: batch, + }); + } + deletedCount += batch.length; + console.log(` progress: ${deletedCount}/${ids.length} deleted`); + } catch (err) { + console.error( + `Batch failed after deleting ${deletedCount}/${ids.length}: ${err instanceof Error ? err.message : String(err)}`, + ); + stoppedEarly = true; + break; + } + } + + console.log("Re-scanning #announcements..."); + const rescanned = await fetchAllMessages(token, channelId); + const rescannedBuckets = bucketMessages(rescanned, keepAfter.getTime(), Date.now()); + printBuckets(rescannedBuckets, "purge-announcements post-delete re-scan"); + + if (stoppedEarly) { + process.exit(1); + } +} + +async function cmdPost(token, args) { + const channelIdx = args.indexOf("--channel"); + const fileIdx = args.indexOf("--file"); + if (channelIdx === -1 || !args[channelIdx + 1] || fileIdx === -1 || !args[fileIdx + 1]) { + console.error("post requires --channel NAME --file PATH"); + process.exit(2); + } + const channelName = args[channelIdx + 1]; + const filePath = args[fileIdx + 1]; + const execute = args.includes("--execute"); + const text = readFileSync(filePath, "utf8"); + + const channelId = await resolveChannelId(token, GUILD_ID, channelName); + console.log(`Target channel: #${channelName} (${channelId})`); + console.log("Text:"); + console.log(text); + + if (!execute) { + console.log("Dry run only — nothing posted. Pass --execute to post."); + return; + } + + const message = await discordRequest(token, "POST", `/channels/${channelId}/messages`, { + content: text, + allowed_mentions: { parse: [] }, + }); + console.log(`Posted message id ${message.id}`); +} + +/** Reads both the application's global and this guild's command lists. */ +async function fetchCommands(token) { + const global = await discordRequest(token, "GET", `/applications/${APPLICATION_ID}/commands`); + const guild = await discordRequest( + token, + "GET", + `/applications/${APPLICATION_ID}/guilds/${GUILD_ID}/commands`, + ); + return { global, guild }; +} + +function printCommandList(global, guild) { + console.log(`global: ${global.length}`); + console.log(`guild: ${guild.length}`); + for (const c of global) console.log(` scope=global name=${c.name} id=${c.id}`); + for (const c of guild) console.log(` scope=guild name=${c.name} id=${c.id}`); +} + +async function cmdCommandsList(token) { + const { global, guild } = await fetchCommands(token); + printCommandList(global, guild); +} + +/** + * D-12: `/raid` and `/analyze` (the only commands this application has ever + * registered) are retired, not ported. A `PUT` of an empty command list + * unregisters everything in that scope; restoring them means re-running + * `bot/src/index.ts`'s `registerCommands`. + */ +async function cmdCommandsRetire(token, args) { + const execute = args.includes("--execute"); + const { global, guild } = await fetchCommands(token); + console.log("Would remove:"); + printCommandList(global, guild); + + if (!execute) { + console.log("Dry run only — nothing retired. Pass --execute to unregister."); + return; + } + + await discordRequest(token, "PUT", `/applications/${APPLICATION_ID}/commands`, []); + await discordRequest(token, "PUT", `/applications/${APPLICATION_ID}/guilds/${GUILD_ID}/commands`, []); + console.log("Retired. Current state:"); + await cmdCommandsList(token); +} + +async function cmdSetInteractionsEndpoint(token, args) { + const urlIdx = args.indexOf("--url"); + const clear = args.includes("--clear"); + if (clear && urlIdx !== -1) { + console.error("set-interactions-endpoint accepts --url URL or --clear, not both"); + process.exit(2); + } + if (!clear && (urlIdx === -1 || !args[urlIdx + 1])) { + console.error("set-interactions-endpoint requires --url URL or --clear"); + process.exit(2); + } + const requestedUrl = clear ? null : args[urlIdx + 1]; + const execute = args.includes("--execute"); + + const app = await discordRequest(token, "GET", "/applications/@me"); + console.log(`current: ${app.interactions_endpoint_url ?? "none"}`); + console.log(`requested: ${clear ? "none" : requestedUrl}`); + + if (!execute) { + console.log("Dry run only — nothing changed. Pass --execute to update."); + return; + } + + if (clear) { + // Discord's documented way to clear interactions_endpoint_url is an + // empty string. Some application configurations answer that with a 400 + // instead; when that happens, fall back to sending `null` and report + // which of the two forms Discord actually accepted. + try { + const updated = await discordRequest(token, "PATCH", "/applications/@me", { + interactions_endpoint_url: "", + }); + console.log( + `Updated (empty string accepted). interactions_endpoint_url: ${updated.interactions_endpoint_url ?? "none"}`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("HTTP 400")) throw err; + const updated = await discordRequest(token, "PATCH", "/applications/@me", { + interactions_endpoint_url: null, + }); + console.log( + `Updated (null accepted after empty string was rejected). interactions_endpoint_url: ${updated.interactions_endpoint_url ?? "none"}`, + ); + } + return; + } + + // Discord validates the new URL itself (a signed PING, then a badly + // signed request) before accepting the PATCH; a validation failure comes + // back as a 400 with Discord's own message, surfaced by discordRequest's + // existing error formatting. + const updated = await discordRequest(token, "PATCH", "/applications/@me", { + interactions_endpoint_url: requestedUrl, + }); + console.log(`Updated. interactions_endpoint_url: ${updated.interactions_endpoint_url}`); +} + +/** Same custom-id format as the bot's handler in bot/src/roles.ts. */ +function pickerCustomId(prefix, roleId) { + return `${prefix}${roleId}`; +} + +function loadRolesTable() { + const raw = readFileSync(path.join(REPO_ROOT, "lib", "discord-roles.json"), "utf8"); + return JSON.parse(raw); +} + +async function cmdRolePicker(token, args) { + const execute = args.includes("--execute"); + const roles = loadRolesTable(); + const channelId = await resolveChannelId(token, GUILD_ID, WELCOME_CHANNEL_NAME); + const me = await discordRequest(token, "GET", "/users/@me"); + + const messages = await fetchAllMessages(token, channelId); + const existing = messages.find( + (m) => + m.author?.id === me.id && + (m.components ?? []).some((row) => + (row.components ?? []).some( + (c) => typeof c.custom_id === "string" && c.custom_id.startsWith(roles.customIdPrefix), + ), + ), + ); + if (existing) { + console.error(`Role picker already present in #welcome: message id ${existing.id}`); + process.exit(1); + } + + const plannedMessages = roles.groups.map((group) => ({ + content: group.heading, + components: chunk(group.roles, 5).map((rowRoles) => ({ + type: 1, + components: rowRoles.map((r) => ({ + type: 2, + style: 2, + label: r.name, + custom_id: pickerCustomId(roles.customIdPrefix, r.id), + })), + })), + })); + + console.log(`Target channel: #welcome (${channelId})`); + for (const [i, msg] of plannedMessages.entries()) { + console.log(`Message ${i + 1}: "${msg.content}"`); + for (const row of msg.components) { + console.log(` row: ${row.components.map((c) => c.label).join(", ")}`); + } + } + + if (!execute) { + console.log("Dry run only — nothing posted. Pass --execute to post."); + return; + } + + const postedIds = []; + for (const msg of plannedMessages) { + const posted = await discordRequest(token, "POST", `/channels/${channelId}/messages`, { + content: msg.content, + components: msg.components, + allowed_mentions: { parse: [] }, + }); + postedIds.push(posted.id); + console.log(`Posted message id ${posted.id}`); + } + console.log(`Posted ${postedIds.length} message(s): ${postedIds.join(", ")}`); +} + +/** + * Reads the first fenced code block under `heading` in docs/DISCORD-SEED.md. + * Shared by readForumGuidelines (forum topic) and readArchiveTopic + * (the #feedback-archive topic, plan 05.1-08). + */ +function readSeedCopyBlock(heading) { + const seedPath = path.join(REPO_ROOT, "docs", "DISCORD-SEED.md"); + const content = readFileSync(seedPath, "utf8"); + const headingIdx = content.indexOf(heading); + if (headingIdx === -1) { + console.error(`docs/DISCORD-SEED.md is missing "${heading}"`); + process.exit(2); + } + const afterHeading = content.slice(headingIdx + heading.length); + const fenceStart = afterHeading.indexOf("```"); + const fenceEnd = fenceStart === -1 ? -1 : afterHeading.indexOf("```", fenceStart + 3); + if (fenceStart === -1 || fenceEnd === -1) { + console.error(`Could not find a fenced code block under "${heading}" in docs/DISCORD-SEED.md`); + process.exit(2); + } + return afterHeading.slice(fenceStart + 3, fenceEnd).trim(); +} + +/** + * Reads the fenced block under "### Copy: #feedback forum guidelines" in + * docs/DISCORD-SEED.md — the text bot-channels sets as the new forum's topic, + * and feedback-forum --open re-reads it so the live topic never drifts from + * this file. + */ +function readForumGuidelines() { + return readSeedCopyBlock("### Copy: #feedback forum guidelines"); +} + +/** + * Reads the fenced block under "### Copy: #feedback-archive topic" in + * docs/DISCORD-SEED.md — the text archive-channel sets as the old #feedback + * channel's topic once it becomes the read-only archive (plan 05.1-08, D-11). + */ +function readArchiveTopic() { + return readSeedCopyBlock("### Copy: #feedback-archive topic"); +} + +/** + * Whether @everyone can exercise `capability` ("view" or "send") in `channel`, + * from that channel's own permission_overwrites only (no category inheritance — + * this plan's channels are created with no parent category). No overwrite for + * @everyone means the guild default (not denied) applies. + */ +function everyoneCan(channel, capability) { + const bit = capability === "view" ? PERM.VIEW_CHANNEL : PERM.SEND_MESSAGES; + const overwrite = (channel.permission_overwrites ?? []).find((o) => o.id === GUILD_ID); + if (!overwrite) return true; + const deny = BigInt(overwrite.deny ?? "0"); + if ((deny & bit) !== 0n) return false; + return true; +} + +function printChannelPreflightLine(label, channel, capability) { + if (!channel) { + console.log(`${label}: missing`); + return; + } + const verb = capability === "view" ? "view" : "send"; + console.log(`${label}: ${channel.id} (@everyone can ${verb}: ${everyoneCan(channel, capability) ? "yes" : "no"})`); +} + +/** + * Read-only. Prints the Message Content intent, the interactions endpoint, + * the bot's effective guild permissions, and the state of the ops/feedback + * channels — the exact snapshot plan 05.1-03's Task 2 checkpoint needs + * before the developer decides whether to go live. + */ +async function cmdPreflight(token) { + const app = await discordRequest(token, "GET", "/applications/@me"); + const flags = typeof app.flags === "number" ? app.flags : 0; + const messageContent = + (flags & APP_FLAG_MESSAGE_CONTENT) !== 0 + ? "on" + : (flags & APP_FLAG_MESSAGE_CONTENT_LIMITED) !== 0 + ? "on (limited — unverified app)" + : "off"; + console.log(`message_content_intent: ${messageContent}`); + console.log(`interactions_endpoint_url: ${app.interactions_endpoint_url ?? "none"}`); + + const me = await discordRequest(token, "GET", "/users/@me"); + const member = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/members/${me.id}`); + const roles = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/roles`); + const everyoneRole = roles.find((r) => r.id === GUILD_ID); + const botRoles = member.roles.map((id) => roles.find((r) => r.id === id)).filter(Boolean); + + let unionPerms = 0n; + if (everyoneRole) unionPerms |= BigInt(everyoneRole.permissions); + for (const r of botRoles) unionPerms |= BigInt(r.permissions); + const has = (bit) => (unionPerms & bit) !== 0n; + + console.log(`manage_threads: ${has(PERM.MANAGE_THREADS) ? "yes" : "no"}`); + console.log(`add_reactions: ${has(PERM.ADD_REACTIONS) ? "yes" : "no"}`); + console.log(`send_in_threads: ${has(PERM.SEND_MESSAGES_IN_THREADS) ? "yes" : "no"}`); + console.log(`manage_channels: ${has(PERM.MANAGE_CHANNELS) ? "yes" : "no"}`); + console.log(`manage_roles: ${has(PERM.MANAGE_ROLES) ? "yes" : "no"}`); + + const channels = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/channels`); + const opsChannel = channels.find((c) => c.name === OPS_CHANNEL_NAME && c.type === CHANNEL_TYPE_TEXT); + const feedbackForum = channels.find((c) => c.name === FEEDBACK_CHANNEL_NAME && c.type === CHANNEL_TYPE_FORUM); + const feedbackText = channels.find((c) => c.name === FEEDBACK_CHANNEL_NAME && c.type === CHANNEL_TYPE_TEXT); + const feedbackArchive = channels.find( + (c) => c.name === FEEDBACK_ARCHIVE_CHANNEL_NAME && c.type === CHANNEL_TYPE_TEXT, + ); + + printChannelPreflightLine("ops", opsChannel, "view"); + if (feedbackForum) { + const canView = everyoneCan(feedbackForum, "view"); + const canSend = everyoneCan(feedbackForum, "send"); + console.log( + `feedback_forum: ${feedbackForum.id} (@everyone can view: ${canView ? "yes" : "no"}) public: ${ + canView && canSend ? "yes" : "no" + }`, + ); + } else { + console.log("feedback_forum: missing"); + } + printChannelPreflightLine("feedback_text", feedbackText, "send"); + if (feedbackArchive) { + // Plan 05.1-08 (D-11): "read-only" means @everyone cannot send — the + // archive otherwise stays viewable with its full history and pinned note. + const readOnly = !everyoneCan(feedbackArchive, "send"); + console.log(`feedback_archive: ${feedbackArchive.id} (read-only: ${readOnly ? "yes" : "no"})`); + } else { + console.log("feedback_archive: missing"); + } +} + +/** + * Creates the two private channels D-03/D-04/D-11 need: #ops (text) and the + * feedback forum. Refuses if either already exists. Read-only without + * --execute (prints both planned payloads). + */ +async function cmdBotChannels(token, args) { + const execute = args.includes("--execute"); + const channels = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/channels`); + const existingOps = channels.find((c) => c.name === OPS_CHANNEL_NAME && c.type === CHANNEL_TYPE_TEXT); + const existingForum = channels.find((c) => c.name === FEEDBACK_CHANNEL_NAME && c.type === CHANNEL_TYPE_FORUM); + if (existingOps || existingForum) { + const parts = []; + if (existingOps) parts.push(`#ops already exists (id=${existingOps.id})`); + if (existingForum) parts.push(`forum "feedback" already exists (id=${existingForum.id})`); + console.error(`Refusing to create: ${parts.join("; ")}`); + process.exit(1); + } + + const me = await discordRequest(token, "GET", "/users/@me"); + const guidelines = readForumGuidelines(); + + const opsPayload = { + name: OPS_CHANNEL_NAME, + type: CHANNEL_TYPE_TEXT, + permission_overwrites: [ + { id: GUILD_ID, type: 0, allow: "0", deny: String(PERM.VIEW_CHANNEL) }, + { + id: DEVELOPER_ROLE_ID, + type: 0, + allow: String(PERM.VIEW_CHANNEL | PERM.SEND_MESSAGES | PERM.READ_MESSAGE_HISTORY | PERM.ADD_REACTIONS), + deny: "0", + }, + { + id: me.id, + type: 1, + allow: String( + PERM.VIEW_CHANNEL | PERM.SEND_MESSAGES | PERM.EMBED_LINKS | PERM.READ_MESSAGE_HISTORY | PERM.ADD_REACTIONS, + ), + deny: "0", + }, + ], + }; + + const forumPayload = { + name: FEEDBACK_CHANNEL_NAME, + type: CHANNEL_TYPE_FORUM, + topic: guidelines, + available_tags: [ + { name: "Bug", emoji_name: "🐛" }, + { name: "Feature", emoji_name: "💡" }, + ], + permission_overwrites: [ + { id: GUILD_ID, type: 0, allow: "0", deny: String(PERM.VIEW_CHANNEL) }, + { + id: DEVELOPER_ROLE_ID, + type: 0, + allow: String( + PERM.VIEW_CHANNEL | + PERM.SEND_MESSAGES | + PERM.SEND_MESSAGES_IN_THREADS | + PERM.READ_MESSAGE_HISTORY | + PERM.ADD_REACTIONS, + ), + deny: "0", + }, + { + id: me.id, + type: 1, + allow: String( + PERM.VIEW_CHANNEL | + PERM.SEND_MESSAGES_IN_THREADS | + PERM.EMBED_LINKS | + PERM.READ_MESSAGE_HISTORY | + PERM.ADD_REACTIONS, + ), + deny: "0", + }, + ], + }; + + console.log("Planned #ops payload:"); + console.log(JSON.stringify(opsPayload, null, 2)); + console.log("Planned feedback forum payload:"); + console.log(JSON.stringify(forumPayload, null, 2)); + + if (!execute) { + console.log("Dry run only — nothing created. Pass --execute to create."); + return; + } + + const createdOps = await discordRequest(token, "POST", `/guilds/${GUILD_ID}/channels`, opsPayload); + console.log(`Created #ops: ${createdOps.id}`); + const createdForum = await discordRequest(token, "POST", `/guilds/${GUILD_ID}/channels`, forumPayload); + console.log(`Created feedback forum: ${createdForum.id}`); +} + +/** + * Builds the replacement permission_overwrites array for a channel, keeping + * every existing overwrite EXCEPT @everyone's, which is replaced wholesale + * with the given allow/deny pair. Discord's PATCH replaces the whole list, + * so every kept overwrite (Developer role, the bot's own member overwrite) + * must be sent back unchanged alongside the new @everyone entry. + */ +function replaceEveryoneOverwrite(existingOverwrites, allow, deny) { + const kept = (existingOverwrites ?? []).filter((o) => o.id !== GUILD_ID); + return [{ id: GUILD_ID, type: 0, allow: String(allow), deny: String(deny) }, ...kept]; +} + +/** + * Merges a forum's existing available_tags (kept by id, never dropped) with + * Bug 🐛 / Feature 💡, added only if a same-named tag isn't already present. + * Discord's PATCH replaces the whole available_tags array, so every existing + * tag must be sent back alongside any newly added ones. + */ +function mergeForumTags(existingTags) { + const tags = [...(existingTags ?? [])]; + const hasByName = (name) => tags.some((t) => t.name.toLowerCase() === name.toLowerCase()); + if (!hasByName("Bug")) tags.push({ name: "Bug", emoji_name: "🐛" }); + if (!hasByName("Feature")) tags.push({ name: "Feature", emoji_name: "💡" }); + return tags; +} + +/** + * Plan 05.1-08 (D-11): opens the private feedback forum to @everyone. Refuses + * (exit 1) unless --id resolves to a type-15 (forum) channel. The forum's + * topic is re-read from docs/DISCORD-SEED.md on every run, so the live topic + * can never drift from the guidelines file. Position is set to the OLD + * #feedback text channel's current position, so the forum takes its place in + * the visible channel list; the old text channel itself is never touched by + * this subcommand (archive-channel is the only thing that changes it). + */ +async function cmdFeedbackForum(token, args) { + const idIdx = args.indexOf("--id"); + if (idIdx === -1 || !args[idIdx + 1] || !args.includes("--open")) { + console.error("feedback-forum requires --id ID --open [--execute]"); + process.exit(2); + } + const forumId = args[idIdx + 1]; + const execute = args.includes("--execute"); + + const channel = await discordRequest(token, "GET", `/channels/${forumId}`); + if (channel.type !== CHANNEL_TYPE_FORUM) { + console.error(`Refusing: channel ${forumId} is type ${channel.type}, not a forum (type ${CHANNEL_TYPE_FORUM})`); + process.exit(1); + } + + const guildChannels = await discordRequest(token, "GET", `/guilds/${GUILD_ID}/channels`); + const oldTextChannel = guildChannels.find( + (c) => c.name === FEEDBACK_CHANNEL_NAME && c.type === CHANNEL_TYPE_TEXT, + ); + + const topic = readForumGuidelines(); + const available_tags = mergeForumTags(channel.available_tags); + const permission_overwrites = replaceEveryoneOverwrite( + channel.permission_overwrites, + PERM.VIEW_CHANNEL | + PERM.SEND_MESSAGES | + PERM.SEND_MESSAGES_IN_THREADS | + PERM.READ_MESSAGE_HISTORY | + PERM.ADD_REACTIONS, + 0n, + ); + + const payload = { topic, available_tags, permission_overwrites }; + if (oldTextChannel) payload.position = oldTextChannel.position; + + console.log(`Target forum: #${channel.name} (${forumId})`); + console.log("Planned PATCH payload:"); + console.log(JSON.stringify(payload, null, 2)); + if (!oldTextChannel) { + console.log("Note: old #feedback text channel not found by name — position left unchanged."); + } + + if (!execute) { + console.log("Dry run only — nothing changed. Pass --execute to open the forum."); + return; + } + + await discordRequest(token, "PATCH", `/channels/${forumId}`, payload); + console.log(`Opened forum ${forumId} to @everyone.`); +} + +/** + * Plan 05.1-08 (D-11): archives the old #feedback TEXT channel read-only — + * renames it, denies @everyone every posting permission while leaving + * VIEW_CHANNEL and READ_MESSAGE_HISTORY alone so its history and Zev's + * pinned note stay readable, and sets its topic from docs/DISCORD-SEED.md. + * Refuses (exit 1) unless --id resolves to a type-0 (text) channel. + * Deliberately has no delete subcommand — this is a one-way-safe operation, + * never a destructive one. + */ +async function cmdArchiveChannel(token, args) { + const idIdx = args.indexOf("--id"); + const nameIdx = args.indexOf("--name"); + if (idIdx === -1 || !args[idIdx + 1] || nameIdx === -1 || !args[nameIdx + 1]) { + console.error("archive-channel requires --id ID --name NAME [--execute]"); + process.exit(2); + } + const channelId = args[idIdx + 1]; + const newName = args[nameIdx + 1]; + const execute = args.includes("--execute"); + + const channel = await discordRequest(token, "GET", `/channels/${channelId}`); + if (channel.type !== CHANNEL_TYPE_TEXT) { + console.error(`Refusing: channel ${channelId} is type ${channel.type}, not a text channel (type ${CHANNEL_TYPE_TEXT})`); + process.exit(1); + } + + const topic = readArchiveTopic(); + const permission_overwrites = replaceEveryoneOverwrite( + channel.permission_overwrites, + PERM.VIEW_CHANNEL | PERM.READ_MESSAGE_HISTORY, + PERM.SEND_MESSAGES | PERM.SEND_MESSAGES_IN_THREADS | PERM.CREATE_PUBLIC_THREADS | PERM.ADD_REACTIONS, + ); + + const payload = { name: newName, topic, permission_overwrites }; + + console.log(`Target channel: #${channel.name} (${channelId})`); + console.log("Planned PATCH payload:"); + console.log(JSON.stringify(payload, null, 2)); + + if (!execute) { + console.log("Dry run only — nothing changed. Pass --execute to archive."); + return; + } + + await discordRequest(token, "PATCH", `/channels/${channelId}`, payload); + console.log(`Archived channel ${channelId} as #${newName} (read-only for @everyone).`); +} + +/** + * Read-only. Prints a forum thread's name/archived/message_count plus how + * many of its latest 20 messages the bot itself authored and an excerpt of + * the newest one — never a member's message text or any user id. + */ +async function cmdThreadStatus(token, args) { + const idIdx = args.indexOf("--id"); + if (idIdx === -1 || !args[idIdx + 1]) { + console.error("thread-status requires --id ID"); + process.exit(2); + } + const threadId = args[idIdx + 1]; + + const channel = await discordRequest(token, "GET", `/channels/${threadId}`); + console.log(`name: ${channel.name}`); + console.log(`archived: ${Boolean(channel.thread_metadata?.archived)}`); + console.log(`message_count: ${channel.message_count ?? "unknown"}`); + + const me = await discordRequest(token, "GET", "/users/@me"); + const messages = await discordRequest(token, "GET", `/channels/${threadId}/messages?limit=20`); + const botMessages = (messages ?? []).filter((m) => m.author?.id === me.id); + console.log(`bot_authored_in_latest_20: ${botMessages.length}`); + if (botMessages.length > 0) { + // Discord returns messages newest-first, so botMessages[0] is the newest + // bot-authored message among the latest 20. + const excerpt = (botMessages[0].content ?? "").slice(0, 120); + console.log(`newest_bot_message_excerpt: ${excerpt}`); + } else { + console.log("newest_bot_message_excerpt: none"); + } +} + +async function main() { + const [, , command, ...args] = process.argv; + const token = loadToken(); + switch (command) { + case "roles": + await cmdRoles(token); + break; + case "scan-announcements": + await cmdScanAnnouncements(token, args); + break; + case "purge-announcements": + await cmdPurgeAnnouncements(token, args); + break; + case "post": + await cmdPost(token, args); + break; + case "commands": { + const sub = args[0]; + if (sub === "list") await cmdCommandsList(token); + else if (sub === "retire") await cmdCommandsRetire(token, args.slice(1)); + else { + console.error("Usage: node scripts/discord-bot.mjs commands [--execute]"); + process.exit(2); + } + break; + } + case "set-interactions-endpoint": + await cmdSetInteractionsEndpoint(token, args); + break; + case "role-picker": + await cmdRolePicker(token, args); + break; + case "preflight": + await cmdPreflight(token); + break; + case "bot-channels": + await cmdBotChannels(token, args); + break; + case "thread-status": + await cmdThreadStatus(token, args); + break; + case "feedback-forum": + await cmdFeedbackForum(token, args); + break; + case "archive-channel": + await cmdArchiveChannel(token, args); + break; + default: + console.error( + "Usage: node scripts/discord-bot.mjs [options]", + ); + process.exit(2); + } +} + +main().catch((err) => { + console.error(`discord-bot failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/scripts/protected-elements.mjs b/scripts/protected-elements.mjs new file mode 100644 index 0000000..e4c459f --- /dev/null +++ b/scripts/protected-elements.mjs @@ -0,0 +1,518 @@ +#!/usr/bin/env node +// ───────────────────────────────────────────────────────────────────────── +// protected-elements: a machine check over docs/PROTECTED-ELEMENTS.md — the +// share-loop checklist Phase 4's ad-placement whitelist and Phase 7's +// per-route SEO gate both consume (D-15). +// +// The checklist doc is the source of truth; this script is the enforcement. +// Half one reads the doc's `## DOM attributes` table and confirms every +// listed `data-protected="…"` attribute is still present in its named owner +// file. Half two fetches the live `/og` route contracts (awards, player, and +// bare report cards for the demo report) and the demo analyze page's +// canonical, against a resolved base URL. Half three (04-03) reads the doc's +// `## Ad slot placements` table and enforces it against `lib/ads.ts` and the +// component tree — slot declaration, ownership, containment away from +// protected elements, dimension agreement, no stray mounts — then fetches +// the raid-audit and demo analyze routes live and confirms every ad-slot +// marker present is one the table names. +// +// A gate that greens when it cannot see its subject is worse than no gate: +// a missing/empty checklist, an owner file that no longer exists, and an +// unreachable base URL are all FATAL (exit 2) — never reported as a passing +// or failing check. +// +// Modes: +// node scripts/protected-elements.mjs -> gate mode: exit 1 on +// any failed check, 0 +// otherwise. +// node scripts/protected-elements.mjs --report -> inspection mode: same +// output, always exits 0 +// for check failures +// (fatal config errors — +// missing checklist, +// unreachable base — +// still exit 2). +// node scripts/protected-elements.mjs --base -> base URL for the +// live route checks +// (default: +// https://parseforge.gg, +// so the gate runs with +// no local server). +// ───────────────────────────────────────────────────────────────────────── + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import process from "node:process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); +const PROD_BASE = "https://parseforge.gg"; +const CHECKLIST_PATH = path.join(REPO_ROOT, "docs", "PROTECTED-ELEMENTS.md"); + +function fatal(message) { + console.error(`FATAL: ${message}`); + process.exit(2); +} + +/** + * Returns the text of a `## Heading` section (up to but excluding the next + * `## ` heading, or EOF), or `null` if the heading never appears as an + * actual heading line. Anchored to line-start (`^...$`, multiline) rather + * than a bare substring search — this doc's own prose refers to section + * headings inline in backticks (e.g. "the `## DOM attributes` table + * below"), and a plain `indexOf` would match that earlier prose mention + * instead of the real heading, slicing out an empty section. + */ +function findSection(src, heading) { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headingRe = new RegExp(`^${escaped}\\s*$`, "m"); + const headingMatch = headingRe.exec(src); + if (!headingMatch) return null; + const rest = src.slice(headingMatch.index + headingMatch[0].length); + const nextHeadingIdx = rest.search(/\n## /); + return nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx); +} + +// ─── Demo report ──────────────────────────────────────────────────────────── + +function readDemoReport() { + const demoPath = path.join(REPO_ROOT, "lib", "demo-report.ts"); + if (!existsSync(demoPath)) { + fatal(`could not find ${demoPath}`); + } + const src = readFileSync(demoPath, "utf8"); + const code = src.match(/code:\s*"([^"]+)"/); + const fight = src.match(/fight:\s*(\d+)/); + const source = src.match(/source:\s*(\d+)/); + if (!code || !fight || !source) { + fatal("could not parse DEMO_REPORT.code/fight/source in lib/demo-report.ts"); + } + return { code: code[1], fight: fight[1], source: source[1] }; +} + +// ─── Checklist parsing (docs/PROTECTED-ELEMENTS.md is the source of truth) ─ + +const DOM_ATTR_HEADING = "## DOM attributes"; + +function parseChecklistRows() { + if (!existsSync(CHECKLIST_PATH)) { + fatal(`checklist not found at ${CHECKLIST_PATH} — an absent checklist is never a pass`); + } + const src = readFileSync(CHECKLIST_PATH, "utf8"); + // Scoped to this section only (04-03): the doc gained a second + // backtick-pair table (`## Ad slot placements`) and an unscoped regex + // would otherwise parse that table's rows as DOM attributes too. + const section = findSection(src, DOM_ATTR_HEADING); + if (section === null) { + fatal(`${DOM_ATTR_HEADING} section not found in ${CHECKLIST_PATH} — an absent checklist is never a pass`); + } + // Table rows whose first cell is a backticked token; the header separator + // row (`|---|---|...`) has no backticks and is naturally excluded. + const rowRe = /^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|/gm; + const rows = []; + let m; + while ((m = rowRe.exec(section)) !== null) { + rows.push({ attribute: m[1], ownerFile: m[2] }); + } + if (rows.length === 0) { + fatal(`${CHECKLIST_PATH} yielded zero parseable attribute rows — an empty checklist is never a pass`); + } + return rows; +} + +function checkAttribute(row) { + const ownerPath = path.isAbsolute(row.ownerFile) + ? row.ownerFile + : path.join(REPO_ROOT, row.ownerFile); + if (!existsSync(ownerPath)) { + return { + id: `attr:${row.attribute}`, + fatal: true, + detail: `owner file ${row.ownerFile} does not exist`, + }; + } + const src = readFileSync(ownerPath, "utf8"); + const needle = `data-protected="${row.attribute}"`; + const pass = src.includes(needle); + return { + id: `attr:${row.attribute}`, + pass, + detail: pass ? `found in ${row.ownerFile}` : `MISSING from ${row.ownerFile}`, + }; +} + +// ─── Ad slot placements (04-03) ───────────────────────────────────────────── + +const AD_SLOT_HEADING = "## Ad slot placements"; +const ADS_TS_PATH = path.join(REPO_ROOT, "lib", "ads.ts"); + +/** + * Reads `lib/ads.ts` as text and extracts every declared slot's id plus its + * base/md pixel dimensions. Parsed rather than imported: this script runs + * under plain node with no build step, exactly as it already does for + * `lib/demo-report.ts` above. + */ +function readDeclaredSlots() { + if (!existsSync(ADS_TS_PATH)) { + fatal(`could not find ${ADS_TS_PATH}`); + } + const src = readFileSync(ADS_TS_PATH, "utf8"); + const slots = {}; + // Each slot entry's closing brace is the first `},` that starts a line + // after the opening `"slot-id": {` — the inline `base: { ... }` / `md: { + // ... }` object literals never start a line with their own closing brace, + // so this non-greedy match captures exactly one slot's body at a time. + const slotBlockRe = /"([a-z0-9-]+)":\s*\{([\s\S]*?)\n\s*\},/g; + let m; + while ((m = slotBlockRe.exec(src)) !== null) { + const [, slotId, block] = m; + const baseMatch = block.match(/base:\s*\{\s*width:\s*(\d+),\s*height:\s*(\d+)\s*\}/); + const mdMatch = block.match(/md:\s*\{\s*width:\s*(\d+),\s*height:\s*(\d+)\s*\}/); + if (!baseMatch || !mdMatch) continue; + slots[slotId] = { + base: { width: Number(baseMatch[1]), height: Number(baseMatch[2]) }, + md: { width: Number(mdMatch[1]), height: Number(mdMatch[2]) }, + }; + } + return slots; +} + +/** Parses a "NNNxNNN"/"NNN×NNN" cell into `{ width, height }`, or `null`. */ +function extractDims(text) { + const m = text.match(/(\d+)\s*[x×]\s*(\d+)/i); + return m ? { width: Number(m[1]), height: Number(m[2]) } : null; +} + +/** + * Parses the `## Ad slot placements` table the same way `parseChecklistRows` + * parses `## DOM attributes` — first two backticked cells are the slot id + * and owner file; the remaining three plain-text cells are position and the + * two box-dimension columns. An absent or empty section is FATAL only when + * `lib/ads.ts` declares at least one slot — an unreadable subject is never a + * pass, but a project with zero ad slots owes this table nothing. + */ +function parseAdSlotRows(declaredSlots) { + if (!existsSync(CHECKLIST_PATH)) { + fatal(`checklist not found at ${CHECKLIST_PATH} — an absent checklist is never a pass`); + } + const src = readFileSync(CHECKLIST_PATH, "utf8"); + const declaredCount = Object.keys(declaredSlots).length; + const section = findSection(src, AD_SLOT_HEADING); + + if (section === null) { + if (declaredCount > 0) { + fatal( + `${AD_SLOT_HEADING} section not found in ${CHECKLIST_PATH} but lib/ads.ts declares ${declaredCount} slot(s) — an absent section is never a pass when there is something to document`, + ); + } + return []; + } + + const rowRe = /^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|([^|]*)\|\s*$/gm; + const rows = []; + let m; + while ((m = rowRe.exec(section)) !== null) { + rows.push({ + slotId: m[1], + ownerFile: m[2], + position: m[3].trim(), + boxBase: m[4].trim(), + boxMd: m[5].trim(), + }); + } + if (rows.length === 0 && declaredCount > 0) { + fatal( + `${AD_SLOT_HEADING} yielded zero parseable rows but lib/ads.ts declares ${declaredCount} slot(s) — an empty table is never a pass when there is something to document`, + ); + } + return rows; +} + +/** The doc row's slot id exists in AD_SLOTS, and its box numbers agree. */ +function checkAdSlotDeclared(row, declaredSlots) { + const id = `adslot:${row.slotId}:declared`; + const spec = declaredSlots[row.slotId]; + if (!spec) { + return { id, pass: false, detail: `lib/ads.ts declares no slot named ${row.slotId}` }; + } + const base = extractDims(row.boxBase); + const md = extractDims(row.boxMd); + const pass = + Boolean(base) && + Boolean(md) && + base.width === spec.base.width && + base.height === spec.base.height && + md.width === spec.md.width && + md.height === spec.md.height; + const specText = `${spec.base.width}x${spec.base.height} / ${spec.md.width}x${spec.md.height}`; + return { + id, + pass, + detail: pass + ? `doc box (${row.boxBase} / ${row.boxMd}) matches lib/ads.ts (${specText})` + : `doc box (${row.boxBase} / ${row.boxMd}) does not match lib/ads.ts (${specText})`, + }; +} + +/** Finds every `.ts`/`.tsx` file under `app/` (mirrors the demo-report parse discipline). */ +function listComponentFiles() { + const files = []; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (/\.tsx?$/.test(entry.name)) { + files.push(full); + } + } + }; + walk(path.join(REPO_ROOT, "app")); + return files; +} + +function slotMountRegex(slotId) { + return new RegExp(` { + if (line.includes("data-protected=")) protectedLines.push(i); + if (mountRe.test(line)) mountLines.push(i); + }); + for (const mLine of mountLines) { + for (const pLine of protectedLines) { + if (Math.abs(mLine - pLine) <= 15) { + return { id, pass: false, detail: `${row.slotId} mounted within 15 lines of a data-protected element in ${path.relative(REPO_ROOT, file)}` }; + } + } + } + } + return { id, pass: true, detail: `no data-protected element within 15 lines of ${row.slotId} in any scanned file` }; +} + +/** No file outside the table's owner files mounts an ad slot at all. */ +function checkNoStraySlots(rows) { + const id = "adslot:no-stray-slots"; + const allowedFiles = new Set( + rows.map((r) => path.resolve(path.isAbsolute(r.ownerFile) ? r.ownerFile : path.join(REPO_ROOT, r.ownerFile))), + ); + const strays = []; + for (const file of listComponentFiles()) { + const src = readFileSync(file, "utf8"); + if (/= 300) { + return { id, fatal: true, detail: `status ${res.status} fetching ${url} — base is unreachable, not merely failing` }; + } + const html = await res.res.text(); + const found = new Set(); + const markerRe = /data-ad-slot="([^"]+)"/g; + let m; + while ((m = markerRe.exec(html)) !== null) found.add(m[1]); + const unknown = [...found].filter((slotId) => !allowedSlotIds.has(slotId)); + return { + id, + pass: unknown.length === 0, + detail: + found.size === 0 + ? "no ad slot markers present (ads may be switched off on this base)" + : unknown.length === 0 + ? `slot marker(s) present and all doc-named: ${[...found].join(", ")}` + : `unknown slot marker(s) not in the doc table: ${unknown.join(", ")}`, + }; +} + +// ─── Live route contracts ─────────────────────────────────────────────────── + +async function fetchRoute(url) { + try { + const res = await fetch(url, { redirect: "follow" }); + return { ok: true, status: res.status, contentType: res.headers.get("content-type") || "", res }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + +function extractLinkHref(html, relValue) { + const tagRe = new RegExp(`]*rel=["']${relValue}["'][^>]*>`, "i"); + const tagMatch = html.match(tagRe); + if (!tagMatch) return null; + const hrefMatch = tagMatch[0].match(/href=["']([^"']*)["']/i); + return hrefMatch ? hrefMatch[1] : null; +} + +async function checkImageRoute(id, url) { + const res = await fetchRoute(url); + if (!res.ok) { + return { id, fatal: true, detail: `unreachable: ${res.error}` }; + } + const pass = res.status >= 200 && res.status < 300 && res.contentType.includes("image"); + return { + id, + pass, + detail: pass ? `${res.status} ${res.contentType}` : `${res.status} ${res.contentType || "(no content-type)"}`, + }; +} + +async function checkCanonicalRoute(id, url) { + const res = await fetchRoute(url); + if (!res.ok) { + return { id, fatal: true, detail: `unreachable: ${res.error}` }; + } + if (res.status < 200 || res.status >= 300) { + return { id, pass: false, detail: `status ${res.status}` }; + } + const html = await res.res.text(); + const canonical = extractLinkHref(html, "canonical"); + const pass = Boolean(canonical) && !canonical.includes("?"); + return { id, pass, detail: canonical ? `canonical=${canonical}` : "no canonical link found" }; +} + +// ─── Reporting ─────────────────────────────────────────────────────────────── + +function printResults(results) { + const sorted = [...results].sort((a, b) => a.id.localeCompare(b.id)); + let passCount = 0; + let failCount = 0; + for (const r of sorted) { + const label = r.pass ? "PASS" : "FAIL"; + console.log(`${label} ${r.id} ${r.detail}`); + if (r.pass) passCount++; + else failCount++; + } + console.log(`\n${passCount} passed, ${failCount} failed`); + return failCount; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const reportMode = args.includes("--report"); + const baseIdx = args.indexOf("--base"); + const base = baseIdx !== -1 ? args[baseIdx + 1] : PROD_BASE; + + const rows = parseChecklistRows(); + const demo = readDemoReport(); + + const attrResults = rows.map(checkAttribute); + const fatalAttr = attrResults.find((r) => r.fatal); + if (fatalAttr) { + fatal(`attribute check ${fatalAttr.id}: ${fatalAttr.detail}`); + } + + const declaredSlots = readDeclaredSlots(); + const adSlotRows = parseAdSlotRows(declaredSlots); + const allowedSlotIds = new Set(adSlotRows.map((r) => r.slotId)); + + const adSlotDeclaredResults = adSlotRows.map((r) => checkAdSlotDeclared(r, declaredSlots)); + const adSlotOwnerResults = adSlotRows.map(checkAdSlotOwner); + const fatalOwner = adSlotOwnerResults.find((r) => r.fatal); + if (fatalOwner) { + fatal(`ad slot owner check ${fatalOwner.id}: ${fatalOwner.detail}`); + } + const adSlotContainmentResults = adSlotRows.map(checkAdSlotContainment); + const fatalContainment = adSlotContainmentResults.find((r) => r.fatal); + if (fatalContainment) { + fatal(`ad slot containment check ${fatalContainment.id}: ${fatalContainment.detail}`); + } + const adSlotStrayResult = checkNoStraySlots(adSlotRows); + + const routeResults = await Promise.all([ + checkImageRoute("route:og-awards", `${base}/og?report=${demo.code}&fight=${demo.fight}&view=awards`), + checkImageRoute("route:og-player", `${base}/og?report=${demo.code}&fight=${demo.fight}&source=${demo.source}`), + checkImageRoute("route:og-report", `${base}/og?report=${demo.code}`), + checkCanonicalRoute("route:analyze-canonical", `${base}/analyze/${demo.code}`), + ]); + + const fatalRoute = routeResults.find((r) => r.fatal); + if (fatalRoute) { + fatal(`route check ${fatalRoute.id}: ${fatalRoute.detail} — base ${base} is unreachable, not merely failing`); + } + + const adSlotLiveResults = await Promise.all([ + checkAdSlotLive("adslot:live-tbc-audit", `${base}/tbc-audit`, allowedSlotIds), + checkAdSlotLive("adslot:live-analyze-demo", `${base}/analyze/${demo.code}`, allowedSlotIds), + ]); + const fatalAdSlotLive = adSlotLiveResults.find((r) => r.fatal); + if (fatalAdSlotLive) { + fatal(`ad slot live check ${fatalAdSlotLive.id}: ${fatalAdSlotLive.detail}`); + } + + const results = [ + ...attrResults, + ...routeResults, + ...adSlotDeclaredResults, + ...adSlotOwnerResults, + ...adSlotContainmentResults, + adSlotStrayResult, + ...adSlotLiveResults, + ]; + const failCount = printResults(results); + + if (reportMode) { + process.exit(0); + } + process.exit(failCount > 0 ? 1 : 0); +} + +main(); diff --git a/scripts/record-wcl-fixtures.mjs b/scripts/record-wcl-fixtures.mjs index 7164875..9627bc5 100644 --- a/scripts/record-wcl-fixtures.mjs +++ b/scripts/record-wcl-fixtures.mjs @@ -25,12 +25,18 @@ // code ZjKgNYxVcAqR8pGJ, fight 23. A committed fixture is permanent, // world-readable data — never point this script at a private report. // +// R0-2 (PARSEFORGE-RANKINGS-SPEC.md §7) extends this recorder with rankings +// fixtures: rankings-ratelimit.json (three rateLimitData samples per run — +// before, after-report-rankings, end) and rankings-report.json (the +// report.rankings(fightIDs:) blob for the demo report, recorded on its own). +// Same secrets/public-entity discipline as the six fixtures above. +// // Usage: -// node scripts/record-wcl-fixtures.mjs -> records all six fixtures, +// node scripts/record-wcl-fixtures.mjs -> records all eight fixtures, // exits 0 on success. // // Exit codes: -// 0 -> all six fixtures written successfully +// 0 -> all fixtures written successfully // 1 -> a WCL query failed (network, GraphQL error, or unexpected shape) // 2 -> missing required environment variable(s), or no healer found in // the target fight (never falls back to a damage-dealer source) @@ -230,6 +236,145 @@ const MASTER_DATA_ACTORS_QUERY = ` } `; +// ─── R0-2 rankings probes (PARSEFORGE-RANKINGS-SPEC.md §7) ──────────────── + +// Budget introspection — never hard-code limitPerHour, the spec's §2.2 +// records it changing mid-session. +const RATE_LIMIT_QUERY = ` + query RateLimitCheck { + rateLimitData { + limitPerHour + pointsSpentThisHour + pointsResetIn + } + } +`; + +// The per-report rankings blob, recorded on its own (not as a by-product of +// PLAYER_FULL_DATA_QUERY above) so rankings-report.json is a faithful, +// independently-verifiable recording. +const REPORT_RANKINGS_QUERY = ` + query ReportRankings($code: String!, $fightIDs: [Int!]!) { + reportData { + report(code: $code) { + rankings(fightIDs: $fightIDs) + } + } + } +`; + +// Boss leaderboard (page 1 of both the per-character and per-fight rankings), +// scoped to the report's own partition — an unpartitioned read would compare +// across raid phases (the "partition-unaware ranking query" anti-pattern). +const ENCOUNTER_RANKINGS_PROBE_QUERY = ` + query EncounterRankingsProbe($encounterID: Int!, $partition: Int) { + worldData { + encounter(id: $encounterID) { + characterRankings(partition: $partition, page: 1) + fightRankings(partition: $partition, page: 1) + } + } + } +`; + +// A character's zone/encounter rankings. Confirmed by probing this session: +// classic.warcraftlogs.com/api/v2/client is REQUIRED for this Classic +// character to resolve — the default www.warcraftlogs.com host returns +// `character: null` for the exact same name/serverSlug/serverRegion. This is +// an explicit R0-2 finding, recorded in the fixture's own _provenance block +// and in README.md, not just in this comment. +const CLASSIC_API_URL = "https://classic.warcraftlogs.com/api/v2/client"; +const CHARACTER_RANKINGS_PROBE_QUERY = ` + query CharacterRankingsProbe( + $name: String! + $serverSlug: String! + $serverRegion: String! + $zoneID: Int! + $encounterID: Int! + $partition: Int + ) { + characterData { + character(name: $name, serverSlug: $serverSlug, serverRegion: $serverRegion) { + id + zoneRankings(zoneID: $zoneID, partition: $partition) + encounterRankings(encounterID: $encounterID, partition: $partition) + } + } + } +`; + +// The bracket vocabulary any displayed percentile has to be labelled with. +const ZONES_PROBE_QUERY = ` + query ZonesProbe { + worldData { + zones { + id + name + brackets { + min + max + bucket + type + } + } + } + } +`; + +// ─── R0-2 guild probe (Task 3) ───────────────────────────────────────────── + +// Probed first, at record time: does the demo report itself resolve to a +// guild? (Confirmed this session: it does not — reportData.report(code: +// ZjKgNYxVcAqR8pGJ).guild is null.) +const REPORT_GUILD_QUERY = ` + query ReportGuild($code: String!) { + reportData { + report(code: $code) { + guild { + id + name + server { slug region { compactName } } + } + } + } + } +`; + +// Fallback public guild, used only because the demo report has no guild. +// "Sage" (guild id 816114, Dreamscythe-US) was found via the demo report's +// own featured character (Effinore)'s public encounterRankings — one of her +// ranked kills lists this guild, i.e. a guild attributed on a real, public +// kill on the exact same realm and zone (SSC/TK) as the demo report. Public +// visibility is confirmed at record time below via reportData.reports()'s +// own `visibility` field, not a logged-out browser check: this environment's +// direct HTTP requests to warcraftlogs.com return 403 to automated readers +// regardless of a report's actual visibility (confirmed against the +// already-known-public demo report URL, which also 403s) — see README.md. +const FALLBACK_GUILD_ID = 816114; + +const GUILD_PROBE_QUERY = ` + query GuildProbe($guildID: Int!, $zoneID: Int) { + guildData { + guild(id: $guildID) { + id + name + server { slug region { compactName } } + members(page: 1) { + data { name guildRank classID } + } + attendance(page: 1) { + data { code startTime zone { id name } } + } + } + } + reportData { + reports(guildID: $guildID, zoneID: $zoneID, limit: 10, page: 1) { + data { code title zone { id name } startTime visibility } + } + } + } +`; + // ─── WCL HTTP plumbing (mirrors lib/wcl-client.ts's auth mechanics; not a // shared import — see header comment) ────────────────────────────────── @@ -254,8 +399,8 @@ async function getAccessToken(clientId, clientSecret) { return data.access_token; } -async function gqlQuery(token, query, variables) { - const res = await fetch(WCL_API_URL, { +async function gqlQuery(token, query, variables, apiUrl = WCL_API_URL) { + const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -298,6 +443,17 @@ function writeFixture(filename, data) { console.log(`Wrote ${path.relative(REPO_ROOT, filePath)}`); } +// Takes one rateLimitData sample, labeled and timestamped, for +// rankings-ratelimit.json. Never writes the token — only the budget fields. +async function sampleRateLimit(token, label) { + const data = await gqlQuery(token, RATE_LIMIT_QUERY, {}); + return { + label, + recorded: new Date().toISOString(), + ...data.rateLimitData, + }; +} + // ─── Main ───────────────────────────────────────────────────────────────── async function main() { @@ -316,6 +472,155 @@ async function main() { console.log(`Recording fixtures for report ${REPORT_CODE}, fight ${FIGHT_ID}...`); const token = await getAccessToken(clientId, clientSecret); + // R0-2 rate-limit sample 1/3 — before any other query in this run. + const rateLimitSamples = []; + rateLimitSamples.push(await sampleRateLimit(token, "before")); + + // R0-2: the per-report rankings blob, recorded on its own. + const rankingsReportData = await gqlQuery(token, REPORT_RANKINGS_QUERY, { + code: REPORT_CODE, + fightIDs: [FIGHT_ID], + }); + writeFixture("rankings-report.json", { + _provenance: { + query: "ReportRankings (reportData.report(code:).rankings(fightIDs:))", + recorded: new Date().toISOString(), + entity: `report ${REPORT_CODE}, fight ${FIGHT_ID}`, + api_host: "www.warcraftlogs.com", + }, + ...rankingsReportData, + }); + + // R0-2 rate-limit sample 2/3 — immediately after the rankings query above. + rateLimitSamples.push(await sampleRateLimit(token, "after-report-rankings")); + + // R0-2 (Task 2): boss leaderboard, character and zone-bracket fixtures. + // Every id below is read from the just-recorded rankings-report.json blob, + // never a literal — including the partition, so the leaderboard read is + // scoped to the report's own raid phase. + const reportRankingEntry = rankingsReportData?.reportData?.report?.rankings?.data?.[0]; + const encounterID = reportRankingEntry?.encounter?.id; + const encounterName = reportRankingEntry?.encounter?.name; + const partition = reportRankingEntry?.partition; + const zoneID = reportRankingEntry?.zone; + const targetCharacter = reportRankingEntry?.roles?.dps?.characters?.[0]; + + const encounterRankingsData = await gqlQuery(token, ENCOUNTER_RANKINGS_PROBE_QUERY, { + encounterID, + partition, + }); + writeFixture("rankings-encounter.json", { + _provenance: { + query: "EncounterRankingsProbe (worldData.encounter.characterRankings + fightRankings)", + recorded: new Date().toISOString(), + entity: `encounter ${encounterID} ("${encounterName}"), partition ${partition}`, + api_host: "www.warcraftlogs.com", + }, + ...encounterRankingsData.worldData.encounter, + }); + rateLimitSamples.push(await sampleRateLimit(token, "after-encounter-rankings")); + + const characterRankingsData = await gqlQuery( + token, + CHARACTER_RANKINGS_PROBE_QUERY, + { + name: targetCharacter.name, + serverSlug: targetCharacter.server.name, + serverRegion: targetCharacter.server.region, + zoneID, + encounterID, + partition, + }, + CLASSIC_API_URL, + ); + writeFixture("rankings-character.json", { + _provenance: { + query: "CharacterRankingsProbe (characterData.character.zoneRankings + encounterRankings)", + recorded: new Date().toISOString(), + entity: `${targetCharacter.name}-${targetCharacter.server.name}-${targetCharacter.server.region}, zone ${zoneID}, partition ${partition}`, + api_host: "classic.warcraftlogs.com", + // R0-2 finding: this Classic character resolves to null on the + // default www.warcraftlogs.com host and requires the classic. host — + // confirmed by probing both hosts this session with identical args. + required_classic_host: true, + }, + ...characterRankingsData.characterData.character, + }); + rateLimitSamples.push(await sampleRateLimit(token, "after-character-rankings")); + + const zonesData = await gqlQuery(token, ZONES_PROBE_QUERY, {}); + writeFixture("rankings-zones.json", { + _provenance: { + query: "ZonesProbe (worldData.zones)", + recorded: new Date().toISOString(), + entity: "all worldData.zones (bracket vocabulary, not report-specific)", + api_host: "www.warcraftlogs.com", + }, + worldData: zonesData.worldData, + }); + rateLimitSamples.push(await sampleRateLimit(token, "after-zones")); + + // R0-2 (Task 3): choose the guild, explicitly. Probe the demo report's own + // guild first; only fall back to the hand-picked public guild above if the + // report has none. + const reportGuildData = await gqlQuery( + token, + REPORT_GUILD_QUERY, + { code: REPORT_CODE }, + CLASSIC_API_URL, + ); + const reportGuild = reportGuildData?.reportData?.report?.guild; + const guildID = reportGuild?.id ?? FALLBACK_GUILD_ID; + const guildChoiceReason = reportGuild + ? `the demo report's own guild (reportData.report(code:).guild)` + : "fallback public guild — the demo report has no guild " + + "(reportData.report(code:).guild is null); see FALLBACK_GUILD_ID's " + + "comment above for how this guild was found and why it was chosen"; + + const guildProbeData = await gqlQuery( + token, + GUILD_PROBE_QUERY, + { guildID, zoneID }, + CLASSIC_API_URL, + ); + const guild = guildProbeData.guildData.guild; + const guildReports = guildProbeData.reportData.reports; + const allReportsPublic = + guildReports.data.length > 0 && + guildReports.data.every((r) => r.visibility === "public"); + + if (!allReportsPublic) { + console.error( + `Guild ${guildID} could not be confirmed public (reports() returned ` + + `${guildReports.data.length} reports, not all public) — never ` + + "recording an unconfirmed entity. Pick a different fallback guild " + + "and rerun.", + ); + process.exit(2); + } + + writeFixture("rankings-guild.json", { + _provenance: { + query: "GuildProbe (guildData.guild.members + attendance, reportData.reports)", + recorded: new Date().toISOString(), + entity: `${guild.name} (${guild.server.slug}-${guild.server.region.compactName}), guild id ${guild.id}`, + api_host: "classic.warcraftlogs.com", + guild_choice: guildChoiceReason, + public_confirmed: true, + public_confirmed_method: + `reportData.reports(guildID) returned visibility: "public" for all ` + + `${guildReports.data.length} sampled reports; a logged-out browser ` + + "check was not possible in this environment (warcraftlogs.com " + + "returns HTTP 403 to automated readers, confirmed against the " + + "already-public demo report URL too — see README.md)", + public_confirmed_date: new Date().toISOString().slice(0, 10), + }, + members: guild.members, + attendance: guild.attendance, + reports: guildReports.data, + }); + rateLimitSamples.push(await sampleRateLimit(token, "after-guild-rankings")); + // 1. DPS player data (fight 23, source 12) const dpsData = await gqlQuery(token, PLAYER_FULL_DATA_QUERY, { code: REPORT_CODE, @@ -414,7 +719,19 @@ async function main() { masterData: masterDataActorsData?.reportData?.report?.masterData ?? null, }); - console.log("All six fixtures recorded successfully."); + // R0-2 rate-limit sample 3/3 — at the very end of the run. + rateLimitSamples.push(await sampleRateLimit(token, "end")); + writeFixture("rankings-ratelimit.json", { + _provenance: { + query: "RateLimitCheck (rateLimitData), sampled multiple times across this run", + recorded: new Date().toISOString(), + entity: "this run's own WCL client-credentials key budget (not report-specific)", + api_host: "www.warcraftlogs.com", + }, + samples: rateLimitSamples, + }); + + console.log("All fixtures recorded successfully."); process.exit(0); }