detached 0818b8d

feat(rpc): serve ListPasskeys and RemovePasskey and list the passkeys feature

commit
0818b8d20e88f0631264c3a462f10a75350de29d
author
brice · · 2026-10-12 14:59 UTC
parent
a2a22bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +236 -23
M internal/adapters/client/client_test.go +5 -2
@@ -140,9 +140,12 @@ func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
143- client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
143+ client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys,
146+ }))
147+ assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
148+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
146149 }))
147150 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148151 }
@@ -140,9 +140,12 @@ func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
143- client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))143+ client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys,
146+ }))
147+ assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
148+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
146 }))149 }))
147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))150 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148 }151 }
M internal/adapters/client/features.go +5 -1
@@ -22,11 +22,15 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey
26+ // (`ermite web passkey`). A server lists it only when passkeys are
27+ // available (https public URL with a DNS host name).
28+ FeaturePasskeys = "passkeys"
2529 )
2630
2731 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2832 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
33+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys}
3034 }
3135
3236 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -22,11 +22,15 @@ const (
22 FeatureSigningKey = "signing_key"22 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).23 // FeatureSession is SessionService (`ermite web login`, `web logout`).
24 FeatureSession = "session"24 FeatureSession = "session"
25+ // FeaturePasskeys is SessionService.ListPasskeys and RemovePasskey
26+ // (`ermite web passkey`). A server lists it only when passkeys are
27+ // available (https public URL with a DNS host name).
28+ FeaturePasskeys = "passkeys"
25 )29 )
26 30
27 // UsedFeatures lists the features the CLI uses, in the order they are reported.31 // UsedFeatures lists the features the CLI uses, in the order they are reported.
28 func UsedFeatures() []string {32 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}33+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys}
30 }34 }
31 35
32 // MissingFeatures returns the features the CLI uses that have is lacking.36 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/rpc/api.go +16 -3
@@ -10,6 +10,7 @@ import (
1010 "context"
1111 "net/http"
1212 "net/netip"
13+ "slices"
1314
1415 "connectrpc.com/connect"
1516 "github.com/bnema/zerowrap"
@@ -25,9 +26,13 @@ import (
2526 const MaxRequestBytes = 1 << 20
2627
2728 // Features are the optional capabilities of the contract that this server
28-// implements, as GetVersion lists them.
29+// always implements, as GetVersion lists them. FeaturePasskeys comes on top
30+// when Config.Passkeys is set.
2931 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
3032
33+// FeaturePasskeys is listed by GetVersion when passkeys are available.
34+const FeaturePasskeys = "passkeys"
35+
3136 // Config configures the API.
3237 type Config struct {
3338 Auth ports.AuthService
@@ -50,6 +55,10 @@ type Config struct {
5055 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
5156 // the interface is not nil, and the service would be served and called).
5257 Sessions ports.SessionService
58+ // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
59+ // not available (the RPCs answer failed_precondition and GetVersion does
60+ // not list "passkeys"). Same rule as Sessions about typed nil pointers.
61+ Passkeys ports.PasskeyService
5362 }
5463
5564 // API is the set of Connect handlers of the contract.
@@ -76,10 +85,14 @@ func New(cfg Config) *API {
7685 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
7786 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
7887 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
79- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
88+ features := slices.Clone(Features)
89+ if cfg.Passkeys != nil {
90+ features = append(features, FeaturePasskeys)
91+ }
92+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
8093 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
8194 if cfg.Sessions != nil {
82- mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
95+ mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
8396 }
8497 return &API{handler: mux}
8598 }
@@ -10,6 +10,7 @@ import (
10 "context"10 "context"
11 "net/http"11 "net/http"
12 "net/netip"12 "net/netip"
13+ "slices"
13 14
14 "connectrpc.com/connect"15 "connectrpc.com/connect"
15 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
@@ -25,9 +26,13 @@ import (
25 const MaxRequestBytes = 1 << 2026 const MaxRequestBytes = 1 << 20
26 27
27 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
28-// implements, as GetVersion lists them.29+// always implements, as GetVersion lists them. FeaturePasskeys comes on top
30+// when Config.Passkeys is set.
29 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}31 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
30 32
33+// FeaturePasskeys is listed by GetVersion when passkeys are available.
34+const FeaturePasskeys = "passkeys"
35+
31 // Config configures the API.36 // Config configures the API.
32 type Config struct {37 type Config struct {
33 Auth ports.AuthService38 Auth ports.AuthService
@@ -50,6 +55,10 @@ type Config struct {
50 // Leave it unset rather than assigning a typed nil pointer (a nil *T in55 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
51 // the interface is not nil, and the service would be served and called).56 // the interface is not nil, and the service would be served and called).
52 Sessions ports.SessionService57 Sessions ports.SessionService
58+ // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
59+ // not available (the RPCs answer failed_precondition and GetVersion does
60+ // not list "passkeys"). Same rule as Sessions about typed nil pointers.
61+ Passkeys ports.PasskeyService
53 }62 }
54 63
55 // API is the set of Connect handlers of the contract.64 // API is the set of Connect handlers of the contract.
@@ -76,10 +85,14 @@ func New(cfg Config) *API {
76 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))85 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
77 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))86 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
78 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))87 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
79- mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))88+ features := slices.Clone(Features)
89+ if cfg.Passkeys != nil {
90+ features = append(features, FeaturePasskeys)
91+ }
92+ mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
80 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))93 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
81 if cfg.Sessions != nil {94 if cfg.Sessions != nil {
82- mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))95+ mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
83 }96 }
84 return &API{handler: mux}97 return &API{handler: mux}
85 }98 }
M internal/adapters/rpc/convert.go +17 -0
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "encoding/base64"
67 "fmt"
78 "time"
89
@@ -403,3 +404,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility
403404 }
404405 return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
405406 }
407+
408+// passkeysToPB converts passkeys; the credential id is base64url without
409+// padding, the form RemovePasskey takes back. Key material never leaves.
410+func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
411+ out := make([]*pb.Passkey, 0, len(list))
412+ for _, p := range list {
413+ out = append(out, &pb.Passkey{
414+ Id: base64.RawURLEncoding.EncodeToString(p.ID),
415+ Name: p.Name,
416+ CreatedAt: timestamp(p.CreatedAt),
417+ LastUsedAt: timestamp(p.LastUsedAt),
418+ Synced: p.BackupState,
419+ })
420+ }
421+ return out
422+}
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "encoding/base64"
6 "fmt"7 "fmt"
7 "time"8 "time"
8 9
@@ -403,3 +404,19 @@ func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility
403 }404 }
404 return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}405 return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
405 }406 }
407+
408+// passkeysToPB converts passkeys; the credential id is base64url without
409+// padding, the form RemovePasskey takes back. Key material never leaves.
410+func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
411+ out := make([]*pb.Passkey, 0, len(list))
412+ for _, p := range list {
413+ out = append(out, &pb.Passkey{
414+ Id: base64.RawURLEncoding.EncodeToString(p.ID),
415+ Name: p.Name,
416+ CreatedAt: timestamp(p.CreatedAt),
417+ LastUsedAt: timestamp(p.LastUsedAt),
418+ Synced: p.BackupState,
419+ })
420+ }
421+ return out
422+}
M internal/adapters/rpc/server.go +4 -3
@@ -14,12 +14,13 @@ import (
1414
1515 // serverService implements ermiteconnect.ServerServiceHandler.
1616 type serverService struct {
17- version string
18- key ports.SigningKeyService
17+ version string
18+ features []string
19+ key ports.SigningKeyService
1920 }
2021
2122 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
22- return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil
23+ return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil
2324 }
2425
2526 // GetSigningKey returns the public key of the commits ermite signs. It needs
@@ -14,12 +14,13 @@ import (
14 14
15 // serverService implements ermiteconnect.ServerServiceHandler.15 // serverService implements ermiteconnect.ServerServiceHandler.
16 type serverService struct {16 type serverService struct {
17- version string17+ version string
18- key ports.SigningKeyService18+ features []string
19+ key ports.SigningKeyService
19 }20 }
20 21
21 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {22 func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) {
22- return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(Features)}), nil23+ return connect.NewResponse(&pb.GetVersionResponse{Version: s.version, Features: slices.Clone(s.features)}), nil
23 }24 }
24 25
25 // GetSigningKey returns the public key of the commits ermite signs. It needs26 // GetSigningKey returns the public key of the commits ermite signs. It needs
M internal/adapters/rpc/server_test.go +25 -1
@@ -4,20 +4,24 @@ package rpc
44
55 import (
66 "context"
7+ "slices"
78 "testing"
89
10+ "net/http/httptest"
11+
912 "connectrpc.com/connect"
1013 "github.com/stretchr/testify/assert"
1114 "github.com/stretchr/testify/require"
1215
1316 pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1418 "bnema.dev/ermite/internal/domain"
1519 "bnema.dev/ermite/internal/ports/mocks"
1620 )
1721
1822 func TestServerServiceGetVersion(t *testing.T) {
1923 t.Parallel()
20- svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)}
24+ svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)}
2125 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
2226 require.NoError(t, err)
2327 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())
@@ -43,3 +47,23 @@ func TestServerServiceGetSigningKey(t *testing.T) {
4347 assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail())
4448 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())
4549 }
50+
51+// GetVersion lists "passkeys" only when the API is built with passkeys.
52+func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
53+ t.Parallel()
54+ list := func(cfg Config) []string {
55+ cfg.Sessions = mocks.NewSessionService(t)
56+ cfg.SigningKey = mocks.NewSigningKeyService(t)
57+ srv := httptest.NewServer(New(cfg).Handler())
58+ t.Cleanup(srv.Close)
59+ c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
60+ resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
61+ require.NoError(t, err)
62+ return resp.Msg.GetFeatures()
63+ }
64+ assert.Equal(t, Features, list(Config{}))
65+ assert.NotContains(t, list(Config{}), "passkeys")
66+ got := list(Config{Passkeys: mocks.NewPasskeyService(t)})
67+ assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)
68+ assert.NotContains(t, Features, "passkeys", "the shared list is not modified")
69+}
@@ -4,20 +4,24 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7+ "slices"
7 "testing"8 "testing"
8 9
10+ "net/http/httptest"
11+
9 "connectrpc.com/connect"12 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
12 15
13 pb "bnema.dev/ermite-api/gen/ermite"16 pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
14 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
16 )20 )
17 21
18 func TestServerServiceGetVersion(t *testing.T) {22 func TestServerServiceGetVersion(t *testing.T) {
19 t.Parallel()23 t.Parallel()
20- svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)}24+ svc := &serverService{version: "v1.2.3", features: slices.Clone(Features), key: mocks.NewSigningKeyService(t)}
21 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))25 resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
22 require.NoError(t, err)26 require.NoError(t, err)
23 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())27 assert.Equal(t, "v1.2.3", resp.Msg.GetVersion())
@@ -43,3 +47,23 @@ func TestServerServiceGetSigningKey(t *testing.T) {
43 assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail())47 assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail())
44 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())48 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())
45 }49 }
50+
51+// GetVersion lists "passkeys" only when the API is built with passkeys.
52+func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
53+ t.Parallel()
54+ list := func(cfg Config) []string {
55+ cfg.Sessions = mocks.NewSessionService(t)
56+ cfg.SigningKey = mocks.NewSigningKeyService(t)
57+ srv := httptest.NewServer(New(cfg).Handler())
58+ t.Cleanup(srv.Close)
59+ c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
60+ resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
61+ require.NoError(t, err)
62+ return resp.Msg.GetFeatures()
63+ }
64+ assert.Equal(t, Features, list(Config{}))
65+ assert.NotContains(t, list(Config{}), "passkeys")
66+ got := list(Config{Passkeys: mocks.NewPasskeyService(t)})
67+ assert.Equal(t, append(slices.Clone(Features), "passkeys"), got)
68+ assert.NotContains(t, Features, "passkeys", "the shared list is not modified")
69+}
M internal/adapters/rpc/session.go +39 -2
@@ -4,16 +4,28 @@ package rpc
44
55 import (
66 "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
710
811 "connectrpc.com/connect"
912
1013 pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
1115 "bnema.dev/ermite/internal/ports"
1216 )
1317
18+// errPasskeysUnavailable is what the passkey RPCs answer when the server runs
19+// without passkeys (no https public URL with a DNS host name).
20+var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
21+
1422 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
15-// never calls it: sessions come from the link, and the API ignores cookies.
16-type sessionService struct{ sessions ports.SessionService }
23+// never calls it: sessions come from the link or a passkey, and the API
24+// ignores cookies. passkeys is nil when passkeys are not available.
25+type sessionService struct {
26+ sessions ports.SessionService
27+ passkeys ports.PasskeyService
28+}
1729
1830 func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
1931 link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))
@@ -29,3 +41,28 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque
2941 }
3042 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
3143 }
44+
45+func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
46+ if s.passkeys == nil {
47+ return nil, errPasskeysUnavailable
48+ }
49+ list, err := s.passkeys.List(ctx, caller(ctx))
50+ if err != nil {
51+ return nil, connectError(ctx, err)
52+ }
53+ return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil
54+}
55+
56+func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
57+ if s.passkeys == nil {
58+ return nil, errPasskeysUnavailable
59+ }
60+ id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId())
61+ if err != nil || len(id) == 0 {
62+ return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid))
63+ }
64+ if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil {
65+ return nil, connectError(ctx, err)
66+ }
67+ return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil
68+}
@@ -4,16 +4,28 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
7 10
8 "connectrpc.com/connect"11 "connectrpc.com/connect"
9 12
10 pb "bnema.dev/ermite-api/gen/ermite"13 pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
12 )16 )
13 17
18+// errPasskeysUnavailable is what the passkey RPCs answer when the server runs
19+// without passkeys (no https public URL with a DNS host name).
20+var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
21+
14 // sessionService implements ermiteconnect.SessionServiceHandler. The browser22 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
15-// never calls it: sessions come from the link, and the API ignores cookies.23+// never calls it: sessions come from the link or a passkey, and the API
16-type sessionService struct{ sessions ports.SessionService }24+// ignores cookies. passkeys is nil when passkeys are not available.
25+type sessionService struct {
26+ sessions ports.SessionService
27+ passkeys ports.PasskeyService
28+}
17 29
18 func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {30 func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) {
19 link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))31 link, err := s.sessions.CreateLoginLink(ctx, caller(ctx))
@@ -29,3 +41,28 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque
29 }41 }
30 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil42 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
31 }43 }
44+
45+func (s *sessionService) ListPasskeys(ctx context.Context, _ *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
46+ if s.passkeys == nil {
47+ return nil, errPasskeysUnavailable
48+ }
49+ list, err := s.passkeys.List(ctx, caller(ctx))
50+ if err != nil {
51+ return nil, connectError(ctx, err)
52+ }
53+ return connect.NewResponse(&pb.ListPasskeysResponse{Passkeys: passkeysToPB(list)}), nil
54+}
55+
56+func (s *sessionService) RemovePasskey(ctx context.Context, req *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
57+ if s.passkeys == nil {
58+ return nil, errPasskeysUnavailable
59+ }
60+ id, err := base64.RawURLEncoding.DecodeString(req.Msg.GetId())
61+ if err != nil || len(id) == 0 {
62+ return nil, connectError(ctx, fmt.Errorf("%w: id is not a base64url credential id", domain.ErrInvalid))
63+ }
64+ if err := s.passkeys.Remove(ctx, caller(ctx), id); err != nil {
65+ return nil, connectError(ctx, err)
66+ }
67+ return connect.NewResponse(&pb.RemovePasskeyResponse{}), nil
68+}
M internal/adapters/rpc/session_test.go +97 -2
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "encoding/base64"
67 "testing"
78 "time"
89
@@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) {
3536 sessions := mocks.NewSessionService(t)
3637 sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()).
3738 Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once()
38- resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
39+ resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
3940 if tt.wantCode != 0 {
4041 requireCode(t, tt.wantCode, err)
4142 return
@@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
6263 t.Parallel()
6364 sessions := mocks.NewSessionService(t)
6465 sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once()
65- _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
66+ _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
6667 if tt.wantCode != 0 {
6768 requireCode(t, tt.wantCode, err)
6869 return
@@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
7172 })
7273 }
7374 }
75+
76+func TestSessionServiceListPasskeys(t *testing.T) {
77+ t.Parallel()
78+ created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
79+ used := created.Add(time.Hour)
80+ tests := []struct {
81+ name string
82+ list []domain.Passkey
83+ usecase error
84+ wantCode connect.Code
85+ }{
86+ {name: "none", list: nil},
87+ {name: "two", list: []domain.Passkey{
88+ {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true},
89+ {ID: []byte("hw"), Name: "key", CreatedAt: created},
90+ }},
91+ {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
92+ {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ t.Parallel()
97+ passkeys := mocks.NewPasskeyService(t)
98+ passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
99+ resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
100+ if tt.wantCode != 0 {
101+ requireCode(t, tt.wantCode, err)
102+ return
103+ }
104+ require.NoError(t, err)
105+ require.Len(t, resp.Msg.GetPasskeys(), len(tt.list))
106+ for i, want := range tt.list {
107+ got := resp.Msg.GetPasskeys()[i]
108+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId())
109+ assert.Equal(t, want.Name, got.GetName())
110+ assert.Equal(t, want.BackupState, got.GetSynced())
111+ assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime()))
112+ if want.LastUsedAt.IsZero() {
113+ assert.Nil(t, got.GetLastUsedAt(), "never used")
114+ } else {
115+ assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime()))
116+ }
117+ }
118+ })
119+ }
120+}
121+
122+func TestSessionServiceRemovePasskey(t *testing.T) {
123+ t.Parallel()
124+ id := []byte{0xfb, 0xff, 0x01, 'x'}
125+ enc := base64.RawURLEncoding.EncodeToString(id)
126+ tests := []struct {
127+ name string
128+ id string
129+ usecase error
130+ called bool
131+ wantCode connect.Code
132+ }{
133+ {name: "ok", id: enc, called: true},
134+ {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
135+ {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
136+ {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
137+ {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument},
138+ {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument},
139+ {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument},
140+ {name: "empty", id: "", wantCode: connect.CodeInvalidArgument},
141+ }
142+ for _, tt := range tests {
143+ t.Run(tt.name, func(t *testing.T) {
144+ t.Parallel()
145+ passkeys := mocks.NewPasskeyService(t)
146+ if tt.called {
147+ passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once()
148+ }
149+ _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id}))
150+ if tt.wantCode != 0 {
151+ requireCode(t, tt.wantCode, err)
152+ return
153+ }
154+ require.NoError(t, err)
155+ })
156+ }
157+}
158+
159+// Without passkeys the RPCs answer failed_precondition and call nothing.
160+func TestSessionServicePasskeysUnavailable(t *testing.T) {
161+ t.Parallel()
162+ svc := &sessionService{sessions: mocks.NewSessionService(t)}
163+ _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
164+ requireCode(t, connect.CodeFailedPrecondition, err)
165+ assert.Contains(t, err.Error(), "passkeys are not available on this server")
166+ _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"}))
167+ requireCode(t, connect.CodeFailedPrecondition, err)
168+}
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "encoding/base64"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -35,7 +36,7 @@ func TestSessionServiceCreateLoginLink(t *testing.T) {
35 sessions := mocks.NewSessionService(t)36 sessions := mocks.NewSessionService(t)
36 sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()).37 sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()).
37 Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once()38 Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once()
38- resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))39+ resp, err := (&sessionService{sessions: sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
39 if tt.wantCode != 0 {40 if tt.wantCode != 0 {
40 requireCode(t, tt.wantCode, err)41 requireCode(t, tt.wantCode, err)
41 return42 return
@@ -62,7 +63,7 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
62 t.Parallel()63 t.Parallel()
63 sessions := mocks.NewSessionService(t)64 sessions := mocks.NewSessionService(t)
64 sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once()65 sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once()
65- _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))66+ _, err := (&sessionService{sessions: sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
66 if tt.wantCode != 0 {67 if tt.wantCode != 0 {
67 requireCode(t, tt.wantCode, err)68 requireCode(t, tt.wantCode, err)
68 return69 return
@@ -71,3 +72,97 @@ func TestSessionServiceSignOutEverywhere(t *testing.T) {
71 })72 })
72 }73 }
73 }74 }
75+
76+func TestSessionServiceListPasskeys(t *testing.T) {
77+ t.Parallel()
78+ created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
79+ used := created.Add(time.Hour)
80+ tests := []struct {
81+ name string
82+ list []domain.Passkey
83+ usecase error
84+ wantCode connect.Code
85+ }{
86+ {name: "none", list: nil},
87+ {name: "two", list: []domain.Passkey{
88+ {ID: []byte{0xfb, 0xff, 0x01}, Name: "phone", PublicKey: []byte("secret"), CreatedAt: created, LastUsedAt: used, BackupState: true},
89+ {ID: []byte("hw"), Name: "key", CreatedAt: created},
90+ }},
91+ {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
92+ {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ t.Parallel()
97+ passkeys := mocks.NewPasskeyService(t)
98+ passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
99+ resp, err := (&sessionService{passkeys: passkeys}).ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
100+ if tt.wantCode != 0 {
101+ requireCode(t, tt.wantCode, err)
102+ return
103+ }
104+ require.NoError(t, err)
105+ require.Len(t, resp.Msg.GetPasskeys(), len(tt.list))
106+ for i, want := range tt.list {
107+ got := resp.Msg.GetPasskeys()[i]
108+ assert.Equal(t, base64.RawURLEncoding.EncodeToString(want.ID), got.GetId())
109+ assert.Equal(t, want.Name, got.GetName())
110+ assert.Equal(t, want.BackupState, got.GetSynced())
111+ assert.True(t, want.CreatedAt.Equal(got.GetCreatedAt().AsTime()))
112+ if want.LastUsedAt.IsZero() {
113+ assert.Nil(t, got.GetLastUsedAt(), "never used")
114+ } else {
115+ assert.True(t, want.LastUsedAt.Equal(got.GetLastUsedAt().AsTime()))
116+ }
117+ }
118+ })
119+ }
120+}
121+
122+func TestSessionServiceRemovePasskey(t *testing.T) {
123+ t.Parallel()
124+ id := []byte{0xfb, 0xff, 0x01, 'x'}
125+ enc := base64.RawURLEncoding.EncodeToString(id)
126+ tests := []struct {
127+ name string
128+ id string
129+ usecase error
130+ called bool
131+ wantCode connect.Code
132+ }{
133+ {name: "ok", id: enc, called: true},
134+ {name: "unknown id", id: enc, called: true, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
135+ {name: "not the owner", id: enc, called: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
136+ {name: "anonymous", id: enc, called: true, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
137+ {name: "not base64url", id: "not base64!", wantCode: connect.CodeInvalidArgument},
138+ {name: "padded base64", id: base64.URLEncoding.EncodeToString(id), wantCode: connect.CodeInvalidArgument},
139+ {name: "standard alphabet", id: "+/8B", wantCode: connect.CodeInvalidArgument},
140+ {name: "empty", id: "", wantCode: connect.CodeInvalidArgument},
141+ }
142+ for _, tt := range tests {
143+ t.Run(tt.name, func(t *testing.T) {
144+ t.Parallel()
145+ passkeys := mocks.NewPasskeyService(t)
146+ if tt.called {
147+ passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(tt.usecase).Once()
148+ }
149+ _, err := (&sessionService{passkeys: passkeys}).RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: tt.id}))
150+ if tt.wantCode != 0 {
151+ requireCode(t, tt.wantCode, err)
152+ return
153+ }
154+ require.NoError(t, err)
155+ })
156+ }
157+}
158+
159+// Without passkeys the RPCs answer failed_precondition and call nothing.
160+func TestSessionServicePasskeysUnavailable(t *testing.T) {
161+ t.Parallel()
162+ svc := &sessionService{sessions: mocks.NewSessionService(t)}
163+ _, err := svc.ListPasskeys(ownerCtx(), connect.NewRequest(&pb.ListPasskeysRequest{}))
164+ requireCode(t, connect.CodeFailedPrecondition, err)
165+ assert.Contains(t, err.Error(), "passkeys are not available on this server")
166+ _, err = svc.RemovePasskey(ownerCtx(), connect.NewRequest(&pb.RemovePasskeyRequest{Id: "AQ"}))
167+ requireCode(t, connect.CodeFailedPrecondition, err)
168+}
M internal/app/cli_e2e_test.go +7 -4
@@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) {
309309 res = c.cli(t, "", "repo", "list", "--json").ok(t)
310310 assert.JSONEq(t, `[]`, res.stdout)
311311
312- // version: no token needed, nothing missing.
312+ // version: no token needed. This server runs on http://127.0.0.1, where
313+ // passkeys are not available: they are the only feature missing.
313314 res = c.cli(t, "", "version", "--json").ok(t)
314- assert.Empty(t, res.stderr, "no missing-feature warning")
315+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
316+ assert.Contains(t, res.stderr, "the server does not support passkeys")
315317 type versionOut struct {
316318 Client struct{ Version string }
317319 Server struct {
@@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
325327 assert.Equal(t, c.url, ver.Server.URL)
326328 assert.NotEmpty(t, ver.Server.Version)
327329 assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
328- assert.Equal(t, []string{}, ver.Server.MissingFeatures)
330+ assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
329331 stdout, stderr, code = c.bin(t, secret, "version")
330332 require.Equal(t, 0, code, stderr)
331333 assert.Contains(t, stdout, "server")
332- assert.Empty(t, stderr)
334+ assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
335+ assert.Contains(t, stderr, "the server does not support passkeys")
333336
334337 // Tokens: create a second one, list, revoke it, see it stop working.
335338 // (through the binary: the in-process runs use a fixed clock)
@@ -309,9 +309,11 @@ func TestAC6CLIEndToEnd(t *testing.T) {
309 res = c.cli(t, "", "repo", "list", "--json").ok(t)309 res = c.cli(t, "", "repo", "list", "--json").ok(t)
310 assert.JSONEq(t, `[]`, res.stdout)310 assert.JSONEq(t, `[]`, res.stdout)
311 311
312- // version: no token needed, nothing missing.312+ // version: no token needed. This server runs on http://127.0.0.1, where
313+ // passkeys are not available: they are the only feature missing.
313 res = c.cli(t, "", "version", "--json").ok(t)314 res = c.cli(t, "", "version", "--json").ok(t)
314- assert.Empty(t, res.stderr, "no missing-feature warning")315+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
316+ assert.Contains(t, res.stderr, "the server does not support passkeys")
315 type versionOut struct {317 type versionOut struct {
316 Client struct{ Version string }318 Client struct{ Version string }
317 Server struct {319 Server struct {
@@ -325,11 +327,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
325 assert.Equal(t, c.url, ver.Server.URL)327 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)328 assert.NotEmpty(t, ver.Server.Version)
327 assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)329 assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
328- assert.Equal(t, []string{}, ver.Server.MissingFeatures)330+ assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")331 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)332 require.Equal(t, 0, code, stderr)
331 assert.Contains(t, stdout, "server")333 assert.Contains(t, stdout, "server")
332- assert.Empty(t, stderr)334+ assert.Equal(t, 1, strings.Count(stderr, "warning:"), stderr)
335+ assert.Contains(t, stderr, "the server does not support passkeys")
333 336
334 // Tokens: create a second one, list, revoke it, see it stop working.337 // Tokens: create a second one, list, revoke it, see it stop working.
335 // (through the binary: the in-process runs use a fixed clock)338 // (through the binary: the in-process runs use a fixed clock)
M internal/app/cli_version.go +1 -0
@@ -106,4 +106,5 @@ var featureUse = map[string]string{
106106 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107107 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108108 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
109+ client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
109110 }
@@ -106,4 +106,5 @@ var featureUse = map[string]string{
106 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",106 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",107 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",108 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
109+ client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
109 }110 }
M internal/app/cli_version_test.go +20 -5
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "errors"
7+ "strings"
78 "testing"
89
910 "connectrpc.com/connect"
@@ -14,7 +15,7 @@ import (
1415 )
1516
1617 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
18+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"}
1819
1920 func versionFake(features ...string) *fakeAPI {
2021 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) {
3536 f := versionFake(allFeatures...)
3637 url := f.serve(t)
3738 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
39+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, passkeys\n", res.stdout)
3940 assert.Empty(t, res.stderr, "no warning when every feature is there")
4041
4142 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
43+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":[]}}`, res.stdout)
4344 }
4445
4546 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
47+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys")
4748 url := f.serve(t)
4849 res := runCLI(t, envFor(url), "", "version").ok(t)
4950 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5152 assert.NotContains(t, res.stderr, "repo.update")
5253
5354 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
55+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
5556 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5657
5758 f = versionFake()
5859 res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)
5960 assert.Contains(t, res.stderr, "repo.update")
6061 assert.Contains(t, res.stderr, "events.resume")
62+ assert.Contains(t, res.stderr, "passkeys")
63+}
64+
65+// A server without passkeys (http public URL, or an IP address host) works for
66+// everything else: only `ermite web passkey` is warned about.
67+func TestVersionWarnsAboutMissingPasskeys(t *testing.T) {
68+ f := versionFake(allFeatures[:len(allFeatures)-1]...)
69+ res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t)
70+ assert.Contains(t, res.stdout, `"missing_features": [
71+ "passkeys"
72+ ]`)
73+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
74+ assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs")
75+ assert.Contains(t, res.stderr, "https public URL")
6176 }
6277
6378 func TestVersionServerFailureStillShowsClient(t *testing.T) {
@@ -4,6 +4,7 @@ package app
4 4
5 import (5 import (
6 "errors"6 "errors"
7+ "strings"
7 "testing"8 "testing"
8 9
9 "connectrpc.com/connect"10 "connectrpc.com/connect"
@@ -14,7 +15,7 @@ import (
14 )15 )
15 16
16 // allFeatures are the features this client uses.17 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}18+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"}
18 19
19 func versionFake(features ...string) *fakeAPI {20 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {21 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +36,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)36 f := versionFake(allFeatures...)
36 url := f.serve(t)37 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)38 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)39+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, passkeys\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")40 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 41
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)42 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)43+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":[]}}`, res.stdout)
43 }44 }
44 45
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {46 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")47+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys")
47 url := f.serve(t)48 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)49 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")50 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,13 +52,27 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")52 assert.NotContains(t, res.stderr, "repo.update")
52 53
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)54 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)55+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")56 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 57
57 f = versionFake()58 f = versionFake()
58 res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)59 res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)
59 assert.Contains(t, res.stderr, "repo.update")60 assert.Contains(t, res.stderr, "repo.update")
60 assert.Contains(t, res.stderr, "events.resume")61 assert.Contains(t, res.stderr, "events.resume")
62+ assert.Contains(t, res.stderr, "passkeys")
63+}
64+
65+// A server without passkeys (http public URL, or an IP address host) works for
66+// everything else: only `ermite web passkey` is warned about.
67+func TestVersionWarnsAboutMissingPasskeys(t *testing.T) {
68+ f := versionFake(allFeatures[:len(allFeatures)-1]...)
69+ res := runCLI(t, envFor(f.serve(t)), "", "version", "--json").ok(t)
70+ assert.Contains(t, res.stdout, `"missing_features": [
71+ "passkeys"
72+ ]`)
73+ assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr)
74+ assert.Contains(t, res.stderr, "the server does not support passkeys, which `ermite web passkey` needs")
75+ assert.Contains(t, res.stderr, "https public URL")
61 }76 }
62 77
63 func TestVersionServerFailureStillShowsClient(t *testing.T) {78 func TestVersionServerFailureStillShowsClient(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close