docs(bf-29wyl): confirm token-supply path dead — header-only, unread

Verify the sim/header-side half of the tokenless-sim REJECT root cause
against current source (second link of the bf-34lwt split). Three facts
re-checked against current source:

- Sim supplies its token ONLY as the X-Spaxel-Token HTTP header on the
  WS dial — cmd/sim/main.go:313, mothership/cmd/sim/main.go:633,
  mothership/cmd/sim/scenario.go:318 (all three connect paths).
- The hello JSON map the sim sends has NO token field —
  cmd/sim/main.go:348-359, mothership/cmd/sim/main.go:652-665,
  mothership/cmd/sim/scenario.go:331-342. Only "--token" hits are CLI
  flag defs, never a JSON body key.
- The mothership NEVER reads that header on the WS path —
  grep 'X-Spaxel-Token' mothership/ excluding _test.go and cmd/sim/
  returns zero hits. HandleNodeWS (server.go:455-469) passes r straight
  to Upgrade; the only Header ref on the path is the outbound
  w.Header().Set response header.

Conclusion: header ignored -> hello.Token (message.go:22) empty ->
tokenOK (server.go:513) always false. The "have the sim supply valid
tokens" option is currently DEAD. Companion to bf-5ig3e (validator half);
both roll up into bf-34lwt.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-29wyl
This commit is contained in:
jedarden 2026-07-07 06:54:52 -04:00
parent 3c5b79b791
commit 4dcc06af2b

View file

@ -0,0 +1,152 @@
# Confirmation: sim supplies its token only as the unread `X-Spaxel-Token` header → token-supply path is DEAD
**Tracking:** bead `bf-29wyl` (second link of the `bf-34lwt` split).
**Date:** 2026-07-07.
**Scope:** isolate and re-verify the *sim/header-side* half of the
tokenless-sim REJECT root cause against current source. This confirms the
three statements "the sim sets the token only as the `X-Spaxel-Token`
header", "the hello JSON body has no `token` field", and "the mothership
never reads that header on the WS path" — i.e. the **token-supply option is
currently dead**. The validator-side half is covered by the sibling note
[`token-validator-wiring-confirm.md`](./token-validator-wiring-confirm.md)
(bead `bf-5ig3e`); both roll up into the parent
[`token-reject-root-cause.md`](./token-reject-root-cause.md) (bead
`bf-34lwt`). This note re-checks the three header-side facts that chain into
"dead path", so a drift in any of them is detectable on its own.
> **Path convention.** Citations are full repo-relative paths. The `bf-29wyl`
> task body drops the `mothership/` module prefix, so its `cmd/sim/main.go`
> is `mothership/cmd/sim/main.go` here.
>
> **Two sim copies — both behave identically for this finding.**
> - `cmd/sim/main.go` — the module `go.work` lists (`use ./cmd/sim`); the
> dev/test path. This copy has no `scenario.go`.
> - `mothership/cmd/sim/` — the copy the **Dockerfile actually builds** (it
> does `COPY mothership/ ./` then `go build ./cmd/sim`, so `./cmd/sim`
> resolves here).
## Fact 1 — the sim sets its token ONLY as the `X-Spaxel-Token` HTTP header
The token is attached to the WebSocket *dial* request as a request header in
all three connect paths, and nowhere else:
- `cmd/sim/main.go:313`: `reqHeader.Set("X-Spaxel-Token", token)` — fed to
`websocket.DefaultDialer.DialContext(ctx, u.String(), reqHeader)` (the dial
a few lines below).
- `mothership/cmd/sim/main.go:633`: `headers.Set("X-Spaxel-Token", token)`.
- `mothership/cmd/sim/scenario.go:318`: `headers.Set("X-Spaxel-Token", token)`.
`token` here is the provisioned node token (auto-generated to a 64-hex dummy
if `--token` is empty — `cmd/sim/main.go:120-125`,
`mothership/cmd/sim/main.go:596-601`). It is supplied to the mothership
exclusively via this HTTP header on the upgrade request. There is no second
delivery channel.
## Fact 2 — the hello JSON body has NO `token` field
The hello map built and sent as the node's first WS frame omits `token`
entirely:
- `cmd/sim/main.go:348-359`:
```
hello := map[string]interface{}{
"type": "hello",
"mac": macToString(n.MAC),
"firmware_version": "sim-1.0.0",
"capabilities": []string{"csi", "ble", "tx", "rx"},
"chip": "ESP32-S3",
"flash_mb": 16,
"uptime_ms": 1000,
"pos_x": n.Position.X,
"pos_y": n.Position.Y,
"pos_z": n.Position.Z,
}
```
- `mothership/cmd/sim/main.go:652-665`: keys are
`type, mac, firmware_version, capabilities, chip, flash_mb, uptime_ms,
wifi_rssi, ip, pos_x, pos_y, pos_z`.
- `mothership/cmd/sim/scenario.go:331-342`: keys are
`type, mac, firmware_version, capabilities, chip, flash_mb, uptime_ms,
wifi_rssi, ip`.
None of the three contains a `token` key. A targeted grep confirms it — the
only `"token"` hits in each file are the `--token` *flag definition* (the CLI
argument), never a JSON body key:
```
$ grep -n '"token"' cmd/sim/main.go mothership/cmd/sim/main.go mothership/cmd/sim/scenario.go
cmd/sim/main.go:54: flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)")
mothership/cmd/sim/main.go:49: flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)")
mothership/cmd/sim/scenario.go: (no "token" hit at all)
```
`HelloMessage.Token` carries the JSON tag `json:"token,omitempty"`
(`mothership/internal/ingestion/message.go:22`). Since the body never sets
`token`, that field deserializes to `""` from either sim binary.
## Fact 3 — the mothership NEVER reads `X-Spaxel-Token` on the WS path
A grep for the literal header name across the entire mothership tree,
excluding the sim sources that *set* it and the test files that document the
contract, returns **zero** hits:
```
$ grep -rn 'X-Spaxel-Token' mothership/ | grep -v '_test.go' | grep -v 'cmd/sim/'
(no output; exit status 1)
```
The only remaining references in the tree are the sim's own `Set(...)` calls
and an e2e test *comment* that asserts this exact fact
(`mothership/tests/e2e/e2e_test.go:86-88`: *"spaxel-sim nodes present no
token in their hello message (only the X-Spaxel-Token header, which the
ingestion server does not read)"*). There is no production code that reads
it.
The WS upgrade handler inspects nothing off the incoming request either.
`mothership/internal/ingestion/server.go:455-469`:
```go
func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
...
w.Header().Set("Content-Type", "application/json") // :463 — OUTBOUND response header
...
conn, err := s.upgrader.Upgrade(w, r, nil) // :469 — r passed through, never read for headers
```
The sole `Header` reference on this path is `w.Header().Set(...)` on the
*outbound* response (`:463`); `r` (the request bearing the `X-Spaxel-Token`
header) is handed straight to `Upgrade` and never inspected. (Every other
`Header.Get` / `Header.Set` hit under `mothership/internal/` is on an
*outbound* webhook/notify path or in its tests — `webhook`, `notify`,
`notifications/ntfy.go`, etc. — none touches the node WS path.)
## Conclusion — the token-supply path is currently DEAD
The validator reads only `hello.Token`
(`mothership/internal/ingestion/server.go:513`:
`tokenOK := hello.Token != "" && validator(hello.MAC, hello.Token)`), and
that field comes only from the JSON body (`message.go:22`). Combining the
three facts above:
- The sim supplies its token **only** as the `X-Spaxel-Token` HTTP header
(Fact 1);
- which the mothership **never reads** on the WS path (Fact 3);
- and the hello body the mothership *does* read has **no `token`** field
(Fact 2).
Therefore for every sim node the header is silently dropped, `hello.Token`
is `""`, and `hello.Token != ""` short-circuits `tokenOK` to `false` at
`server.go:513` **regardless of what token the sim provisioned**. The
parent bead's "have the sim supply valid tokens" option is **currently
NON-FUNCTIONAL** — the token is supplied to a header nobody reads.
> The token-supply path is dead: header ignored → `hello.Token` empty →
> `tokenOK` always `false`.
This isolates the sim/header-side half of `bf-34lwt`. Combined with the
validator-side facts in the sibling note (`bf-5ig3e`: the validator is
always wired, and acceptance/rejection hinges entirely on `hello.Token`),
it follows that a sim node is accepted only while the migration window is
open (the default 24h mask), then `sendReject`-ed with `invalid_token`
(`server.go:519-528`) once it closes — because the supplied token never
reaches the validator at all.