ScuttleBot

scuttlebot / internal / api / channels_topology_test.go
Source Blame History 395 lines
dd3a887… lmata 1 package api
dd3a887… lmata 2
dd3a887… lmata 3 import (
dd3a887… lmata 4 "bytes"
dd3a887… lmata 5 "encoding/json"
0902a34… lmata 6 "fmt"
dd3a887… lmata 7 "io"
dd3a887… lmata 8 "log/slog"
dd3a887… lmata 9 "net/http"
dd3a887… lmata 10 "net/http/httptest"
dd3a887… lmata 11 "testing"
dd3a887… lmata 12 "time"
dd3a887… lmata 13
68677f9… noreply 14 "github.com/conflicthq/scuttlebot/internal/auth"
dd3a887… lmata 15 "github.com/conflicthq/scuttlebot/internal/config"
dd3a887… lmata 16 "github.com/conflicthq/scuttlebot/internal/registry"
dd3a887… lmata 17 "github.com/conflicthq/scuttlebot/internal/topology"
dd3a887… lmata 18 )
0902a34… lmata 19
0902a34… lmata 20 // accessCall records a single GrantAccess or RevokeAccess invocation.
0902a34… lmata 21 type accessCall struct {
0902a34… lmata 22 Nick string
0902a34… lmata 23 Channel string
0902a34… lmata 24 Level string // "OP", "VOICE", or "" for revoke
0902a34… lmata 25 }
dd3a887… lmata 26
dd3a887… lmata 27 // stubTopologyManager implements topologyManager for tests.
dd3a887… lmata 28 // It records the last ProvisionChannel call and returns a canned Policy.
dd3a887… lmata 29 type stubTopologyManager struct {
dd3a887… lmata 30 last topology.ChannelConfig
dd3a887… lmata 31 policy *topology.Policy
dd3a887… lmata 32 provErr error
0902a34… lmata 33 grants []accessCall
0902a34… lmata 34 revokes []accessCall
dd3a887… lmata 35 }
dd3a887… lmata 36
dd3a887… lmata 37 func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
dd3a887… lmata 38 s.last = ch
dd3a887… lmata 39 return s.provErr
dd3a887… lmata 40 }
dd3a887… lmata 41
f0853f5… lmata 42 func (s *stubTopologyManager) DropChannel(_ string) {}
f0853f5… lmata 43
dd3a887… lmata 44 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
f0853f5… lmata 45
0902a34… lmata 46 func (s *stubTopologyManager) GrantAccess(nick, channel, level string) {
0902a34… lmata 47 s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
0902a34… lmata 48 }
0902a34… lmata 49
0902a34… lmata 50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
0902a34… lmata 51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
0902a34… lmata 52 }
900677e… noreply 53
900677e… noreply 54 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
68677f9… noreply 55
0902a34… lmata 56 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
0902a34… lmata 57 type stubProvisioner struct {
0902a34… lmata 58 accounts map[string]string
0902a34… lmata 59 }
0902a34… lmata 60
0902a34… lmata 61 func newStubProvisioner() *stubProvisioner {
0902a34… lmata 62 return &stubProvisioner{accounts: make(map[string]string)}
0902a34… lmata 63 }
0902a34… lmata 64
0902a34… lmata 65 func (p *stubProvisioner) RegisterAccount(name, pass string) error {
0902a34… lmata 66 if _, ok := p.accounts[name]; ok {
0902a34… lmata 67 return fmt.Errorf("ACCOUNT_EXISTS")
0902a34… lmata 68 }
0902a34… lmata 69 p.accounts[name] = pass
0902a34… lmata 70 return nil
0902a34… lmata 71 }
0902a34… lmata 72
0902a34… lmata 73 func (p *stubProvisioner) ChangePassword(name, pass string) error {
0902a34… lmata 74 p.accounts[name] = pass
0902a34… lmata 75 return nil
0902a34… lmata 76 }
0902a34… lmata 77
dd3a887… lmata 78 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
dd3a887… lmata 79 t.Helper()
dd3a887… lmata 80 reg := registry.New(nil, []byte("key"))
dd3a887… lmata 81 log := slog.New(slog.NewTextHandler(io.Discard, nil))
68677f9… noreply 82 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
0902a34… lmata 83 t.Cleanup(srv.Close)
0902a34… lmata 84 return srv, "tok"
0902a34… lmata 85 }
0902a34… lmata 86
0902a34… lmata 87 // newTopoTestServerWithRegistry creates a test server with both topology and a
0902a34… lmata 88 // real registry backed by stubProvisioner, so agent registration works.
0902a34… lmata 89 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
0902a34… lmata 90 t.Helper()
0902a34… lmata 91 reg := registry.New(newStubProvisioner(), []byte("key"))
0902a34… lmata 92 log := slog.New(slog.NewTextHandler(io.Discard, nil))
68677f9… noreply 93 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
dd3a887… lmata 94 t.Cleanup(srv.Close)
dd3a887… lmata 95 return srv, "tok"
dd3a887… lmata 96 }
dd3a887… lmata 97
dd3a887… lmata 98 func TestHandleProvisionChannel(t *testing.T) {
dd3a887… lmata 99 pol := topology.NewPolicy(config.TopologyConfig{
dd3a887… lmata 100 Types: []config.ChannelTypeConfig{
dd3a887… lmata 101 {
dd3a887… lmata 102 Name: "task",
dd3a887… lmata 103 Prefix: "task.",
dd3a887… lmata 104 Autojoin: []string{"bridge", "scribe"},
dd3a887… lmata 105 TTL: config.Duration{Duration: 72 * time.Hour},
dd3a887… lmata 106 },
dd3a887… lmata 107 },
dd3a887… lmata 108 })
dd3a887… lmata 109 stub := &stubTopologyManager{policy: pol}
dd3a887… lmata 110 srv, tok := newTopoTestServer(t, stub)
dd3a887… lmata 111
dd3a887… lmata 112 body, _ := json.Marshal(map[string]string{"name": "#task.gh-1"})
dd3a887… lmata 113 req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels", bytes.NewReader(body))
dd3a887… lmata 114 req.Header.Set("Authorization", "Bearer "+tok)
dd3a887… lmata 115 req.Header.Set("Content-Type", "application/json")
dd3a887… lmata 116 resp, err := http.DefaultClient.Do(req)
dd3a887… lmata 117 if err != nil {
dd3a887… lmata 118 t.Fatal(err)
dd3a887… lmata 119 }
dd3a887… lmata 120 defer resp.Body.Close()
dd3a887… lmata 121
dd3a887… lmata 122 if resp.StatusCode != http.StatusCreated {
dd3a887… lmata 123 t.Fatalf("want 201, got %d", resp.StatusCode)
dd3a887… lmata 124 }
dd3a887… lmata 125 var got struct {
dd3a887… lmata 126 Channel string `json:"channel"`
dd3a887… lmata 127 Type string `json:"type"`
dd3a887… lmata 128 Autojoin []string `json:"autojoin"`
dd3a887… lmata 129 }
dd3a887… lmata 130 if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
dd3a887… lmata 131 t.Fatal(err)
dd3a887… lmata 132 }
dd3a887… lmata 133 if got.Channel != "#task.gh-1" {
dd3a887… lmata 134 t.Errorf("channel = %q, want #task.gh-1", got.Channel)
dd3a887… lmata 135 }
dd3a887… lmata 136 if got.Type != "task" {
dd3a887… lmata 137 t.Errorf("type = %q, want task", got.Type)
dd3a887… lmata 138 }
dd3a887… lmata 139 if len(got.Autojoin) != 2 || got.Autojoin[0] != "bridge" {
dd3a887… lmata 140 t.Errorf("autojoin = %v, want [bridge scribe]", got.Autojoin)
dd3a887… lmata 141 }
dd3a887… lmata 142 // Verify autojoin was forwarded to ProvisionChannel.
dd3a887… lmata 143 if len(stub.last.Autojoin) != 2 {
dd3a887… lmata 144 t.Errorf("stub.last.Autojoin = %v, want [bridge scribe]", stub.last.Autojoin)
dd3a887… lmata 145 }
dd3a887… lmata 146 }
dd3a887… lmata 147
dd3a887… lmata 148 func TestHandleProvisionChannelInvalidName(t *testing.T) {
dd3a887… lmata 149 stub := &stubTopologyManager{}
dd3a887… lmata 150 srv, tok := newTopoTestServer(t, stub)
dd3a887… lmata 151
dd3a887… lmata 152 body, _ := json.Marshal(map[string]string{"name": "no-hash"})
dd3a887… lmata 153 req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels", bytes.NewReader(body))
dd3a887… lmata 154 req.Header.Set("Authorization", "Bearer "+tok)
dd3a887… lmata 155 req.Header.Set("Content-Type", "application/json")
336984b… lmata 156 resp, err := http.DefaultClient.Do(req)
336984b… lmata 157 if err != nil {
336984b… lmata 158 t.Fatalf("do: %v", err)
336984b… lmata 159 }
dd3a887… lmata 160 defer resp.Body.Close()
dd3a887… lmata 161 if resp.StatusCode != http.StatusBadRequest {
dd3a887… lmata 162 t.Errorf("want 400, got %d", resp.StatusCode)
dd3a887… lmata 163 }
dd3a887… lmata 164 }
dd3a887… lmata 165
dd3a887… lmata 166 func TestHandleGetTopology(t *testing.T) {
dd3a887… lmata 167 pol := topology.NewPolicy(config.TopologyConfig{
dd3a887… lmata 168 Channels: []config.StaticChannelConfig{{Name: "#general"}},
dd3a887… lmata 169 Types: []config.ChannelTypeConfig{
dd3a887… lmata 170 {Name: "task", Prefix: "task.", Autojoin: []string{"bridge"}},
dd3a887… lmata 171 },
dd3a887… lmata 172 })
dd3a887… lmata 173 stub := &stubTopologyManager{policy: pol}
dd3a887… lmata 174 srv, tok := newTopoTestServer(t, stub)
dd3a887… lmata 175
dd3a887… lmata 176 req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/topology", nil)
dd3a887… lmata 177 req.Header.Set("Authorization", "Bearer "+tok)
dd3a887… lmata 178 resp, err := http.DefaultClient.Do(req)
dd3a887… lmata 179 if err != nil {
dd3a887… lmata 180 t.Fatal(err)
dd3a887… lmata 181 }
dd3a887… lmata 182 defer resp.Body.Close()
dd3a887… lmata 183
dd3a887… lmata 184 if resp.StatusCode != http.StatusOK {
dd3a887… lmata 185 t.Fatalf("want 200, got %d", resp.StatusCode)
dd3a887… lmata 186 }
dd3a887… lmata 187 var got struct {
dd3a887… lmata 188 StaticChannels []string `json:"static_channels"`
dd3a887… lmata 189 Types []struct {
dd3a887… lmata 190 Name string `json:"name"`
dd3a887… lmata 191 Prefix string `json:"prefix"`
dd3a887… lmata 192 } `json:"types"`
dd3a887… lmata 193 }
dd3a887… lmata 194 if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
dd3a887… lmata 195 t.Fatal(err)
dd3a887… lmata 196 }
dd3a887… lmata 197 if len(got.StaticChannels) != 1 || got.StaticChannels[0] != "#general" {
dd3a887… lmata 198 t.Errorf("static_channels = %v, want [#general]", got.StaticChannels)
dd3a887… lmata 199 }
dd3a887… lmata 200 if len(got.Types) != 1 || got.Types[0].Name != "task" {
dd3a887… lmata 201 t.Errorf("types = %v", got.Types)
0902a34… lmata 202 }
0902a34… lmata 203 }
0902a34… lmata 204
0902a34… lmata 205 // --- Agent mode assignment tests ---
0902a34… lmata 206
0902a34… lmata 207 // topoDoJSON is a helper for issuing authenticated JSON requests against a test server.
0902a34… lmata 208 func topoDoJSON(t *testing.T, srv *httptest.Server, tok, method, path string, body any) *http.Response {
0902a34… lmata 209 t.Helper()
0902a34… lmata 210 var buf bytes.Buffer
0902a34… lmata 211 if body != nil {
0902a34… lmata 212 if err := json.NewEncoder(&buf).Encode(body); err != nil {
0902a34… lmata 213 t.Fatalf("encode: %v", err)
0902a34… lmata 214 }
0902a34… lmata 215 }
0902a34… lmata 216 req, _ := http.NewRequest(method, srv.URL+path, &buf)
0902a34… lmata 217 req.Header.Set("Authorization", "Bearer "+tok)
0902a34… lmata 218 req.Header.Set("Content-Type", "application/json")
0902a34… lmata 219 resp, err := http.DefaultClient.Do(req)
0902a34… lmata 220 if err != nil {
0902a34… lmata 221 t.Fatalf("do: %v", err)
0902a34… lmata 222 }
0902a34… lmata 223 return resp
0902a34… lmata 224 }
0902a34… lmata 225
0902a34… lmata 226 func TestRegisterGrantsOPForOrchestrator(t *testing.T) {
0902a34… lmata 227 stub := &stubTopologyManager{}
0902a34… lmata 228 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 229
0902a34… lmata 230 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 231 "nick": "orch-1",
0902a34… lmata 232 "type": "orchestrator",
0902a34… lmata 233 "channels": []string{"#fleet", "#project.foo"},
0902a34… lmata 234 })
0902a34… lmata 235 defer resp.Body.Close()
0902a34… lmata 236 if resp.StatusCode != http.StatusCreated {
0902a34… lmata 237 t.Fatalf("register: want 201, got %d", resp.StatusCode)
0902a34… lmata 238 }
0902a34… lmata 239
0902a34… lmata 240 if len(stub.grants) != 2 {
0902a34… lmata 241 t.Fatalf("grants: want 2, got %d", len(stub.grants))
0902a34… lmata 242 }
0902a34… lmata 243 for i, want := range []accessCall{
0902a34… lmata 244 {Nick: "orch-1", Channel: "#fleet", Level: "OP"},
0902a34… lmata 245 {Nick: "orch-1", Channel: "#project.foo", Level: "OP"},
0902a34… lmata 246 } {
0902a34… lmata 247 if stub.grants[i] != want {
0902a34… lmata 248 t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
0902a34… lmata 249 }
0902a34… lmata 250 }
0902a34… lmata 251 }
0902a34… lmata 252
0902a34… lmata 253 func TestRegisterGrantsVOICEForWorker(t *testing.T) {
0902a34… lmata 254 stub := &stubTopologyManager{}
0902a34… lmata 255 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 256
0902a34… lmata 257 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 258 "nick": "worker-1",
0902a34… lmata 259 "type": "worker",
0902a34… lmata 260 "channels": []string{"#fleet"},
0902a34… lmata 261 })
0902a34… lmata 262 defer resp.Body.Close()
0902a34… lmata 263 if resp.StatusCode != http.StatusCreated {
0902a34… lmata 264 t.Fatalf("register: want 201, got %d", resp.StatusCode)
0902a34… lmata 265 }
0902a34… lmata 266
0902a34… lmata 267 if len(stub.grants) != 1 {
0902a34… lmata 268 t.Fatalf("grants: want 1, got %d", len(stub.grants))
0902a34… lmata 269 }
0902a34… lmata 270 if stub.grants[0].Level != "VOICE" {
0902a34… lmata 271 t.Errorf("level = %q, want VOICE", stub.grants[0].Level)
0902a34… lmata 272 }
0902a34… lmata 273 }
0902a34… lmata 274
0902a34… lmata 275 func TestRegisterNoModeForObserver(t *testing.T) {
0902a34… lmata 276 stub := &stubTopologyManager{}
0902a34… lmata 277 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 278
0902a34… lmata 279 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 280 "nick": "obs-1",
0902a34… lmata 281 "type": "observer",
0902a34… lmata 282 "channels": []string{"#fleet"},
0902a34… lmata 283 })
0902a34… lmata 284 defer resp.Body.Close()
0902a34… lmata 285 if resp.StatusCode != http.StatusCreated {
0902a34… lmata 286 t.Fatalf("register: want 201, got %d", resp.StatusCode)
0902a34… lmata 287 }
0902a34… lmata 288
0902a34… lmata 289 if len(stub.grants) != 0 {
0902a34… lmata 290 t.Errorf("grants: want 0, got %d — observer should get no mode", len(stub.grants))
0902a34… lmata 291 }
0902a34… lmata 292 }
0902a34… lmata 293
0902a34… lmata 294 func TestRegisterGrantsOPForOperator(t *testing.T) {
0902a34… lmata 295 stub := &stubTopologyManager{}
0902a34… lmata 296 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 297
0902a34… lmata 298 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 299 "nick": "human-op",
0902a34… lmata 300 "type": "operator",
0902a34… lmata 301 "channels": []string{"#fleet"},
0902a34… lmata 302 })
0902a34… lmata 303 defer resp.Body.Close()
0902a34… lmata 304 if resp.StatusCode != http.StatusCreated {
0902a34… lmata 305 t.Fatalf("register: want 201, got %d", resp.StatusCode)
0902a34… lmata 306 }
0902a34… lmata 307
0902a34… lmata 308 if len(stub.grants) != 1 {
0902a34… lmata 309 t.Fatalf("grants: want 1, got %d", len(stub.grants))
0902a34… lmata 310 }
0902a34… lmata 311 if stub.grants[0].Level != "OP" {
0902a34… lmata 312 t.Errorf("level = %q, want OP", stub.grants[0].Level)
3e3b163… lmata 313 }
3e3b163… lmata 314 }
3e3b163… lmata 315
3e3b163… lmata 316 func TestRegisterOrchestratorWithOpsChannels(t *testing.T) {
3e3b163… lmata 317 stub := &stubTopologyManager{}
3e3b163… lmata 318 srv, tok := newTopoTestServerWithRegistry(t, stub)
3e3b163… lmata 319
3e3b163… lmata 320 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
3e3b163… lmata 321 "nick": "orch-ops",
3e3b163… lmata 322 "type": "orchestrator",
3e3b163… lmata 323 "channels": []string{"#fleet", "#project.foo", "#project.bar"},
3e3b163… lmata 324 "ops_channels": []string{"#fleet"},
3e3b163… lmata 325 })
3e3b163… lmata 326 defer resp.Body.Close()
3e3b163… lmata 327 if resp.StatusCode != http.StatusCreated {
3e3b163… lmata 328 t.Fatalf("register: want 201, got %d", resp.StatusCode)
3e3b163… lmata 329 }
3e3b163… lmata 330
3e3b163… lmata 331 if len(stub.grants) != 3 {
3e3b163… lmata 332 t.Fatalf("grants: want 3, got %d", len(stub.grants))
3e3b163… lmata 333 }
3e3b163… lmata 334 for i, want := range []accessCall{
3e3b163… lmata 335 {Nick: "orch-ops", Channel: "#fleet", Level: "OP"},
3e3b163… lmata 336 {Nick: "orch-ops", Channel: "#project.foo", Level: "VOICE"},
3e3b163… lmata 337 {Nick: "orch-ops", Channel: "#project.bar", Level: "VOICE"},
3e3b163… lmata 338 } {
3e3b163… lmata 339 if stub.grants[i] != want {
3e3b163… lmata 340 t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
3e3b163… lmata 341 }
0902a34… lmata 342 }
0902a34… lmata 343 }
0902a34… lmata 344
0902a34… lmata 345 func TestRevokeRemovesAccess(t *testing.T) {
0902a34… lmata 346 stub := &stubTopologyManager{}
0902a34… lmata 347 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 348
0902a34… lmata 349 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 350 "nick": "orch-rev",
0902a34… lmata 351 "type": "orchestrator",
0902a34… lmata 352 "channels": []string{"#fleet", "#project.x"},
0902a34… lmata 353 })
0902a34… lmata 354 resp.Body.Close()
0902a34… lmata 355
0902a34… lmata 356 resp = topoDoJSON(t, srv, tok, "POST", "/v1/agents/orch-rev/revoke", nil)
0902a34… lmata 357 defer resp.Body.Close()
0902a34… lmata 358 if resp.StatusCode != http.StatusNoContent {
0902a34… lmata 359 t.Fatalf("revoke: want 204, got %d", resp.StatusCode)
0902a34… lmata 360 }
0902a34… lmata 361
0902a34… lmata 362 if len(stub.revokes) != 2 {
0902a34… lmata 363 t.Fatalf("revokes: want 2, got %d", len(stub.revokes))
0902a34… lmata 364 }
0902a34… lmata 365 for i, want := range []string{"#fleet", "#project.x"} {
0902a34… lmata 366 if stub.revokes[i].Channel != want {
0902a34… lmata 367 t.Errorf("revoke[%d].Channel = %q, want %q", i, stub.revokes[i].Channel, want)
0902a34… lmata 368 }
0902a34… lmata 369 }
0902a34… lmata 370 }
0902a34… lmata 371
0902a34… lmata 372 func TestDeleteRemovesAccess(t *testing.T) {
0902a34… lmata 373 stub := &stubTopologyManager{}
0902a34… lmata 374 srv, tok := newTopoTestServerWithRegistry(t, stub)
0902a34… lmata 375
0902a34… lmata 376 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
0902a34… lmata 377 "nick": "del-agent",
0902a34… lmata 378 "type": "worker",
0902a34… lmata 379 "channels": []string{"#fleet"},
0902a34… lmata 380 })
0902a34… lmata 381 resp.Body.Close()
0902a34… lmata 382
0902a34… lmata 383 resp = topoDoJSON(t, srv, tok, "DELETE", "/v1/agents/del-agent", nil)
0902a34… lmata 384 defer resp.Body.Close()
0902a34… lmata 385 if resp.StatusCode != http.StatusNoContent {
0902a34… lmata 386 t.Fatalf("delete: want 204, got %d", resp.StatusCode)
0902a34… lmata 387 }
0902a34… lmata 388
0902a34… lmata 389 if len(stub.revokes) != 1 {
0902a34… lmata 390 t.Fatalf("revokes: want 1, got %d", len(stub.revokes))
0902a34… lmata 391 }
0902a34… lmata 392 if stub.revokes[0].Nick != "del-agent" || stub.revokes[0].Channel != "#fleet" {
0902a34… lmata 393 t.Errorf("revoke = %+v, want del-agent on #fleet", stub.revokes[0])
dd3a887… lmata 394 }
dd3a887… lmata 395 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button