Merge branch 'worktree-contract-module' into main
19 files changed,
+1397
−5
.gitignore+7 −0README.md+37 −5conformance/conformance.go+140 −0conformance/conformance_test.go+177 −0fixtures.go+75 −0fixtures/custody.json+105 −0fixtures/hostile_hosts.json+36 −0fixtures_test.go+47 −0go.mod+3 −0s1.go+53 −0s1_test.go+75 −0s2.go+97 −0s2_test.go+121 −0s3.go+45 −0s3_test.go+57 −0subject.go+67 −0subject_test.go+71 −0transport.go+91 −0transport_test.go+93 −0
diff --git a/.gitignore b/.gitignore| index 1181530..a38480e 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -16,3 +16,10 @@ |
| .claude/launch.json |
| .claude/settings.local.json |
| .superpowers/ |
| + |
| +# Go build cache, kept in-tree because the sandbox will not write to |
| +# ~/.cache/go-build. |
| +.gocache/ |
| + |
| +# Scratch for `amadan branch describe -body -`. |
| +.branch-desc.txt |
diff --git a/README.md b/README.md| index 3b5297a..c7bcad6 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -13,15 +13,47 @@ owns the rule that binds the others. |
| - `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`: the |
| doctrine. The team is the boundary. Inside it the apps are one thing; |
| across it, sharing is membership and nothing moves. Read §1 first. |
| -- The contract module, `amadan.net/oficina/suite`: types, golden |
| - fixtures and a thin client for the calls apps make to each other and to |
| - the home. Not written yet; the doctrine's §12 says when. |
| +- The contract module, `amadan.net/oficina/suite`: types, golden fixtures |
| + and a thin client for the calls apps make to each other. It has no |
| + dependencies, so the pin chain from an app is one deep. |
| +- `fixtures/`: the golden set. `custody.json` is one case per rung of the |
| + sharing ladder; `hostile_hosts.json` is the list of hostnames a sibling |
| + client must refuse. Both are shared so every app runs the same gate |
| + rather than inventing its own. |
| +- `conformance/`: the harness an app points at its own server half. |
| ## How an app uses it |
| An app spec cites the doctrine and lists the rulings it consumes. It never |
| -restates one. An app imports the module, serves its server half, and runs |
| -the conformance suite in CI against the version it pins. |
| +restates one. |
| + |
| +In code, an app imports the module, mounts the three sibling routes on a |
| +handler, and runs the harness against that handler in CI: |
| + |
| +```go |
| +func TestSuiteConformance(t *testing.T) { |
| + conformance.Run(t, myapp.SuiteRoutes()) |
| +} |
| +``` |
| + |
| +For the calls it makes, it takes a client that cannot leave its own team: |
| + |
| +```go |
| +c := suite.NewSiblingClient("acme", nil) // refuses another team's label, |
| + // and never follows a redirect |
| +``` |
| + |
| +## What is built, and what is not |
| + |
| +Built: S1 the change feed, S2 authorization with its total decoder, S3 |
| +content, the typed asker subject, the team-bound client, and gates 1 to 4. |
| +That is the doctrine's §12 step 2, and it does not wait on the home. |
| + |
| +Not built, deliberately: the server half — that arrives with the first app |
| +that serves one, and the fixtures get corrected by the second, which is the |
| +point of §10. Nor call 7, call 8, the roster or anything needing an |
| +assertion; those are §12 steps 4 and 5 and they wait on the home's identity |
| +layer. |
| ## Status |
diff --git a/conformance/conformance.go b/conformance/conformance.go| new file mode 100644 |
| index 0000000..d2ce631 |
| --- /dev/null |
| +++ b/conformance/conformance.go |
| @@ -0,0 +1,140 @@ |
| +// Package conformance drives an app's server half against the doctrine's |
| +// golden fixtures. |
| +// |
| +// An app mounts its suite routes on a handler and hands that handler here. |
| +// The harness fires the fixture's requests at it and reports what the contract |
| +// says should have happened and did not. Doctrine §10: two specs are guesses, |
| +// two implementations are facts — this is where the second implementation |
| +// corrects the first. |
| +package conformance |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strconv" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| +) |
| + |
| +// Report is what a run found. An empty Failures is a pass. |
| +type Report struct { |
| + Checked int |
| + Failures []string |
| +} |
| + |
| +func (r *Report) fail(format string, args ...any) { |
| + r.Failures = append(r.Failures, fmt.Sprintf(format, args...)) |
| +} |
| + |
| +// Run drives the handler and fails the test with anything the contract |
| +// forbids. This is what an app calls from its own CI. |
| +func Run(t *testing.T, h http.Handler) { |
| + t.Helper() |
| + report := Check(h) |
| + for _, f := range report.Failures { |
| + t.Error(f) |
| + } |
| + if report.Checked == 0 { |
| + t.Error("conformance ran no checks; a gate that asserts nothing passes everything") |
| + } |
| +} |
| + |
| +// Check drives the handler and returns what it found, without touching a |
| +// *testing.T. It exists so the harness itself can be tested: a harness that |
| +// passes every server is worse than no harness, and the only way to know is to |
| +// point it at servers that should fail. |
| +func Check(h http.Handler) Report { |
| + var r Report |
| + |
| + fixture, err := suite.LoadCustodyFixture() |
| + if err != nil { |
| + r.fail("loading the custody fixture: %v", err) |
| + return r |
| + } |
| + |
| + checkS3(h, fixture, &r) |
| + checkS2(h, fixture, &r) |
| + checkS1(h, &r) |
| + return r |
| +} |
| + |
| +// Gate 3, the content half. Every rung but `team` answers 404, and it answers |
| +// the same 404 as a deleted item — a consumer that could tell "not |
| +// team-visible" from "does not exist" would learn that a private item exists. |
| +func checkS3(h http.Handler, f suite.CustodyFixture, r *Report) { |
| + for _, c := range f.Cases { |
| + url := suite.RouteS3 + "/" + c.ItemID + "?revision=" + strconv.FormatInt(c.Revision, 10) |
| + rec := do(h, "GET", url, nil) |
| + r.Checked++ |
| + |
| + if rec.Code != c.S3Status { |
| + r.fail("S3 %s (%s, rung %s): got %d, want %d", c.ItemID, c.Name, c.Rung, rec.Code, c.S3Status) |
| + } |
| + if rec.Code == 403 { |
| + r.fail("S3 %s: answered 403, and the contract is 404 never 403 — a 403 says the item exists, which is the fact custody hides", c.ItemID) |
| + } |
| + } |
| +} |
| + |
| +// Gate 3, the authorization half. The fixture's asker deliberately holds a |
| +// live grant on the private and granted items and a former grant on the |
| +// deleted one: the custody intersection (§4.3) has to beat the grant, or a |
| +// consumer holding an ingested id learns the item is still there. |
| +func checkS2(h http.Handler, f suite.CustodyFixture, r *Report) { |
| + pairs := make([]suite.ItemRevision, 0, len(f.Cases)) |
| + want := make(map[string]suite.Authorization, len(f.Cases)) |
| + for _, c := range f.Cases { |
| + pairs = append(pairs, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision}) |
| + want[c.ItemID] = c.S2Result |
| + } |
| + |
| + if err := suite.CheckS2Batch(pairs); err != nil { |
| + r.fail("custody fixture is over the S2 cap: %v", err) |
| + return |
| + } |
| + |
| + body, err := json.Marshal(suite.S2Request{Asker: f.Asker, Pairs: pairs}) |
| + if err != nil { |
| + r.fail("encoding the S2 question: %v", err) |
| + return |
| + } |
| + |
| + rec := do(h, "POST", suite.RouteS2, body) |
| + got := suite.DecodeS2(pairs, rec.Code, rec.Body.Bytes()) |
| + |
| + for i, p := range pairs { |
| + r.Checked++ |
| + if got[i] != want[p.ItemID] { |
| + r.fail("S2 %s: got %s, want %s", p.ItemID, got[i], want[p.ItemID]) |
| + } |
| + } |
| +} |
| + |
| +// Gate 1 for the feed, and the leak memoria's draft 1 shipped. The decoder |
| +// refuses unknown fields, so a row carrying a title, a body or a visibility |
| +// flag fails here rather than in a store somebody greps a year later. |
| +func checkS1(h http.Handler, r *Report) { |
| + rec := do(h, "GET", suite.RouteS1+"?cursor=", nil) |
| + r.Checked++ |
| + |
| + if _, err := suite.DecodeS1(rec.Code, rec.Body.Bytes()); err != nil { |
| + r.fail("S1: %v", err) |
| + } |
| +} |
| + |
| +func do(h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder { |
| + var rdr *bytes.Reader |
| + if body == nil { |
| + rdr = bytes.NewReader(nil) |
| + } else { |
| + rdr = bytes.NewReader(body) |
| + } |
| + req := httptest.NewRequest(method, url, rdr) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go| new file mode 100644 |
| index 0000000..ddd0ccb |
| --- /dev/null |
| +++ b/conformance/conformance_test.go |
| @@ -0,0 +1,177 @@ |
| +package conformance_test |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| + "amadan.net/oficina/suite/conformance" |
| +) |
| + |
| +// referenceServer is a correct server half, built straight from the fixture. |
| +// It exists so the harness can be tested against something that should pass — |
| +// and, with its behaviour bent, against things that should fail. |
| +type referenceServer struct { |
| + fixture suite.CustodyFixture |
| + |
| + // The bugs each broken variant introduces. |
| + forbidInsteadOfNotFound bool // answers 403 where the contract says 404 |
| + honourGrantOverCustody bool // answers VISIBLE for a granted item |
| + leakVisibilityInFeed bool // adds a field the feed row may not carry |
| +} |
| + |
| +func (s *referenceServer) find(itemID string) (suite.CustodyCase, bool) { |
| + for _, c := range s.fixture.Cases { |
| + if c.ItemID == itemID { |
| + return c, true |
| + } |
| + } |
| + return suite.CustodyCase{}, false |
| +} |
| + |
| +func (s *referenceServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case strings.HasPrefix(r.URL.Path, suite.RouteS3+"/"): |
| + s.serveS3(w, r) |
| + case r.URL.Path == suite.RouteS2: |
| + s.serveS2(w, r) |
| + case r.URL.Path == suite.RouteS1: |
| + s.serveS1(w, r) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +func (s *referenceServer) serveS3(w http.ResponseWriter, r *http.Request) { |
| + id := strings.TrimPrefix(r.URL.Path, suite.RouteS3+"/") |
| + c, ok := s.find(id) |
| + if !ok { |
| + w.WriteHeader(404) |
| + return |
| + } |
| + if c.Rung == suite.RungTeam { |
| + w.WriteHeader(200) |
| + w.Write([]byte("body of " + id)) |
| + return |
| + } |
| + if s.forbidInsteadOfNotFound { |
| + w.WriteHeader(403) |
| + return |
| + } |
| + w.WriteHeader(404) |
| +} |
| + |
| +func (s *referenceServer) serveS2(w http.ResponseWriter, r *http.Request) { |
| + var req suite.S2Request |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + w.WriteHeader(400) |
| + return |
| + } |
| + |
| + type result struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Result suite.Authorization `json:"result"` |
| + } |
| + out := struct { |
| + Results []result `json:"results"` |
| + }{} |
| + |
| + for _, p := range req.Pairs { |
| + c, ok := s.find(p.ItemID) |
| + answer := suite.NOT_VISIBLE |
| + switch { |
| + case !ok: |
| + answer = suite.NOT_VISIBLE |
| + case c.Rung == suite.RungTeam: |
| + answer = suite.VISIBLE |
| + case s.honourGrantOverCustody && c.Rung == suite.RungGranted: |
| + // The bug the intersection exists to prevent: answering from the |
| + // grants join alone, so a grantee learns a non-team item exists. |
| + answer = suite.VISIBLE |
| + } |
| + out.Results = append(out.Results, result{ItemID: p.ItemID, Revision: p.Revision, Result: answer}) |
| + } |
| + |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +func (s *referenceServer) serveS1(w http.ResponseWriter, r *http.Request) { |
| + rows := []map[string]any{} |
| + for _, c := range s.fixture.Cases { |
| + row := map[string]any{ |
| + "item_id": c.ItemID, |
| + "revision": c.Revision, |
| + "gone": c.Rung != suite.RungTeam, |
| + "touched_at": "2026-09-05T09:00:00Z", |
| + } |
| + if s.leakVisibilityInFeed { |
| + row["visible_to_team"] = c.Rung == suite.RungTeam |
| + } |
| + rows = append(rows, row) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]any{"cursor": "c-" + strconv.Itoa(len(rows)), "rows": rows}) |
| +} |
| + |
| +func newReference(t *testing.T) *referenceServer { |
| + t.Helper() |
| + f, err := suite.LoadCustodyFixture() |
| + if err != nil { |
| + t.Fatalf("loading fixture: %v", err) |
| + } |
| + return &referenceServer{fixture: f} |
| +} |
| + |
| +// A correct server half passes. Without this the harness could fail |
| +// everything and still catch every bug below. |
| +func TestHarnessPassesACorrectServer(t *testing.T) { |
| + report := conformance.Check(newReference(t)) |
| + |
| + if len(report.Failures) != 0 { |
| + t.Errorf("a correct server failed conformance: %v", report.Failures) |
| + } |
| +} |
| + |
| +// Gate 3's real target. A server answering 403 for a non-team item tells the |
| +// caller the item exists, which is what "404, never 403" hides. |
| +func TestHarnessCatches403ForANonTeamItem(t *testing.T) { |
| + s := newReference(t) |
| + s.forbidInsteadOfNotFound = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a server answering 403 where 404 is required") |
| + } |
| +} |
| + |
| +// §4.3's custody intersection. A server answering from its grants join alone |
| +// says VISIBLE for an individually granted item, and a consumer holding that |
| +// item's id learns it still exists. |
| +func TestHarnessCatchesGrantBeatingCustody(t *testing.T) { |
| + s := newReference(t) |
| + s.honourGrantOverCustody = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a server whose S2 ignored the custody intersection") |
| + } |
| +} |
| + |
| +// The leak memoria's draft 1 shipped: a visibility flag on a feed row. |
| +func TestHarnessCatchesAFeedRowCarryingVisibility(t *testing.T) { |
| + s := newReference(t) |
| + s.leakVisibilityInFeed = true |
| + |
| + report := conformance.Check(s) |
| + |
| + if len(report.Failures) == 0 { |
| + t.Error("harness passed a feed row carrying a visibility flag") |
| + } |
| +} |
diff --git a/fixtures.go b/fixtures.go| new file mode 100644 |
| index 0000000..4541ab9 |
| --- /dev/null |
| +++ b/fixtures.go |
| @@ -0,0 +1,75 @@ |
| +package suite |
| + |
| +import ( |
| + "embed" |
| + "encoding/json" |
| + "fmt" |
| +) |
| + |
| +//go:embed fixtures/*.json |
| +var fixtureFS embed.FS |
| + |
| +// Route shapes the server half mounts beside an app's own router. They are |
| +// part of the contract: the conformance harness fires exactly these, so an app |
| +// that mounts them elsewhere fails gate 1 rather than failing in production. |
| +const ( |
| + RouteS1 = "/suite/s1" // GET, ?cursor= |
| + RouteS2 = "/suite/s2" // POST, body is S2Request |
| + RouteS3 = "/suite/s3" // GET, /suite/s3/{item_id}?revision= |
| +) |
| + |
| +// S2Request is the wire shape of an authorization question: a typed asker |
| +// (§3) and up to MaxS2Pairs items. The consumer token rides the transport, |
| +// not the body. |
| +type S2Request struct { |
| + Asker Subject `json:"asker"` |
| + Pairs []ItemRevision `json:"pairs"` |
| +} |
| + |
| +// Rung is where an item sits on the sharing ladder (§4.2, ruling 15). A link |
| +// is not a rung; it is a read-only capability that any rung may carry, and |
| +// custody keys on the rung alone. |
| +type Rung string |
| + |
| +const ( |
| + RungPrivate Rung = "private" |
| + RungGranted Rung = "granted" |
| + RungTeam Rung = "team" |
| + RungDeleted Rung = "deleted" |
| +) |
| + |
| +// CustodyCase is one row of the custody fixture: an item, where it sits, and |
| +// what both calls must answer for the fixture's asker. |
| +type CustodyCase struct { |
| + Name string `json:"name"` |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Rung Rung `json:"rung"` |
| + Link string `json:"link"` |
| + S3Status int `json:"s3_status"` |
| + S2Result Authorization `json:"s2_result"` |
| +} |
| + |
| +// CustodyFixture is gate 3's world: one case per rung, plus team-plus-link and |
| +// the after-ingest transitions the reviews asked for. |
| +type CustodyFixture struct { |
| + Team string `json:"team"` |
| + Asker Subject `json:"asker"` |
| + Cases []CustodyCase `json:"cases"` |
| +} |
| + |
| +// LoadCustodyFixture returns the golden custody cases every app runs. |
| +func LoadCustodyFixture() (CustodyFixture, error) { |
| + raw, err := fixtureFS.ReadFile("fixtures/custody.json") |
| + if err != nil { |
| + return CustodyFixture{}, fmt.Errorf("suite: reading custody fixture: %w", err) |
| + } |
| + var f CustodyFixture |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + return CustodyFixture{}, fmt.Errorf("suite: parsing custody fixture: %w", err) |
| + } |
| + if len(f.Cases) == 0 { |
| + return CustodyFixture{}, fmt.Errorf("suite: custody fixture has no cases") |
| + } |
| + return f, nil |
| +} |
diff --git a/fixtures/custody.json b/fixtures/custody.json| new file mode 100644 |
| index 0000000..6d5865e |
| --- /dev/null |
| +++ b/fixtures/custody.json |
| @@ -0,0 +1,105 @@ |
| +{ |
| + "comment": [ |
| + "Gate 3. One fixture per rung of the sharing ladder (doctrine §4.2,", |
| + "ruling 15), plus the two cases the reviews asked for: an item on the", |
| + "team rung that also carries a link, and an item whose link changed", |
| + "after it was ingested.", |
| + "", |
| + "The asker is the same person for every case, and the item id is quoted", |
| + "in every case, so a server cannot pass by answering differently for", |
| + "different ids. What must differ is the rung; what must not differ is", |
| + "the answer for everything that is not `team`.", |
| + "", |
| + "s3_status is what a content fetch answers. s2_result is what an", |
| + "authorization call answers for `asker`, who deliberately holds a live", |
| + "grant on doc-private and doc-granted and a former grant on doc-deleted:", |
| + "the intersection with custody (§4.3) must beat the grant every time." |
| + ], |
| + |
| + "team": "acme", |
| + "asker": { "kind": "person_ref", "value": "pers_ana" }, |
| + |
| + "cases": [ |
| + { |
| + "name": "team rung is the only thing custody admits", |
| + "item_id": "doc-team", |
| + "revision": 7, |
| + "rung": "team", |
| + "link": null, |
| + "s3_status": 200, |
| + "s2_result": "VISIBLE" |
| + }, |
| + { |
| + "name": "private, and the asker owns it", |
| + "item_id": "doc-private", |
| + "revision": 4, |
| + "rung": "private", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "granted to the asker individually", |
| + "item_id": "doc-granted", |
| + "revision": 9, |
| + "rung": "granted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "granted to a guest only", |
| + "item_id": "doc-guest", |
| + "revision": 2, |
| + "rung": "granted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "link-shared but not team-shared: a link is not a rung", |
| + "item_id": "doc-link", |
| + "revision": 5, |
| + "rung": "private", |
| + "link": "read", |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "team-shared and link-shared: team-visible because of the rung", |
| + "item_id": "doc-team-link", |
| + "revision": 3, |
| + "rung": "team", |
| + "link": "read", |
| + "s3_status": 200, |
| + "s2_result": "VISIBLE" |
| + }, |
| + { |
| + "name": "deleted, with a former grant held by the asker", |
| + "item_id": "doc-deleted", |
| + "revision": 6, |
| + "rung": "deleted", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "ingested on the team rung, since moved to private", |
| + "item_id": "doc-was-team", |
| + "revision": 8, |
| + "rung": "private", |
| + "link": null, |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + }, |
| + { |
| + "name": "a link added after ingest changes nothing either way", |
| + "item_id": "doc-link-added-late", |
| + "revision": 11, |
| + "rung": "private", |
| + "link": "read", |
| + "s3_status": 404, |
| + "s2_result": "NOT_VISIBLE" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/hostile_hosts.json b/fixtures/hostile_hosts.json| new file mode 100644 |
| index 0000000..ded880c |
| --- /dev/null |
| +++ b/fixtures/hostile_hosts.json |
| @@ -0,0 +1,36 @@ |
| +{ |
| + "comment": [ |
| + "Gate 4. Hosts a sibling client belonging to team `acme` must refuse,", |
| + "and the one it must reach. Doctrine §4.1.", |
| + "", |
| + "The gate counts connections, not errors: a refused host must cost zero", |
| + "connection attempts. The redirect case is the exception the doctrine", |
| + "calls out — a client has to reach an allowed host to be told to", |
| + "redirect, so what is forbidden there is the follow-up hop." |
| + ], |
| + |
| + "team": "acme", |
| + |
| + "refuse": [ |
| + { "host": "acme-evil.docs.oficina.works", "why": "a different team label that merely starts with ours" }, |
| + { "host": "acmex.docs.oficina.works", "why": "a longer label with ours as a prefix" }, |
| + { "host": "acme.docs.oficina.works.evil", "why": "in evil, not in oficina.works, despite reading like it" }, |
| + { "host": "evil.com", "why": "outside the suite domain entirely" }, |
| + { "host": "docs.oficina.works", "why": "no team label at all" }, |
| + { "host": "acme.team.docs.oficina.works", "why": "an extra label between team and app" }, |
| + { "host": "ACME-EVIL.docs.oficina.works", "why": "case must not smuggle a different label through" } |
| + ], |
| + |
| + "reach": [ |
| + { "host": "acme.docs.oficina.works", "why": "our own team's Docs instance" }, |
| + { "host": "acme.sheets.oficina.works", "why": "our own team's Sheets instance" }, |
| + { "host": "acme.docs.oficina.works.", "why": "a fully-qualified trailing dot is the same host, and normalising it is the check's job — refusing it would break a legitimate caller" }, |
| + { "host": "ACME.docs.oficina.works", "why": "hostnames are case-insensitive; our own host shouted is still ours" } |
| + ], |
| + |
| + "redirect": { |
| + "from": "acme.docs.oficina.works", |
| + "to": "acme-evil.docs.oficina.works", |
| + "why": "the first hop is legitimate; following the redirect is not" |
| + } |
| +} |
diff --git a/fixtures_test.go b/fixtures_test.go| new file mode 100644 |
| index 0000000..db69f59 |
| --- /dev/null |
| +++ b/fixtures_test.go |
| @@ -0,0 +1,47 @@ |
| +package suite |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "testing" |
| +) |
| + |
| +type hostCase struct { |
| + Host string `json:"host"` |
| + Why string `json:"why"` |
| +} |
| + |
| +type hostileHosts struct { |
| + Team string `json:"team"` |
| + Refuse []hostCase `json:"refuse"` |
| + Reach []hostCase `json:"reach"` |
| + Comment []string `json:"comment"` |
| +} |
| + |
| +// The hostile-hostname fixture is the shared list, so every app runs gate 4 |
| +// against the same hosts rather than each inventing its own. This test is the |
| +// module holding itself to the fixture it publishes. |
| +func TestHostileHostFixture(t *testing.T) { |
| + raw, err := os.ReadFile("fixtures/hostile_hosts.json") |
| + if err != nil { |
| + t.Fatalf("reading fixture: %v", err) |
| + } |
| + var f hostileHosts |
| + if err := json.Unmarshal(raw, &f); err != nil { |
| + t.Fatalf("parsing fixture: %v", err) |
| + } |
| + if len(f.Refuse) == 0 || len(f.Reach) == 0 { |
| + t.Fatal("fixture has no cases; a gate that asserts nothing passes everything") |
| + } |
| + |
| + for _, c := range f.Refuse { |
| + if err := CheckSiblingHost(f.Team, c.Host); err == nil { |
| + t.Errorf("reached %q, want it refused — %s", c.Host, c.Why) |
| + } |
| + } |
| + for _, c := range f.Reach { |
| + if err := CheckSiblingHost(f.Team, c.Host); err != nil { |
| + t.Errorf("refused %q (%v), want it reached — %s", c.Host, err, c.Why) |
| + } |
| + } |
| +} |
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..d538f9f |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,3 @@ |
| +module amadan.net/oficina/suite |
| + |
| +go 1.26 |
diff --git a/s1.go b/s1.go| new file mode 100644 |
| index 0000000..702bd89 |
| --- /dev/null |
| +++ b/s1.go |
| @@ -0,0 +1,53 @@ |
| +package suite |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// FeedRow is one row of the S1 change feed, and it is the whole row. |
| +// |
| +// Doctrine §6: (item_id, revision, gone, touched_at) — no title, no content, |
| +// no visibility flag. memoria's draft 1 carried a visible-to-team field and it |
| +// was a leak: `visible=false, gone=false` told a consumer that a guest-only |
| +// item existed, at what revision and when it was touched, which is what S3's |
| +// "404, never 403" is there to hide. §4.2: departure from team-visibility is |
| +// `gone`, indistinguishable from deletion. |
| +type FeedRow struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Gone bool `json:"gone"` |
| + TouchedAt time.Time `json:"touched_at"` |
| +} |
| + |
| +// Feed is one page of the change feed. The cursor is opaque: a position in the |
| +// sibling's own event log, issued by the sibling and stored per consumer. |
| +type Feed struct { |
| + Cursor string `json:"cursor"` |
| + Rows []FeedRow `json:"rows"` |
| +} |
| + |
| +// DecodeS1 reads a change-feed page, refusing anything the contract does not |
| +// allow on the wire. |
| +func DecodeS1(status int, body []byte) (Feed, error) { |
| + if status < 200 || status > 299 { |
| + // Not an empty feed. An empty feed means "nothing changed", and |
| + // returning that for a failed call advances a consumer's cursor past |
| + // changes it never saw. |
| + return Feed{}, fmt.Errorf("suite: S1 answered %d", status) |
| + } |
| + |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + // A row carrying more than the four fields is either a sibling leaking or |
| + // a sibling running a different contract. Ignoring the extra field would |
| + // let either go unnoticed until somebody grepped the store. |
| + dec.DisallowUnknownFields() |
| + |
| + var feed Feed |
| + if err := dec.Decode(&feed); err != nil { |
| + return Feed{}, fmt.Errorf("suite: S1 body outside the contract: %w", err) |
| + } |
| + return feed, nil |
| +} |
diff --git a/s1_test.go b/s1_test.go| new file mode 100644 |
| index 0000000..28d381b |
| --- /dev/null |
| +++ b/s1_test.go |
| @@ -0,0 +1,75 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// S1 rows are (item_id, revision, gone, touched_at) and nothing else |
| +// (doctrine §6). Draft 1 of memoria's feed carried a visible-to-team field and |
| +// that was a leak: `visible=false, gone=false` tells a consumer that a |
| +// guest-only item exists, at what revision and when it was touched — exactly |
| +// what S3's "404, never 403" exists to hide. |
| +func TestS1DecodeRows(t *testing.T) { |
| + body := []byte(`{"cursor":"c-92","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}, |
| + {"item_id":"doc-2","revision":2,"gone":true,"touched_at":"2026-09-05T09:01:00Z"} |
| + ]}`) |
| + |
| + feed, err := DecodeS1(200, body) |
| + if err != nil { |
| + t.Fatalf("unexpected error: %v", err) |
| + } |
| + |
| + if feed.Cursor != "c-92" { |
| + t.Errorf("cursor: got %q, want %q", feed.Cursor, "c-92") |
| + } |
| + if len(feed.Rows) != 2 { |
| + t.Fatalf("got %d rows, want 2", len(feed.Rows)) |
| + } |
| + if feed.Rows[0].ItemID != "doc-1" || feed.Rows[0].Revision != 7 || feed.Rows[0].Gone { |
| + t.Errorf("row 0: got %+v", feed.Rows[0]) |
| + } |
| + if !feed.Rows[1].Gone { |
| + t.Errorf("row 1: got gone=false, want the tombstone") |
| + } |
| +} |
| + |
| +// A feed row carrying anything beyond the four fields is refused, loudly. The |
| +// contract says a row has no title, no content and no visibility flag; a |
| +// sibling sending one is either leaking or running a different contract, and |
| +// silently ignoring the field would let either go unnoticed for as long as |
| +// nobody grepped the store. |
| +func TestS1RejectsRowCarryingContent(t *testing.T) { |
| + body := []byte(`{"cursor":"c-92","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z","title":"Q3 plan"} |
| + ]}`) |
| + |
| + _, err := DecodeS1(200, body) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a row carrying a title, want a refusal") |
| + } |
| +} |
| + |
| +// Departure from team-visibility and deletion are the same row, and the |
| +// client must not offer a caller any way to tell them apart (§4.2). |
| +func TestS1GoneCarriesNoReason(t *testing.T) { |
| + body := []byte(`{"cursor":"c-1","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":true,"touched_at":"2026-09-05T09:00:00Z","reason":"went_private"} |
| + ]}`) |
| + |
| + _, err := DecodeS1(200, body) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a gone row carrying a reason, want a refusal") |
| + } |
| +} |
| + |
| +// A non-2xx feed answer is an error, not an empty feed. An empty feed means |
| +// "nothing changed"; returning that for a failed call would advance a |
| +// consumer's cursor past changes it never saw. |
| +func TestS1NonSuccessIsAnError(t *testing.T) { |
| + _, err := DecodeS1(503, []byte(`{"cursor":"c-1","rows":[]}`)) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 503, want a refusal") |
| + } |
| +} |
diff --git a/s2.go b/s2.go| new file mode 100644 |
| index 0000000..e9cf5d9 |
| --- /dev/null |
| +++ b/s2.go |
| @@ -0,0 +1,97 @@ |
| +package suite |
| + |
| +import "encoding/json" |
| + |
| +// Authorization is what S2 answers for one item, for one named asker. |
| +// |
| +// Doctrine §4.3: four values, none collapsible into another. Only |
| +// NOT_VISIBLE may be silent; everything else that is not VISIBLE withholds |
| +// loudly and names the app. |
| +type Authorization string |
| + |
| +const ( |
| + // VISIBLE: this person may read this revision. |
| + VISIBLE Authorization = "VISIBLE" |
| + // NOT_VISIBLE: this person may not read it. The only value a consumer |
| + // may act on silently. |
| + NOT_VISIBLE Authorization = "NOT_VISIBLE" |
| + // STALE: the item is readable, but the app of record has moved past the |
| + // revision asked about. A synchronisation condition, never a denial. |
| + STALE Authorization = "STALE" |
| + // UNKNOWN: the app of record did not answer usefully, or cannot vouch |
| + // for the revision asked about. Withheld loudly. |
| + UNKNOWN Authorization = "UNKNOWN" |
| +) |
| + |
| +// ItemRevision is one pair in an S2 question: which item, at which revision. |
| +type ItemRevision struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| +} |
| + |
| +type s2Result struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Result Authorization `json:"result"` |
| +} |
| + |
| +type s2Body struct { |
| + Results []s2Result `json:"results"` |
| +} |
| + |
| +// DecodeS2 turns an S2 answer into exactly one Authorization per pair asked, |
| +// in the order they were asked. |
| +func DecodeS2(asked []ItemRevision, status int, body []byte) []Authorization { |
| + // Whole-call failures. A body that arrives with a non-2xx status is not |
| + // an answer however well it parses, and a body that does not parse is |
| + // not an answer however it arrived. |
| + if status < 200 || status > 299 { |
| + return allUnknown(len(asked)) |
| + } |
| + var parsed s2Body |
| + if err := json.Unmarshal(body, &parsed); err != nil { |
| + return allUnknown(len(asked)) |
| + } |
| + |
| + byPair := make(map[ItemRevision]Authorization, len(parsed.Results)) |
| + for _, r := range parsed.Results { |
| + pair := ItemRevision{ItemID: r.ItemID, Revision: r.Revision} |
| + if _, already := byPair[pair]; already { |
| + // Answered twice. Two answers are not an answer, and choosing |
| + // between them would be this client making a decision the app of |
| + // record did not make. |
| + byPair[pair] = UNKNOWN |
| + continue |
| + } |
| + byPair[pair] = known(r.Result) |
| + } |
| + |
| + out := make([]Authorization, len(asked)) |
| + for i, pair := range asked { |
| + answer, answered := byPair[pair] |
| + if !answered { |
| + answer = UNKNOWN |
| + } |
| + out[i] = answer |
| + } |
| + return out |
| +} |
| + |
| +// known maps anything outside the closed four-value vocabulary to UNKNOWN. |
| +// A fifth word is a sibling this client does not understand; passing it |
| +// through would hand a caller a value it cannot switch on. |
| +func known(a Authorization) Authorization { |
| + switch a { |
| + case VISIBLE, NOT_VISIBLE, STALE, UNKNOWN: |
| + return a |
| + } |
| + return UNKNOWN |
| +} |
| + |
| +func allUnknown(n int) []Authorization { |
| + out := make([]Authorization, n) |
| + for i := range out { |
| + out[i] = UNKNOWN |
| + } |
| + return out |
| +} |
diff --git a/s2_test.go b/s2_test.go| new file mode 100644 |
| index 0000000..11f8adb |
| --- /dev/null |
| +++ b/s2_test.go |
| @@ -0,0 +1,121 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// Gate 2, first case. A well-formed answer yields exactly one result per |
| +// requested pair, in request order. |
| +// |
| +// Doctrine §4.3: four values, none collapsible into another. §6: cap 256 |
| +// pairs, total decoder. |
| +func TestS2DecodeWellFormed(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"NOT_VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + want := []Authorization{VISIBLE, NOT_VISIBLE} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %d results, want %d", len(got), len(want)) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Errorf("result %d: got %v, want %v", i, got[i], want[i]) |
| + } |
| + } |
| +} |
| + |
| +// An item the app of record did not answer for is UNKNOWN — never the zero |
| +// value, and never NOT_VISIBLE. memoria §4.1: a non-answer is synthesised by |
| +// the caller, because a sibling that never answers cannot return anything. |
| +func TestS2MissingItemIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"VISIBLE"}]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[1] != UNKNOWN { |
| + t.Errorf("missing item: got %q, want UNKNOWN", got[1]) |
| + } |
| + if got[0] != VISIBLE { |
| + t.Errorf("answered item: got %q, want VISIBLE", got[0]) |
| + } |
| +} |
| + |
| +// A non-2xx status is a whole-call failure: every pair is UNKNOWN, whatever |
| +// the body happens to contain. A body arriving with a 500 is not an answer. |
| +func TestS2NonSuccessStatusIsWholeCallUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 500, body) |
| + |
| + for i, a := range got { |
| + if a != UNKNOWN { |
| + t.Errorf("pair %d: got %q, want UNKNOWN", i, a) |
| + } |
| + } |
| +} |
| + |
| +// A body that does not parse is a whole-call failure too. |
| +func TestS2MalformedBodyIsWholeCallUnknown(t *testing.T) { |
| + asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| + |
| + got := DecodeS2(asked, 200, []byte(`{"results":[{"item_id":`)) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("got %q, want UNKNOWN", got[0]) |
| + } |
| +} |
| + |
| +// An item answered twice is UNKNOWN for that item. Two answers are not an |
| +// answer, and picking either one would be the caller inventing a decision the |
| +// app of record did not make. The other pairs are unaffected. |
| +func TestS2DuplicateItemIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("duplicated item: got %q, want UNKNOWN", got[0]) |
| + } |
| + if got[1] != VISIBLE { |
| + t.Errorf("other item: got %q, want VISIBLE", got[1]) |
| + } |
| +} |
| + |
| +// A value outside the four is UNKNOWN. The vocabulary is closed (§4.3), so an |
| +// unrecognised word is a sibling this client does not understand, not a fifth |
| +// meaning to pass through to a caller that will switch on it. |
| +func TestS2UnrecognisedValueIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| + body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"MAYBE"}]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("got %q, want UNKNOWN", got[0]) |
| + } |
| +} |
diff --git a/s3.go b/s3.go| new file mode 100644 |
| index 0000000..31dbdd2 |
| --- /dev/null |
| +++ b/s3.go |
| @@ -0,0 +1,45 @@ |
| +package suite |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| +) |
| + |
| +// ErrNotAvailable is the whole vocabulary of an S3 refusal. |
| +// |
| +// Doctrine §4.2: a content fetch for anything outside the custody predicate |
| +// answers 404, never 403, and the status is identical for "not team-visible" |
| +// and "does not exist". Deleted, never team-visible, and departed from |
| +// team-visibility are one answer here on purpose — a consumer that could tell |
| +// them apart would learn that a private item exists. |
| +var ErrNotAvailable = errors.New("suite: item not available") |
| + |
| +// ErrContractBreach marks a sibling answering in a way the contract forbids. |
| +// It is separate from a refusal and from an outage because it is neither: it |
| +// is a bug in the sibling that a consumer should surface rather than absorb. |
| +var ErrContractBreach = errors.New("suite: sibling broke the contract") |
| + |
| +// DecodeS3 reads a content fetch. There is no asker anywhere in this call: |
| +// S3 is custody-scoped and person-less, and memoria's draft 3 added a person |
| +// to it, which both broke the ingest path — the warm-up, the rebuild and |
| +// derived-artefact regeneration have no asker — and pulled individually |
| +// granted rows into a store that may not hold them. |
| +func DecodeS3(status int, body []byte) ([]byte, error) { |
| + switch { |
| + case status >= 200 && status <= 299: |
| + return body, nil |
| + |
| + case status == 404: |
| + return nil, ErrNotAvailable |
| + |
| + case status == 403: |
| + // "404, never 403" is the rule, because 403 means "this exists and |
| + // you may not have it" — the one fact custody hides. A sibling |
| + // answering 403 is leaking, and folding it into ErrNotAvailable would |
| + // keep that leak invisible to everyone downstream. |
| + return nil, fmt.Errorf("%w: S3 answered 403 where the contract requires 404", ErrContractBreach) |
| + |
| + default: |
| + return nil, fmt.Errorf("suite: S3 answered %d", status) |
| + } |
| +} |
diff --git a/s3_test.go b/s3_test.go| new file mode 100644 |
| index 0000000..accf823 |
| --- /dev/null |
| +++ b/s3_test.go |
| @@ -0,0 +1,57 @@ |
| +package suite |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// S3 is the ingest call: (id, revision), no person, team-visible only. |
| +func TestS3ReturnsBody(t *testing.T) { |
| + got, err := DecodeS3(200, []byte("the body")) |
| + if err != nil { |
| + t.Fatalf("unexpected error: %v", err) |
| + } |
| + |
| + if string(got) != "the body" { |
| + t.Errorf("got %q, want %q", got, "the body") |
| + } |
| +} |
| + |
| +// 404 is the whole vocabulary of refusal. Deleted, never team-visible, or |
| +// departed from team-visibility all answer the same way, so a consumer cannot |
| +// learn from a refusal that the item exists (§4.2). |
| +func TestS3NotFoundIsIndistinguishable(t *testing.T) { |
| + _, err := DecodeS3(404, nil) |
| + |
| + if !errors.Is(err, ErrNotAvailable) { |
| + t.Errorf("got %v, want ErrNotAvailable", err) |
| + } |
| +} |
| + |
| +// A 403 breaks the contract, and the client says so rather than folding it in |
| +// with 404. "404, never 403" is the rule (§4.2, §6) because 403 means "this |
| +// exists and you may not have it" — which is precisely the fact custody hides. |
| +// A sibling answering 403 has a bug that leaks, and quietly treating it as a |
| +// refusal would keep that bug invisible. |
| +func TestS3ForbiddenIsAContractBreach(t *testing.T) { |
| + _, err := DecodeS3(403, nil) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 403, want a contract breach reported") |
| + } |
| + if errors.Is(err, ErrNotAvailable) { |
| + t.Error("403 was folded into ErrNotAvailable; it must be distinguishable") |
| + } |
| +} |
| + |
| +// Any other non-2xx is a failed call, not a refusal. |
| +func TestS3ServerErrorIsNotARefusal(t *testing.T) { |
| + _, err := DecodeS3(503, nil) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 503, want a failure") |
| + } |
| + if errors.Is(err, ErrNotAvailable) { |
| + t.Error("503 was reported as a refusal; an outage is not an answer") |
| + } |
| +} |
diff --git a/subject.go b/subject.go| new file mode 100644 |
| index 0000000..a3d9bca |
| --- /dev/null |
| +++ b/subject.go |
| @@ -0,0 +1,67 @@ |
| +package suite |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// SubjectKind says how to read an asker subject's value. Doctrine §3: the |
| +// meaning is shape-dependent and nothing else on the wire says which shape |
| +// the pair is in, so a receiver that does not key on the kind it is sent |
| +// answers UNKNOWN — loudly — rather than failing to match and answering |
| +// NOT_VISIBLE, which callers are entitled to act on silently. |
| +type SubjectKind string |
| + |
| +const ( |
| + // SubjectPersonRef is the assertion's opaque `sub` (home §5.3). Suite |
| + // shape, where the home is the identity authority. |
| + SubjectPersonRef SubjectKind = "person_ref" |
| + // SubjectAddress is a lowercased address. Lone shape, where the instance |
| + // keys its own members on the address it signed them in with. |
| + SubjectAddress SubjectKind = "address" |
| +) |
| + |
| +// Subject is the named asker an S2 or S6 call carries beside its consumer |
| +// token. S3 carries none, by design (§6). |
| +type Subject struct { |
| + Kind SubjectKind `json:"kind"` |
| + Value string `json:"value"` |
| +} |
| + |
| +// NewSubject builds a subject, refusing anything a receiver could not key on |
| +// and normalising what it can. |
| +func NewSubject(kind SubjectKind, value string) (Subject, error) { |
| + switch kind { |
| + case SubjectPersonRef: |
| + // Opaque. Case may be significant, so it is passed through untouched. |
| + case SubjectAddress: |
| + // Lowercased, because that is what a lone instance stores. Sending |
| + // mixed case to a receiver holding the lowercased form misses, and a |
| + // miss answers NOT_VISIBLE — the silent value — for what is really a |
| + // normalisation bug. |
| + value = strings.ToLower(value) |
| + default: |
| + return Subject{}, fmt.Errorf("suite: unknown asker subject kind %q", kind) |
| + } |
| + |
| + if value == "" { |
| + return Subject{}, fmt.Errorf("suite: empty asker subject value for kind %q", kind) |
| + } |
| + return Subject{Kind: kind, Value: value}, nil |
| +} |
| + |
| +// MaxS2Pairs caps one S2 question. 256 is memoria §6.1's number, carried here |
| +// with the contract: it bounds the work a sibling can be made to do in one |
| +// call, and it bounds the size of a response the total decoder has to keep in |
| +// memory before it can answer. |
| +const MaxS2Pairs = 256 |
| + |
| +// CheckS2Batch refuses an over-cap question rather than truncating it. A |
| +// silently shortened question produces a confident answer about the items |
| +// that survived; the ones dropped decode as UNKNOWN and nobody learns why. |
| +func CheckS2Batch(pairs []ItemRevision) error { |
| + if len(pairs) > MaxS2Pairs { |
| + return fmt.Errorf("suite: %d pairs exceeds the S2 cap of %d", len(pairs), MaxS2Pairs) |
| + } |
| + return nil |
| +} |
diff --git a/subject_test.go b/subject_test.go| new file mode 100644 |
| index 0000000..5f4092d |
| --- /dev/null |
| +++ b/subject_test.go |
| @@ -0,0 +1,71 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// Doctrine §3: the asker subject is typed on the wire, because its meaning is |
| +// shape-dependent — a person ref in suite shape, a lowercased address in lone |
| +// shape — and nothing else in an S2 or S6 call says which shape the pair is |
| +// in. A kind the receiver does not key on must reach the caller as UNKNOWN, |
| +// never as NOT_VISIBLE, which is the one value it may act on silently. |
| +func TestSubjectRejectsUnknownKind(t *testing.T) { |
| + _, err := NewSubject("employee_number", "4471") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for an unknown subject kind, want a refusal") |
| + } |
| +} |
| + |
| +func TestSubjectRejectsEmptyValue(t *testing.T) { |
| + _, err := NewSubject(SubjectAddress, "") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for an empty subject value, want a refusal") |
| + } |
| +} |
| + |
| +// An address subject is lowercased, because that is what a lone instance keys |
| +// its members on (§3). Sending "Ana@Example.com" to a receiver storing |
| +// "ana@example.com" misses, and a miss answers NOT_VISIBLE — silently. |
| +func TestSubjectLowercasesAddress(t *testing.T) { |
| + s, err := NewSubject(SubjectAddress, "Ana@Example.COM") |
| + if err != nil { |
| + t.Fatalf("unexpected refusal: %v", err) |
| + } |
| + |
| + if s.Value != "ana@example.com" { |
| + t.Errorf("got %q, want %q", s.Value, "ana@example.com") |
| + } |
| +} |
| + |
| +// A person ref is opaque and is not touched. It is the assertion's `sub` |
| +// (home §5.3), and case may be significant in it. |
| +func TestSubjectLeavesPersonRefAlone(t *testing.T) { |
| + s, err := NewSubject(SubjectPersonRef, "PeRs_7Fa2") |
| + if err != nil { |
| + t.Fatalf("unexpected refusal: %v", err) |
| + } |
| + |
| + if s.Value != "PeRs_7Fa2" { |
| + t.Errorf("got %q, want the person ref unchanged", s.Value) |
| + } |
| +} |
| + |
| +// §6 caps an S2 question at 256 pairs. Over the cap the batch is refused, not |
| +// truncated: a silently shortened question produces a confident answer about |
| +// items nobody asked about, and the missing ones would decode as UNKNOWN |
| +// without anyone learning why. |
| +func TestS2BatchOverCapIsRefused(t *testing.T) { |
| + pairs := make([]ItemRevision, MaxS2Pairs+1) |
| + |
| + if err := CheckS2Batch(pairs); err == nil { |
| + t.Fatalf("got nil error for %d pairs, want a refusal", len(pairs)) |
| + } |
| +} |
| + |
| +func TestS2BatchAtCapIsAccepted(t *testing.T) { |
| + pairs := make([]ItemRevision, MaxS2Pairs) |
| + |
| + if err := CheckS2Batch(pairs); err != nil { |
| + t.Errorf("got %v for %d pairs, want it accepted", err, len(pairs)) |
| + } |
| +} |
diff --git a/transport.go b/transport.go| new file mode 100644 |
| index 0000000..b5e43f6 |
| --- /dev/null |
| +++ b/transport.go |
| @@ -0,0 +1,91 @@ |
| +package suite |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +// SuiteDomain is the registrable domain every suite host sits under. A sibling |
| +// host is `<team>.<app>.oficina.works` (doctrine §4.1). |
| +const SuiteDomain = "oficina.works" |
| + |
| +// DialFunc is the hook the sibling client dials through. Tests pass a |
| +// recorder; production passes nil and gets the standard dialer. |
| +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) |
| + |
| +// NewSiblingClient returns an HTTP client that will only ever reach hosts |
| +// inside its own team, and will never follow a redirect. |
| +// |
| +// Doctrine §4.1: every sibling client refuses a host whose `<team>` label |
| +// differs from its own, and refuses to follow redirects. Neither is a barrier |
| +// against code execution on an instance — they stop mis-provisioning and |
| +// accidental cross-team dialling, and that is all they are claimed as. |
| +func NewSiblingClient(team string, dial DialFunc) *http.Client { |
| + base := &http.Transport{} |
| + if dial != nil { |
| + base.DialContext = dial |
| + } |
| + |
| + return &http.Client{ |
| + Transport: &teamBoundTransport{team: team, next: base}, |
| + CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| + // An allowed host has to be reached to tell us to redirect; that |
| + // hop is legitimate. Following it is not: a sibling could walk |
| + // this client across the boundary the guard just enforced. |
| + return fmt.Errorf("suite: refusing to follow a redirect to %q", req.URL.Host) |
| + }, |
| + } |
| +} |
| + |
| +type teamBoundTransport struct { |
| + team string |
| + next http.RoundTripper |
| +} |
| + |
| +func (t *teamBoundTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| + if err := CheckSiblingHost(t.team, req.URL.Hostname()); err != nil { |
| + // Before the transport dials, so a refused host costs no connection. |
| + return nil, err |
| + } |
| + return t.next.RoundTrip(req) |
| +} |
| + |
| +// CheckSiblingHost answers whether `host` is a sibling of `team`'s instances. |
| +// |
| +// It is exported because it guards more than the HTTP client: §7.1's hand-off |
| +// validates a form's destination with it before the form is rendered, since an |
| +// HTML form action is not this client and a body posted to another team's host |
| +// has already crossed the boundary by the time that host could refuse it. |
| +func CheckSiblingHost(team, host string) error { |
| + if team == "" { |
| + return fmt.Errorf("suite: no team configured for the sibling client") |
| + } |
| + |
| + host = strings.TrimSuffix(strings.ToLower(host), ".") |
| + suffix := "." + SuiteDomain |
| + |
| + // The host must sit inside the suite's registrable domain. |
| + if !strings.HasSuffix(host, suffix) { |
| + return fmt.Errorf("suite: host %q is not in %s", host, SuiteDomain) |
| + } |
| + |
| + // Exactly `<team>.<app>` remains once the domain is removed. **This is |
| + // the check that does the work**, and the one to be careful editing. |
| + // A mutation test weakening the HasSuffix above to a Contains left every |
| + // hostile-host fixture still refused, because `acme.docs.oficina.works.evil` |
| + // does not end with the suffix, so nothing is trimmed and it arrives here |
| + // with five labels rather than two. The suffix check is defence in depth |
| + // and a clearer error message; it is not what stops the suffix attack. |
| + labels := strings.Split(strings.TrimSuffix(host, suffix), ".") |
| + if len(labels) != 2 { |
| + return fmt.Errorf("suite: host %q is not <team>.<app>.%s", host, SuiteDomain) |
| + } |
| + |
| + if labels[0] != strings.ToLower(team) { |
| + return fmt.Errorf("suite: host %q is team %q, not %q", host, labels[0], team) |
| + } |
| + return nil |
| +} |
diff --git a/transport_test.go b/transport_test.go| new file mode 100644 |
| index 0000000..58d163b |
| --- /dev/null |
| +++ b/transport_test.go |
| @@ -0,0 +1,93 @@ |
| +package suite |
| + |
| +import ( |
| + "context" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// recordingDialer stands in for the network. It records every address a |
| +// client tried to reach and refuses to connect, so a test can assert on the |
| +// attempt rather than on the outcome. Gate 4 is about connections not made. |
| +type recordingDialer struct{ dialed []string } |
| + |
| +func (d *recordingDialer) dial(ctx context.Context, network, addr string) (net.Conn, error) { |
| + d.dialed = append(d.dialed, addr) |
| + return nil, errRefusedByTest |
| +} |
| + |
| +var errRefusedByTest = &net.OpError{Op: "dial", Err: errTest("no network in tests")} |
| + |
| +type errTest string |
| + |
| +func (e errTest) Error() string { return string(e) } |
| + |
| +// Gate 4. A host whose <team> label differs from the caller's own is refused |
| +// before any connection is attempted. §4.1: the barrier stops mis-provisioning |
| +// and accidental cross-team dialling, and it is claimed as nothing more. |
| +func TestSiblingClientRefusesForeignTeamLabel(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, err := c.Get("https://acme-evil.docs.oficina.works/s3/doc-1") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error dialling another team's label, want a refusal") |
| + } |
| + if len(d.dialed) != 0 { |
| + t.Errorf("dialled %v, want no connection attempted at all", d.dialed) |
| + } |
| +} |
| + |
| +// The suffix attack: a host that merely ends with something resembling the |
| +// suite's domain. `acme.docs.oficina.works.evil` is not in oficina.works at |
| +// all, and a naive prefix check on the first label would wave it through. |
| +func TestSiblingClientRefusesSuffixAttack(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, err := c.Get("https://acme.docs.oficina.works.evil/s3/doc-1") |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a host outside the suite domain, want a refusal") |
| + } |
| + if len(d.dialed) != 0 { |
| + t.Errorf("dialled %v, want no connection attempted at all", d.dialed) |
| + } |
| +} |
| + |
| +// A same-team sibling is reached. Without this the guard could refuse |
| +// everything and still pass the two tests above. |
| +func TestSiblingClientReachesSameTeamHost(t *testing.T) { |
| + d := &recordingDialer{} |
| + c := NewSiblingClient("acme", d.dial) |
| + |
| + _, _ = c.Get("https://acme.docs.oficina.works/s3/doc-1") |
| + |
| + if len(d.dialed) != 1 { |
| + t.Fatalf("dialled %v, want exactly one connection to the sibling", d.dialed) |
| + } |
| + if !strings.HasPrefix(d.dialed[0], "acme.docs.oficina.works:") { |
| + t.Errorf("dialled %q, want the same-team host", d.dialed[0]) |
| + } |
| +} |
| + |
| +// Redirects are never followed. An allowed host must be reached to be told to |
| +// redirect — that connection is legitimate and gate 4 counts follow-ups, not |
| +// first hops — but following one would let a sibling walk the client across |
| +// the team boundary the first check just enforced. |
| +func TestSiblingClientDoesNotFollowRedirects(t *testing.T) { |
| + c := NewSiblingClient("acme", nil) |
| + |
| + req, err := http.NewRequest("GET", "https://acme.docs.oficina.works/s3/doc-1", nil) |
| + if err != nil { |
| + t.Fatalf("building request: %v", err) |
| + } |
| + via := []*http.Request{req} |
| + |
| + if err := c.CheckRedirect(req, via); err == nil { |
| + t.Error("got nil from CheckRedirect, want every redirect refused") |
| + } |
| +} |