coverage~bigbes/sr-ht-spec3cb1c03dmcpsrv/cache.go

Coverage
100.0% 14/14 statements
Δ
Blob
13f7aeb
Uncovered nothing — every instrumented line ran
1 package mcpsrv
2
3 import "net/http"
4
5 // What this endpoint tells a cache, and why it is said here rather than by the
6 // router's middleware.
7 //
8 // # The shared helper this is a copy of
9 //
10 // sr-ht-ecore/mcphttp.PrivateCache is this function, one package up, written for
11 // the services on the instance that mount an MCP endpoint. It is not imported
12 // because it is not resolvable: the ecore commit that adds mcphttp is not
13 // published, and this module pins an ecore from before it. So the code is here,
14 // deliberately identical in behaviour and in header values, and the swap when
15 // mcphttp is reachable is a delete and an import with no observable change to
16 // any response — which is the reason the constants below are byte for byte what
17 // mcphttp emits rather than a spelling of this package's own.
18 const (
19 // cacheControl is what every response of this endpoint carries.
20 //
21 // private and no-store are the instance's pair for an answer that depends
22 // entirely on the credential the request carried and says nothing about it in
23 // its URL — which is every answer here. no-cache would not do: it still
24 // permits a shared cache to *store* the body and merely revalidate, which is
25 // the one thing no-store forbids.
26 //
27 // no-transform is the SDK's own, kept. Its streamable transport writes
28 // `no-cache, no-transform` on every response it produces; no-transform
29 // protects the SSE framing from an intermediary that would recompress or
30 // rechunk it, and there is no reason to drop it. Only no-cache is replaced.
31 cacheControl = "private, no-store, no-transform"
32
33 // cacheVary names what an answer here depends on, and it names both planes
34 // because on this service both planes genuinely reach /mcp.
35 //
36 // That is worth stating, because the sibling that mounts the same surface
37 // decided the other way: dolt.sr.ht varies on Authorization alone, arguing
38 // that its /mcp is bearer-only and that naming Cookie would promise a cache
39 // a dependency the surface never reads. The argument is right and does not
40 // apply here. authn.Resolver.Resolve prefers a bearer token when one is
41 // present, but falls through to login.UsernameFromRequest when none is —
42 // and an owner cookie resolves to KindOwner, which is exactly what Gate's
43 // CanRead admits. So on this service the cookie is not an unread header: it
44 // is the difference between the whole corpus and a 401, which is the
45 // strongest reason a response can have to vary on something.
46 cacheVary = "Cookie, Authorization"
47 )
48
49 // privateCache marks every response this endpoint writes as one no cache may
50 // keep, and states what it depends on.
51 //
52 // It cannot be a middleware that sets the headers before the handler runs, which
53 // is how the rest of this service does it. The SDK's streamable transport sets
54 // Cache-Control itself, with Set, from inside the handler — so a value written
55 // on the way in is overwritten on the way out, and the response leaves with
56 // `no-cache, no-transform` and no Vary at all. The headers are therefore written
57 // at the last moment they still can be: when the status line is committed and
58 // every Set the handler was going to make has been made.
59 //
60 // It wraps everything Handler owns, the Host allowlist's 403 included. Gate's
61 // 401 is written outside this chain — it has to be, since Gate runs inside the
62 // resolver middleware and Handler runs inside Gate — so Gate sets the same two
63 // headers itself.
64 24 func privateCache(next http.Handler) http.Handler {
65 25 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 25 next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
67 25 })
68 }
69
70 // cacheWriter is the http.ResponseWriter privateCache hands down: it sets the
71 // two headers when the response is committed, by whichever of the three routes
72 // the handler takes.
73 type cacheWriter struct {
74 http.ResponseWriter
75 committed bool
76 }
77
78 15 func (w *cacheWriter) WriteHeader(status int) {
79 15 w.commit()
80 15 w.ResponseWriter.WriteHeader(status)
81 15 }
82
83 // Write commits first: net/http commits the response on the first Write and
84 // drops every header set after that, silently. A handler that never calls
85 // WriteHeader is the ordinary case and not an exotic one.
86 19 func (w *cacheWriter) Write(b []byte) (int, error) {
87 19 w.commit()
88 19 return w.ResponseWriter.Write(b)
89 19 }
90
91 // Flush is the third commit and the one an event stream takes. It has to be a
92 // method on this type: http.ResponseController prefers a Flush on the writer it
93 // was handed over one reached through Unwrap, so without this the flush would
94 // commit the response at the writer below and the two headers would never be
95 // written — on exactly the answers that stay open longest, and with every other
96 // test in this package still green (TestHeadersLandOnAFlush).
97 9 func (w *cacheWriter) Flush() {
98 9 w.commit()
99 9 // http.Flusher.Flush reports nothing, and the controller's error can only be
100 9 // "this writer does not support flushing" — which, if it happens, is a writer
101 9 // that could not have streamed through any wrapper.
102 9 _ = http.NewResponseController(w.ResponseWriter).Flush()
103 9 }
104
105 43 func (w *cacheWriter) commit() {
106 43 if w.committed {
107 19 return
108 19 }
109 24 w.committed = true
110 24 w.Header().Set("Cache-Control", cacheControl)
111 24 w.Header().Set("Vary", cacheVary)
112 }
113
114 // Unwrap is what http.ResponseController follows to reach the real writer for
115 // everything this type does not implement itself: the deadlines, Hijack,
116 // EnableFullDuplex. Without it a controller handed this writer answers
117 // ErrNotSupported to all of them, because cacheWriter embeds the
118 // http.ResponseWriter *interface* and so promotes nothing of the writer below.
119 //
120 // It is worth being exact about what it does *not* do, because the sentence it
121 // is usually given — "without Unwrap the wrapper hides the flusher and SSE
122 // streaming breaks" — is not true of this type and is easy to keep repeating.
123 // It is true of a wrapper whose only method is WriteHeader; here Flush above is
124 // a method on cacheWriter, so a controller finds that one and never needs to
125 // unwrap to flush. Deleting Unwrap leaves every flush, every header and every
126 // MCP session in this package's tests working, which is measured rather than
127 // asserted: TestUnwrapReachesTheWriterBelow pins the deadline call, which is the
128 // thing that actually stops working, and it is the test that goes red.
129 //
130 // The SDK asks for none of those today — its streamable transport calls Flush
131 // and nothing else — so this method is here for the wrapper to be a wrapper
132 // rather than to keep a feature alive.
133 1 func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }