diff --git a/.changeset/watch-registry-names.md b/.changeset/watch-registry-names.md new file mode 100644 index 00000000..bed15c23 --- /dev/null +++ b/.changeset/watch-registry-names.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Know the shared registry's new names for an Apple Watch: role `watch` and capability `cmd.exchange` (ADR 0009). The box claims neither yet. Its page and the app refuse a watch code, and refuse to change a phone into a watch, until a phone can add one. diff --git a/contract/registry.yaml b/contract/registry.yaml index 5d71d3a7..18f3cd52 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -80,6 +80,10 @@ capabilities: # only and offers no FTW Cloud. It says nothing about whether the owner # turned FTW Cloud on; the box's grant decides that. - cloud.link + # The box can take a watch: it opens the relay's exchange socket and mints + # watch codes. Absent means the phone offers no Add watch. The wire is + # docs/watch-exchange.md in srcfl/ftw-webapp. + - cmd.exchange # --------------------------------------------------------------------------- # Scopes. One object axis, two verb axes: .. @@ -104,6 +108,13 @@ roles: viewer: label: Viewer scopes: [ftw.live.read] + # A watch that an owner phone added. That phone is its parent, and each check + # keeps only the scopes below that the parent holds at that moment, so a + # watch never does more than its phone can do now. Read and charge: no site + # mode, no members, no history and no passthrough. + watch: + label: Watch + scopes: [ftw.live.read, ftw.plan.read, ftw.events.read, ftw.dispatch.write] # --------------------------------------------------------------------------- # FTW Cloud. What an owner can let the box share with FTW Cloud, and why. diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go index a8e71979..de346212 100644 --- a/go/internal/api/api_app_link.go +++ b/go/internal/api/api_app_link.go @@ -447,6 +447,15 @@ func (s *Server) appLinkRoleAllowed(w http.ResponseWriter, r *http.Request, aske "say which kind of access this is for") return false } + // A watch belongs to the owner phone that adds it (ADR 0009). This box + // cannot bind a watch to a phone yet, so neither door hands one out: a + // watch code from the LAN, or a phone changed into a watch, would be a + // watch with no phone above it. + if asked == apiauth.RoleWatch { + writeAppLinkError(w, http.StatusForbidden, + "a watch is added from its owner's phone, and this box cannot do that yet") + return false + } if appLinkOverSession(r) && asked != apiauth.RoleViewer { writeAppLinkError(w, http.StatusForbidden, "from the app you can let someone view this home. "+ diff --git a/go/internal/api/api_app_link_session_test.go b/go/internal/api/api_app_link_session_test.go index 0dcdbf88..4d33238b 100644 --- a/go/internal/api/api_app_link_session_test.go +++ b/go/internal/api/api_app_link_session_test.go @@ -20,6 +20,7 @@ import ( "errors" "log/slog" "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -451,6 +452,72 @@ func TestTheAppCannotMakeAnotherOwner(t *testing.T) { } } +// No door hands out a watch yet. +// +// The registry names the role, so the box's own enrolment would accept it. A +// watch belongs to the owner phone that adds it (ADR 0009), and until the box +// can bind one to its phone the API refuses the role at both doors. This runs +// against the real enrolment because a stub that knows only owner and viewer +// would refuse for its own reasons. +func TestNoDoorHandsOutAWatchYet(t *testing.T) { + enrol := newEnrolment(t) + + code, _, err := enrol.id.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL) + if err != nil { + t.Fatalf("minting the guest's code: %v", err) + } + guest, err := enrol.id.Authorise(appKey(2), code) + if err != nil { + t.Fatalf("enrolling the guest: %v", err) + } + + lan := func(method, path, body string) *http.Request { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Host = "192.168.1.1" + r.RemoteAddr = "192.168.1.5:1234" + return r + } + s := New(&Deps{AppEnroll: enrol}) + + for _, c := range []struct { + name string + serve func(http.ResponseWriter) + }{ + {"a scanned watch code from the LAN", func(w http.ResponseWriter) { + s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch"}`)) + }}, + {"a spoken watch code from the LAN", func(w http.ResponseWriter) { + s.handleAppLinkPairing(w, lan(http.MethodPost, "/api/app-link/pairing", `{"role":"watch","kind":"spoken"}`)) + }}, + {"the guest changed into a watch from the LAN", func(w http.ResponseWriter) { + r := lan(http.MethodPatch, "/api/app-link/devices/"+guest.DeviceID, `{"role":"watch"}`) + r.SetPathValue("id", guest.DeviceID) + s.handleAppLinkDeviceRole(w, r) + }}, + } { + w := httptest.NewRecorder() + c.serve(w) + if w.Code != http.StatusForbidden { + t.Fatalf("%s answered %d %s, want 403", c.name, w.Code, w.Body.String()) + } + } + + rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol)) + status, refusal, body := rig.call(t, 1, appproto.APIReq{ + Method: appproto.APIPost, Path: "/api/app-link/pairing", + Body: []byte(`{"role":"watch"}`), StepUp: true}) + if status != http.StatusForbidden { + t.Fatalf("a watch code over the session answered %d %q %s, want 403", status, refusal, body) + } + + for _, d := range enrol.Devices() { + if d.Role == apiauth.RoleWatch { + t.Fatalf("%s is now a watch", d.ID) + } + } +} + // A code to read aloud is made at the box, whatever role it is for. // // Forty bits are safe to say down a phone line because of what surrounds diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go index 515c5e6b..4eb336e0 100644 --- a/go/internal/apiauth/contract_gen.go +++ b/go/internal/apiauth/contract_gen.go @@ -10,6 +10,8 @@ const ( RoleOwner = "owner" // RoleViewer — Viewer. RoleViewer = "viewer" + // RoleWatch — Watch. + RoleWatch = "watch" ) // RoleScopes is what each role carries, with '*' already expanded. @@ -31,4 +33,10 @@ var RoleScopes = map[string][]string{ RoleViewer: { "ftw.live.read", }, + RoleWatch: { + "ftw.live.read", + "ftw.plan.read", + "ftw.events.read", + "ftw.dispatch.write", + }, } diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 3bc79c08..de4a0402 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -80,6 +80,7 @@ const ( CapPriceSpot = "price.spot" CapApiPassthrough = "api.passthrough" CapCloudLink = "cloud.link" + CapCmdExchange = "cmd.exchange" ) // AllCapabilities is every capability name the registry defines. @@ -101,6 +102,7 @@ var AllCapabilities = []string{ CapPriceSpot, CapApiPassthrough, CapCloudLink, + CapCmdExchange, } // Scopes are one object axis and two verb axes. Roles are a projection over