Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/watch-registry-names.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <object>.<read|write>.
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions go/internal/api/api_app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. "+
Expand Down
67 changes: 67 additions & 0 deletions go/internal/api/api_app_link_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions go/internal/apiauth/contract_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions go/protocol/contract_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading