Files
pad/internal/server/session_identity.go
T
xarmian d68474f775 feat(server): armed-session declaration + push delivery filter (PLAN-2613 S1, TASK-2616) (#1130)
Adds a server-side consent gate for push delivery ahead of the plugin/CLI
version flip: a stream now declares armed=true at connect (query param) to
receive KindPush notifications, while legacy (unarmed) streams keep ordinary
watch-matched delivery during the skew window. LiveSession exposes the armed
bit so the web target picker can eventually show honest accepting-pushes
counts, and push delivery counts are now armed-aware end to end (broadcast,
targeted, and the pre-publish snapshot used to skip a guaranteed no-op).
2026-08-17 01:59:02 -04:00

159 lines
6.7 KiB
Go

package server
import (
"net/http"
"strconv"
"strings"
"unicode"
)
// Session-identity headers on GET /api/v1/events/stream (PLAN-2558 S2,
// TASK-2560). A connecting client uses them to say what it is, so the
// presence registry holds nameable sessions ("docapp") rather than
// opaque uuids — which is what turns S1's count into S5's target picker.
//
// WHY HEADERS AND NOT A QUERY STRING. The task body sketched "the
// stream connect carries it" without picking a transport; three were
// on the table (query param, header, separate registration POST) and
// this is the reasoning, recorded because the choice is otherwise
// invisible:
//
// - A query param would put the label and pid in every access log
// line — this server logs `path=` for each request — and in any
// proxy log in front of it. The whole point of the privacy line
// here (basename, never the full cwd; never the socket path) is to
// stop local-machine detail from travelling further than it needs
// to, and a durable log entry is exactly travelling further.
// - A separate registration POST would need its own correlation to
// the connection it describes, and a lifecycle to match — the
// registry entry lives and dies with the stream, so anything that
// can arrive independently of the stream can also fail to.
// - A header rides the request that already exists, is trivially set
// by the one client that has this data (the Go CLI monitor), and
// sits alongside Last-Event-ID, which is already doing exactly this
// job on exactly this endpoint.
//
// The known cost, stated so the next person doesn't discover it as a
// surprise: a browser EventSource cannot set request headers. Nothing
// in the web UI opens this stream today (it uses the workspace-scoped
// /api/v1/events), but if a browser tab ever wants to appear in
// presence, it will need either a query-param fallback added
// deliberately — with the logging consequence above accepted — or a
// fetch-based SSE reader.
const (
sessionLabelHeader = "X-Pad-Session-Label"
sessionPIDHeader = "X-Pad-Session-Pid"
)
// sessionArmedQueryParam is PLAN-2613 S1's consent declaration —
// deliberately a QUERY PARAM, not a third header alongside label/pid.
// The header choice above is privacy-driven: a label or pid is local-
// machine detail that shouldn't travel into an access log. Armed is the
// opposite of private — it's a plain boolean, and a durable log trail of
// "this session declared consent" is a reasonable audit artifact for a
// security-relevant gate, not a leak. A query param also sidesteps the
// header doc's own noted gap (a browser EventSource can't set request
// headers), so a future non-CLI client can declare arming without
// needing a transport change. Only "true" (exact match, mirroring this
// codebase's existing boolean-query convention — e.g. handlers_items.go's
// include_archived) counts as armed; anything else, including absence,
// is the legacy/unarmed shape.
const sessionArmedQueryParam = "armed"
// maxSessionLabelLen bounds a label in RUNES, not bytes: the value is a
// directory basename, which is user-controlled text that ends up
// rendered in a picker. 64 is far more than any real project directory
// needs and short enough that no UI has to plan for it. Over-long
// labels are TRUNCATED rather than rejected — a session that shows up
// with a clipped name is strictly better than one that silently doesn't
// show up at all, which is the failure this whole slice exists to
// prevent.
const maxSessionLabelLen = 64
// parseSessionIdentity reads the self-declared session identity off a
// stream request. It never fails: every malformed input degrades to the
// S1 behaviour (an unlabelled session) rather than rejecting the
// connection, because presence is a convenience and event delivery is
// not — a client with a garbled header still deserves its events.
func parseSessionIdentity(r *http.Request) SessionIdentity {
return SessionIdentity{
Label: sanitizeSessionLabel(r.Header.Get(sessionLabelHeader)),
PID: parseSessionPID(r.Header.Get(sessionPIDHeader)),
Armed: r.URL.Query().Get(sessionArmedQueryParam) == "true",
}
}
// sanitizeSessionLabel makes a client-supplied label safe to store and
// hand to a UI: printable characters only, collapsed whitespace,
// truncated to maxSessionLabelLen runes.
//
// Control characters are dropped rather than escaped — a label is
// destined for terminal output (`pad` listing sessions) as much as for
// a browser, and an escape sequence there rewrites what the reader
// sees rather than merely looking ugly.
//
// Measured, not assumed: that particular arm is UNREACHABLE over HTTP
// today. Go's server rejects a request whose header value contains a
// control byte with 400 Bad Request before any handler runs (verified
// with a raw socket against httptest, since Go's own client refuses to
// send one either — neither behaviour is something to take on faith).
// It is kept as defence in depth for the next caller in: a WebSocket
// frame, a registration POST body, or a non-Go client on a laxer stack
// would all reach this function without that protection. The
// whitespace and length arms, by contrast, are reachable right now —
// tabs are legal in header values and nothing bounds their length.
//
// Either way this is hygiene, not a security boundary: the label is
// self-declared and only ever appears in its own owner's session list
// (see SessionIdentity).
func sanitizeSessionLabel(raw string) string {
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
lastWasSpace := false
for _, r := range raw {
switch {
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
// Collapse any run of whitespace to a single space; leading
// runs are dropped by the lastWasSpace seed below.
if b.Len() > 0 && !lastWasSpace {
b.WriteRune(' ')
lastWasSpace = true
}
case !unicode.IsPrint(r):
// Dropped entirely, and deliberately NOT treated as a word
// boundary: "do\x00capp" is far likelier to be a mangled
// "docapp" than two words.
continue
default:
b.WriteRune(r)
lastWasSpace = false
}
}
label := strings.TrimRight(b.String(), " ")
runes := []rune(label)
if len(runes) > maxSessionLabelLen {
label = strings.TrimRight(string(runes[:maxSessionLabelLen]), " ")
}
return label
}
// parseSessionPID parses the pid header, returning 0 for anything that
// isn't a plausible process id. 0 is the sentinel for "not stated"
// (LiveSession omits it from JSON), so every rejection path lands
// there rather than propagating a nonsense number into a picker.
func parseSessionPID(raw string) int {
if raw == "" {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || pid <= 0 {
return 0
}
return pid
}