Skip to content

Commit 46abcd4

Browse files
committed
fix(api): redirect events pages to canonical site
1 parent 349708d commit 46abcd4

10 files changed

Lines changed: 331 additions & 60 deletions

File tree

apps/api/README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ Minimal Go service for the public `events.vercount.one` surface.
44

55
## What it serves
66

7-
- `/`
8-
- `/healthz`
9-
- `/js`
10-
- `/bench/write`
11-
- `/log`
12-
- `/api/v1/log`
13-
- `/api/v2/log`
7+
- `/` redirects to the canonical homepage at `https://www.vercount.one/`
8+
- `/dashboard`, `/dashboard/analytics`, `/dashboard/domains`, and `/auth/signin` redirect to the same path on `https://www.vercount.one`
9+
- `/healthz` exposes Redis-backed readiness JSON
10+
- `/js` serves the public browser counter script
11+
- `/bench/write` exposes the benchmark write helper
12+
- `/log`, `/api/v1/log`, and `/api/v2/log` serve the public counter API
1413

1514
`GET /bench/write` is a public benchmark helper for external probe tools. It always writes to the fixed synthetic target `https://bench.vercount.one/gurt`, returns the same v2-style JSON envelope used by the modern public API, and sends no-store cache headers so latency checks hit the Go service instead of cached content.
1615

@@ -20,7 +19,7 @@ The Go API stays intentionally small and is organized by runtime surface instead
2019

2120
- `internal/app/runtime.go` - env/config loading and logger setup
2221
- `internal/app/server.go` - dependency wiring and chi route registration
23-
- `internal/api/public.go` - `/`, `/healthz`, and `/js`
22+
- `internal/api/public.go` - canonical redirects, `/healthz`, and `/js`
2423
- `internal/api/log.go` - `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log`
2524
- `internal/counter/*` - counter reads/writes and Redis-backed counting behavior
2625

@@ -126,7 +125,7 @@ If GHCR creates the package with private visibility on first publish, update the
126125

127126
1. Build the web app script so `apps/web/public/js/client.min.js` is up to date.
128127
2. Deploy this service behind `events.vercount.one`.
129-
3. Verify `/js`, `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log` on the new host.
128+
3. Verify `/` redirects to `https://www.vercount.one/`, and verify `/js`, `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log` on the new host.
130129
4. Confirm `/bench/write` writes only to the fixed synthetic benchmark target `https://bench.vercount.one/gurt` and returns an uncached v2-style success envelope.
131130
5. Confirm the dashboard in `apps/web` still reads the shared Redis data correctly.
132131

apps/api/internal/api/public.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import (
1212
redis "github.com/redis/go-redis/v9"
1313
)
1414

15-
const publicServiceName = "vercount-events-api"
15+
const (
16+
publicServiceName = "vercount-events-api"
17+
canonicalWebOrigin = "https://www.vercount.one"
18+
)
1619

1720
type Logger interface {
1821
Debug(message string, data any)
@@ -31,23 +34,17 @@ func NewPublicHandler(scriptPath string, log Logger, redisClient *redis.Client)
3134
return &PublicHandler{scriptPath: scriptPath, log: log, redis: redisClient}
3235
}
3336

34-
func (h *PublicHandler) Root(w http.ResponseWriter, _ *http.Request) {
35-
writeJSON(w, http.StatusOK, map[string]any{
36-
"service": publicServiceName,
37-
"description": "Straightforward, Fast, and Reliable Website Visitor Counter.",
38-
"status": "ok",
39-
"routes": []string{
40-
"/",
41-
"/healthz",
42-
"/js",
43-
"/bench/write",
44-
"/log",
45-
"/api/v1/log",
46-
"/api/v2/log",
47-
},
48-
"github": "https://github.com/EvanNotFound/vercount",
49-
"homepage": "https://www.vercount.one",
50-
})
37+
func (h *PublicHandler) Root(w http.ResponseWriter, r *http.Request) {
38+
h.CanonicalRedirect(w, r)
39+
}
40+
41+
func (h *PublicHandler) CanonicalRedirect(w http.ResponseWriter, r *http.Request) {
42+
target := canonicalWebOrigin + r.URL.EscapedPath()
43+
if r.URL.RawQuery != "" {
44+
target += "?" + r.URL.RawQuery
45+
}
46+
47+
http.Redirect(w, r, target, http.StatusMovedPermanently)
5148
}
5249

5350
func (h *PublicHandler) Healthz(w http.ResponseWriter, r *http.Request) {

apps/api/internal/app/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ func (s *Server) Routes() http.Handler {
3131
r.Use(requestLoggingMiddleware(s.log))
3232

3333
r.Get("/", s.public.Root)
34+
r.Head("/", s.public.Root)
35+
r.Get("/dashboard", s.public.CanonicalRedirect)
36+
r.Head("/dashboard", s.public.CanonicalRedirect)
37+
r.Get("/dashboard/analytics", s.public.CanonicalRedirect)
38+
r.Head("/dashboard/analytics", s.public.CanonicalRedirect)
39+
r.Get("/dashboard/domains", s.public.CanonicalRedirect)
40+
r.Head("/dashboard/domains", s.public.CanonicalRedirect)
41+
r.Get("/auth/signin", s.public.CanonicalRedirect)
42+
r.Head("/auth/signin", s.public.CanonicalRedirect)
3443
r.Get("/healthz", s.public.Healthz)
3544
r.Get("/js", s.public.Script)
3645
r.Head("/js", s.public.Script)

apps/api/internal/app/server_test.go

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,82 @@ import (
1616
redis "github.com/redis/go-redis/v9"
1717
)
1818

19-
func TestRootEndpointReturnsServiceMetadata(t *testing.T) {
19+
func TestRootRedirectsToCanonicalHomepage(t *testing.T) {
2020
handler := newTestHandler(t, mustStartMiniRedis(t).Addr())
21-
recorder := httptest.NewRecorder()
2221

23-
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
22+
for _, method := range []string{http.MethodGet, http.MethodHead} {
23+
t.Run(method, func(t *testing.T) {
24+
recorder := httptest.NewRecorder()
25+
handler.ServeHTTP(recorder, httptest.NewRequest(method, "/", nil))
2426

25-
if recorder.Code != http.StatusOK {
26-
t.Fatalf("expected 200, got %d", recorder.Code)
27+
assertRedirectLocation(t, recorder, "https://www.vercount.one/")
28+
})
2729
}
30+
}
2831

29-
var payload map[string]any
30-
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
31-
t.Fatalf("unmarshal root response: %v", err)
32-
}
32+
func TestSelectedPageRoutesRedirectToCanonicalWebHost(t *testing.T) {
33+
handler := newTestHandler(t, mustStartMiniRedis(t).Addr())
3334

34-
if payload["service"] != "vercount-events-api" {
35-
t.Fatalf("expected service name, got %#v", payload["service"])
35+
for _, tc := range []struct {
36+
path string
37+
want string
38+
}{
39+
{path: "/dashboard", want: "https://www.vercount.one/dashboard"},
40+
{path: "/dashboard/analytics", want: "https://www.vercount.one/dashboard/analytics"},
41+
{path: "/dashboard/domains", want: "https://www.vercount.one/dashboard/domains"},
42+
{path: "/auth/signin", want: "https://www.vercount.one/auth/signin"},
43+
{
44+
path: "/dashboard/domains?from=events&next=%2Fsettings",
45+
want: "https://www.vercount.one/dashboard/domains?from=events&next=%2Fsettings",
46+
},
47+
} {
48+
t.Run(tc.path, func(t *testing.T) {
49+
recorder := httptest.NewRecorder()
50+
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil))
51+
52+
assertRedirectLocation(t, recorder, tc.want)
53+
})
3654
}
55+
}
56+
57+
func TestKnownMachineRoutesDoNotRedirectToCanonicalWebHost(t *testing.T) {
58+
handler, scriptContents := newTestHandlerWithScript(t, mustStartMiniRedis(t).Addr(), "console.log('vercount');")
3759

38-
routes, ok := payload["routes"].([]any)
39-
if !ok || len(routes) == 0 {
40-
t.Fatalf("expected routes array, got %#v", payload["routes"])
60+
for _, path := range []string{
61+
"/healthz",
62+
"/js",
63+
"/log?url=file:///bad",
64+
"/api/v1/log?url=file:///bad",
65+
"/api/v2/log?url=file:///bad",
66+
} {
67+
t.Run(path, func(t *testing.T) {
68+
recorder := httptest.NewRecorder()
69+
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
70+
71+
if recorder.Code == http.StatusMovedPermanently {
72+
t.Fatalf("expected machine route not to redirect, got location %q", recorder.Header().Get("Location"))
73+
}
74+
if strings.HasPrefix(recorder.Header().Get("Location"), "https://www.vercount.one") {
75+
t.Fatalf("expected no canonical redirect, got %q", recorder.Header().Get("Location"))
76+
}
77+
if path == "/js" && recorder.Body.String() != scriptContents {
78+
t.Fatalf("expected script contents %q, got %q", scriptContents, recorder.Body.String())
79+
}
80+
})
4181
}
82+
}
83+
84+
func TestUnknownAPIPathDoesNotRedirectToCanonicalWebHost(t *testing.T) {
85+
handler := newTestHandler(t, mustStartMiniRedis(t).Addr())
86+
recorder := httptest.NewRecorder()
87+
88+
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v3/log", nil))
4289

43-
if !containsRoute(routes, "/bench/write") {
44-
t.Fatalf("expected benchmark route in metadata, got %#v", routes)
90+
if recorder.Code != http.StatusNotFound {
91+
t.Fatalf("expected 404, got %d", recorder.Code)
4592
}
46-
for _, route := range []string{"/log", "/api/v1/log", "/api/v2/log"} {
47-
if !containsRoute(routes, route) {
48-
t.Fatalf("expected route %q in metadata, got %#v", route, routes)
49-
}
93+
if recorder.Header().Get("Location") != "" {
94+
t.Fatalf("expected no redirect location, got %q", recorder.Header().Get("Location"))
5095
}
5196
}
5297

@@ -649,14 +694,15 @@ func hasLogEvent(output string, event string) bool {
649694
return false
650695
}
651696

652-
func containsRoute(routes []any, want string) bool {
653-
for _, route := range routes {
654-
if route == want {
655-
return true
656-
}
657-
}
697+
func assertRedirectLocation(t *testing.T, recorder *httptest.ResponseRecorder, want string) {
698+
t.Helper()
658699

659-
return false
700+
if recorder.Code != http.StatusMovedPermanently {
701+
t.Fatalf("expected 301, got %d", recorder.Code)
702+
}
703+
if recorder.Header().Get("Location") != want {
704+
t.Fatalf("expected redirect to %q, got %q", want, recorder.Header().Get("Location"))
705+
}
660706
}
661707

662708
func seedBenchmarkNamespace(t *testing.T, client *redis.Client) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-24
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
## Context
2+
3+
The public events service now lives in `apps/api` and serves the machine-facing `events.vercount.one` surface for the browser script, counter writes/reads, readiness checks, and benchmarks. That host previously served the Next.js web app, so search engines may still treat routes on the events host as human-facing Vercount pages.
4+
5+
The current Go root handler returns JSON metadata at `/`, and the current `public-service-meta-endpoints` spec requires that behavior. The canonical web experience is now `https://www.vercount.one`, so the events host should stop acting like an indexable page origin while preserving its public API contract.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- Send browser and crawler traffic for the events-host root to `https://www.vercount.one/`.
12+
- Redirect known old web-app page paths from the events host to the same path on `https://www.vercount.one`.
13+
- Keep `/healthz`, `/js`, `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log` available on the events host without redirects.
14+
- Keep unknown API-style paths on the events host as misses instead of redirecting them to the web app.
15+
- Update docs so the events host is described as an API/script host, not a main website host.
16+
17+
**Non-Goals:**
18+
19+
- Do not change counter semantics, response shapes, CORS behavior, rate limiting, or Redis key usage.
20+
- Do not move the browser counter script or public counter endpoints to the web app.
21+
- Do not introduce a broad catch-all redirect for every unknown events-host path.
22+
- Do not change the web app routing implementation beyond documentation or canonical references needed for this behavior.
23+
24+
## Decisions
25+
26+
### Use permanent redirects for known human-facing routes
27+
28+
`GET` and `HEAD` requests for `/` and selected old web-app paths should return a permanent redirect to the canonical web host. This directly addresses search indexing and avoids continuing to expose API metadata as the apparent homepage.
29+
30+
Alternatives considered:
31+
32+
- Keep root JSON and add `X-Robots-Tag: noindex`: lower product risk, but users and search results can still land on a JSON response instead of the website.
33+
- Redirect only `/`: simpler, but does not clean up old indexed Next.js page paths that may still exist under the events host.
34+
- Redirect all unknown paths: broader cleanup, but it can hide accidental API URL mistakes and make the events host less predictable for API clients.
35+
36+
### Keep the redirect allowlist explicit
37+
38+
The redirect set should include `/` plus the known web-app page surfaces that may have existed when the host served Next.js, currently `/dashboard`, `/dashboard/analytics`, `/dashboard/domains`, and `/auth/signin`. Redirect targets should preserve the path and query string so bookmarked or indexed URLs land on the equivalent canonical page.
39+
40+
An explicit list keeps API-host behavior predictable and avoids turning unrelated unknown paths into web redirects. Future page paths can be added intentionally if they are found in search results or logs.
41+
42+
### Preserve machine endpoints exactly
43+
44+
The existing public events machine endpoints should remain registered before or alongside redirect handling so they continue to return their current API/script responses. Unknown `/api/*` paths should not redirect to the web app because API consumers benefit from clear misses on the API host.
45+
46+
### Keep canonical host fixed and simple
47+
48+
Use `https://www.vercount.one` as the canonical redirect base. A compile-time constant is enough for the current product need and avoids introducing new environment configuration for a stable public site URL.
49+
50+
## Risks / Trade-offs
51+
52+
- **Risk:** Permanent redirects are sticky in browsers and crawlers. → **Mitigation:** Limit redirects to the root and known human-facing paths, leaving machine endpoints untouched.
53+
- **Risk:** The old JSON root may have been used by ad-hoc health checks. → **Mitigation:** `/healthz` remains the supported readiness endpoint and documentation should point monitors there.
54+
- **Risk:** Some old indexed pages may not be covered by the initial allowlist. → **Mitigation:** Keep the list easy to extend based on logs or search console findings.
55+
- **Risk:** Dashboard redirects may send unauthenticated users to protected web routes. → **Mitigation:** The canonical web app already owns auth redirects for dashboard pages, so the events host should only move traffic to the correct origin.
56+
57+
## Migration Plan
58+
59+
1. Update the Go public handler/router so root and selected human-facing paths redirect to `https://www.vercount.one`.
60+
2. Verify the preserved machine endpoints still serve their existing responses without redirects.
61+
3. Update API documentation and route listings to describe `/` as a canonical redirect and `/healthz` as the machine-readable readiness endpoint.
62+
4. Deploy the Go API service and check search-console or access logs for additional old page paths that should be added later.
63+
64+
Rollback is straightforward: restore the root metadata handler and remove the selected page redirects if the redirect behavior causes unexpected operational issues.
65+
66+
## Open Questions
67+
68+
- Should the web app metadata consistently use `https://www.vercount.one` as its metadata base if `www` is the canonical host? This is related SEO cleanup but can be handled separately if desired.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## Why
2+
3+
Search engines still index the public Go events host as if it were the main Vercount web page because the host previously served the Next.js site and now returns API metadata at `/`. The public events host should keep serving machine endpoints, while human-facing and previously indexed page URLs should move users and crawlers to the canonical `https://www.vercount.one` site.
4+
5+
## What Changes
6+
7+
- Change `GET /` on the public events host from a JSON metadata response to a redirect to `https://www.vercount.one/`.
8+
- Redirect selected human-facing page paths that may have been indexed on the events host to the same path on the canonical web host.
9+
- Keep public machine endpoints on the events host available and non-redirecting, including `/healthz`, `/js`, `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log`.
10+
- Keep unknown API-style paths as API-host misses rather than redirecting them to the web app.
11+
- Document the new split between canonical web pages and public events API endpoints.
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- None.
18+
19+
### Modified Capabilities
20+
21+
- `public-service-meta-endpoints`: replace the root JSON metadata behavior with canonical redirects for human-facing events-host pages while preserving Redis-backed readiness behavior.
22+
23+
## Impact
24+
25+
- Affected code: Go API router and public handler code under `apps/api/internal/app/server.go` and `apps/api/internal/api/public.go`.
26+
- Affected documentation: `apps/api/README.md` and any route listing that currently describes `/` as JSON metadata.
27+
- Public API compatibility: counter and script endpoints must continue to serve existing clients without redirects.
28+
- SEO behavior: crawlers and browser users requesting selected page URLs on `events.vercount.one` should be sent to `www.vercount.one`.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Public events host SHALL redirect service root to canonical web homepage
4+
5+
The system SHALL redirect `GET` and `HEAD` requests for `/` on the public Go API host to the canonical Vercount web homepage at `https://www.vercount.one/`.
6+
7+
#### Scenario: Client requests the service root
8+
9+
- **WHEN** a client sends a `GET` request to `/`
10+
- **THEN** the system SHALL return a permanent redirect to `https://www.vercount.one/`
11+
- **AND** the system SHALL NOT return the public Go API service metadata JSON
12+
13+
#### Scenario: Client sends a HEAD request to the service root
14+
15+
- **WHEN** a client sends a `HEAD` request to `/`
16+
- **THEN** the system SHALL return a permanent redirect to `https://www.vercount.one/`
17+
18+
### Requirement: Public events host SHALL redirect known human-facing pages to the canonical web host
19+
20+
The system SHALL redirect known human-facing page requests on the public events host to the same path on `https://www.vercount.one` so old indexed or bookmarked web-app URLs resolve to the canonical site.
21+
22+
#### Scenario: Client requests an old dashboard page on the events host
23+
24+
- **WHEN** a client sends a `GET` request to `/dashboard` on the public events host
25+
- **THEN** the system SHALL return a permanent redirect to `https://www.vercount.one/dashboard`
26+
27+
#### Scenario: Client requests an old dashboard subpage on the events host
28+
29+
- **WHEN** a client sends a `GET` request to `/dashboard/analytics` or `/dashboard/domains` on the public events host
30+
- **THEN** the system SHALL return a permanent redirect to the same path on `https://www.vercount.one`
31+
32+
#### Scenario: Client requests an old auth page on the events host
33+
34+
- **WHEN** a client sends a `GET` request to `/auth/signin` on the public events host
35+
- **THEN** the system SHALL return a permanent redirect to `https://www.vercount.one/auth/signin`
36+
37+
#### Scenario: Client request includes a query string
38+
39+
- **WHEN** a redirected human-facing request includes a query string
40+
- **THEN** the system SHALL preserve that query string on the canonical web-host redirect target
41+
42+
### Requirement: Public events host SHALL preserve machine endpoint routing
43+
44+
The system SHALL keep machine-facing events-host endpoints available without canonical web redirects.
45+
46+
#### Scenario: Client requests a supported machine endpoint
47+
48+
- **WHEN** a client sends a request to `/healthz`, `/js`, `/bench/write`, `/log`, `/api/v1/log`, or `/api/v2/log`
49+
- **THEN** the system SHALL serve the existing endpoint behavior on the public events host
50+
- **AND** the system SHALL NOT redirect the request to the canonical web host
51+
52+
#### Scenario: Client requests an unknown API-style path
53+
54+
- **WHEN** a client sends a request to an unsupported `/api/*` path on the public events host
55+
- **THEN** the system SHALL treat the request as an API-host miss
56+
- **AND** the system SHALL NOT redirect the request to the canonical web host
57+
58+
## REMOVED Requirements
59+
60+
### Requirement: Public events host SHALL expose a service metadata root endpoint
61+
62+
**Reason**: The public events host is no longer the canonical human-facing site, and returning service metadata at `/` causes search engines and users to land on an API response instead of the Vercount homepage.
63+
64+
**Migration**: Use `/healthz` for machine-readable service readiness and `https://www.vercount.one/` for the canonical homepage.

0 commit comments

Comments
 (0)