~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/mcpsrv/cache.go -rw-r--r-- 6.4 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package mcpsrv

import "net/http"

// What this endpoint tells a cache, and why it is said here rather than by the
// router's middleware.
//
// # The shared helper this is a copy of
//
// sr-ht-ecore/mcphttp.PrivateCache is this function, one package up, written for
// the services on the instance that mount an MCP endpoint. It is not imported
// because it is not resolvable: the ecore commit that adds mcphttp is not
// published, and this module pins an ecore from before it. So the code is here,
// deliberately identical in behaviour and in header values, and the swap when
// mcphttp is reachable is a delete and an import with no observable change to
// any response — which is the reason the constants below are byte for byte what
// mcphttp emits rather than a spelling of this package's own.
const (
	// cacheControl is what every response of this endpoint carries.
	//
	// private and no-store are the instance's pair for an answer that depends
	// entirely on the credential the request carried and says nothing about it in
	// its URL — which is every answer here. no-cache would not do: it still
	// permits a shared cache to *store* the body and merely revalidate, which is
	// the one thing no-store forbids.
	//
	// no-transform is the SDK's own, kept. Its streamable transport writes
	// `no-cache, no-transform` on every response it produces; no-transform
	// protects the SSE framing from an intermediary that would recompress or
	// rechunk it, and there is no reason to drop it. Only no-cache is replaced.
	cacheControl = "private, no-store, no-transform"

	// cacheVary names what an answer here depends on, and it names both planes
	// because on this service both planes genuinely reach /mcp.
	//
	// That is worth stating, because the sibling that mounts the same surface
	// decided the other way: dolt.sr.ht varies on Authorization alone, arguing
	// that its /mcp is bearer-only and that naming Cookie would promise a cache
	// a dependency the surface never reads. The argument is right and does not
	// apply here. authn.Resolver.Resolve prefers a bearer token when one is
	// present, but falls through to login.UsernameFromRequest when none is —
	// and an owner cookie resolves to KindOwner, which is exactly what Gate's
	// CanRead admits. So on this service the cookie is not an unread header: it
	// is the difference between the whole corpus and a 401, which is the
	// strongest reason a response can have to vary on something.
	cacheVary = "Cookie, Authorization"
)

// privateCache marks every response this endpoint writes as one no cache may
// keep, and states what it depends on.
//
// It cannot be a middleware that sets the headers before the handler runs, which
// is how the rest of this service does it. The SDK's streamable transport sets
// Cache-Control itself, with Set, from inside the handler — so a value written
// on the way in is overwritten on the way out, and the response leaves with
// `no-cache, no-transform` and no Vary at all. The headers are therefore written
// at the last moment they still can be: when the status line is committed and
// every Set the handler was going to make has been made.
//
// It wraps everything Handler owns, the Host allowlist's 403 included. Gate's
// 401 is written outside this chain — it has to be, since Gate runs inside the
// resolver middleware and Handler runs inside Gate — so Gate sets the same two
// headers itself.
func privateCache(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
	})
}

// cacheWriter is the http.ResponseWriter privateCache hands down: it sets the
// two headers when the response is committed, by whichever of the three routes
// the handler takes.
type cacheWriter struct {
	http.ResponseWriter
	committed bool
}

func (w *cacheWriter) WriteHeader(status int) {
	w.commit()
	w.ResponseWriter.WriteHeader(status)
}

// Write commits first: net/http commits the response on the first Write and
// drops every header set after that, silently. A handler that never calls
// WriteHeader is the ordinary case and not an exotic one.
func (w *cacheWriter) Write(b []byte) (int, error) {
	w.commit()
	return w.ResponseWriter.Write(b)
}

// Flush is the third commit and the one an event stream takes. It has to be a
// method on this type: http.ResponseController prefers a Flush on the writer it
// was handed over one reached through Unwrap, so without this the flush would
// commit the response at the writer below and the two headers would never be
// written — on exactly the answers that stay open longest, and with every other
// test in this package still green (TestHeadersLandOnAFlush).
func (w *cacheWriter) Flush() {
	w.commit()
	// http.Flusher.Flush reports nothing, and the controller's error can only be
	// "this writer does not support flushing" — which, if it happens, is a writer
	// that could not have streamed through any wrapper.
	_ = http.NewResponseController(w.ResponseWriter).Flush()
}

func (w *cacheWriter) commit() {
	if w.committed {
		return
	}
	w.committed = true
	w.Header().Set("Cache-Control", cacheControl)
	w.Header().Set("Vary", cacheVary)
}

// Unwrap is what http.ResponseController follows to reach the real writer for
// everything this type does not implement itself: the deadlines, Hijack,
// EnableFullDuplex. Without it a controller handed this writer answers
// ErrNotSupported to all of them, because cacheWriter embeds the
// http.ResponseWriter *interface* and so promotes nothing of the writer below.
//
// It is worth being exact about what it does *not* do, because the sentence it
// is usually given — "without Unwrap the wrapper hides the flusher and SSE
// streaming breaks" — is not true of this type and is easy to keep repeating.
// It is true of a wrapper whose only method is WriteHeader; here Flush above is
// a method on cacheWriter, so a controller finds that one and never needs to
// unwrap to flush. Deleting Unwrap leaves every flush, every header and every
// MCP session in this package's tests working, which is measured rather than
// asserted: TestUnwrapReachesTheWriterBelow pins the deadline call, which is the
// thing that actually stops working, and it is the test that goes red.
//
// The SDK asks for none of those today — its streamable transport calls Flush
// and nothing else — so this method is here for the wrapper to be a wrapper
// rather than to keep a feature alive.
func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }