> ## Documentation Index
> Fetch the complete documentation index at: https://docs.veristar.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Redirect research: can a permanent redirect replace a domain transfer?

> Research on whether permanent redirects can replace a domain transfer during handover.

Plan phase 21 research item, run 2026-11-13. The handover plan ([plan.md](https://docs.veristar.app/handover/plan.md), section 5)
offers two ways to move `BASE_DOMAIN`: transfer the domain, or keep the old hostnames answering
permanent redirects to new ones. Every tenant page carries a snippet like this (built by
`packages/tracker/src/snippet.ts` and the control plane):

```html theme={null}
<script defer src="https://in.<old domain>/v/1.1.0/sdk.js" integrity="sha384-..." crossorigin="anonymous" data-website-id="<site id>"></script>
```

The script reads its gateway origin from its own `src` (`packages/tracker/src/script.ts`), so after
a redirect the tracker still sends `GET /v1/c` and `POST /v1/e` (fetch, `keepalive: true`,
`credentials: "omit"`, body as `text/plain`) to the **old** origin. Every one of those requests
goes through the redirect too.

## Result

**The redirect path cannot preserve existing tenant integrations. Domain transfer is the only
safe option.** The two browser questions alone would allow a redirect, but only when the
redirect response itself carries `Access-Control-Allow-Origin`. The released tracker still loses
every event, in all three engines, because the redirected requests reach the gateway with
`Origin: null`, and the gateway refuses that origin.

| Case | Chromium 156.0.8078.4 | Firefox 157.0 | WebKit 27.2 |
| - | - | - | - |
| a. control: sdk.js loaded directly from the new origin runs | pass | pass | pass |
| a. sdk.js through a cross-origin 308 that carries `Access-Control-Allow-Origin: *` runs, integrity passes | pass | pass | pass |
| a. sdk.js through a cross-origin 308 with no cors header (as a plain redirect answers) | **fail**: blocked by CORS, never fetched from the new origin | **fail**: same | **fail**: same |
| a. control: a wrong integrity value through the 308 is blocked after the bytes were fetched | pass | pass | pass |
| b. control: direct keepalive post | pass | pass | pass |
| b. keepalive post through a 308 with cors: method POST and body byte-identical at the new origin | pass | pass | pass |
| b. keepalive post through a 308 without cors | **fail**: `TypeError: Failed to fetch`, nothing reaches the new origin | **fail**: `NetworkError` | **fail**: `Load failed` |
| b. keepalive post started in `pagehide`, through a 308 with cors, connection already open | pass | pass | pass |
| b. control: the same post sent directly | pass | pass | pass |
| b. observation: `pagehide` post on a cold connection, direct and redirected | not delivered either way (see notes) | delivered both ways | delivered both ways |
| `Origin` header the new origin received on every redirected request | `null` | `null` | `null` |
| c. control: released tracker 1.1.0, snippet on the new origin: pageview accepted (202) | pass | pass | pass |
| c. released tracker 1.1.0, old snippet, every request redirected with cors: pageview accepted | **fail**: challenge refused 403 (origin `null`), no event sent | **fail**: same | **fail**: same |

Two runs gave identical outcomes (30 passed, 9 failed each; the observation cases always pass and
only print what happened).

### Why the origin becomes null

All three engines implement the Fetch standard's tainted-origin rule: when a cross-origin request
is redirected to a third origin, its `Origin` header is serialised as `null`. The tenant page, the
old gateway and the new gateway are three different origins, so every redirected challenge and
event arrives with `Origin: null`. The gateway checks the origin of both the challenge and the
event against the site's registered origins (`CheckOrigin` in `internal/gateway/origin.go`, called
from `challenge` and `event` in `internal/gateway/server.go`); `internal/gateway/origin_test.go`
asserts that `null` is refused with the origin reason. The harness applies the same rule. So the
tracker gets no challenge, sends no event, and the page sees no error (the tracker swallows a
failed challenge by design).

Accepting `Origin: null` at the gateway would mean dropping the origin check for every redirected
request, which is one of the checks against forged traffic (plan section 4.4 item 6,
`docs/security/bot-and-inflation.md`). The `Referer` header still named the tenant page in all
three engines, but a tenant's `Referrer-Policy` can remove it, so it is not a substitute. This
research does not recommend either change.

### OTLP exporters

Checked with Go 1.27.4 `net/http` (the transport of the Go OTLP HTTP exporters): a `POST` with a
body answered by a 308 to another host is re-sent with method and body intact, but the
`Authorization` header is dropped, because `net/http` removes sensitive headers on a redirect to
a host that is not the original host or its subdomain. The collector then refuses the request,
since the tenant token travels in that header. Other SDKs and OTLP gRPC were not tested; gRPC has
no HTTP redirects at all. Tenant API integrations therefore also need the old `otlp.` name to keep
resolving to a working collector, which only a domain transfer gives.

## Notes on the harness

* The cold-connection `pagehide` observation: headless Chromium behind the harness proxy did not
  deliver an unload post that needed a new connection, with or without a redirect (deterministic
  over 3 repeats). With the connection already open, as on a real page that has sent its pageview,
  the redirected post arrived intact. The miss is not caused by the redirect, so it does not change
  the result; it is not known whether it happens outside the harness.
* The redirect responses carry `Cache-Control: no-store` so cases stay independent. A production
  308 is cacheable by default, which does not change the cors or origin behaviour.

## Method and how to rerun

Harness: `tests/e2e/redirect/` (test fixture only).

* `fixture.ts` starts a forward proxy, a TLS front and a control port (47280, 47643, 47290, apart
  from the main matrix's ports), with these names: `site.sa-rtenant.example` serves a page with the
  snippet built by `snippet()` for the origin under test; `in.sa-old.example` answers 308 to
  `in.sa-new.example` for every path with `Access-Control-Allow-Origin: *`;
  `in.sa-oldbare.example` answers the same 308 with no cors header; `in.sa-new.example` is the
  fixture gateway of `tests/e2e/fixture/gateway.ts` (released `sdk.js` bytes from
  `packages/tracker/releases` with the production headers, challenge, proof and event checks) plus
  the go gateway's origin rule on `/v1/c`. Every request each host saw is recorded with headers and
  body.
* `redirect.research.ts` holds the cases above; each prints a `RESULT <engine> <case> {...}` line
  with what the browser did, then asserts what a safe redirect path would need.

```sh theme={null}
source ~/.nvm/nvm.sh && nvm use 26.13.1
pnpm --filter @veristar/e2e redirect
```

Playwright 1.64.1 with its bundled engines (versions in the table header).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.