Recording the full mcp-lifecycle fixture against real isolated PHP (08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's assumed contract and actual production PHP behavior: - Every explicit "Cache-Control: no-store" PHP sets is actually delivered as "no-store, private" (Laravel's session-cookie default merges "private" onto any explicit value); wristband's own default for unheadered JSON error responses is "no-cache, private" (matching the house convention already used elsewhere), not empty. - PHP's redirect responses (authorize success and every error redirect) render Symfony's default HTML redirect body with Content-Type "text/html; charset=utf-8"; Go's bare 302 with no body never matched. wristband/redirect_html.go ports that exact byte template, including PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses different quote entities). tide/normalize.go: isIDKey now also masks "_ids" plural array fields (e.g. collection_ids), a latent parity-corpus gap no prior fixture had exercised with a literal, non-empty, non-placeholder array value.
61 lines
2.0 KiB
Go
61 lines
2.0 KiB
Go
package wristband
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// htmlEscapePHP ports PHP's htmlspecialchars($s, ENT_QUOTES, 'UTF-8') byte
|
|
// for byte: Go's stdlib html.EscapeString differs on the quote entities
|
|
// ('/" vs PHP's '/"), which would diverge from the
|
|
// recorded redirect body whenever a redirect_uri or state value contains a
|
|
// quote character.
|
|
func htmlEscapePHP(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
switch r {
|
|
case '&':
|
|
b.WriteString("&")
|
|
case '"':
|
|
b.WriteString(""")
|
|
case '\'':
|
|
b.WriteString("'")
|
|
case '<':
|
|
b.WriteString("<")
|
|
case '>':
|
|
b.WriteString(">")
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// writeRedirectHTML ports Laravel/Symfony's RedirectResponse default HTML
|
|
// body byte-for-byte (T-08-OPEN-REDIRECT/D-04). Go's net/http never emits a
|
|
// body for a 3xx Location redirect; every wristband redirect needs this
|
|
// exact body plus Content-Type because real recorded PHP traffic includes
|
|
// it, and an unchanged browser-based client (the Nuxt /connect handoff)
|
|
// observes it. Cache-Control is the caller's responsibility -- callers set
|
|
// it before invoking this helper because its value differs between the
|
|
// authorize success path and error redirects versus other endpoints.
|
|
func writeRedirectHTML(w http.ResponseWriter, status int, target string) {
|
|
escaped := htmlEscapePHP(target)
|
|
var b strings.Builder
|
|
b.WriteString("<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\" />\n <meta http-equiv=\"refresh\" content=\"0;url='")
|
|
b.WriteString(escaped)
|
|
b.WriteString("'\" />\n\n <title>Redirecting to ")
|
|
b.WriteString(escaped)
|
|
b.WriteString("</title>\n </head>\n <body>\n Redirecting to <a href=\"")
|
|
b.WriteString(escaped)
|
|
b.WriteString("\">")
|
|
b.WriteString(escaped)
|
|
b.WriteString("</a>.\n </body>\n</html>")
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Location", target)
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(b.String()))
|
|
}
|