detached 1dc333e

fix(events): end Watch for revoked tokens, bound watchers, and keep the visibility of event time

- Watch re-checks token expiry for every event and asks Auth.Revalidate at
  most every 5 s per watcher (also when idle); a revoked or expired token
  ends the stream with unauthenticated.
- At most 64 topics (invalid_argument), 256 watchers overall and 16 per
  client address (resource_exhausted); in-process callers count globally.
- Buffered events record the visibility and generation of their repository:
  a repository made public later does not reveal its private-era events, a
  repository deleted and created again does not inherit the old ones, and a
  repository made private sends one last repo_updated to those who could
  read it before.
- Events.Close honours its context; forge.Events is renamed forge.Bus.
- Event tests use sentinel events instead of fixed negative waits.
commit
1dc333ecdfe74d6d13a515e46edacda7966cbb16
author
brice · · 2026-10-10 11:10 UTC
parent
f305b1c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +678 -70
M cmd/ermite/e2e_helpers_test.go +3 -1
@@ -46,6 +46,8 @@ type harnessOpts struct {
4646 maxBody int64
4747 // eventBuffer overrides the size of the event ring when positive.
4848 eventBuffer int
49+ // revalidateEvery overrides how often a watcher's token is re-checked.
50+ revalidateEvery time.Duration
4951 }
5052
5153 // newHarness starts a forge. The data directory is short: the hook socket
@@ -79,7 +81,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
7981 if hookExe == "" {
8082 hookExe = binary(t)
8183 }
82- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer})
84+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})
8385 require.NoError(t, err)
8486
8587 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
@@ -46,6 +46,8 @@ type harnessOpts struct {
46 maxBody int6446 maxBody int64
47 // eventBuffer overrides the size of the event ring when positive.47 // eventBuffer overrides the size of the event ring when positive.
48 eventBuffer int48 eventBuffer int
49+ // revalidateEvery overrides how often a watcher's token is re-checked.
50+ revalidateEvery time.Duration
49 }51 }
50 52
51 // newHarness starts a forge. The data directory is short: the hook socket53 // newHarness starts a forge. The data directory is short: the hook socket
@@ -79,7 +81,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
79 if hookExe == "" {81 if hookExe == "" {
80 hookExe = binary(t)82 hookExe = binary(t)
81 }83 }
82- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer})84+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})
83 require.NoError(t, err)85 require.NoError(t, err)
84 86
85 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}87 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
M cmd/ermite/e2e_test.go +2 -2
@@ -210,7 +210,7 @@ func TestAC2PushAuthorization(t *testing.T) {
210210 // AC3: first push creates a private repository with HEAD on the pushed branch.
211211 func TestAC3CreateOnPush(t *testing.T) {
212212 h := newHarness(t, harnessOpts{})
213- events := h.forge.Events.Subscribe(h.ctx)
213+ events := h.forge.Bus.Subscribe(h.ctx)
214214
215215 // Only "dev" is pushed: HEAD (initially main) moves to it.
216216 wt := h.workTree("dev", "a")
@@ -316,7 +316,7 @@ func TestAC4HooksUnknownTokenAndMissingBinary(t *testing.T) {
316316
317317 func TestAC4PostReceiveAttribution(t *testing.T) {
318318 h := newHarness(t, harnessOpts{})
319- events := h.forge.Events.Subscribe(h.ctx)
319+ events := h.forge.Bus.Subscribe(h.ctx)
320320
321321 // Two repositories, pushed at the same time with different refs.
322322 const n = 4
@@ -210,7 +210,7 @@ func TestAC2PushAuthorization(t *testing.T) {
210 // AC3: first push creates a private repository with HEAD on the pushed branch.210 // AC3: first push creates a private repository with HEAD on the pushed branch.
211 func TestAC3CreateOnPush(t *testing.T) {211 func TestAC3CreateOnPush(t *testing.T) {
212 h := newHarness(t, harnessOpts{})212 h := newHarness(t, harnessOpts{})
213- events := h.forge.Events.Subscribe(h.ctx)213+ events := h.forge.Bus.Subscribe(h.ctx)
214 214
215 // Only "dev" is pushed: HEAD (initially main) moves to it.215 // Only "dev" is pushed: HEAD (initially main) moves to it.
216 wt := h.workTree("dev", "a")216 wt := h.workTree("dev", "a")
@@ -316,7 +316,7 @@ func TestAC4HooksUnknownTokenAndMissingBinary(t *testing.T) {
316 316
317 func TestAC4PostReceiveAttribution(t *testing.T) {317 func TestAC4PostReceiveAttribution(t *testing.T) {
318 h := newHarness(t, harnessOpts{})318 h := newHarness(t, harnessOpts{})
319- events := h.forge.Events.Subscribe(h.ctx)319+ events := h.forge.Bus.Subscribe(h.ctx)
320 320
321 // Two repositories, pushed at the same time with different refs.321 // Two repositories, pushed at the same time with different refs.
322 const n = 4322 const n = 4
M cmd/ermite/events_test.go +57 -0
@@ -18,6 +18,8 @@ import (
1818 "google.golang.org/protobuf/types/known/fieldmaskpb"
1919
2020 pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite/internal/adapters/rpc"
22+ "bnema.dev/ermite/internal/app"
2123 "bnema.dev/ermite/internal/domain"
2224 )
2325
@@ -317,3 +319,58 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) {
317319 assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String())
318320 assert.False(t, errors.Is(err, context.Canceled))
319321 }
322+
323+// A token that is revoked while its Watch is open ends the stream with
324+// unauthenticated, and the private events that follow never reach it.
325+func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
326+ h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond})
327+ h.seed("priv", domain.VisibilityPrivate)
328+ created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{
329+ Label: "watcher", Scopes: scopes(domain.ScopeRepoRead),
330+ })
331+ require.NoError(t, err)
332+ w := h.watch(h.api(created.Secret), "")
333+ wt := h.workTree("main", "a.txt")
334+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
335+ assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv")
336+
337+ require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
338+ h.writeFile(wt, "b.txt", []byte("b\n"))
339+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
340+
341+ select {
342+ case <-w.done:
343+ case <-time.After(5 * time.Second):
344+ t.Fatal("the stream of a revoked token did not end")
345+ }
346+ assert.Equal(t, "unauthenticated", codeName(w.err))
347+ for ev := range w.events {
348+ t.Fatalf("an event reached the revoked token: %v", ev)
349+ }
350+}
351+
352+// Watch refuses too many topics, and too many streams from one client.
353+func TestWatchLimits(t *testing.T) {
354+ h := newHarness(t, harnessOpts{})
355+ api := h.api(adminToken)
356+
357+ topics := make([]string, app.MaxWatchTopics+1)
358+ for i := range topics {
359+ topics[i] = "home"
360+ }
361+ assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
362+
363+ for range app.MaxWatchersPerClient {
364+ h.watch(api, "")
365+ }
366+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
367+ assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api)))
368+ // A stream of another client address is not affected (the harness trusts
369+ // the loopback proxy only when configured: use the in-process caller).
370+ inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL)
371+ ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root()))
372+ defer cancel()
373+ st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
374+ require.NoError(t, err)
375+ _ = st.Close()
376+}
@@ -18,6 +18,8 @@ import (
18 "google.golang.org/protobuf/types/known/fieldmaskpb"18 "google.golang.org/protobuf/types/known/fieldmaskpb"
19 19
20 pb "bnema.dev/ermite-api/gen/ermite"20 pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite/internal/adapters/rpc"
22+ "bnema.dev/ermite/internal/app"
21 "bnema.dev/ermite/internal/domain"23 "bnema.dev/ermite/internal/domain"
22 )24 )
23 25
@@ -317,3 +319,58 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) {
317 assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String())319 assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String())
318 assert.False(t, errors.Is(err, context.Canceled))320 assert.False(t, errors.Is(err, context.Canceled))
319 }321 }
322+
323+// A token that is revoked while its Watch is open ends the stream with
324+// unauthenticated, and the private events that follow never reach it.
325+func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
326+ h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond})
327+ h.seed("priv", domain.VisibilityPrivate)
328+ created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{
329+ Label: "watcher", Scopes: scopes(domain.ScopeRepoRead),
330+ })
331+ require.NoError(t, err)
332+ w := h.watch(h.api(created.Secret), "")
333+ wt := h.workTree("main", "a.txt")
334+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
335+ assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv")
336+
337+ require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
338+ h.writeFile(wt, "b.txt", []byte("b\n"))
339+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
340+
341+ select {
342+ case <-w.done:
343+ case <-time.After(5 * time.Second):
344+ t.Fatal("the stream of a revoked token did not end")
345+ }
346+ assert.Equal(t, "unauthenticated", codeName(w.err))
347+ for ev := range w.events {
348+ t.Fatalf("an event reached the revoked token: %v", ev)
349+ }
350+}
351+
352+// Watch refuses too many topics, and too many streams from one client.
353+func TestWatchLimits(t *testing.T) {
354+ h := newHarness(t, harnessOpts{})
355+ api := h.api(adminToken)
356+
357+ topics := make([]string, app.MaxWatchTopics+1)
358+ for i := range topics {
359+ topics[i] = "home"
360+ }
361+ assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...)))
362+
363+ for range app.MaxWatchersPerClient {
364+ h.watch(api, "")
365+ }
366+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
367+ assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api)))
368+ // A stream of another client address is not affected (the harness trusts
369+ // the loopback proxy only when configured: use the in-process caller).
370+ inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL)
371+ ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root()))
372+ defer cancel()
373+ st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{}))
374+ require.NoError(t, err)
375+ _ = st.Close()
376+}
M cmd/ermite/forge.go +9 -5
@@ -41,6 +41,8 @@ type options struct {
4141 maxBodyBytes int64
4242 // eventBuffer overrides app.DefaultEventBuffer when positive.
4343 eventBuffer int
44+ // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.
45+ revalidateEvery time.Duration
4446 }
4547
4648 // forge is the wired server: every adapter and use case, ready to serve.
@@ -61,7 +63,7 @@ type forge struct {
6163 Browse *app.Browse
6264 EventLog *app.Events
6365 API *rpc.API
64- Events ports.EventBus
66+ Bus ports.EventBus
6567 }
6668
6769 // serverVersion is the version GetVersion reports: the module version of the
@@ -116,8 +118,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
116118 }
117119
118120 clock := systemClock{}
119- f.Events = eventbus.New()
120- f.Repos = app.NewRepos(store, git, f.Events, clock)
121+ f.Bus = eventbus.New()
122+ f.Repos = app.NewRepos(store, git, f.Bus, clock)
121123 report, err := f.Repos.Reconcile(ctx)
122124 if err != nil {
123125 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -131,7 +133,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
131133 f.Tokens = app.NewTokens(store.Tokens(), clock)
132134 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
133135 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
134- f.Pushes = app.NewPushes(store, f.Repos, git, f.Events, clock, f.maint, f.Auth)
136+ f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
135137
136138 exe := opts.hookExe
137139 if exe == "" {
@@ -148,7 +150,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
148150 }
149151
150152 f.Browse = app.NewBrowse(store, git)
151- f.EventLog = app.NewEvents(ctx, f.Repos, f.Events, opts.eventBuffer, clock)
153+ f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
154+ Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
155+ })
152156
153157 // One budget of failed authentications per client, for Git and the API.
154158 limiter := clientlimit.NewLimiter(nil)
@@ -41,6 +41,8 @@ type options struct {
41 maxBodyBytes int6441 maxBodyBytes int64
42 // eventBuffer overrides app.DefaultEventBuffer when positive.42 // eventBuffer overrides app.DefaultEventBuffer when positive.
43 eventBuffer int43 eventBuffer int
44+ // revalidateEvery overrides app.DefaultRevalidateEvery when not zero.
45+ revalidateEvery time.Duration
44 }46 }
45 47
46 // forge is the wired server: every adapter and use case, ready to serve.48 // forge is the wired server: every adapter and use case, ready to serve.
@@ -61,7 +63,7 @@ type forge struct {
61 Browse *app.Browse63 Browse *app.Browse
62 EventLog *app.Events64 EventLog *app.Events
63 API *rpc.API65 API *rpc.API
64- Events ports.EventBus66+ Bus ports.EventBus
65 }67 }
66 68
67 // serverVersion is the version GetVersion reports: the module version of the69 // serverVersion is the version GetVersion reports: the module version of the
@@ -116,8 +118,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
116 }118 }
117 119
118 clock := systemClock{}120 clock := systemClock{}
119- f.Events = eventbus.New()121+ f.Bus = eventbus.New()
120- f.Repos = app.NewRepos(store, git, f.Events, clock)122+ f.Repos = app.NewRepos(store, git, f.Bus, clock)
121 report, err := f.Repos.Reconcile(ctx)123 report, err := f.Repos.Reconcile(ctx)
122 if err != nil {124 if err != nil {
123 return nil, fmt.Errorf("reconcile repositories: %w", err)125 return nil, fmt.Errorf("reconcile repositories: %w", err)
@@ -131,7 +133,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
131 f.Tokens = app.NewTokens(store.Tokens(), clock)133 f.Tokens = app.NewTokens(store.Tokens(), clock)
132 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)134 f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
133 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)135 f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay)
134- f.Pushes = app.NewPushes(store, f.Repos, git, f.Events, clock, f.maint, f.Auth)136+ f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
135 137
136 exe := opts.hookExe138 exe := opts.hookExe
137 if exe == "" {139 if exe == "" {
@@ -148,7 +150,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
148 }150 }
149 151
150 f.Browse = app.NewBrowse(store, git)152 f.Browse = app.NewBrowse(store, git)
151- f.EventLog = app.NewEvents(ctx, f.Repos, f.Events, opts.eventBuffer, clock)153+ f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
154+ Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
155+ })
152 156
153 // One budget of failed authentications per client, for Git and the API.157 // One budget of failed authentications per client, for Git and the API.
154 limiter := clientlimit.NewLimiter(nil)158 limiter := clientlimit.NewLimiter(nil)
M internal/adapters/rpc/errors.go +2 -0
@@ -50,6 +50,8 @@ func codeOf(err error) (connect.Code, bool) {
5050 return connect.CodeFailedPrecondition, true
5151 case errors.Is(err, domain.ErrConflict):
5252 return connect.CodeAborted, true
53+ case errors.Is(err, domain.ErrResourceExhausted):
54+ return connect.CodeResourceExhausted, true
5355 }
5456 for _, invalid := range invalidErrors {
5557 if errors.Is(err, invalid) {
@@ -50,6 +50,8 @@ func codeOf(err error) (connect.Code, bool) {
50 return connect.CodeFailedPrecondition, true50 return connect.CodeFailedPrecondition, true
51 case errors.Is(err, domain.ErrConflict):51 case errors.Is(err, domain.ErrConflict):
52 return connect.CodeAborted, true52 return connect.CodeAborted, true
53+ case errors.Is(err, domain.ErrResourceExhausted):
54+ return connect.CodeResourceExhausted, true
53 }55 }
54 for _, invalid := range invalidErrors {56 for _, invalid := range invalidErrors {
55 if errors.Is(err, invalid) {57 if errors.Is(err, invalid) {
M internal/adapters/rpc/event.go +4 -1
@@ -16,10 +16,13 @@ type eventService struct{ events *app.Events }
1616
1717 // Watch streams events until the client goes away or the server stops. The
1818 // stream ends with success in both cases: the contract has no error for
19-// "server is shutting down".
19+// "server is shutting down". It ends with unauthenticated when the token of
20+// the caller is revoked or expires, and a caller that has too many streams
21+// open gets resource_exhausted.
2022 func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error {
2123 params := app.WatchParams{
2224 Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),
25+ Client: clientFromContext(ctx),
2326 // A client's call returns when the response begins: send the headers
2427 // now, not with the first event, which may be far away. Connect flushes
2528 // them for a nil message. A failure shows on the first real send.
@@ -16,10 +16,13 @@ type eventService struct{ events *app.Events }
16 16
17 // Watch streams events until the client goes away or the server stops. The17 // Watch streams events until the client goes away or the server stops. The
18 // stream ends with success in both cases: the contract has no error for18 // stream ends with success in both cases: the contract has no error for
19-// "server is shutting down".19+// "server is shutting down". It ends with unauthenticated when the token of
20+// the caller is revoked or expires, and a caller that has too many streams
21+// open gets resource_exhausted.
20 func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error {22 func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error {
21 params := app.WatchParams{23 params := app.WatchParams{
22 Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),24 Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(),
25+ Client: clientFromContext(ctx),
23 // A client's call returns when the response begins: send the headers26 // A client's call returns when the response begins: send the headers
24 // now, not with the first event, which may be far away. Connect flushes27 // now, not with the first event, which may be far away. Connect flushes
25 // them for a nil message. A failure shows on the first real send.28 // them for a nil message. A failure shows on the first real send.
M internal/app/events.go +206 -25
@@ -9,6 +9,7 @@ import (
99 "strconv"
1010 "strings"
1111 "sync"
12+ "time"
1213
1314 "github.com/bnema/zerowrap"
1415
@@ -19,6 +20,20 @@ import (
1920 // DefaultEventBuffer is the number of events the Events use case remembers.
2021 const DefaultEventBuffer = 1024
2122
23+// Limits of the watchers.
24+const (
25+ // MaxWatchTopics is the most topics one watch may follow.
26+ MaxWatchTopics = 64
27+ // MaxWatchers is the most watches in progress at once.
28+ MaxWatchers = 256
29+ // MaxWatchersPerClient is the most watches in progress for one client
30+ // address (see WatchParams.Client).
31+ MaxWatchersPerClient = 16
32+ // DefaultRevalidateEvery is how long a watcher with a stored token goes
33+ // without asking the token store whether the token still exists.
34+ DefaultRevalidateEvery = 5 * time.Second
35+)
36+
2237 // Topics a watcher may follow.
2338 const (
2439 // TopicHome follows every repository the caller may see.
@@ -27,10 +42,23 @@ const (
2742 topicRepoPrefix = "repo:"
2843 )
2944
30-// bufferedEvent is an event with its sequence number in the buffer.
45+// bufferedEvent is an event with its sequence number in the buffer, and what
46+// was true of its repository when it was buffered. Access to an event is
47+// decided from that, and from the repository as it is when the event is
48+// delivered: a repository made public later does not reveal what happened
49+// while it was private, and a repository deleted and created again under the
50+// same name does not inherit the events of the old one.
3151 type bufferedEvent struct {
3252 seq uint64
3353 ev domain.Event
54+
55+ // known is false when the repository did not exist any more when the
56+ // event was buffered: nobody is told about it.
57+ known bool
58+ // vis is the visibility of the repository at that time.
59+ vis domain.Visibility
60+ // created is the creation time of that repository.
61+ created time.Time
3462 }
3563
3664 // Events keeps the last events of the bus in a ring buffer, numbers them, and
@@ -46,8 +74,11 @@ type bufferedEvent struct {
4674 // NewEvents and lives until Close.
4775 type Events struct {
4876 repos *Repos
77+ auth Revalidator
78+ clock ports.Clock
4979 boot string
5080 size int
81+ every time.Duration // see EventsConfig.RevalidateEvery
5182
5283 stop context.CancelFunc
5384 done chan struct{}
@@ -58,18 +89,39 @@ type Events struct {
5889 changed chan struct{} // closed and replaced on every append and on close
5990 closed bool
6091 watches int
92+ clients map[string]int // watches in progress by client
6193 }
6294
63-// NewEvents subscribes to the bus and starts remembering its events, at most
64-// size of them (DefaultEventBuffer when not positive). ctx carries the
65-// logger; its cancellation is not followed, call Close.
66-func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, size int, clock ports.Clock) *Events {
95+// EventsConfig configures Events.
96+type EventsConfig struct {
97+ // Size is the number of events remembered; DefaultEventBuffer when not
98+ // positive.
99+ Size int
100+ // Auth confirms that the token of a watcher still exists. Required.
101+ Auth Revalidator
102+ // RevalidateEvery is how long the answer of Auth is kept for a watcher;
103+ // DefaultRevalidateEvery when zero, and negative to ask for every event.
104+ RevalidateEvery time.Duration
105+}
106+
107+// NewEvents subscribes to the bus and starts remembering its events. ctx
108+// carries the logger; its cancellation is not followed, call Close.
109+func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, clock ports.Clock, cfg EventsConfig) *Events {
110+ size := cfg.Size
67111 if size <= 0 {
68112 size = DefaultEventBuffer
69113 }
114+ every := cfg.RevalidateEvery
115+ if every == 0 {
116+ every = DefaultRevalidateEvery
117+ }
70118 run, stop := context.WithCancel(context.WithoutCancel(ctx))
71119 e := &Events{
72120 repos: repos,
121+ auth: cfg.Auth,
122+ clock: clock,
123+ every: every,
124+ clients: make(map[string]int),
73125 boot: strconv.FormatInt(clock.Now().UnixMilli(), 36),
74126 size: size,
75127 stop: stop,
@@ -92,21 +144,48 @@ func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
92144 if !ok {
93145 return
94146 }
95- e.append(ev)
147+ e.append(ctx, ev)
96148 case <-ctx.Done():
97149 return
98150 }
99151 }
100152 }
101153
102-func (e *Events) append(ev domain.Event) {
154+// snapshot records the visibility and the generation of the repository an
155+// event is about. The events of the repository lifecycle carry them; for a
156+// push the repository is read now.
157+func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
158+ switch ev := ev.(type) {
159+ case domain.RepoCreated:
160+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.At}
161+ case domain.RepoUpdated:
162+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
163+ case domain.RepoDeleted:
164+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
165+ case domain.PushEvent:
166+ repo, err := lookupRepo(ctx, e.repos.meta, ev.Repo)
167+ if err != nil {
168+ if !errors.Is(err, domain.ErrNotFound) {
169+ log := zerowrap.FromCtx(ctx)
170+ log.Error().Err(err).Str("repo", ev.Repo.String()).Msg("read repository of a buffered event")
171+ }
172+ return bufferedEvent{} // gone, or unknown: nobody is told
173+ }
174+ return bufferedEvent{known: true, vis: repo.Visibility, created: repo.CreatedAt}
175+ }
176+ return bufferedEvent{known: true}
177+}
178+
179+func (e *Events) append(ctx context.Context, ev domain.Event) {
180+ be := e.snapshot(ctx, ev) // outside the lock: it reads the store
103181 e.mu.Lock()
104182 defer e.mu.Unlock()
105183 if len(e.ring) == e.size {
106184 copy(e.ring, e.ring[1:])
107185 e.ring = e.ring[:e.size-1]
108186 }
109- e.ring = append(e.ring, bufferedEvent{seq: e.nextSeq, ev: ev})
187+ be.seq, be.ev = e.nextSeq, ev
188+ e.ring = append(e.ring, be)
110189 e.nextSeq++
111190 e.wake()
112191 }
@@ -126,11 +205,14 @@ func (e *Events) shutdown() {
126205 }
127206 }
128207
129-// Close ends every watch cleanly, stops feeding and waits for the feeder.
130-// It is safe to call more than once.
131-func (e *Events) Close(context.Context) {
208+// Close ends every watch cleanly, stops feeding and waits for the feeder,
209+// until ctx ends. It is safe to call more than once.
210+func (e *Events) Close(ctx context.Context) {
132211 e.stop()
133- <-e.done
212+ select {
213+ case <-e.done:
214+ case <-ctx.Done():
215+ }
134216 }
135217
136218 // Watchers returns the number of watches in progress.
@@ -159,6 +241,10 @@ type WatchParams struct {
159241 // ResumeAfter is the id of the last event the caller saw; empty to
160242 // receive only new events.
161243 ResumeAfter string
244+ // Client identifies where the watch comes from, for the limit per client
245+ // (MaxWatchersPerClient): an opaque key such as a normalized address. Empty
246+ // for callers in this process, which only count against MaxWatchers.
247+ Client string
162248 // Ready, when not nil, is called once the topics are valid and the watch
163249 // is registered: events published from then on are not missed. Nothing is
164250 // sent before it returns.
@@ -184,15 +270,20 @@ type watch struct {
184270 p domain.Principal
185271 home bool
186272 repos map[domain.RepoName]bool
273+
274+ checked time.Time // when the token store last confirmed the principal
187275 }
188276
189277 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
190278
191279 // parseTopics validates the topics of a watch. A repository topic needs a
192280 // repository the principal may read (else domain.ErrNotFound); an unknown
193-// topic is domain.ErrInvalid.
281+// topic, or more than MaxWatchTopics, is domain.ErrInvalid.
194282 func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
195- w := &watch{p: p, repos: make(map[domain.RepoName]bool)}
283+ if len(topics) > MaxWatchTopics {
284+ return nil, fmt.Errorf("%w: %d topics, at most %d", domain.ErrInvalid, len(topics), MaxWatchTopics)
285+ }
286+ w := &watch{p: p, repos: make(map[domain.RepoName]bool), checked: time.Now()}
196287 if len(topics) == 0 {
197288 w.home = true
198289 }
@@ -220,6 +311,12 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s
220311 // Watch calls send for the events the principal may see until ctx ends, the
221312 // buffer closes (both return nil) or send fails (its error is returned).
222313 //
314+// A principal with a stored token is checked again for every event, and every
315+// few seconds when idle: when the token expired or was revoked the watch ends
316+// with domain.ErrUnauthenticated and nothing more is sent. More watches than
317+// MaxWatchers, or than MaxWatchersPerClient for one Client, give
318+// domain.ErrResourceExhausted.
319+//
223320 // Without ResumeAfter only new events are sent. With it, the buffered events
224321 // after that id are sent first, then new ones. When the id is no longer
225322 // buffered, or was not issued by this run, a gap comes first and the oldest
@@ -244,11 +341,23 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
244341 next, gap = oldest, true
245342 }
246343 }
344+ if e.watches >= MaxWatchers || (params.Client != "" && e.clients[params.Client] >= MaxWatchersPerClient) {
345+ e.mu.Unlock()
346+ return fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted)
347+ }
247348 e.watches++
349+ if params.Client != "" {
350+ e.clients[params.Client]++
351+ }
248352 e.mu.Unlock()
249353 defer func() {
250354 e.mu.Lock()
251355 e.watches--
356+ if params.Client != "" {
357+ if e.clients[params.Client]--; e.clients[params.Client] == 0 {
358+ delete(e.clients, params.Client)
359+ }
360+ }
252361 e.mu.Unlock()
253362 }()
254363
@@ -263,6 +372,9 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
263372 }
264373 }
265374 for {
375+ if err := e.stillAllowed(ctx, w); err != nil {
376+ return err
377+ }
266378 batch, changed, oldest, lost, closed := e.since(next)
267379 if lost {
268380 if err := send(WatchEvent{Gap: true}); err != nil {
@@ -283,12 +395,60 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
283395 if closed {
284396 return nil
285397 }
286- select {
287- case <-changed:
288- case <-ctx.Done():
398+ e.wait(ctx, w, changed)
399+ if ctx.Err() != nil {
400+ return nil
401+ }
402+ }
403+}
404+
405+// wait blocks until more events arrive or ctx ends. A watcher with a stored
406+// token also wakes up every few seconds, so that a revocation ends the watch
407+// of a quiet forge too (the caller checks the token on the next turn).
408+func (e *Events) wait(ctx context.Context, w *watch, changed <-chan struct{}) {
409+ var idle <-chan time.Time
410+ if w.p.TokenID() != "" {
411+ t := time.NewTimer(e.idleCheck())
412+ defer t.Stop()
413+ idle = t.C
414+ }
415+ select {
416+ case <-changed:
417+ case <-idle:
418+ case <-ctx.Done():
419+ }
420+}
421+
422+// idleCheck is how long a watcher with a stored token waits for news before
423+// checking its token anyway.
424+func (e *Events) idleCheck() time.Duration {
425+ if e.every <= 0 {
426+ return DefaultRevalidateEvery
427+ }
428+ return min(e.every, DefaultRevalidateEvery)
429+}
430+
431+// stillAllowed checks that the holder of a stored token may still watch: the
432+// token has not expired and, at most every few seconds, still exists. Root and
433+// anonymous callers have nothing to lose.
434+func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
435+ if w.p.TokenID() == "" {
436+ return nil
437+ }
438+ if exp := w.p.ExpiresAt(); !exp.IsZero() && !e.clock.Now().Before(exp) {
439+ return domain.ErrUnauthenticated
440+ }
441+ if e.every > 0 && time.Since(w.checked) < e.every {
442+ return nil
443+ }
444+ if err := e.auth.Revalidate(ctx, w.p); err != nil {
445+ if ctx.Err() != nil {
289446 return nil
290447 }
448+ return err
291449 }
450+ w.checked = time.Now()
451+ return nil
292452 }
293453
294454 // since returns the buffered events from sequence next on, and the channel
@@ -308,6 +468,9 @@ func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struc
308468
309469 // deliver sends the event if the watcher follows its topic and may see it.
310470 func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(WatchEvent) error) error {
471+ if err := e.stillAllowed(ctx, w); err != nil {
472+ return err
473+ }
311474 out := WatchEvent{ID: e.id(be.seq), Event: be.ev}
312475 switch ev := be.ev.(type) {
313476 case domain.EventGap:
@@ -320,23 +483,28 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
320483 }
321484 return send(out)
322485 case domain.RepoCreated:
323- return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
486+ return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
324487 case domain.RepoUpdated:
325- return e.deliverRepo(ctx, w, ev.Repo, true, out, send)
488+ return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
326489 case domain.PushEvent:
327- return e.deliverRepo(ctx, w, ev.Repo, false, out, send)
490+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
328491 }
329492 return nil // an event this version does not stream
330493 }
331494
332-// deliverRepo re-checks the access to the repository, with its present
333-// visibility, and sends the event. A repository that no longer exists is
495+// deliverRepo sends an event about a repository if the watcher could read the
496+// repository when the event happened and can read it now, and the repository
497+// is still the one the event is about. A repository that no longer exists is
334498 // skipped: its RepoDeleted follows or came before.
335-func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, out WatchEvent, send func(WatchEvent) error) error {
336- if !w.wants(name) {
499+//
500+// The exception is a repository that became private: the watchers who could
501+// read it before are told once, with its name and its new visibility only,
502+// so that what they show can be removed.
503+func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out WatchEvent, send func(WatchEvent) error) error {
504+ if !w.wants(name) || !be.known {
337505 return nil
338506 }
339- repo, err := e.repos.Get(ctx, w.p, name)
507+ repo, err := lookupRepo(ctx, e.repos.meta, name)
340508 switch {
341509 case errors.Is(err, domain.ErrNotFound):
342510 return nil
@@ -345,6 +513,19 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
345513 case err != nil:
346514 return fmt.Errorf("check access to %q: %w", name, err)
347515 }
516+ if repo.CreatedAt.After(be.created) {
517+ return nil // the event is about a repository of this name that was deleted
518+ }
519+ if !canReadAs(w.p, name, be.vis) {
520+ if up, ok := be.ev.(domain.RepoUpdated); ok && canReadAs(w.p, name, up.Previous) {
521+ out.Repo = &RepoView{Repo: domain.Repo{Name: name, Visibility: up.Visibility}}
522+ return send(out)
523+ }
524+ return nil
525+ }
526+ if !canRead(w.p, repo) {
527+ return nil
528+ }
348529 if withRepo {
349530 view, err := e.repos.View(ctx, repo)
350531 if err != nil {
@@ -9,6 +9,7 @@ import (
9 "strconv"9 "strconv"
10 "strings"10 "strings"
11 "sync"11 "sync"
12+ "time"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 15
@@ -19,6 +20,20 @@ import (
19 // DefaultEventBuffer is the number of events the Events use case remembers.20 // DefaultEventBuffer is the number of events the Events use case remembers.
20 const DefaultEventBuffer = 102421 const DefaultEventBuffer = 1024
21 22
23+// Limits of the watchers.
24+const (
25+ // MaxWatchTopics is the most topics one watch may follow.
26+ MaxWatchTopics = 64
27+ // MaxWatchers is the most watches in progress at once.
28+ MaxWatchers = 256
29+ // MaxWatchersPerClient is the most watches in progress for one client
30+ // address (see WatchParams.Client).
31+ MaxWatchersPerClient = 16
32+ // DefaultRevalidateEvery is how long a watcher with a stored token goes
33+ // without asking the token store whether the token still exists.
34+ DefaultRevalidateEvery = 5 * time.Second
35+)
36+
22 // Topics a watcher may follow.37 // Topics a watcher may follow.
23 const (38 const (
24 // TopicHome follows every repository the caller may see.39 // TopicHome follows every repository the caller may see.
@@ -27,10 +42,23 @@ const (
27 topicRepoPrefix = "repo:"42 topicRepoPrefix = "repo:"
28 )43 )
29 44
30-// bufferedEvent is an event with its sequence number in the buffer.45+// bufferedEvent is an event with its sequence number in the buffer, and what
46+// was true of its repository when it was buffered. Access to an event is
47+// decided from that, and from the repository as it is when the event is
48+// delivered: a repository made public later does not reveal what happened
49+// while it was private, and a repository deleted and created again under the
50+// same name does not inherit the events of the old one.
31 type bufferedEvent struct {51 type bufferedEvent struct {
32 seq uint6452 seq uint64
33 ev domain.Event53 ev domain.Event
54+
55+ // known is false when the repository did not exist any more when the
56+ // event was buffered: nobody is told about it.
57+ known bool
58+ // vis is the visibility of the repository at that time.
59+ vis domain.Visibility
60+ // created is the creation time of that repository.
61+ created time.Time
34 }62 }
35 63
36 // Events keeps the last events of the bus in a ring buffer, numbers them, and64 // Events keeps the last events of the bus in a ring buffer, numbers them, and
@@ -46,8 +74,11 @@ type bufferedEvent struct {
46 // NewEvents and lives until Close.74 // NewEvents and lives until Close.
47 type Events struct {75 type Events struct {
48 repos *Repos76 repos *Repos
77+ auth Revalidator
78+ clock ports.Clock
49 boot string79 boot string
50 size int80 size int
81+ every time.Duration // see EventsConfig.RevalidateEvery
51 82
52 stop context.CancelFunc83 stop context.CancelFunc
53 done chan struct{}84 done chan struct{}
@@ -58,18 +89,39 @@ type Events struct {
58 changed chan struct{} // closed and replaced on every append and on close89 changed chan struct{} // closed and replaced on every append and on close
59 closed bool90 closed bool
60 watches int91 watches int
92+ clients map[string]int // watches in progress by client
61 }93 }
62 94
63-// NewEvents subscribes to the bus and starts remembering its events, at most95+// EventsConfig configures Events.
64-// size of them (DefaultEventBuffer when not positive). ctx carries the96+type EventsConfig struct {
65-// logger; its cancellation is not followed, call Close.97+ // Size is the number of events remembered; DefaultEventBuffer when not
66-func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, size int, clock ports.Clock) *Events {98+ // positive.
99+ Size int
100+ // Auth confirms that the token of a watcher still exists. Required.
101+ Auth Revalidator
102+ // RevalidateEvery is how long the answer of Auth is kept for a watcher;
103+ // DefaultRevalidateEvery when zero, and negative to ask for every event.
104+ RevalidateEvery time.Duration
105+}
106+
107+// NewEvents subscribes to the bus and starts remembering its events. ctx
108+// carries the logger; its cancellation is not followed, call Close.
109+func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, clock ports.Clock, cfg EventsConfig) *Events {
110+ size := cfg.Size
67 if size <= 0 {111 if size <= 0 {
68 size = DefaultEventBuffer112 size = DefaultEventBuffer
69 }113 }
114+ every := cfg.RevalidateEvery
115+ if every == 0 {
116+ every = DefaultRevalidateEvery
117+ }
70 run, stop := context.WithCancel(context.WithoutCancel(ctx))118 run, stop := context.WithCancel(context.WithoutCancel(ctx))
71 e := &Events{119 e := &Events{
72 repos: repos,120 repos: repos,
121+ auth: cfg.Auth,
122+ clock: clock,
123+ every: every,
124+ clients: make(map[string]int),
73 boot: strconv.FormatInt(clock.Now().UnixMilli(), 36),125 boot: strconv.FormatInt(clock.Now().UnixMilli(), 36),
74 size: size,126 size: size,
75 stop: stop,127 stop: stop,
@@ -92,21 +144,48 @@ func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
92 if !ok {144 if !ok {
93 return145 return
94 }146 }
95- e.append(ev)147+ e.append(ctx, ev)
96 case <-ctx.Done():148 case <-ctx.Done():
97 return149 return
98 }150 }
99 }151 }
100 }152 }
101 153
102-func (e *Events) append(ev domain.Event) {154+// snapshot records the visibility and the generation of the repository an
155+// event is about. The events of the repository lifecycle carry them; for a
156+// push the repository is read now.
157+func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
158+ switch ev := ev.(type) {
159+ case domain.RepoCreated:
160+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.At}
161+ case domain.RepoUpdated:
162+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
163+ case domain.RepoDeleted:
164+ return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
165+ case domain.PushEvent:
166+ repo, err := lookupRepo(ctx, e.repos.meta, ev.Repo)
167+ if err != nil {
168+ if !errors.Is(err, domain.ErrNotFound) {
169+ log := zerowrap.FromCtx(ctx)
170+ log.Error().Err(err).Str("repo", ev.Repo.String()).Msg("read repository of a buffered event")
171+ }
172+ return bufferedEvent{} // gone, or unknown: nobody is told
173+ }
174+ return bufferedEvent{known: true, vis: repo.Visibility, created: repo.CreatedAt}
175+ }
176+ return bufferedEvent{known: true}
177+}
178+
179+func (e *Events) append(ctx context.Context, ev domain.Event) {
180+ be := e.snapshot(ctx, ev) // outside the lock: it reads the store
103 e.mu.Lock()181 e.mu.Lock()
104 defer e.mu.Unlock()182 defer e.mu.Unlock()
105 if len(e.ring) == e.size {183 if len(e.ring) == e.size {
106 copy(e.ring, e.ring[1:])184 copy(e.ring, e.ring[1:])
107 e.ring = e.ring[:e.size-1]185 e.ring = e.ring[:e.size-1]
108 }186 }
109- e.ring = append(e.ring, bufferedEvent{seq: e.nextSeq, ev: ev})187+ be.seq, be.ev = e.nextSeq, ev
188+ e.ring = append(e.ring, be)
110 e.nextSeq++189 e.nextSeq++
111 e.wake()190 e.wake()
112 }191 }
@@ -126,11 +205,14 @@ func (e *Events) shutdown() {
126 }205 }
127 }206 }
128 207
129-// Close ends every watch cleanly, stops feeding and waits for the feeder.208+// Close ends every watch cleanly, stops feeding and waits for the feeder,
130-// It is safe to call more than once.209+// until ctx ends. It is safe to call more than once.
131-func (e *Events) Close(context.Context) {210+func (e *Events) Close(ctx context.Context) {
132 e.stop()211 e.stop()
133- <-e.done212+ select {
213+ case <-e.done:
214+ case <-ctx.Done():
215+ }
134 }216 }
135 217
136 // Watchers returns the number of watches in progress.218 // Watchers returns the number of watches in progress.
@@ -159,6 +241,10 @@ type WatchParams struct {
159 // ResumeAfter is the id of the last event the caller saw; empty to241 // ResumeAfter is the id of the last event the caller saw; empty to
160 // receive only new events.242 // receive only new events.
161 ResumeAfter string243 ResumeAfter string
244+ // Client identifies where the watch comes from, for the limit per client
245+ // (MaxWatchersPerClient): an opaque key such as a normalized address. Empty
246+ // for callers in this process, which only count against MaxWatchers.
247+ Client string
162 // Ready, when not nil, is called once the topics are valid and the watch248 // Ready, when not nil, is called once the topics are valid and the watch
163 // is registered: events published from then on are not missed. Nothing is249 // is registered: events published from then on are not missed. Nothing is
164 // sent before it returns.250 // sent before it returns.
@@ -184,15 +270,20 @@ type watch struct {
184 p domain.Principal270 p domain.Principal
185 home bool271 home bool
186 repos map[domain.RepoName]bool272 repos map[domain.RepoName]bool
273+
274+ checked time.Time // when the token store last confirmed the principal
187 }275 }
188 276
189 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }277 func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
190 278
191 // parseTopics validates the topics of a watch. A repository topic needs a279 // parseTopics validates the topics of a watch. A repository topic needs a
192 // repository the principal may read (else domain.ErrNotFound); an unknown280 // repository the principal may read (else domain.ErrNotFound); an unknown
193-// topic is domain.ErrInvalid.281+// topic, or more than MaxWatchTopics, is domain.ErrInvalid.
194 func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {282 func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
195- w := &watch{p: p, repos: make(map[domain.RepoName]bool)}283+ if len(topics) > MaxWatchTopics {
284+ return nil, fmt.Errorf("%w: %d topics, at most %d", domain.ErrInvalid, len(topics), MaxWatchTopics)
285+ }
286+ w := &watch{p: p, repos: make(map[domain.RepoName]bool), checked: time.Now()}
196 if len(topics) == 0 {287 if len(topics) == 0 {
197 w.home = true288 w.home = true
198 }289 }
@@ -220,6 +311,12 @@ func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []s
220 // Watch calls send for the events the principal may see until ctx ends, the311 // Watch calls send for the events the principal may see until ctx ends, the
221 // buffer closes (both return nil) or send fails (its error is returned).312 // buffer closes (both return nil) or send fails (its error is returned).
222 //313 //
314+// A principal with a stored token is checked again for every event, and every
315+// few seconds when idle: when the token expired or was revoked the watch ends
316+// with domain.ErrUnauthenticated and nothing more is sent. More watches than
317+// MaxWatchers, or than MaxWatchersPerClient for one Client, give
318+// domain.ErrResourceExhausted.
319+//
223 // Without ResumeAfter only new events are sent. With it, the buffered events320 // Without ResumeAfter only new events are sent. With it, the buffered events
224 // after that id are sent first, then new ones. When the id is no longer321 // after that id are sent first, then new ones. When the id is no longer
225 // buffered, or was not issued by this run, a gap comes first and the oldest322 // buffered, or was not issued by this run, a gap comes first and the oldest
@@ -244,11 +341,23 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
244 next, gap = oldest, true341 next, gap = oldest, true
245 }342 }
246 }343 }
344+ if e.watches >= MaxWatchers || (params.Client != "" && e.clients[params.Client] >= MaxWatchersPerClient) {
345+ e.mu.Unlock()
346+ return fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted)
347+ }
247 e.watches++348 e.watches++
349+ if params.Client != "" {
350+ e.clients[params.Client]++
351+ }
248 e.mu.Unlock()352 e.mu.Unlock()
249 defer func() {353 defer func() {
250 e.mu.Lock()354 e.mu.Lock()
251 e.watches--355 e.watches--
356+ if params.Client != "" {
357+ if e.clients[params.Client]--; e.clients[params.Client] == 0 {
358+ delete(e.clients, params.Client)
359+ }
360+ }
252 e.mu.Unlock()361 e.mu.Unlock()
253 }()362 }()
254 363
@@ -263,6 +372,9 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
263 }372 }
264 }373 }
265 for {374 for {
375+ if err := e.stillAllowed(ctx, w); err != nil {
376+ return err
377+ }
266 batch, changed, oldest, lost, closed := e.since(next)378 batch, changed, oldest, lost, closed := e.since(next)
267 if lost {379 if lost {
268 if err := send(WatchEvent{Gap: true}); err != nil {380 if err := send(WatchEvent{Gap: true}); err != nil {
@@ -283,12 +395,60 @@ func (e *Events) Watch(ctx context.Context, p domain.Principal, params WatchPara
283 if closed {395 if closed {
284 return nil396 return nil
285 }397 }
286- select {398+ e.wait(ctx, w, changed)
287- case <-changed:399+ if ctx.Err() != nil {
288- case <-ctx.Done():400+ return nil
401+ }
402+ }
403+}
404+
405+// wait blocks until more events arrive or ctx ends. A watcher with a stored
406+// token also wakes up every few seconds, so that a revocation ends the watch
407+// of a quiet forge too (the caller checks the token on the next turn).
408+func (e *Events) wait(ctx context.Context, w *watch, changed <-chan struct{}) {
409+ var idle <-chan time.Time
410+ if w.p.TokenID() != "" {
411+ t := time.NewTimer(e.idleCheck())
412+ defer t.Stop()
413+ idle = t.C
414+ }
415+ select {
416+ case <-changed:
417+ case <-idle:
418+ case <-ctx.Done():
419+ }
420+}
421+
422+// idleCheck is how long a watcher with a stored token waits for news before
423+// checking its token anyway.
424+func (e *Events) idleCheck() time.Duration {
425+ if e.every <= 0 {
426+ return DefaultRevalidateEvery
427+ }
428+ return min(e.every, DefaultRevalidateEvery)
429+}
430+
431+// stillAllowed checks that the holder of a stored token may still watch: the
432+// token has not expired and, at most every few seconds, still exists. Root and
433+// anonymous callers have nothing to lose.
434+func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
435+ if w.p.TokenID() == "" {
436+ return nil
437+ }
438+ if exp := w.p.ExpiresAt(); !exp.IsZero() && !e.clock.Now().Before(exp) {
439+ return domain.ErrUnauthenticated
440+ }
441+ if e.every > 0 && time.Since(w.checked) < e.every {
442+ return nil
443+ }
444+ if err := e.auth.Revalidate(ctx, w.p); err != nil {
445+ if ctx.Err() != nil {
289 return nil446 return nil
290 }447 }
448+ return err
291 }449 }
450+ w.checked = time.Now()
451+ return nil
292 }452 }
293 453
294 // since returns the buffered events from sequence next on, and the channel454 // since returns the buffered events from sequence next on, and the channel
@@ -308,6 +468,9 @@ func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struc
308 468
309 // deliver sends the event if the watcher follows its topic and may see it.469 // deliver sends the event if the watcher follows its topic and may see it.
310 func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(WatchEvent) error) error {470 func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(WatchEvent) error) error {
471+ if err := e.stillAllowed(ctx, w); err != nil {
472+ return err
473+ }
311 out := WatchEvent{ID: e.id(be.seq), Event: be.ev}474 out := WatchEvent{ID: e.id(be.seq), Event: be.ev}
312 switch ev := be.ev.(type) {475 switch ev := be.ev.(type) {
313 case domain.EventGap:476 case domain.EventGap:
@@ -320,23 +483,28 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
320 }483 }
321 return send(out)484 return send(out)
322 case domain.RepoCreated:485 case domain.RepoCreated:
323- return e.deliverRepo(ctx, w, ev.Repo, true, out, send)486+ return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
324 case domain.RepoUpdated:487 case domain.RepoUpdated:
325- return e.deliverRepo(ctx, w, ev.Repo, true, out, send)488+ return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
326 case domain.PushEvent:489 case domain.PushEvent:
327- return e.deliverRepo(ctx, w, ev.Repo, false, out, send)490+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
328 }491 }
329 return nil // an event this version does not stream492 return nil // an event this version does not stream
330 }493 }
331 494
332-// deliverRepo re-checks the access to the repository, with its present495+// deliverRepo sends an event about a repository if the watcher could read the
333-// visibility, and sends the event. A repository that no longer exists is496+// repository when the event happened and can read it now, and the repository
497+// is still the one the event is about. A repository that no longer exists is
334 // skipped: its RepoDeleted follows or came before.498 // skipped: its RepoDeleted follows or came before.
335-func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, out WatchEvent, send func(WatchEvent) error) error {499+//
336- if !w.wants(name) {500+// The exception is a repository that became private: the watchers who could
501+// read it before are told once, with its name and its new visibility only,
502+// so that what they show can be removed.
503+func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out WatchEvent, send func(WatchEvent) error) error {
504+ if !w.wants(name) || !be.known {
337 return nil505 return nil
338 }506 }
339- repo, err := e.repos.Get(ctx, w.p, name)507+ repo, err := lookupRepo(ctx, e.repos.meta, name)
340 switch {508 switch {
341 case errors.Is(err, domain.ErrNotFound):509 case errors.Is(err, domain.ErrNotFound):
342 return nil510 return nil
@@ -345,6 +513,19 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
345 case err != nil:513 case err != nil:
346 return fmt.Errorf("check access to %q: %w", name, err)514 return fmt.Errorf("check access to %q: %w", name, err)
347 }515 }
516+ if repo.CreatedAt.After(be.created) {
517+ return nil // the event is about a repository of this name that was deleted
518+ }
519+ if !canReadAs(w.p, name, be.vis) {
520+ if up, ok := be.ev.(domain.RepoUpdated); ok && canReadAs(w.p, name, up.Previous) {
521+ out.Repo = &RepoView{Repo: domain.Repo{Name: name, Visibility: up.Visibility}}
522+ return send(out)
523+ }
524+ return nil
525+ }
526+ if !canRead(w.p, repo) {
527+ return nil
528+ }
348 if withRepo {529 if withRepo {
349 view, err := e.repos.View(ctx, repo)530 view, err := e.repos.View(ctx, repo)
350 if err != nil {531 if err != nil {
M internal/app/events_test.go +367 -33
@@ -19,23 +19,63 @@ import (
1919 "bnema.dev/ermite/internal/ports/mocks"
2020 )
2121
22+// fakeAuth is the Revalidator of the rig: tokens are valid until revoked.
23+type fakeAuth struct {
24+ mu sync.Mutex
25+ revoked map[string]bool
26+ calls int
27+}
28+
29+func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error {
30+ a.mu.Lock()
31+ defer a.mu.Unlock()
32+ a.calls++
33+ if a.revoked[p.TokenID()] {
34+ return domain.ErrUnauthenticated
35+ }
36+ return nil
37+}
38+
39+func (a *fakeAuth) revoke(id string) {
40+ a.mu.Lock()
41+ defer a.mu.Unlock()
42+ if a.revoked == nil {
43+ a.revoked = map[string]bool{}
44+ }
45+ a.revoked[id] = true
46+}
47+
48+func (a *fakeAuth) count() int {
49+ a.mu.Lock()
50+ defer a.mu.Unlock()
51+ return a.calls
52+}
53+
2254 // eventRig is an Events use case fed by a channel the test writes to.
2355 type eventRig struct {
2456 t *testing.T
2557 meta *mocks.RepoMetaStore
2658 git *mocks.GitRepos
2759 feed chan domain.Event
60+ auth *fakeAuth
2861 events *app.Events
2962 }
3063
3164 func newEventRig(t *testing.T, size int) *eventRig {
65+ t.Helper()
66+ return newEventRigConfig(t, app.EventsConfig{Size: size})
67+}
68+
69+func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
3270 t.Helper()
3371 r := &eventRig{
3472 t: t,
3573 meta: mocks.NewRepoMetaStore(t),
3674 git: mocks.NewGitRepos(t),
3775 feed: make(chan domain.Event, 4096),
76+ auth: &fakeAuth{},
3877 }
78+ cfg.Auth = r.auth
3979 bus := mocks.NewEventBus(t)
4080 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
4181 go func() { <-ctx.Done(); close(r.feed) }()
@@ -45,7 +85,7 @@ func newEventRig(t *testing.T, size int) *eventRig {
4585 clock.EXPECT().Now().Return(now).Maybe()
4686 r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
4787 repos := app.NewRepos(r.meta, r.git, bus, clock)
48- r.events = app.NewEvents(context.Background(), repos, bus, size, clock)
88+ r.events = app.NewEvents(context.Background(), repos, bus, clock, cfg)
4989 t.Cleanup(func() { r.events.Close(context.Background()) })
5090 return r
5191 }
@@ -92,12 +132,20 @@ func (w *watcher) next(t *testing.T) app.WatchEvent {
92132 return app.WatchEvent{}
93133 }
94134
95-func (w *watcher) none(t *testing.T) {
135+// sentinelOID marks the push events that only prove the ones before them were
136+// handled.
137+var sentinelOID = fmt.Sprintf("%040x", 0xfeed)
138+
139+// quiet proves that the watcher got nothing since its last read: it pushes an
140+// event on n, which the watcher may see, and requires it to be the next one.
141+// Events reach a watcher in order, so anything sent before would show up first.
142+func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
96143 t.Helper()
97- select {
98- case e := <-w.got:
99- t.Fatalf("unexpected event %+v", e)
100- case <-time.After(100 * time.Millisecond):
144+ r.feed <- domain.PushEvent{Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: sentinelOID, Ref: "refs/heads/sentinel"}}, At: now}
145+ e := w.next(t)
146+ push, ok := e.Event.(domain.PushEvent)
147+ if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID {
148+ t.Fatalf("expected nothing before the sentinel, got %+v", e)
101149 }
102150 }
103151
@@ -121,7 +169,7 @@ func TestWatchLiveAndFiltered(t *testing.T) {
121169 for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
122170 e := w.next(t)
123171 assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
124- w.none(t)
172+ w.quiet(t, r, pub)
125173 }
126174 first, second := root.next(t), root.next(t)
127175 assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
@@ -130,7 +178,7 @@ func TestWatchLiveAndFiltered(t *testing.T) {
130178 assert.NotEqual(t, first.ID, second.ID)
131179
132180 assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
133- onlyPriv.none(t)
181+ onlyPriv.quiet(t, r, priv)
134182 }
135183
136184 func TestWatchRechecksAccessPerEvent(t *testing.T) {
@@ -155,7 +203,7 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) {
155203 vis = domain.VisibilityPrivate
156204 mu.Unlock()
157205 r.push(n, 2)
158- w.none(t)
206+ w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
159207 }
160208
161209 func TestWatchResumeAndGap(t *testing.T) {
@@ -176,21 +224,8 @@ func TestWatchResumeAndGap(t *testing.T) {
176224 }
177225 require.Len(t, ids, 20)
178226
179- t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
180- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
181- for _, want := range ids[17:] {
182- e := w.next(t)
183- assert.False(t, e.Gap)
184- assert.Equal(t, want, e.ID)
185- }
186- w.none(t)
187- })
188-
189- t.Run("resume at the last event replays nothing", func(t *testing.T) {
190- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
191- w.none(t)
192- })
193-
227+ // The subtests that read the state of the ring come first: the ones that
228+ // need a sentinel add events to it.
194229 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
195230 // 20 events were taken, the buffer holds the last 8: ids[12] is the
196231 // oldest still present; ids[0] is long gone.
@@ -215,13 +250,27 @@ func TestWatchResumeAndGap(t *testing.T) {
215250 assert.True(t, w.next(t).Gap)
216251 })
217252
218- t.Run("a resumed watch goes live after the replay", func(t *testing.T) {
219- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
220- w.none(t)
253+ t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
254+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
255+ for _, want := range ids[17:] {
256+ e := w.next(t)
257+ assert.False(t, e.Gap)
258+ assert.Equal(t, want, e.ID)
259+ }
260+ // Nothing else was replayed: the next event is the one pushed now.
261+ r.push(n, 98)
262+ live := w.next(t)
263+ assert.False(t, live.Gap)
264+ assert.NotContains(t, ids, live.ID)
265+ ids = append(ids, live.ID)
266+ })
267+
268+ t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
269+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})
221270 r.push(n, 99)
222271 e := w.next(t)
223272 assert.False(t, e.Gap)
224- assert.NotEmpty(t, e.ID)
273+ assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
225274 })
226275 }
227276
@@ -314,14 +363,24 @@ func TestWatchSlowWatcherGetsAGap(t *testing.T) {
314363 return nil
315364 })
316365 }()
317- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
366+ // A second, fast watcher tells when the use case has taken every event:
367+ // it receives them in order.
368+ fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
369+ require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
318370 r.push(n, 0)
319371 <-blocked
320372 for i := 1; i < 30; i++ {
321373 r.push(n, i)
322374 }
323- // Wait for the use case to have taken them all (it does not block).
324- time.Sleep(100 * time.Millisecond)
375+ // The ring holds four events: the fast watcher may get a gap, but it ends
376+ // on the last push, which proves the use case took all of them.
377+ last := fmt.Sprintf("%040x", 29+1)
378+ for {
379+ e := fast.next(t)
380+ if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
381+ break
382+ }
383+ }
325384 close(release)
326385
327386 require.Eventually(t, func() bool {
@@ -349,7 +408,7 @@ func TestEventsRepoPayloads(t *testing.T) {
349408 require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
350409
351410 r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
352- r.feed <- domain.RepoUpdated{Repo: pub, At: now}
411+ r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic}
353412 // A deleted repository cannot be read any more: its visibility decides.
354413 r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
355414 r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
@@ -364,5 +423,280 @@ func TestEventsRepoPayloads(t *testing.T) {
364423 assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
365424 assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
366425 assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
367- anon.none(t)
426+ anon.quiet(t, r, pub)
427+}
428+
429+// tokenWith builds the principal of a stored token with an id and an expiry.
430+func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
431+ tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires}
432+ for _, r := range repos {
433+ n, err := domain.ParseRepoName(r)
434+ if err != nil {
435+ panic(err)
436+ }
437+ tok.Repos = append(tok.Repos, n)
438+ }
439+ return domain.TokenPrincipal(tok)
440+}
441+
442+func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
443+ // Asking at every turn keeps the test independent of the clock.
444+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
445+ pub := r.repo("pub", domain.VisibilityPublic)
446+ priv := r.repo("priv", domain.VisibilityPrivate)
447+ p := tokenWith("tok", time.Time{})
448+ ctx, cancel := context.WithCancel(context.Background())
449+ defer cancel()
450+
451+ w := r.watch(ctx, p, app.WatchParams{})
452+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
453+ r.push(pub, 1)
454+ w.next(t)
455+
456+ r.auth.revoke("tok")
457+ r.push(priv, 2) // would be delivered to a token that may read priv
458+ select {
459+ case err := <-w.done:
460+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
461+ case <-time.After(5 * time.Second):
462+ t.Fatal("the watch of a revoked token did not end")
463+ }
464+ select {
465+ case e := <-w.got:
466+ t.Fatalf("an event was sent after the revocation: %+v", e)
467+ default:
468+ }
469+}
470+
471+func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
472+ r := newEventRig(t, 0)
473+ pub := r.repo("pub", domain.VisibilityPublic)
474+ // The rig's clock stands at now: this token expired at now.
475+ p := tokenWith("tok", now)
476+ w := r.watch(context.Background(), p, app.WatchParams{})
477+ r.push(pub, 1)
478+ select {
479+ case err := <-w.done:
480+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
481+ case <-time.After(5 * time.Second):
482+ t.Fatal("the watch of an expired token did not end")
483+ }
484+ assert.Empty(t, w.got)
485+}
486+
487+func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
488+ t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
489+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
490+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
491+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
492+ r.auth.revoke("tok")
493+ select {
494+ case err := <-w.done:
495+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
496+ case <-time.After(5 * time.Second):
497+ t.Fatal("an idle watch of a revoked token did not end")
498+ }
499+ })
500+
501+ t.Run("the answer is kept between checks", func(t *testing.T) {
502+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
503+ pub := r.repo("pub", domain.VisibilityPublic)
504+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
505+ for i := range 20 {
506+ r.push(pub, i)
507+ }
508+ for range 20 {
509+ w.next(t)
510+ }
511+ assert.Zero(t, r.auth.count(), "no store lookup within the interval")
512+ })
513+
514+ t.Run("root and anonymous never ask", func(t *testing.T) {
515+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
516+ pub := r.repo("pub", domain.VisibilityPublic)
517+ a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
518+ b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
519+ r.push(pub, 1)
520+ a.next(t)
521+ b.next(t)
522+ assert.Zero(t, r.auth.count())
523+ })
524+}
525+
526+func TestWatchLimits(t *testing.T) {
527+ noop := func(app.WatchEvent) error { return nil }
528+
529+ t.Run("too many topics", func(t *testing.T) {
530+ r := newEventRig(t, 0)
531+ r.repo("pub", domain.VisibilityPublic)
532+ ok := make([]string, app.MaxWatchTopics)
533+ for i := range ok {
534+ ok[i] = "home"
535+ }
536+ ctx, cancel := context.WithCancel(context.Background())
537+ cancel() // returns at once once the topics are accepted
538+ assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))
539+ err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)
540+ assert.ErrorIs(t, err, domain.ErrInvalid)
541+ })
542+
543+ t.Run("total watchers", func(t *testing.T) {
544+ r := newEventRig(t, 0)
545+ ctx, cancel := context.WithCancel(context.Background())
546+ defer cancel()
547+ for range app.MaxWatchers {
548+ r.watch(ctx, domain.Root(), app.WatchParams{})
549+ }
550+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond)
551+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)
552+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
553+ assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
554+
555+ cancel() // all end; a new one is accepted again
556+ require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
557+ again, stop := context.WithCancel(context.Background())
558+ stop()
559+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))
560+ })
561+
562+ t.Run("watchers per client", func(t *testing.T) {
563+ r := newEventRig(t, 0)
564+ ctx, cancel := context.WithCancel(context.Background())
565+ defer cancel()
566+ for range app.MaxWatchersPerClient {
567+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})
568+ }
569+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
570+
571+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)
572+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
573+
574+ // Another client, and callers in process, are not affected.
575+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})
576+ r.watch(ctx, domain.Root(), app.WatchParams{})
577+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond)
578+
579+ cancel()
580+ require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
581+ again, stop := context.WithCancel(context.Background())
582+ stop()
583+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
584+ })
585+}
586+
587+// repoAt declares a repository whose metadata the test changes.
588+type liveRepo struct {
589+ mu sync.Mutex
590+ repo domain.Repo
591+}
592+
593+func (l *liveRepo) set(f func(*domain.Repo)) {
594+ l.mu.Lock()
595+ defer l.mu.Unlock()
596+ f(&l.repo)
597+}
598+
599+func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) {
600+ n := name(r.t, s)
601+ l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}}
602+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
603+ l.mu.Lock()
604+ defer l.mu.Unlock()
605+ return l.repo, nil
606+ }).Maybe()
607+ return l, n
608+}
609+
610+func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
611+ ctx, cancel := context.WithCancel(context.Background())
612+ defer cancel()
613+
614+ t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
615+ r := newEventRig(t, 0)
616+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
617+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
618+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
619+ r.push(n, 1)
620+ r.push(n, 2)
621+ root.next(t)
622+ root.next(t) // both are buffered now
623+
624+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
625+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
626+ root.next(t)
627+ r.push(n, 3) // public era
628+
629+ // An anonymous caller that resumes with an id nobody issued gets what
630+ // is buffered, filtered: the public update and the public push, not the
631+ // pushes of the private era.
632+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
633+ assert.True(t, anon.next(t).Gap)
634+ upd := anon.next(t)
635+ require.IsType(t, domain.RepoUpdated{}, upd.Event)
636+ push := anon.next(t)
637+ require.IsType(t, domain.PushEvent{}, push.Event)
638+ assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
639+ anon.quiet(t, r, n)
640+ })
641+
642+ t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
643+ r := newEventRig(t, 0)
644+ first := now.Add(-time.Hour)
645+ l, n := r.liveRepo("again", domain.VisibilityPublic, first)
646+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
647+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
648+ r.push(n, 1)
649+ root.next(t)
650+
651+ l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
652+ r.push(n, 2)
653+ root.next(t)
654+
655+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
656+ assert.True(t, anon.next(t).Gap)
657+ e := anon.next(t)
658+ require.IsType(t, domain.PushEvent{}, e.Event)
659+ assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
660+ anon.quiet(t, r, n)
661+ })
662+
663+ t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
664+ r := newEventRig(t, 0)
665+ l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
666+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
667+ reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})
668+ other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})
669+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
670+ require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond)
671+
672+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
673+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
674+
675+ for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
676+ e := w.next(t)
677+ up, ok := e.Event.(domain.RepoUpdated)
678+ require.True(t, ok, name)
679+ assert.Equal(t, n, up.Repo, name)
680+ require.NotNil(t, e.Repo, name)
681+ assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
682+ assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
683+ }
684+
685+ // Later events of the repository reach those who may read it now, only.
686+ r.push(n, 1)
687+ assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
688+ assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
689+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
690+ other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
691+ })
692+
693+ t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
694+ r := newEventRig(t, 0)
695+ _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
696+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
697+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
698+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
699+ r.push(n, 1)
700+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
701+ })
368702 }
@@ -19,23 +19,63 @@ import (
19 "bnema.dev/ermite/internal/ports/mocks"19 "bnema.dev/ermite/internal/ports/mocks"
20 )20 )
21 21
22+// fakeAuth is the Revalidator of the rig: tokens are valid until revoked.
23+type fakeAuth struct {
24+ mu sync.Mutex
25+ revoked map[string]bool
26+ calls int
27+}
28+
29+func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error {
30+ a.mu.Lock()
31+ defer a.mu.Unlock()
32+ a.calls++
33+ if a.revoked[p.TokenID()] {
34+ return domain.ErrUnauthenticated
35+ }
36+ return nil
37+}
38+
39+func (a *fakeAuth) revoke(id string) {
40+ a.mu.Lock()
41+ defer a.mu.Unlock()
42+ if a.revoked == nil {
43+ a.revoked = map[string]bool{}
44+ }
45+ a.revoked[id] = true
46+}
47+
48+func (a *fakeAuth) count() int {
49+ a.mu.Lock()
50+ defer a.mu.Unlock()
51+ return a.calls
52+}
53+
22 // eventRig is an Events use case fed by a channel the test writes to.54 // eventRig is an Events use case fed by a channel the test writes to.
23 type eventRig struct {55 type eventRig struct {
24 t *testing.T56 t *testing.T
25 meta *mocks.RepoMetaStore57 meta *mocks.RepoMetaStore
26 git *mocks.GitRepos58 git *mocks.GitRepos
27 feed chan domain.Event59 feed chan domain.Event
60+ auth *fakeAuth
28 events *app.Events61 events *app.Events
29 }62 }
30 63
31 func newEventRig(t *testing.T, size int) *eventRig {64 func newEventRig(t *testing.T, size int) *eventRig {
65+ t.Helper()
66+ return newEventRigConfig(t, app.EventsConfig{Size: size})
67+}
68+
69+func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
32 t.Helper()70 t.Helper()
33 r := &eventRig{71 r := &eventRig{
34 t: t,72 t: t,
35 meta: mocks.NewRepoMetaStore(t),73 meta: mocks.NewRepoMetaStore(t),
36 git: mocks.NewGitRepos(t),74 git: mocks.NewGitRepos(t),
37 feed: make(chan domain.Event, 4096),75 feed: make(chan domain.Event, 4096),
76+ auth: &fakeAuth{},
38 }77 }
78+ cfg.Auth = r.auth
39 bus := mocks.NewEventBus(t)79 bus := mocks.NewEventBus(t)
40 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {80 bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event {
41 go func() { <-ctx.Done(); close(r.feed) }()81 go func() { <-ctx.Done(); close(r.feed) }()
@@ -45,7 +85,7 @@ func newEventRig(t *testing.T, size int) *eventRig {
45 clock.EXPECT().Now().Return(now).Maybe()85 clock.EXPECT().Now().Return(now).Maybe()
46 r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()86 r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe()
47 repos := app.NewRepos(r.meta, r.git, bus, clock)87 repos := app.NewRepos(r.meta, r.git, bus, clock)
48- r.events = app.NewEvents(context.Background(), repos, bus, size, clock)88+ r.events = app.NewEvents(context.Background(), repos, bus, clock, cfg)
49 t.Cleanup(func() { r.events.Close(context.Background()) })89 t.Cleanup(func() { r.events.Close(context.Background()) })
50 return r90 return r
51 }91 }
@@ -92,12 +132,20 @@ func (w *watcher) next(t *testing.T) app.WatchEvent {
92 return app.WatchEvent{}132 return app.WatchEvent{}
93 }133 }
94 134
95-func (w *watcher) none(t *testing.T) {135+// sentinelOID marks the push events that only prove the ones before them were
136+// handled.
137+var sentinelOID = fmt.Sprintf("%040x", 0xfeed)
138+
139+// quiet proves that the watcher got nothing since its last read: it pushes an
140+// event on n, which the watcher may see, and requires it to be the next one.
141+// Events reach a watcher in order, so anything sent before would show up first.
142+func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
96 t.Helper()143 t.Helper()
97- select {144+ r.feed <- domain.PushEvent{Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: sentinelOID, Ref: "refs/heads/sentinel"}}, At: now}
98- case e := <-w.got:145+ e := w.next(t)
99- t.Fatalf("unexpected event %+v", e)146+ push, ok := e.Event.(domain.PushEvent)
100- case <-time.After(100 * time.Millisecond):147+ if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID {
148+ t.Fatalf("expected nothing before the sentinel, got %+v", e)
101 }149 }
102 }150 }
103 151
@@ -121,7 +169,7 @@ func TestWatchLiveAndFiltered(t *testing.T) {
121 for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {169 for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
122 e := w.next(t)170 e := w.next(t)
123 assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)171 assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
124- w.none(t)172+ w.quiet(t, r, pub)
125 }173 }
126 first, second := root.next(t), root.next(t)174 first, second := root.next(t), root.next(t)
127 assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)175 assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
@@ -130,7 +178,7 @@ func TestWatchLiveAndFiltered(t *testing.T) {
130 assert.NotEqual(t, first.ID, second.ID)178 assert.NotEqual(t, first.ID, second.ID)
131 179
132 assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)180 assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
133- onlyPriv.none(t)181+ onlyPriv.quiet(t, r, priv)
134 }182 }
135 183
136 func TestWatchRechecksAccessPerEvent(t *testing.T) {184 func TestWatchRechecksAccessPerEvent(t *testing.T) {
@@ -155,7 +203,7 @@ func TestWatchRechecksAccessPerEvent(t *testing.T) {
155 vis = domain.VisibilityPrivate203 vis = domain.VisibilityPrivate
156 mu.Unlock()204 mu.Unlock()
157 r.push(n, 2)205 r.push(n, 2)
158- w.none(t)206+ w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
159 }207 }
160 208
161 func TestWatchResumeAndGap(t *testing.T) {209 func TestWatchResumeAndGap(t *testing.T) {
@@ -176,21 +224,8 @@ func TestWatchResumeAndGap(t *testing.T) {
176 }224 }
177 require.Len(t, ids, 20)225 require.Len(t, ids, 20)
178 226
179- t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {227+ // The subtests that read the state of the ring come first: the ones that
180- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})228+ // need a sentinel add events to it.
181- for _, want := range ids[17:] {
182- e := w.next(t)
183- assert.False(t, e.Gap)
184- assert.Equal(t, want, e.ID)
185- }
186- w.none(t)
187- })
188-
189- t.Run("resume at the last event replays nothing", func(t *testing.T) {
190- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})
191- w.none(t)
192- })
193-
194 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {229 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
195 // 20 events were taken, the buffer holds the last 8: ids[12] is the230 // 20 events were taken, the buffer holds the last 8: ids[12] is the
196 // oldest still present; ids[0] is long gone.231 // oldest still present; ids[0] is long gone.
@@ -215,13 +250,27 @@ func TestWatchResumeAndGap(t *testing.T) {
215 assert.True(t, w.next(t).Gap)250 assert.True(t, w.next(t).Gap)
216 })251 })
217 252
218- t.Run("a resumed watch goes live after the replay", func(t *testing.T) {253+ t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
219- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[19]})254+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
220- w.none(t)255+ for _, want := range ids[17:] {
256+ e := w.next(t)
257+ assert.False(t, e.Gap)
258+ assert.Equal(t, want, e.ID)
259+ }
260+ // Nothing else was replayed: the next event is the one pushed now.
261+ r.push(n, 98)
262+ live := w.next(t)
263+ assert.False(t, live.Gap)
264+ assert.NotContains(t, ids, live.ID)
265+ ids = append(ids, live.ID)
266+ })
267+
268+ t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
269+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})
221 r.push(n, 99)270 r.push(n, 99)
222 e := w.next(t)271 e := w.next(t)
223 assert.False(t, e.Gap)272 assert.False(t, e.Gap)
224- assert.NotEmpty(t, e.ID)273+ assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
225 })274 })
226 }275 }
227 276
@@ -314,14 +363,24 @@ func TestWatchSlowWatcherGetsAGap(t *testing.T) {
314 return nil363 return nil
315 })364 })
316 }()365 }()
317- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)366+ // A second, fast watcher tells when the use case has taken every event:
367+ // it receives them in order.
368+ fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
369+ require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
318 r.push(n, 0)370 r.push(n, 0)
319 <-blocked371 <-blocked
320 for i := 1; i < 30; i++ {372 for i := 1; i < 30; i++ {
321 r.push(n, i)373 r.push(n, i)
322 }374 }
323- // Wait for the use case to have taken them all (it does not block).375+ // The ring holds four events: the fast watcher may get a gap, but it ends
324- time.Sleep(100 * time.Millisecond)376+ // on the last push, which proves the use case took all of them.
377+ last := fmt.Sprintf("%040x", 29+1)
378+ for {
379+ e := fast.next(t)
380+ if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
381+ break
382+ }
383+ }
325 close(release)384 close(release)
326 385
327 require.Eventually(t, func() bool {386 require.Eventually(t, func() bool {
@@ -349,7 +408,7 @@ func TestEventsRepoPayloads(t *testing.T) {
349 require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)408 require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
350 409
351 r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}410 r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
352- r.feed <- domain.RepoUpdated{Repo: pub, At: now}411+ r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic}
353 // A deleted repository cannot be read any more: its visibility decides.412 // A deleted repository cannot be read any more: its visibility decides.
354 r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}413 r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
355 r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}414 r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
@@ -364,5 +423,280 @@ func TestEventsRepoPayloads(t *testing.T) {
364 assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)423 assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
365 assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)424 assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
366 assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")425 assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
367- anon.none(t)426+ anon.quiet(t, r, pub)
427+}
428+
429+// tokenWith builds the principal of a stored token with an id and an expiry.
430+func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
431+ tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires}
432+ for _, r := range repos {
433+ n, err := domain.ParseRepoName(r)
434+ if err != nil {
435+ panic(err)
436+ }
437+ tok.Repos = append(tok.Repos, n)
438+ }
439+ return domain.TokenPrincipal(tok)
440+}
441+
442+func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
443+ // Asking at every turn keeps the test independent of the clock.
444+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
445+ pub := r.repo("pub", domain.VisibilityPublic)
446+ priv := r.repo("priv", domain.VisibilityPrivate)
447+ p := tokenWith("tok", time.Time{})
448+ ctx, cancel := context.WithCancel(context.Background())
449+ defer cancel()
450+
451+ w := r.watch(ctx, p, app.WatchParams{})
452+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
453+ r.push(pub, 1)
454+ w.next(t)
455+
456+ r.auth.revoke("tok")
457+ r.push(priv, 2) // would be delivered to a token that may read priv
458+ select {
459+ case err := <-w.done:
460+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
461+ case <-time.After(5 * time.Second):
462+ t.Fatal("the watch of a revoked token did not end")
463+ }
464+ select {
465+ case e := <-w.got:
466+ t.Fatalf("an event was sent after the revocation: %+v", e)
467+ default:
468+ }
469+}
470+
471+func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
472+ r := newEventRig(t, 0)
473+ pub := r.repo("pub", domain.VisibilityPublic)
474+ // The rig's clock stands at now: this token expired at now.
475+ p := tokenWith("tok", now)
476+ w := r.watch(context.Background(), p, app.WatchParams{})
477+ r.push(pub, 1)
478+ select {
479+ case err := <-w.done:
480+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
481+ case <-time.After(5 * time.Second):
482+ t.Fatal("the watch of an expired token did not end")
483+ }
484+ assert.Empty(t, w.got)
485+}
486+
487+func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
488+ t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
489+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
490+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
491+ require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
492+ r.auth.revoke("tok")
493+ select {
494+ case err := <-w.done:
495+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
496+ case <-time.After(5 * time.Second):
497+ t.Fatal("an idle watch of a revoked token did not end")
498+ }
499+ })
500+
501+ t.Run("the answer is kept between checks", func(t *testing.T) {
502+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
503+ pub := r.repo("pub", domain.VisibilityPublic)
504+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
505+ for i := range 20 {
506+ r.push(pub, i)
507+ }
508+ for range 20 {
509+ w.next(t)
510+ }
511+ assert.Zero(t, r.auth.count(), "no store lookup within the interval")
512+ })
513+
514+ t.Run("root and anonymous never ask", func(t *testing.T) {
515+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
516+ pub := r.repo("pub", domain.VisibilityPublic)
517+ a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
518+ b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
519+ r.push(pub, 1)
520+ a.next(t)
521+ b.next(t)
522+ assert.Zero(t, r.auth.count())
523+ })
524+}
525+
526+func TestWatchLimits(t *testing.T) {
527+ noop := func(app.WatchEvent) error { return nil }
528+
529+ t.Run("too many topics", func(t *testing.T) {
530+ r := newEventRig(t, 0)
531+ r.repo("pub", domain.VisibilityPublic)
532+ ok := make([]string, app.MaxWatchTopics)
533+ for i := range ok {
534+ ok[i] = "home"
535+ }
536+ ctx, cancel := context.WithCancel(context.Background())
537+ cancel() // returns at once once the topics are accepted
538+ assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))
539+ err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)
540+ assert.ErrorIs(t, err, domain.ErrInvalid)
541+ })
542+
543+ t.Run("total watchers", func(t *testing.T) {
544+ r := newEventRig(t, 0)
545+ ctx, cancel := context.WithCancel(context.Background())
546+ defer cancel()
547+ for range app.MaxWatchers {
548+ r.watch(ctx, domain.Root(), app.WatchParams{})
549+ }
550+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond)
551+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)
552+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
553+ assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
554+
555+ cancel() // all end; a new one is accepted again
556+ require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
557+ again, stop := context.WithCancel(context.Background())
558+ stop()
559+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))
560+ })
561+
562+ t.Run("watchers per client", func(t *testing.T) {
563+ r := newEventRig(t, 0)
564+ ctx, cancel := context.WithCancel(context.Background())
565+ defer cancel()
566+ for range app.MaxWatchersPerClient {
567+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})
568+ }
569+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
570+
571+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)
572+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
573+
574+ // Another client, and callers in process, are not affected.
575+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})
576+ r.watch(ctx, domain.Root(), app.WatchParams{})
577+ require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond)
578+
579+ cancel()
580+ require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
581+ again, stop := context.WithCancel(context.Background())
582+ stop()
583+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
584+ })
585+}
586+
587+// repoAt declares a repository whose metadata the test changes.
588+type liveRepo struct {
589+ mu sync.Mutex
590+ repo domain.Repo
591+}
592+
593+func (l *liveRepo) set(f func(*domain.Repo)) {
594+ l.mu.Lock()
595+ defer l.mu.Unlock()
596+ f(&l.repo)
597+}
598+
599+func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) {
600+ n := name(r.t, s)
601+ l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}}
602+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
603+ l.mu.Lock()
604+ defer l.mu.Unlock()
605+ return l.repo, nil
606+ }).Maybe()
607+ return l, n
608+}
609+
610+func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
611+ ctx, cancel := context.WithCancel(context.Background())
612+ defer cancel()
613+
614+ t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
615+ r := newEventRig(t, 0)
616+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
617+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
618+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
619+ r.push(n, 1)
620+ r.push(n, 2)
621+ root.next(t)
622+ root.next(t) // both are buffered now
623+
624+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
625+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
626+ root.next(t)
627+ r.push(n, 3) // public era
628+
629+ // An anonymous caller that resumes with an id nobody issued gets what
630+ // is buffered, filtered: the public update and the public push, not the
631+ // pushes of the private era.
632+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
633+ assert.True(t, anon.next(t).Gap)
634+ upd := anon.next(t)
635+ require.IsType(t, domain.RepoUpdated{}, upd.Event)
636+ push := anon.next(t)
637+ require.IsType(t, domain.PushEvent{}, push.Event)
638+ assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
639+ anon.quiet(t, r, n)
640+ })
641+
642+ t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
643+ r := newEventRig(t, 0)
644+ first := now.Add(-time.Hour)
645+ l, n := r.liveRepo("again", domain.VisibilityPublic, first)
646+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
647+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
648+ r.push(n, 1)
649+ root.next(t)
650+
651+ l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
652+ r.push(n, 2)
653+ root.next(t)
654+
655+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
656+ assert.True(t, anon.next(t).Gap)
657+ e := anon.next(t)
658+ require.IsType(t, domain.PushEvent{}, e.Event)
659+ assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
660+ anon.quiet(t, r, n)
661+ })
662+
663+ t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
664+ r := newEventRig(t, 0)
665+ l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
666+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
667+ reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})
668+ other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})
669+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
670+ require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond)
671+
672+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
673+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
674+
675+ for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
676+ e := w.next(t)
677+ up, ok := e.Event.(domain.RepoUpdated)
678+ require.True(t, ok, name)
679+ assert.Equal(t, n, up.Repo, name)
680+ require.NotNil(t, e.Repo, name)
681+ assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
682+ assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
683+ }
684+
685+ // Later events of the repository reach those who may read it now, only.
686+ r.push(n, 1)
687+ assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
688+ assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
689+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
690+ other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
691+ })
692+
693+ t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
694+ r := newEventRig(t, 0)
695+ _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
696+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
697+ require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
698+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
699+ r.push(n, 1)
700+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
701+ })
368 }702 }
M internal/app/repos_write.go +6 -2
@@ -73,6 +73,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7373 if err := adminAccess(p, repo); err != nil {
7474 return domain.Repo{}, err
7575 }
76+ before := repo
7677 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
7778 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
7879 }
@@ -123,7 +124,10 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
123124 Bool("default_branch", params.DefaultBranch != nil).
124125 Str("principal", p.Label()).Str("token_id", p.TokenID()).
125126 Msg("repository updated")
126- r.bus.Publish(ctx, domain.RepoUpdated{Repo: name, At: r.clock.Now().UTC()})
127+ r.bus.Publish(ctx, domain.RepoUpdated{
128+ Repo: name, At: r.clock.Now().UTC(),
129+ Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
130+ })
127131 return repo, nil
128132 }
129133
@@ -176,6 +180,6 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
176180 return fmt.Errorf("delete metadata of %q: %w", name, err)
177181 }
178182 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
179- r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC()})
183+ r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
180184 return nil
181185 }
@@ -73,6 +73,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
73 if err := adminAccess(p, repo); err != nil {73 if err := adminAccess(p, repo); err != nil {
74 return domain.Repo{}, err74 return domain.Repo{}, err
75 }75 }
76+ before := repo
76 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {77 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
77 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)78 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
78 }79 }
@@ -123,7 +124,10 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
123 Bool("default_branch", params.DefaultBranch != nil).124 Bool("default_branch", params.DefaultBranch != nil).
124 Str("principal", p.Label()).Str("token_id", p.TokenID()).125 Str("principal", p.Label()).Str("token_id", p.TokenID()).
125 Msg("repository updated")126 Msg("repository updated")
126- r.bus.Publish(ctx, domain.RepoUpdated{Repo: name, At: r.clock.Now().UTC()})127+ r.bus.Publish(ctx, domain.RepoUpdated{
128+ Repo: name, At: r.clock.Now().UTC(),
129+ Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
130+ })
127 return repo, nil131 return repo, nil
128 }132 }
129 133
@@ -176,6 +180,6 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
176 return fmt.Errorf("delete metadata of %q: %w", name, err)180 return fmt.Errorf("delete metadata of %q: %w", name, err)
177 }181 }
178 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")182 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
179- r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC()})183+ r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
180 return nil184 return nil
181 }185 }
M internal/app/repos_write_test.go +3 -1
@@ -29,7 +29,9 @@ func TestUpdate(t *testing.T) {
2929 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
3030 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}
3131 f.meta.EXPECT().Update(ctx, want).Return(nil)
32- f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{Repo: n, At: now}).Return().Once()
32+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
33+ Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
34+ }).Return().Once()
3335
3436 got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
3537 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
@@ -29,7 +29,9 @@ func TestUpdate(t *testing.T) {
29 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()29 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}30 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}
31 f.meta.EXPECT().Update(ctx, want).Return(nil)31 f.meta.EXPECT().Update(ctx, want).Return(nil)
32- f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{Repo: n, At: now}).Return().Once()32+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
33+ Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
34+ }).Return().Once()
33 35
34 got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{36 got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
35 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),37 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
M internal/clientlimit/limiter.go +5 -0
@@ -77,6 +77,11 @@ func limiterKey(addr string) string {
7777 return netip.PrefixFrom(a, ipv6Prefix).Masked().String()
7878 }
7979
80+// Key returns the identity under which a client address is counted, by the
81+// limiter and by the other per-client limits: an IPv4 address, or the /64
82+// network of an IPv6 address.
83+func Key(addr string) string { return limiterKey(addr) }
84+
8085 // Blocked reports whether addr has used up its failures, and how long until
8186 // it may try again.
8287 func (l *Limiter) Blocked(_ context.Context, addr string) (bool, time.Duration) {
@@ -77,6 +77,11 @@ func limiterKey(addr string) string {
77 return netip.PrefixFrom(a, ipv6Prefix).Masked().String()77 return netip.PrefixFrom(a, ipv6Prefix).Masked().String()
78 }78 }
79 79
80+// Key returns the identity under which a client address is counted, by the
81+// limiter and by the other per-client limits: an IPv4 address, or the /64
82+// network of an IPv6 address.
83+func Key(addr string) string { return limiterKey(addr) }
84+
80 // Blocked reports whether addr has used up its failures, and how long until85 // Blocked reports whether addr has used up its failures, and how long until
81 // it may try again.86 // it may try again.
82 func (l *Limiter) Blocked(_ context.Context, addr string) (bool, time.Duration) {87 func (l *Limiter) Blocked(_ context.Context, addr string) (bool, time.Duration) {
M internal/domain/errors.go +3 -0
@@ -33,4 +33,7 @@ var (
3333 // ErrPreconditionFailed means the state of the forge does not allow the
3434 // operation, for example a default branch that does not exist.
3535 ErrPreconditionFailed = errors.New("failed precondition")
36+ // ErrResourceExhausted means a limit of the server is reached, for example
37+ // the number of event watchers; the caller may retry later.
38+ ErrResourceExhausted = errors.New("resource exhausted")
3639 )
@@ -33,4 +33,7 @@ var (
33 // ErrPreconditionFailed means the state of the forge does not allow the33 // ErrPreconditionFailed means the state of the forge does not allow the
34 // operation, for example a default branch that does not exist.34 // operation, for example a default branch that does not exist.
35 ErrPreconditionFailed = errors.New("failed precondition")35 ErrPreconditionFailed = errors.New("failed precondition")
36+ // ErrResourceExhausted means a limit of the server is reached, for example
37+ // the number of event watchers; the caller may retry later.
38+ ErrResourceExhausted = errors.New("resource exhausted")
36 )39 )
M internal/domain/event.go +11 -0
@@ -62,9 +62,18 @@ func (RepoCreated) EventKind() string { return "repo.created" }
6262
6363 // RepoUpdated is published when the metadata of a repository, or its default
6464 // branch, changed. The new state is read from the repository.
65+//
66+// The visibility fields tell who could read the repository before and after
67+// the change, and CreatedAt tells which repository of that name it was (a
68+// name can be deleted and created again): the event stream decides who may be
69+// told from them.
6570 type RepoUpdated struct {
6671 Repo RepoName
6772 At time.Time
73+ // Visibility is the visibility after the change, Previous the one before.
74+ Visibility, Previous Visibility
75+ // CreatedAt is the creation time of the repository.
76+ CreatedAt time.Time
6877 }
6978
7079 // EventKind implements Event.
@@ -76,6 +85,8 @@ type RepoDeleted struct {
7685 Repo RepoName
7786 Visibility Visibility
7887 At time.Time
88+ // CreatedAt is the creation time of the repository that was deleted.
89+ CreatedAt time.Time
7990 }
8091
8192 // EventKind implements Event.
@@ -62,9 +62,18 @@ func (RepoCreated) EventKind() string { return "repo.created" }
62 62
63 // RepoUpdated is published when the metadata of a repository, or its default63 // RepoUpdated is published when the metadata of a repository, or its default
64 // branch, changed. The new state is read from the repository.64 // branch, changed. The new state is read from the repository.
65+//
66+// The visibility fields tell who could read the repository before and after
67+// the change, and CreatedAt tells which repository of that name it was (a
68+// name can be deleted and created again): the event stream decides who may be
69+// told from them.
65 type RepoUpdated struct {70 type RepoUpdated struct {
66 Repo RepoName71 Repo RepoName
67 At time.Time72 At time.Time
73+ // Visibility is the visibility after the change, Previous the one before.
74+ Visibility, Previous Visibility
75+ // CreatedAt is the creation time of the repository.
76+ CreatedAt time.Time
68 }77 }
69 78
70 // EventKind implements Event.79 // EventKind implements Event.
@@ -76,6 +85,8 @@ type RepoDeleted struct {
76 Repo RepoName85 Repo RepoName
77 Visibility Visibility86 Visibility Visibility
78 At time.Time87 At time.Time
88+ // CreatedAt is the creation time of the repository that was deleted.
89+ CreatedAt time.Time
79 }90 }
80 91
81 // EventKind implements Event.92 // EventKind implements Event.

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 2

Tags 45

↑↓ moveenter openesc close