mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
d68474f775
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).
159 lines
6.7 KiB
Go
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
|
|
}
|