Skip to content

Commit c9c7919

Browse files
frahlgclaude
andauthored
fix(appenroll): let the last phone go, at the box (#880)
The box's own page could remove phones down to the last one and then stop: the row drew "add another before removing this one" where the Remove button belongs. A household that lost its only phone, or wanted to start clean, had no way back to an empty list from anywhere. The refusal guards a real lockout, but only through one door. A phone that empties the roster from the other side of the world leaves a box nobody can administer, and nothing remote can mend it. Somebody at the box is in a different position: the same page mints a fresh pairing code, so the way back in is the button above the list. Revoke now takes the presence of whoever is asking and refuses the last owner over a session only. The decision stays in enrollment rather than moving up to a screen; what is new is that the box is told which door the request came through. That comes from the caller it named at admission — LAN or kind "app" — never from a field a remote caller could set. Demotion is unchanged at both doors: removing the last phone leaves a list somebody at the box can fill again, and demoting it leaves phones that can only look. The device list follows. The last owner's row carries the same Remove, and the warning lands at the press: a home down to one phone is told nothing will see or change it until a phone is paired again with a code from this page, and one with guests still paired is told those phones will be left able to look and change nothing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f29c21 commit c9c7919

12 files changed

Lines changed: 370 additions & 67 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
A household can get back to no phones paired, from the box's own page. Until now the last owner could not be removed at all: the device list drew a sentence where the Remove button goes — add another before removing this one — so a home that lost its only phone, or wanted to start clean, had no way down to an empty list from anywhere.
6+
7+
The refusal was right about one door and wrong about the other. What it guards against is a phone emptying the roster from anywhere in the world: remove the last owner over a session and nobody can administer the box, and nothing done remotely can mend that. Standing at the box is a different position. Whoever reads that page is in the building, and the same page mints a fresh pairing code — the way back in is the button above the list. Refusing there protected nothing and stranded the household the rule was written for.
8+
9+
So `appenroll.Revoke` now takes the presence of whoever is asking, and refuses the last owner over a session only. The decision stays in enrollment rather than moving up to a screen; what is new is that the box is told which door the request came through. That fact comes from the caller the box named when it admitted the request — a LAN caller is minted as a local owner, an app session carries the kind `app` — and never from a field a remote caller could set. Removing the last owner through the app is still `E_LAST_OWNER_PROTECTED`, the same sentence and the same code.
10+
11+
Stepping the last owner down is still refused at both doors, the box's own page included. Removing the last phone leaves a list somebody at the box can fill again; demoting it leaves a list of phones that can only look, which is the lockout rather than a way out of it.
12+
13+
The box's device list follows. The last owner's row carries the Remove button every other row has, and the warning lands where it belongs — at the press, saying what is on the other side of it. A household down to one phone is told that nothing will see or change this home until a phone is paired again, with a code from this page. One with guests still paired is told those phones will be left able to look and change nothing. Both are what happens, which the old sentence was not: it described a rule the box no longer keeps.

‎go/cmd/ftw/app_link.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -689,8 +689,11 @@ func (a *appLinkAPI) SetDeviceRole(id, role string) error {
689689
return appLinkError(a.enroll.SetRole(id, role))
690690
}
691691

692-
func (a *appLinkAPI) RevokeDevice(id string) error {
693-
key, err := a.enroll.Revoke(id)
692+
// RevokeDevice locks one phone out. atTheBox is the API's word for the door
693+
// the request came through, and it is the only thing that decides whether the
694+
// last owner may go; Presence is enrollment's word for the same fact.
695+
func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error {
696+
key, err := a.enroll.Revoke(id, appenroll.Presence(atTheBox))
694697
if err != nil {
695698
return appLinkError(err)
696699
}

‎go/internal/api/api_app_link.go‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@ type AppEnroller interface {
4747
SetDeviceRole(id, role string) error
4848
// RevokeDevice forgets one and tears down its live sessions. Returns
4949
// ErrUnknownAppDevice when no row carries the id, and
50-
// ErrLastAppOwnerProtected when it would leave the box with no owner.
51-
RevokeDevice(id string) error
50+
// ErrLastAppOwnerProtected when it would leave the box with no owner and
51+
// the caller is not standing at it.
52+
//
53+
// atTheBox is that last fact, and it is the caller's to establish rather
54+
// than the request's to claim: it comes from which door the request came
55+
// through, never from anything a remote caller can put in one.
56+
RevokeDevice(id string, atTheBox bool) error
5257
}
5358

5459
// AppDevice is one paired phone. The id is a short prefix of its key —
@@ -61,8 +66,10 @@ type AppDevice struct {
6166
// list rather than on a screen of its own, because a guest's phone is a
6267
// paired phone and removing one is the same action as locking one out.
6368
Role string `json:"role"`
64-
// LastOwner marks the row that cannot be removed or demoted, so the page
65-
// can say why before somebody presses the button.
69+
// LastOwner marks the only phone left that can change anything here. It
70+
// cannot be stepped down, and cannot be removed over a session — so a
71+
// screen can say what it is before somebody presses a button. The box's
72+
// own page may remove it, and warns instead of refusing.
6673
LastOwner bool `json:"last_owner,omitempty"`
6774
}
6875

@@ -232,6 +239,17 @@ func (s *Server) handleAppLinkDevices(w http.ResponseWriter, r *http.Request) {
232239
writeJSON(w, http.StatusOK, map[string]any{"devices": s.deps.AppEnroll.Devices()})
233240
}
234241

242+
// handleAppLinkDeviceRevoke locks one phone out, guest or owner.
243+
//
244+
// The last owner is the one row where the two doors part. Over a session the
245+
// removal is refused, because a phone that empties the roster from anywhere in
246+
// the world leaves a box nobody can administer and no remote way to mend it.
247+
// At the box it goes through: the person pressing the button is in the
248+
// building, and the same page mints a fresh pairing code, so refusing there
249+
// protects nothing and only strands a household that has lost the phone.
250+
//
251+
// Which door it was comes from the caller the box named at admission, never
252+
// from the request — there is no field a remote caller could set to claim it.
235253
func (s *Server) handleAppLinkDeviceRevoke(w http.ResponseWriter, r *http.Request) {
236254
if !s.appLinkGate(w, r, appproto.ScopeMembersWrite) {
237255
return
@@ -241,7 +259,7 @@ func (s *Server) handleAppLinkDeviceRevoke(w http.ResponseWriter, r *http.Reques
241259
return
242260
}
243261
id := r.PathValue("id")
244-
if err := s.deps.AppEnroll.RevokeDevice(id); err != nil {
262+
if err := s.deps.AppEnroll.RevokeDevice(id, !appLinkOverSession(r)); err != nil {
245263
if errors.Is(err, ErrUnknownAppDevice) {
246264
writeAppLinkError(w, http.StatusNotFound, "that phone is no longer paired")
247265
return

‎go/internal/api/api_app_link_session_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ func (e *realEnroller) SetDeviceRole(id, role string) error {
7373
return enrollError(e.id.SetRole(id, role))
7474
}
7575

76-
func (e *realEnroller) RevokeDevice(id string) error {
77-
_, err := e.id.Revoke(id)
76+
func (e *realEnroller) RevokeDevice(id string, atTheBox bool) error {
77+
_, err := e.id.Revoke(id, appenroll.Presence(atTheBox))
7878
return enrollError(err)
7979
}
8080

‎go/internal/api/api_app_link_sharing_test.go‎

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"strings"
1616
"testing"
1717

18+
"github.com/srcfl/ftw/go/internal/apiauth"
1819
"github.com/srcfl/ftw/go/internal/appproto"
1920
)
2021

@@ -30,6 +31,23 @@ func localRequest(method, target, body string) *http.Request {
3031
return r
3132
}
3233

34+
// sessionRequest is what the passthrough hands these handlers for an owner's
35+
// phone: a request built to look local on purpose — Host localhost, loopback
36+
// address, no forwarding header — carrying the caller the box named when the
37+
// session was admitted. An address alone cannot tell it from the page in the
38+
// kitchen, which is exactly why these handlers ask the caller instead.
39+
func sessionRequest(method, target, body string) *http.Request {
40+
r := localRequest(method, target, body)
41+
r.Host = "localhost"
42+
r.RemoteAddr = "127.0.0.1:0"
43+
return r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{
44+
Subject: apiauth.KindApp + ":aaaa1111",
45+
Kind: apiauth.KindApp,
46+
Role: apiauth.RoleOwner,
47+
Scopes: apiauth.EveryScope(),
48+
}))
49+
}
50+
3351
// An invite is minted for the role that was asked for, and the answer names
3452
// that role back so the screen can say it in words above the code. A code
3553
// whose power is invisible is the one that gets read to the wrong person.
@@ -232,16 +250,22 @@ func TestARoleIsChangedFromTheDeviceList(t *testing.T) {
232250

233251
// The last owner is protected, and the refusal has to be one a person can act
234252
// on: 409 with a sentence saying what to do first, not a 500.
253+
//
254+
// Removing it is refused over the session, where the household could be
255+
// anywhere. Stepping it down is refused at either door — see the box's own
256+
// removal test below for where the two part and why.
235257
func TestTheLastOwnerIsRefusedWithSomethingToDo(t *testing.T) {
236258
for _, c := range []struct {
237259
name string
238260
serve func(*Server, http.ResponseWriter, *http.Request)
239261
req *http.Request
240262
}{
241-
{"demote", (*Server).handleAppLinkDeviceRole,
263+
{"demote at the box", (*Server).handleAppLinkDeviceRole,
242264
localRequest(http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`)},
243-
{"remove", (*Server).handleAppLinkDeviceRevoke,
244-
localRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")},
265+
{"demote from the app", (*Server).handleAppLinkDeviceRole,
266+
sessionRequest(http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`)},
267+
{"remove from the app", (*Server).handleAppLinkDeviceRevoke,
268+
sessionRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")},
245269
} {
246270
t.Run(c.name, func(t *testing.T) {
247271
enroll := &stubEnroller{lastOwner: true}
@@ -281,6 +305,66 @@ func TestTheLastOwnerIsRefusedWithSomethingToDo(t *testing.T) {
281305
}
282306
}
283307

308+
// At the box, the last phone comes off the list.
309+
//
310+
// The refusal above guards against a phone emptying the roster from anywhere
311+
// in the world, leaving a box nobody can administer and no remote way to mend
312+
// it. Whoever is standing at the box has the mend in front of them — the same
313+
// page shows a new pairing code — so refusing there protects nothing, and it
314+
// stranded every household that lost its only phone.
315+
//
316+
// The handler tells the two apart by the caller the box named, not by an
317+
// address and not by anything in the request. A remote caller has no field to
318+
// set that would get it here.
319+
func TestTheLastPhoneIsRemovedAtTheBox(t *testing.T) {
320+
enroll := &stubEnroller{lastOwner: true}
321+
s := New(&Deps{AppEnroll: enroll})
322+
323+
w := httptest.NewRecorder()
324+
r := localRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")
325+
r.SetPathValue("id", "aaaa1111")
326+
s.handleAppLinkDeviceRevoke(w, r)
327+
328+
if w.Code != http.StatusOK {
329+
t.Fatalf("got %d, want 200 — a household at its own box cannot start clean: %s",
330+
w.Code, w.Body.String())
331+
}
332+
if enroll.revoked != 1 {
333+
t.Fatalf("the phone was answered for but never removed: %+v", enroll)
334+
}
335+
if !enroll.revokedAtTheBox {
336+
t.Fatal("the handler told enrolment the caller was remote; the last owner would survive a real box")
337+
}
338+
}
339+
340+
// Removing a phone that is not the last owner is the same at both doors.
341+
// Nothing about this change touches the ordinary case.
342+
func TestRemovingAPhoneThatIsNotTheLastOwnerIsUnchanged(t *testing.T) {
343+
for _, c := range []struct {
344+
name string
345+
req *http.Request
346+
}{
347+
{"at the box", localRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")},
348+
{"from the app", sessionRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")},
349+
} {
350+
t.Run(c.name, func(t *testing.T) {
351+
enroll := &stubEnroller{}
352+
s := New(&Deps{AppEnroll: enroll})
353+
354+
w := httptest.NewRecorder()
355+
c.req.SetPathValue("id", "aaaa1111")
356+
s.handleAppLinkDeviceRevoke(w, c.req)
357+
358+
if w.Code != http.StatusOK {
359+
t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
360+
}
361+
if enroll.revoked != 1 {
362+
t.Fatalf("the phone was answered for but never removed: %+v", enroll)
363+
}
364+
})
365+
}
366+
}
367+
284368
// A role the registry does not define is a 400, not a 500 and not a silent
285369
// success. The list of roles lives in contract/registry.yaml and the answer
286370
// has to come from there rather than from a literal on this floor.

‎go/internal/api/api_app_link_test.go‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ type stubEnroller struct {
1717
mintedRole string
1818
roleSet string
1919
err error
20-
// lastOwner makes the stub refuse to remove or demote its one row, the
21-
// way appenroll does when it is the only owner left.
20+
// lastOwner makes the stub refuse to demote its one row, and to remove it
21+
// for anyone who is not standing at the box — the way appenroll does when
22+
// it is the only owner left.
2223
lastOwner bool
24+
// revokedAtTheBox records the door the last removal came through, so a
25+
// test can prove the handler passed the fact rather than a constant.
26+
revokedAtTheBox bool
2327
}
2428

2529
func (s *stubEnroller) MintPairingCode(role string) ([]byte, time.Time, error) {
@@ -68,14 +72,15 @@ func (s *stubEnroller) SetDeviceRole(id, role string) error {
6872
return nil
6973
}
7074

71-
func (s *stubEnroller) RevokeDevice(id string) error {
75+
func (s *stubEnroller) RevokeDevice(id string, atTheBox bool) error {
7276
if id != "aaaa1111" {
7377
return ErrUnknownAppDevice
7478
}
75-
if s.lastOwner {
79+
if s.lastOwner && !atTheBox {
7680
return ErrLastAppOwnerProtected
7781
}
7882
s.revoked++
83+
s.revokedAtTheBox = atTheBox
7984
return nil
8085
}
8186

‎go/internal/appenroll/enroll.go‎

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ var (
9292
ErrBadPairing = errors.New("appenroll: pairing code is not valid")
9393
// ErrUnknownRole is a role that is not in contract/registry.yaml.
9494
ErrUnknownRole = errors.New("appenroll: no such role")
95-
// ErrLastOwnerProtected is an attempt to remove or demote the only owner.
95+
// ErrLastOwnerProtected is an attempt to demote the only owner, or to
96+
// remove it from somewhere other than the box itself.
9697
ErrLastOwnerProtected = errors.New("appenroll: that is the only owner")
9798
)
9899

@@ -156,8 +157,10 @@ type DeviceInfo struct {
156157
// and locking out are the same screen: a household that cannot see which
157158
// phone is a guest cannot decide which one to remove.
158159
Role string
159-
// LastOwner marks the row that cannot be removed or demoted, so the
160-
// screen can say why before somebody presses the button rather than after.
160+
// LastOwner marks the only phone left that can change anything here. It
161+
// cannot be stepped down at either door, and cannot be removed over a
162+
// session — so a screen can say what it is before somebody presses a
163+
// button rather than after.
161164
LastOwner bool
162165
}
163166

@@ -659,6 +662,12 @@ func (i *Identity) GrantFor(id string) (Grant, bool) {
659662
// Refused for the only owner, here rather than in the API layer — otherwise
660663
// the box's own web UI could do what the app cannot, and the protection would
661664
// be a property of the screen instead of a property of the box.
665+
//
666+
// Refused at both doors, the box's own page included. Removing the last owner
667+
// there is allowed — see Revoke — because a household whose phone is gone has
668+
// to be able to empty the list and start again. Stepping the last owner down
669+
// leaves that same box carrying a list of phones that can only look, which is
670+
// the shape of the problem rather than a way out of it.
662671
func (i *Identity) SetRole(id, role string) error {
663672
if err := knownRole(role); err != nil {
664673
return err
@@ -714,6 +723,21 @@ func (i *Identity) Devices() []DeviceInfo {
714723
// ErrUnknownDevice is a revoke aimed at an id no paired phone carries.
715724
var ErrUnknownDevice = errors.New("appenroll: no such device")
716725

726+
// Presence says whether whoever is asking is standing at the box.
727+
//
728+
// It is not a role and it is not a scope: an owner's phone holds every scope
729+
// this box grants and still cannot prove where it is. Presence is the one
730+
// thing only the LAN door establishes, and here it settles exactly one
731+
// question — whether the last owner may go.
732+
type Presence bool
733+
734+
const (
735+
// AtTheBox is a request off the box's own page, on the home network.
736+
AtTheBox Presence = true
737+
// OverASession is an enrolled phone, which could be anywhere on earth.
738+
OverASession Presence = false
739+
)
740+
717741
// Revoke forgets a phone by its device id and returns the full key, so the
718742
// caller can also tear down any session that key is running right now. The
719743
// next handshake from it meets ErrNoPairing like any stranger's.
@@ -722,17 +746,27 @@ var ErrUnknownDevice = errors.New("appenroll: no such device")
722746
// Sharing does not get its own gesture with its own bugs: a shared phone is a
723747
// row in the same list, removed by the same button.
724748
//
725-
// Refused for the only owner. A household can always pair a new owner at the
726-
// box and then remove the old one; what it cannot do is reduce itself to a set
727-
// of phones that can only look.
728-
func (i *Identity) Revoke(id string) ([]byte, error) {
749+
// Refused for the only owner over a session, and allowed at the box.
750+
//
751+
// What the refusal guards against is a phone locking a household out of its
752+
// own box from anywhere in the world: remove the last owner remotely and
753+
// nobody can administer the box, and nothing done remotely can undo that.
754+
// Presence is the way back — whoever is standing at the box can mint a fresh
755+
// owner's code on the same page the Remove button is on. So at the box the
756+
// refusal protects nothing, and all it does is stop a household emptying the
757+
// list after a phone is lost or starting clean.
758+
//
759+
// The decision stays here rather than in the API layer. What is new is that
760+
// the box is told which door the request came through; which screen drew the
761+
// button still decides nothing.
762+
func (i *Identity) Revoke(id string, from Presence) ([]byte, error) {
729763
i.mu.Lock()
730764
fullKey, meta := i.findLocked(id)
731765
if meta == nil {
732766
i.mu.Unlock()
733767
return nil, ErrUnknownDevice
734768
}
735-
if meta.role == apiauth.RoleOwner && i.ownerCountLocked() == 1 {
769+
if from == OverASession && meta.role == apiauth.RoleOwner && i.ownerCountLocked() == 1 {
736770
i.mu.Unlock()
737771
return nil, ErrLastOwnerProtected
738772
}

‎go/internal/appenroll/enroll_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
374374

375375
// Revoke by row id: the key comes back so live sessions can be dropped,
376376
// and the next handshake meets ErrNoPairing like any stranger's.
377-
key, err := id.Revoke(devices[1].ID)
377+
key, err := id.Revoke(devices[1].ID, OverASession)
378378
if err != nil {
379379
t.Fatal(err)
380380
}
@@ -384,7 +384,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
384384
if _, err := id.Authorise(second, nil); !errors.Is(err, ErrNoPairing) {
385385
t.Fatalf("a revoked key reconnected: %v", err)
386386
}
387-
if _, err := id.Revoke("nosuchid"); !errors.Is(err, ErrUnknownDevice) {
387+
if _, err := id.Revoke("nosuchid", OverASession); !errors.Is(err, ErrUnknownDevice) {
388388
t.Fatalf("revoking a ghost: %v", err)
389389
}
390390

@@ -513,7 +513,7 @@ func TestARevokedDeviceHasNoLiveGrant(t *testing.T) {
513513
if live, ok := id.GrantFor(guest.DeviceID); !ok || live.Epoch != guest.Epoch {
514514
t.Fatalf("grant before the revoke = %+v/%v, want %+v/live", live, ok, guest)
515515
}
516-
if _, err := id.Revoke(guest.DeviceID); err != nil {
516+
if _, err := id.Revoke(guest.DeviceID, OverASession); err != nil {
517517
t.Fatalf("Revoke: %v", err)
518518
}
519519
if live, ok := id.GrantFor(guest.DeviceID); ok {

0 commit comments

Comments
 (0)