carlos / platform Public

edge: the browser door — origin, size, shape, rate, attribution, then the sink or the meter

POST /__carlos/e and /__carlos/a on every route, dispatched from
serveReserved before the observing writer, so a beacon is never billed,
compressed, cached, gated or proxied (§3.4, §4). Every branch answers 202
with an empty body: the endpoint tells a caller nothing, and where refusals
show is the owner's own dropped counters.

The order is the flow: no app to attribute to → nothing; the Origin rule
against the hostname's own route (the reserved segment is never
prefix-steered, so up is always the root route); the 16 KB body cap read one
byte past; the beacon shape; then attribution by the beacon's own path
through lookupUpstreamPath, so a path-routed hostname's beacon lands on the
app owning its path. Only then the CHARGED app's switches (door for errors,
door+count for events), its rate buckets keyed acct/app, and finally the
error sink or the meter's Track. A browser pageview is not counted in edge
mode — the HTML response already was the pageview.

Drops are recorded only for an app whose count switch is on (ruling 2), so
an uncounted app cannot be made to accumulate anything. Drops before
attribution — origin, size, malformed — are charged to the route the post
arrived on (ruling 3).

doorLimit is set by New and created lazily under doorOnce for a hand-built
&Edge{}, so the door never depends on which constructor a caller used.

Tests: the door's accept-and-attribute path (including that a browser fp is
carried with its version stamp and an absent one is left for the sink to
stamp), a drop table that pins every refusal reason and its 202, the two
switches in both positions, and path-prefix attribution. setPathMap drives
the LIVE map through the seam the prefix-routing tests already share —
upsertPath writes one route_paths row the way the domains reconcile pass
does, then Reload folds it onto the upstream entries. Nothing pokes
e.upstreams.

Two deliberate deviations from the brief's test sketch. The app-bucket case
drains the bucket through the limiter rather than over 601 HTTP posts: the
bucket refills at ten tokens a second, so a 601-request loop only refuses
while it stays inside a tenth of a second of wall clock, and the assertion
would be a coin flip on a slow machine. What the DOOR owes is that it
consults the app bucket under acct+"/"+app for the right door, and that is
what the test now pins; TestDoorLimiterAppBucket already pins the bucket's
arithmetic on a fixed clock. And the sink double reads through a copying
snap() rather than the slice, matching memAnalytics.

Also lands the pin deferred from the reserved-path intercept:
TestReservedPathIsUnmetered drives carlos.js and a beacon through a real
UsageMeter and asserts no row for the host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 60da8d015921ea9b7b5814d01a2c7ab4764f1adb parent fd9a121
4 files changed, +491 −5
  • internal/edge/browserdoor.go +147 −0
  • internal/edge/browserdoor_test.go +314 −0
  • internal/edge/edge.go +8 −1
  • internal/edge/reserved.go +22 −4
diff --git a/internal/edge/browserdoor.go b/internal/edge/browserdoor.go
index a90fe0469..b17152714 100644
--- a/internal/edge/browserdoor.go
+++ b/internal/edge/browserdoor.go
@@ -2,12 +2,18 @@ package edge
import (
"encoding/json"
+ "hash/fnv"
+ "io"
"net/http"
"net/url"
"sort"
"strings"
+ "time"
+ "github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/apperr"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
// The browser door (spec 2026-09-07 §3.4): what a page may post, how much,
@@ -161,3 +167,144 @@ func beaconSourceOK(m map[string]upstream, raw, want string) bool {
up, ok := lookupIn(m, strings.ToLower(host))
return ok && up.route.Host == want
}
+
+// doorLimiter is the door's rate buckets (doorlimit.go), created lazily so a
+// hand-built &Edge{} in a test has one. New sets the field for every real
+// edge; the Once is what makes the two paths agree on a single limiter
+// rather than handing concurrent requests a fresh empty one each.
+func (e *Edge) doorLimiter() *doorLimiter {
+ e.doorOnce.Do(func() {
+ if e.doorLimit == nil {
+ e.doorLimit = newDoorLimiter()
+ }
+ })
+ return e.doorLimit
+}
+
+// serveBeacon is POST /__carlos/e (door 'e') and /__carlos/a (door 'a'),
+// dispatched from serveReserved — before the observing writer, so a beacon
+// is never billed, compressed, cached, gated or proxied (§3.4, §4).
+//
+// EVERY branch answers 202 with no body. The endpoint tells a caller
+// nothing: not whether the app exists, not whether its door is open, not
+// whether the beacon was kept. Where refusals show is the owner's own
+// dropped counters, by door and reason — and those are recorded only for an
+// app whose count switch is on (ruling 2), so an app that is not being
+// counted cannot be made to accumulate anything at all.
+//
+// Order matters and is the order below: who may post (origin), how much
+// (size), what shape (parse), whose it is (attribution), whether they asked
+// for it (switches), how fast (rate), and only then the sink or the meter.
+// Attribution comes before the switch check because a path-routed hostname's
+// beacon belongs to the app owning its path, and it is THAT app's switches
+// and THAT app's buckets which decide — the front route is only the door the
+// post arrived at.
+func (e *Edge) serveBeacon(w http.ResponseWriter, r *http.Request, host string, up upstream, door byte) {
+ defer w.WriteHeader(http.StatusAccepted)
+ // A status page has no app to attribute to, and neither does a route
+ // with no app set. Nothing is recorded, not even a drop: there is no
+ // owner to record it against.
+ if up.route.Kind == "status" || up.route.App == "" {
+ return
+ }
+ now := time.Now()
+ // drop records one refusal against rt's app, if that app is counted.
+ // Refusals before attribution are charged to the route the post arrived
+ // on (ruling 3): the beacon named no path we could trust yet.
+ drop := func(rt registry.Route, reason string) {
+ if ref := e.analyticsMeter.Load(); ref != nil && e.switchesFor(rt).Count {
+ ref.Drop(release.RouteAccount(rt), rt.App, door, reason, now)
+ }
+ }
+ // Identification, not authentication: the page that posts must be one
+ // this hostname's route serves. up is the hostname's own root route —
+ // the reserved segment is never prefix-steered — so its Host is what an
+ // Origin has to resolve to.
+ if !beaconOriginOK(e.current(), r, up.route.Host) {
+ drop(up.route, analytics.DropOrigin)
+ return
+ }
+ // One byte past the cap is read so a body exactly at the cap is still
+ // accepted and one over it is refused, without ever buffering more.
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxBeaconBody+1))
+ if err != nil || len(body) > maxBeaconBody {
+ drop(up.route, analytics.DropSize)
+ return
+ }
+ var (
+ eb errorBeacon
+ ab eventBeacon
+ path string
+ ok bool
+ )
+ if door == analytics.DoorErrors {
+ eb, ok = parseErrorBeacon(body)
+ path = eb.Path
+ } else {
+ ab, ok = parseEventBeacon(body)
+ path = ab.Path
+ }
+ if !ok {
+ drop(up.route, analytics.DropMalformed)
+ return
+ }
+ // Attribution (§3.4): the beacon's own path, raw, through the same
+ // prefix matcher a request for that path would take. A miss — no prefix
+ // map, an unlisted prefix, a dangling target, no path at all — leaves
+ // the front route, which is the safe answer for the same reason
+ // lookupUpstreamPath's misses fall through.
+ tu := up
+ if path != "" {
+ if t, found := e.lookupUpstreamPath(host, path); found {
+ tu = t
+ }
+ }
+ // The charged app's switches, not the front route's. The door switch
+ // opens the door at all; an event additionally needs count, because a
+ // named event is a counted thing and there is nowhere to put it
+ // otherwise. Neither refusal is a drop: the owner turned it off.
+ sw := e.switchesFor(tu.route)
+ if !sw.Door || (door == analytics.DoorEvents && !sw.Count) {
+ return
+ }
+ acct, app := release.RouteAccount(tu.route), tu.route.App
+ // The visitor for the rate buckets: address and User-Agent, hashed and
+ // never stored. clientIP reads X-Forwarded-For only on a proven mesh
+ // hop, so a header a page set cannot split itself into fresh buckets.
+ h := fnv.New64a()
+ h.Write([]byte(clientIP(r)))
+ h.Write([]byte{0})
+ h.Write([]byte(r.Header.Get("User-Agent")))
+ if !e.doorLimiter().allow(acct+"/"+app, door, h.Sum64(), now) {
+ drop(tu.route, analytics.DropRate)
+ return
+ }
+ if door == analytics.DoorErrors {
+ ref := e.errSink.Load()
+ if ref == nil {
+ return
+ }
+ rec := apperr.Record{
+ Host: tu.route.Host, Source: apperr.SourceBrowser,
+ Kind: eb.Kind, Name: eb.Name, Stack: eb.Stack, Path: eb.Path,
+ Fields: eb.Fields, FP: eb.FP, Release: tu.route.Version,
+ }
+ // Stamp the algorithm version only for a fingerprint the browser
+ // computed. Left empty, the sink fingerprints and stamps it itself,
+ // and claiming a version for a hash we did not produce would group
+ // two different algorithms under one key.
+ if rec.FP != "" {
+ rec.FPV = apperr.FPVersion
+ }
+ ref.Add(acct, app, rec)
+ return
+ }
+ // Edge mode: the HTML response WAS the pageview (§3.3), already observed
+ // by the observer defer. Counting the browser's own would double it.
+ if ab.Name == "pageview" {
+ return
+ }
+ if ref := e.analyticsMeter.Load(); ref != nil {
+ ref.Track(acct, app, ab.Name, now)
+ }
+}
diff --git a/internal/edge/browserdoor_test.go b/internal/edge/browserdoor_test.go
index b738cb629..70973b56b 100644
--- a/internal/edge/browserdoor_test.go
+++ b/internal/edge/browserdoor_test.go
@@ -1,12 +1,19 @@
package edge
import (
+ "io"
"net/http"
"net/http/httptest"
"strings"
+ "sync"
"testing"
+ "time"
+ "github.com/carlosframework/platform/internal/analytics"
+ "github.com/carlosframework/platform/internal/apperr"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/telemetry"
)
func TestParseErrorBeaconCaps(t *testing.T) {
@@ -201,3 +208,310 @@ func TestBeaconOriginHostileInputs(t *testing.T) {
t.Error("origin accepted against an empty map")
}
}
+
+// ---------------------------------------------------------------------------
+// The door handler (spec §3.4): POST /__carlos/e and /__carlos/a.
+
+// memSink is the error sink the door tests file against — the interface, not
+// apperr's buffer, so nothing here depends on the sink's own sanitising.
+type memSink struct {
+ mu sync.Mutex
+ recs []sinkRec
+}
+
+type sinkRec struct {
+ Acct, App string
+ R apperr.Record
+}
+
+func (s *memSink) Add(acct, app string, r apperr.Record) {
+ s.mu.Lock()
+ s.recs = append(s.recs, sinkRec{acct, app, r})
+ s.mu.Unlock()
+}
+
+// snap copies what has landed. The tests read through this rather than the
+// slice: the records are appended on the server's goroutine.
+func (s *memSink) snap() []sinkRec {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]sinkRec(nil), s.recs...)
+}
+
+// doorFixture: one counted, door-open app on app.example.com whose upstream
+// must never be reached (everything under ReservedPrefix is answered before
+// the proxy), a memory sink and a memory meter.
+func doorFixture(t *testing.T) (*Edge, *httptest.Server, *memSink, *memAnalytics) {
+ t.Helper()
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Errorf("upstream reached: %s", r.URL.Path)
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(),
+ Status: "ready", App: "web", Version: "v7",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sink, meter := &memSink{}, &memAnalytics{}
+ e.EnableErrors(sink)
+ e.EnableAnalytics(meter)
+ e.SetSwitches(map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}})
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return e, srv, sink, meter
+}
+
+// postBeacon posts one beacon body and returns the status. A header named
+// with an empty value is DELETED rather than sent empty — the two are
+// different requests to the Origin rule, and a test that wants "no Origin"
+// must send no Origin at all.
+func postBeacon(t *testing.T, srv *httptest.Server, path, body string, hdr map[string]string) int {
+ t.Helper()
+ req, err := http.NewRequest("POST", srv.URL+path, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = "app.example.com"
+ req.Header.Set("Origin", "https://app.example.com")
+ for k, v := range hdr {
+ if v == "" {
+ req.Header.Del(k)
+ } else {
+ req.Header.Set(k, v)
+ }
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if len(b) != 0 {
+ t.Fatalf("beacon answered with a body: %q", b)
+ }
+ return resp.StatusCode
+}
+
+// setPathMap gives host a prefix map through the seam the prefix-routing
+// tests already share: one route_paths row per prefix (upsertPath, the shape
+// the domains reconcile pass derives from a claim's Paths), then a Reload,
+// which is what folds those rows onto the live upstream entries. Nothing here
+// pokes e.upstreams directly — the point is to exercise the same map build
+// serving uses.
+func setPathMap(t *testing.T, e *Edge, host string, m map[string]string) {
+ t.Helper()
+ for prefix, target := range m {
+ upsertPath(t, e.reg, host, prefix, target)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+}
+
+func TestDoorAcceptsAndAttributes(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ if st := postBeacon(t, srv, "/__carlos/e", `{"kind":"TypeError","name":"x is not a function","stack":"at a (app.js:1:2)","path":"/checkout?step=2","fields":{"user":"a@b.co"}}`, nil); st != 202 {
+ t.Fatalf("status %d", st)
+ }
+ recs := sink.snap()
+ if len(recs) != 1 {
+ t.Fatalf("records %d", len(recs))
+ }
+ r := recs[0]
+ if r.Acct != release.OpsAccount || r.App != "web" || r.R.Source != apperr.SourceBrowser ||
+ r.R.Kind != "TypeError" || r.R.Host != "app.example.com" || r.R.Release != "v7" ||
+ r.R.Path != "/checkout?step=2" {
+ t.Fatalf("record %+v", r)
+ }
+ if r.R.FP != "" || r.R.FPV != 0 {
+ t.Fatalf("a beacon with no fp must reach the sink unstamped, got fp=%q fpv=%d", r.R.FP, r.R.FPV)
+ }
+ if st := postBeacon(t, srv, "/__carlos/a", `{"name":"signup","path":"/"}`, nil); st != 202 {
+ t.Fatalf("status %d", st)
+ }
+ if tr := meter.snapTracks(); len(tr) != 1 || tr[0].Name != "signup" || tr[0].App != "web" || tr[0].Acct != release.OpsAccount {
+ t.Fatalf("tracks %+v", tr)
+ }
+ postBeacon(t, srv, "/__carlos/a", `{"name":"pageview","path":"/"}`, nil)
+ if len(meter.snapTracks()) != 1 {
+ t.Fatal("a browser pageview is not counted in edge mode")
+ }
+ if len(meter.snapDrops()) != 0 {
+ t.Fatalf("nothing should be dropped: %+v", meter.snapDrops())
+ }
+ // A browser-supplied fingerprint is carried through WITH its version
+ // stamp, so the console never groups a v1 hash under an unversioned key.
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n","fp":"deadbeef"}`, nil)
+ recs = sink.snap()
+ if last := recs[len(recs)-1]; last.R.FP != "deadbeef" || last.R.FPV != apperr.FPVersion {
+ t.Fatalf("fp carried as %q/%d, want deadbeef/%d", last.R.FP, last.R.FPV, apperr.FPVersion)
+ }
+}
+
+func TestDoorDropsAndAlways202(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ cases := []struct {
+ name, path, body string
+ hdr map[string]string
+ door byte
+ reason string
+ }{
+ {"foreign origin", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"}, 'e', "origin"},
+ {"foreign origin, matching referer", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net", "Referer": "https://app.example.com/"}, 'e', "origin"},
+ {"cross-site", "/__carlos/a", `{"name":"n"}`, map[string]string{"Sec-Fetch-Site": "cross-site"}, 'a', "origin"},
+ {"no origin no referer", "/__carlos/a", `{"name":"n"}`, map[string]string{"Origin": ""}, 'a', "origin"},
+ {"17 KB", "/__carlos/e", `{"name":"` + strings.Repeat("x", 17<<10) + `"}`, nil, 'e', "size"},
+ {"not json", "/__carlos/e", `nope`, nil, 'e', "malformed"},
+ {"no name", "/__carlos/a", `{"path":"/"}`, nil, 'a', "malformed"},
+ }
+ for _, c := range cases {
+ before := len(meter.snapDrops())
+ if st := postBeacon(t, srv, c.path, c.body, c.hdr); st != 202 {
+ t.Errorf("%s: status %d", c.name, st)
+ }
+ drops := meter.snapDrops()
+ if len(drops) != before+1 || drops[before].Door != c.door || drops[before].Reason != c.reason ||
+ drops[before].Acct != release.OpsAccount || drops[before].App != "web" {
+ t.Errorf("%s: drops %+v", c.name, drops[before:])
+ }
+ }
+ if len(sink.snap()) != 0 || len(meter.snapTracks()) != 0 {
+ t.Fatal("dropped beacons must not land")
+ }
+ // Missing Origin with a matching Referer counts.
+ if st := postBeacon(t, srv, "/__carlos/a", `{"name":"ok"}`, map[string]string{"Origin": "", "Referer": "https://app.example.com/p"}); st != 202 || len(meter.snapTracks()) != 1 {
+ t.Fatal("referer fallback")
+ }
+}
+
+func TestDoorRateLimit(t *testing.T) {
+ _, srv, sink, meter := doorFixture(t)
+ for i := 0; i < doorVisitorPerMinute; i++ {
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ }
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if n := len(sink.snap()); n != doorVisitorPerMinute {
+ t.Fatalf("landed %d", n)
+ }
+ if d := meter.snapDrops(); len(d) != 1 || d[0].Reason != "rate" || d[0].Door != 'e' {
+ t.Fatalf("drops %+v", d)
+ }
+ // The visitor key is the address AND the User-Agent, so the same address
+ // behind another UA is another visitor with a bucket of its own.
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"User-Agent": "another-agent"})
+ if n := len(sink.snap()); n != doorVisitorPerMinute+1 {
+ t.Fatalf("landed %d — a different User-Agent is a different visitor", n)
+ }
+
+ // The app bucket. Drained through the limiter with a fresh visitor per
+ // token rather than over 601 posts: the bucket refills at 10/s, so a
+ // 601-request loop would only refuse while the loop stayed inside a tenth
+ // of a second of wall clock. What the DOOR owes is that it consults the
+ // app bucket under acct+"/"+app for the right door, and that is what this
+ // pins; TestDoorLimiterAppBucket pins the bucket's own arithmetic on a
+ // fixed clock.
+ e2, srv2, sink2, meter2 := doorFixture(t)
+ key := release.OpsAccount + "/web"
+ for i := 0; i < doorAppPerMinute; i++ {
+ if !e2.doorLimiter().allow(key, analytics.DoorEvents, uint64(i), time.Now()) {
+ t.Fatalf("app bucket refused its own token %d", i)
+ }
+ }
+ postBeacon(t, srv2, "/__carlos/a", `{"name":"n"}`, nil)
+ if len(meter2.snapTracks()) != 0 {
+ t.Fatal("an event landed with the app's events bucket empty")
+ }
+ if d := meter2.snapDrops(); len(d) != 1 || d[0].Reason != "rate" || d[0].Door != 'a' {
+ t.Fatalf("drops %+v", d)
+ }
+ // The two doors have separate buckets: /e is unaffected.
+ postBeacon(t, srv2, "/__carlos/e", `{"name":"n"}`, nil)
+ if n := len(sink2.snap()); n != 1 {
+ t.Fatalf("errors landed %d — the errors bucket is separate from the events one", n)
+ }
+}
+
+func TestDoorSwitches(t *testing.T) {
+ e, srv, sink, meter := doorFixture(t)
+ key := release.OpsAccount + "/web"
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}})
+ postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(meter.snapTracks()) != 0 || len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 {
+ t.Fatal("count off: /a dropped silently, /e accepted, nothing recorded as a drop")
+ }
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: true, Door: false}})
+ postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(meter.snapTracks()) != 0 || len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 {
+ t.Fatal("door off: both dropped silently")
+ }
+ // Count off: an origin drop is not recorded either (ruling 2).
+ e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}})
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"})
+ if len(meter.snapDrops()) != 0 {
+ t.Fatal("drops are recorded only for a counted app")
+ }
+ // No switch map at all is a closed door, which is every app until the
+ // telemetry pass pushes one.
+ e.SetSwitches(nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ if len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 {
+ t.Fatal("an unswitched app's door is shut")
+ }
+}
+
+// TestDoorAttributesByPathPrefix: on a path-routed hostname the beacon lands
+// on the app that owns its path, not on the hostname's own route.
+func TestDoorAttributesByPathPrefix(t *testing.T) {
+ e, srv, sink, _ := doorFixture(t)
+ if err := e.reg.Upsert(registry.Route{
+ Host: "team.internal", Kind: "service", Addr: "127.0.0.1:1", Status: "ready", App: "team",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ setPathMap(t, e, "app.example.com", map[string]string{"team": "team.internal"})
+ e.SetSwitches(map[string]telemetry.Switches{
+ release.OpsAccount + "/web": {Count: true, Door: true},
+ release.OpsAccount + "/team": {Count: true, Door: true},
+ })
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/team/page"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/other"}`, nil)
+ postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil)
+ recs := sink.snap()
+ if len(recs) != 3 || recs[0].App != "team" || recs[1].App != "web" || recs[2].App != "web" {
+ t.Fatalf("attribution %+v", recs)
+ }
+ if recs[0].R.Host != "team.internal" {
+ t.Fatalf("record host %q, want the attributed route's own host", recs[0].R.Host)
+ }
+}
+
+// TestReservedPathIsUnmetered is §4's other half of the reserved-path ruling,
+// and the pin deferred from the intercept task: the dispatch sits before the
+// observing writer in ServeHTTP, so neither the snippet nor a beacon may
+// produce a usage row for the host.
+func TestReservedPathIsUnmetered(t *testing.T) {
+ e, srv, _, meter := doorFixture(t)
+ um := NewUsageMeter()
+ e.EnableUsage(um)
+
+ if code, body := getHostPath(t, srv.URL, "app.example.com", ReservedPrefix+"carlos.js"); code != http.StatusOK || body == "" {
+ t.Fatalf("carlos.js: status=%d len=%d", code, len(body))
+ }
+ if st := postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil); st != 202 {
+ t.Fatalf("beacon status %d", st)
+ }
+ if len(meter.snapTracks()) != 1 {
+ t.Fatal("the beacon must actually have been served — otherwise this proves nothing")
+ }
+ if rows := um.Drain(); len(rows) != 0 {
+ t.Fatalf("usage rows = %+v, want nothing billed under %s", rows, ReservedPrefix)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 44208ee61..8afce80a1 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -144,6 +144,13 @@ type Edge struct {
// app nobody has switched on.
analyticsMeter atomic.Pointer[analyticsMeterRef]
switches atomic.Pointer[map[string]telemetry.Switches]
+ // doorLimit is the browser door's rate buckets (doorlimit.go), per app
+ // per door plus a bounded per-visitor table. Set by New; a hand-built
+ // &Edge{} gets one lazily through e.doorLimiter() under doorOnce, so
+ // the door never depends on which constructor a caller used and never
+ // hands two concurrent requests two different limiters.
+ doorLimit *doorLimiter
+ doorOnce sync.Once
// tunnels, when non-nil, is the reverse-channel hub (tunnel.go): remote
// hosts register on its reserved hostname and routes with Tunnel set are
// proxied over the channel they name. nil = tunnel serving not configured
@@ -295,7 +302,7 @@ type upstream struct {
// New builds an Edge over reg and performs the startup reload. act may be nil
// (no hibernation).
func New(reg *registry.Registry, act Activator) (*Edge, error) {
- e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()}
+ e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv(), doorLimit: newDoorLimiter()}
if err := e.Reload(); err != nil {
return nil, err
}
diff --git a/internal/edge/reserved.go b/internal/edge/reserved.go
index ee2153a42..35d4161d3 100644
--- a/internal/edge/reserved.go
+++ b/internal/edge/reserved.go
@@ -1,6 +1,10 @@
package edge
-import "net/http"
+import (
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/analytics"
+)
// ReservedPrefix is the platform's own path space on every route (spec
// 2026-09-07 §3.4, §8 ruling 2): the browser door and its snippet. It is
@@ -16,10 +20,24 @@ const reservedSegment = "__carlos"
// serveReserved answers a request under ReservedPrefix for the route the
// lookup resolved. Anything not explicitly served is a plain 404 — never
-// the upstream's answer. Tasks 4 and 7 add the snippet and the beacon
-// dispatch inside it; host and up are already threaded through because both
-// need them for attribution.
+// the upstream's answer. host and up are threaded through because the
+// beacon door needs both for attribution: up is the hostname's own root
+// route (the reserved segment is never prefix-steered), and host is what
+// the beacon's own path is re-resolved against.
+//
+// The POST dispatch is exact-match on the two door paths and comes first,
+// so nothing else under the prefix can ever be reached by a POST.
func (e *Edge) serveReserved(w http.ResponseWriter, r *http.Request, host string, up upstream) {
+ if r.Method == http.MethodPost {
+ switch r.URL.Path {
+ case ReservedPrefix + "e":
+ e.serveBeacon(w, r, host, up, analytics.DoorErrors)
+ return
+ case ReservedPrefix + "a":
+ e.serveBeacon(w, r, host, up, analytics.DoorEvents)
+ return
+ }
+ }
if serveCarlosJS(w, r) {
return
}