Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

knotserver: backfill members & collaborators from casbin

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 16, 2026, 9:04 PM +0300) commit ba857c7c parent 8b6da860 change-id nvkwtvnw
+356 -98
+74 -68
knotserver/backfill.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log/slog" 7 - "time" 7 + "slices" 8 8 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 9 "github.com/bluesky-social/indigo/atproto/syntax" 11 - "github.com/bluesky-social/indigo/xrpc" 12 10 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/idresolver" 15 11 "tangled.org/core/knotserver/db" 16 12 "tangled.org/core/rbac" 17 13 ) 18 14 19 15 const ( 20 - knotMembersBackfillMigration = "backfill-knot-members-from-pds-v2" 21 - knotMembersBackfillPerOwner = 30 * time.Second 16 + collaboratorBackfillMigration = "backfill-collaborators-from-casbin-v1" 17 + knotMemberBackfillMigration = "backfill-knot-members-from-casbin-v1" 22 18 ) 23 19 24 20 func BackfillKnotMembers( 25 21 ctx context.Context, 26 22 d *db.DB, 27 23 e *rbac.Enforcer, 28 - resolver *idresolver.Resolver, 29 - hostname string, 24 + ownerDid string, 30 25 logger *slog.Logger, 31 26 ) error { 32 - l := logger.With("migration", knotMembersBackfillMigration) 27 + l := logger.With("migration", knotMemberBackfillMigration) 33 28 34 - applied, err := d.IsMigrationApplied(knotMembersBackfillMigration) 29 + applied, err := d.IsMigrationApplied(knotMemberBackfillMigration) 35 30 if err != nil { 36 31 return fmt.Errorf("check migration applied: %w", err) 37 32 } ··· 39 34 return nil 40 35 } 41 36 37 + owner, err := syntax.ParseDID(ownerDid) 38 + if err != nil { 39 + return fmt.Errorf("invalid knot owner DID %q: %w", ownerDid, err) 40 + } 41 + 42 + members, err := e.GetKnotUsersByRole("server:member", rbac.ThisServer) 43 + if err != nil { 44 + return fmt.Errorf("list members: %w", err) 45 + } 42 46 owners, err := e.GetKnotUsersByRole("server:owner", rbac.ThisServer) 43 47 if err != nil { 44 48 return fmt.Errorf("list owners: %w", err) 45 49 } 46 50 47 51 var rows []db.KnotMember 48 - for _, owner := range owners { 49 - ownerCtx, cancel := context.WithTimeout(ctx, knotMembersBackfillPerOwner) 50 - ownerRows, err := fetchOwnerKnotMembers(ownerCtx, resolver, hostname, owner, l) 51 - cancel() 52 + for _, candidate := range members { 53 + if slices.Contains(owners, candidate) { 54 + continue 55 + } 56 + subject, err := syntax.ParseDID(candidate) 52 57 if err != nil { 53 - l.Warn("skipping owner during backfill", "owner", owner, "err", err) 58 + l.Warn("skipping member with invalid DID", "candidate", candidate, "err", err) 54 59 continue 55 60 } 56 - rows = append(rows, ownerRows...) 57 - } 58 - 59 - for _, m := range rows { 60 - if err := e.AddKnotMember(rbac.ThisServer, m.Subject.String()); err != nil { 61 - return fmt.Errorf("grant ACL for %s: %w", m.Subject, err) 62 - } 61 + rows = append(rows, db.KnotMember{Did: owner, Subject: subject}) 63 62 } 64 63 65 - if err := d.ApplyKnotMembersBackfill(ctx, rows, knotMembersBackfillMigration); err != nil { 64 + if err := d.ApplyKnotMemberBackfill(ctx, rows, knotMemberBackfillMigration); err != nil { 66 65 return fmt.Errorf("apply backfill: %w", err) 67 66 } 68 67 69 - l.Info("backfilled knot members", "count", len(rows), "owners", len(owners)) 68 + l.Info("backfilled knot members from casbin", "count", len(rows)) 70 69 return nil 71 70 } 72 71 73 - func fetchOwnerKnotMembers( 72 + func BackfillCollaborators( 74 73 ctx context.Context, 75 - resolver *idresolver.Resolver, 76 - hostname string, 77 - owner string, 78 - l *slog.Logger, 79 - ) ([]db.KnotMember, error) { 80 - ownerDid, err := syntax.ParseDID(owner) 74 + d *db.DB, 75 + e *rbac.Enforcer, 76 + logger *slog.Logger, 77 + markApplied bool, 78 + ) error { 79 + l := logger.With("migration", collaboratorBackfillMigration) 80 + 81 + applied, err := d.IsMigrationApplied(collaboratorBackfillMigration) 81 82 if err != nil { 82 - return nil, fmt.Errorf("invalid owner DID %q: %w", owner, err) 83 + return fmt.Errorf("check migration applied: %w", err) 84 + } 85 + if applied { 86 + return nil 83 87 } 84 88 85 - ident, err := resolver.ResolveIdent(ctx, owner) 89 + repoDids, err := d.ListRepoDids() 86 90 if err != nil { 87 - return nil, fmt.Errorf("resolve %s: %w", owner, err) 91 + return fmt.Errorf("list repos: %w", err) 88 92 } 89 - client := &xrpc.Client{Host: ident.PDSEndpoint()} 90 93 91 - var ( 92 - rows []db.KnotMember 93 - cursor string 94 - ) 95 - for { 96 - out, err := comatproto.RepoListRecords(ctx, client, tangled.KnotMemberNSID, cursor, 100, owner, false) 94 + var rows []db.Collaborator 95 + for _, repoDid := range repoDids { 96 + ownerDid, _, err := d.GetRepoKeyOwner(repoDid) 97 97 if err != nil { 98 - return nil, fmt.Errorf("list records: %w", err) 98 + l.Warn("skipping repo during collaborator backfill", "repoDid", repoDid, "err", err) 99 + continue 99 100 } 100 - for _, rec := range out.Records { 101 - m, ok := rec.Value.Val.(*tangled.KnotMember) 102 - if !ok || m.Domain != hostname { 103 - continue 104 - } 105 - subject, err := syntax.ParseDID(m.Subject) 101 + 102 + repo, err := syntax.ParseDID(repoDid) 103 + if err != nil { 104 + l.Warn("skipping repo with invalid DID", "repoDid", repoDid, "err", err) 105 + continue 106 + } 107 + owner, err := syntax.ParseDID(ownerDid) 108 + if err != nil { 109 + l.Warn("skipping repo with invalid owner DID", "repoDid", repoDid, "owner", ownerDid, "err", err) 110 + continue 111 + } 112 + 113 + collaborators, err := e.GetUserByRoleInRepo("repo:collaborator", rbac.ThisServer, repoDid) 114 + if err != nil { 115 + return fmt.Errorf("list collaborators for %s: %w", repoDid, err) 116 + } 117 + 118 + for _, candidate := range collaborators { 119 + subject, err := syntax.ParseDID(candidate) 106 120 if err != nil { 107 - l.Warn("invalid subject DID in record, skipping", "uri", rec.Uri, "err", err) 108 - continue 109 - } 110 - uri, err := syntax.ParseATURI(rec.Uri) 111 - if err != nil { 112 - l.Warn("invalid AT URI in record, skipping", "uri", rec.Uri, "err", err) 121 + l.Warn("skipping collaborator with invalid DID", "repoDid", repoDid, "candidate", candidate, "err", err) 113 122 continue 114 123 } 115 - rkey := uri.RecordKey().String() 116 - if rkey == "" { 117 - l.Warn("empty rkey in AT URI, skipping", "uri", rec.Uri) 118 - continue 119 - } 120 - rows = append(rows, db.KnotMember{ 121 - Did: ownerDid, 122 - Rkey: rkey, 124 + rows = append(rows, db.Collaborator{ 125 + RepoDid: repo, 123 126 Subject: subject, 127 + AddedBy: owner, 124 128 }) 125 129 } 126 - if out.Cursor == nil || *out.Cursor == "" || *out.Cursor == cursor { 127 - break 128 - } 129 - cursor = *out.Cursor 130 + } 131 + 132 + if err := d.ApplyCollaboratorBackfill(ctx, rows, collaboratorBackfillMigration, markApplied); err != nil { 133 + return fmt.Errorf("apply backfill: %w", err) 130 134 } 131 - return rows, nil 135 + 136 + l.Info("backfilled collaborators from casbin", "count", len(rows), "repos", len(repoDids), "marked", markApplied) 137 + return nil 132 138 }
+266
knotserver/backfill_test.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "log/slog" 7 + "path/filepath" 8 + "testing" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "tangled.org/core/knotserver/db" 13 + "tangled.org/core/rbac" 14 + ) 15 + 16 + const ( 17 + bfOwner = "did:plc:akshay" 18 + bfCollab = "did:plc:boltless" 19 + bfRepo = "did:plc:limpet" 20 + ) 21 + 22 + func newBackfillEnv(t *testing.T) (*db.DB, *rbac.Enforcer) { 23 + t.Helper() 24 + dir := t.TempDir() 25 + d, err := db.Setup(context.Background(), filepath.Join(dir, "knot.db")) 26 + if err != nil { 27 + t.Fatalf("db.Setup: %v", err) 28 + } 29 + e, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) 30 + if err != nil { 31 + t.Fatalf("NewEnforcer: %v", err) 32 + } 33 + if err := e.AddKnot(rbac.ThisServer); err != nil { 34 + t.Fatalf("AddKnot: %v", err) 35 + } 36 + if err := e.AddKnotOwner(rbac.ThisServer, bfOwner); err != nil { 37 + t.Fatalf("AddKnotOwner: %v", err) 38 + } 39 + return d, e 40 + } 41 + 42 + func seedCasbinRepo(t *testing.T, d *db.DB, e *rbac.Enforcer, repoDid string, collaborators ...string) { 43 + t.Helper() 44 + if err := d.StoreRepoKey(repoDid, []byte("signing"), bfOwner, "reponame"); err != nil { 45 + t.Fatalf("StoreRepoKey: %v", err) 46 + } 47 + if err := e.AddRepo(bfOwner, rbac.ThisServer, repoDid); err != nil { 48 + t.Fatalf("AddRepo: %v", err) 49 + } 50 + for _, c := range collaborators { 51 + if err := e.AddCollaborator(c, rbac.ThisServer, repoDid); err != nil { 52 + t.Fatalf("AddCollaborator %s: %v", c, err) 53 + } 54 + } 55 + } 56 + 57 + func runBackfill(t *testing.T, d *db.DB, e *rbac.Enforcer) { 58 + t.Helper() 59 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 60 + if err := BackfillCollaborators(context.Background(), d, e, logger, true); err != nil { 61 + t.Fatalf("BackfillCollaborators: %v", err) 62 + } 63 + } 64 + 65 + func runMemberBackfill(t *testing.T, d *db.DB, e *rbac.Enforcer) { 66 + t.Helper() 67 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 68 + if err := BackfillKnotMembers(context.Background(), d, e, bfOwner, logger); err != nil { 69 + t.Fatalf("BackfillKnotMembers: %v", err) 70 + } 71 + } 72 + 73 + func TestBackfillCollaborators_FoldsCasbinAndExcludesOwner(t *testing.T) { 74 + d, e := newBackfillEnv(t) 75 + seedCasbinRepo(t, d, e, bfRepo, bfCollab) 76 + 77 + runBackfill(t, d, e) 78 + 79 + list, _, err := db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 80 + if err != nil { 81 + t.Fatalf("ListCollaborators: %v", err) 82 + } 83 + if len(list) != 1 { 84 + t.Fatalf("collaborators = %+v, want exactly one (owner must be excluded)", list) 85 + } 86 + if list[0].Subject != syntax.DID(bfCollab) { 87 + t.Errorf("subject = %s, want %s", list[0].Subject, bfCollab) 88 + } 89 + if list[0].AddedBy != syntax.DID(bfOwner) { 90 + t.Errorf("addedBy = %s, want owner %s", list[0].AddedBy, bfOwner) 91 + } 92 + } 93 + 94 + func TestBackfillCollaborators_OneTimeAndNonDestructive(t *testing.T) { 95 + d, e := newBackfillEnv(t) 96 + seedCasbinRepo(t, d, e, bfRepo, bfCollab) 97 + 98 + runBackfill(t, d, e) 99 + 100 + if err := e.AddCollaborator("did:plc:scallop", rbac.ThisServer, bfRepo); err != nil { 101 + t.Fatalf("post-migration casbin add: %v", err) 102 + } 103 + runBackfill(t, d, e) 104 + 105 + list, _, err := db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 106 + if err != nil { 107 + t.Fatalf("ListCollaborators: %v", err) 108 + } 109 + if len(list) != 1 { 110 + t.Fatalf("collaborators = %d, want 1; backfill must run once and never resurrect later casbin state", len(list)) 111 + } 112 + if list[0].Subject != syntax.DID(bfCollab) { 113 + t.Errorf("subject = %s, want original %s preserved", list[0].Subject, bfCollab) 114 + } 115 + } 116 + 117 + func TestBackfillCollaborators_LeavesMembersUntouched(t *testing.T) { 118 + d, e := newBackfillEnv(t) 119 + seedCasbinRepo(t, d, e, bfRepo, bfCollab) 120 + 121 + owner := syntax.DID(bfOwner) 122 + member := syntax.DID("did:plc:whelk") 123 + if err := db.AddKnotMemberDirect(d, owner, member); err != nil { 124 + t.Fatalf("seed member: %v", err) 125 + } 126 + if err := e.AddKnotMember(rbac.ThisServer, member.String()); err != nil { 127 + t.Fatalf("seed member acl: %v", err) 128 + } 129 + 130 + runBackfill(t, d, e) 131 + 132 + members, _, err := db.ListKnotMembers(d, db.ListPage{Limit: db.ListMaxLimit}) 133 + if err != nil { 134 + t.Fatalf("ListKnotMembers: %v", err) 135 + } 136 + if len(members) != 1 || members[0].Subject != member { 137 + t.Fatalf("members = %+v, want the seeded member preserved", members) 138 + } 139 + 140 + collabs, _, err := db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 141 + if err != nil { 142 + t.Fatalf("ListCollaborators: %v", err) 143 + } 144 + if len(collabs) != 1 || collabs[0].Subject != syntax.DID(bfCollab) { 145 + t.Errorf("collaborators = %+v, want only the casbin collaborator", collabs) 146 + } 147 + } 148 + 149 + func TestBackfillCollaborators_UnmarkedRunDefersMarker(t *testing.T) { 150 + d, e := newBackfillEnv(t) 151 + seedCasbinRepo(t, d, e, bfRepo, bfCollab) 152 + 153 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 154 + if err := BackfillCollaborators(context.Background(), d, e, logger, false); err != nil { 155 + t.Fatalf("unmarked backfill: %v", err) 156 + } 157 + 158 + list, _, err := db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 159 + if err != nil { 160 + t.Fatalf("ListCollaborators: %v", err) 161 + } 162 + if len(list) != 1 { 163 + t.Fatalf("collaborators = %d, want 1 after unmarked run", len(list)) 164 + } 165 + applied, err := d.IsMigrationApplied(collaboratorBackfillMigration) 166 + if err != nil { 167 + t.Fatalf("IsMigrationApplied: %v", err) 168 + } 169 + if applied { 170 + t.Fatal("unmarked run must not write the migration marker") 171 + } 172 + 173 + if err := e.AddCollaborator("did:plc:scallop", rbac.ThisServer, bfRepo); err != nil { 174 + t.Fatalf("late casbin add: %v", err) 175 + } 176 + runBackfill(t, d, e) 177 + 178 + list, _, err = db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 179 + if err != nil { 180 + t.Fatalf("ListCollaborators after marked run: %v", err) 181 + } 182 + if len(list) != 2 { 183 + t.Errorf("collaborators = %d, want 2; the marked rerun must fold late casbin state", len(list)) 184 + } 185 + if applied, _ := d.IsMigrationApplied(collaboratorBackfillMigration); !applied { 186 + t.Error("marked run did not write the migration marker") 187 + } 188 + } 189 + 190 + func TestBackfillKnotMembers_FoldsCasbinAndExcludesOwner(t *testing.T) { 191 + d, e := newBackfillEnv(t) 192 + member := syntax.DID("did:plc:whelk") 193 + if err := e.AddKnotMember(rbac.ThisServer, member.String()); err != nil { 194 + t.Fatalf("seed casbin member: %v", err) 195 + } 196 + 197 + runMemberBackfill(t, d, e) 198 + 199 + members, _, err := db.ListKnotMembers(d, db.ListPage{Limit: db.ListMaxLimit}) 200 + if err != nil { 201 + t.Fatalf("ListKnotMembers: %v", err) 202 + } 203 + if len(members) != 1 { 204 + t.Fatalf("members = %+v, want exactly one; the owner must be excluded", members) 205 + } 206 + if members[0].Subject != member { 207 + t.Errorf("subject = %s, want %s", members[0].Subject, member) 208 + } 209 + if members[0].Did != syntax.DID(bfOwner) { 210 + t.Errorf("added by = %s, want owner %s", members[0].Did, bfOwner) 211 + } 212 + } 213 + 214 + func TestBackfillKnotMembers_OneTimeAndNonDestructive(t *testing.T) { 215 + d, e := newBackfillEnv(t) 216 + member := syntax.DID("did:plc:whelk") 217 + if err := e.AddKnotMember(rbac.ThisServer, member.String()); err != nil { 218 + t.Fatalf("seed casbin member: %v", err) 219 + } 220 + 221 + runMemberBackfill(t, d, e) 222 + 223 + if err := e.AddKnotMember(rbac.ThisServer, "did:plc:scallop"); err != nil { 224 + t.Fatalf("post-migration casbin add: %v", err) 225 + } 226 + runMemberBackfill(t, d, e) 227 + 228 + members, _, err := db.ListKnotMembers(d, db.ListPage{Limit: db.ListMaxLimit}) 229 + if err != nil { 230 + t.Fatalf("ListKnotMembers: %v", err) 231 + } 232 + if len(members) != 1 || members[0].Subject != member { 233 + t.Fatalf("members = %+v, want only the original member; backfill must run once", members) 234 + } 235 + if applied, _ := d.IsMigrationApplied(knotMemberBackfillMigration); !applied { 236 + t.Error("member backfill did not write its migration marker") 237 + } 238 + } 239 + 240 + func TestBackfillCollaborators_EmptyMarksApplied(t *testing.T) { 241 + d, e := newBackfillEnv(t) 242 + seedCasbinRepo(t, d, e, bfRepo) 243 + 244 + runBackfill(t, d, e) 245 + 246 + applied, err := d.IsMigrationApplied(collaboratorBackfillMigration) 247 + if err != nil { 248 + t.Fatalf("IsMigrationApplied: %v", err) 249 + } 250 + if !applied { 251 + t.Fatal("migration not marked applied after a zero-collaborator backfill; it would re-scan every boot") 252 + } 253 + 254 + if err := e.AddCollaborator(bfCollab, rbac.ThisServer, bfRepo); err != nil { 255 + t.Fatalf("post-migration casbin add: %v", err) 256 + } 257 + runBackfill(t, d, e) 258 + 259 + list, _, err := db.ListCollaborators(d, syntax.DID(bfRepo), db.ListPage{Limit: db.ListMaxLimit}) 260 + if err != nil { 261 + t.Fatalf("ListCollaborators: %v", err) 262 + } 263 + if len(list) != 0 { 264 + t.Errorf("collaborators = %d, want 0; an applied migration must not fold later casbin state", len(list)) 265 + } 266 + }
-23
knotserver/db/member.go
··· 94 94 ) 95 95 } 96 96 97 - func (d *DB) ApplyKnotMembersBackfill(ctx context.Context, rows []KnotMember, migrationName string) error { 98 - conn, err := d.db.Conn(ctx) 99 - if err != nil { 100 - return err 101 - } 102 - defer conn.Close() 103 - 104 - return orm.RunMigration(conn, d.logger, migrationName, func(tx *sql.Tx) error { 105 - for _, m := range rows { 106 - if err := AddDid(tx, m.Subject.String()); err != nil { 107 - return err 108 - } 109 - if _, err := tx.ExecContext(ctx, 110 - `insert or ignore into knot_members (did, rkey, subject) values (?, ?, ?)`, 111 - m.Did, m.Rkey, m.Subject, 112 - ); err != nil { 113 - return err 114 - } 115 - } 116 - return nil 117 - }) 118 - } 119 - 120 97 func AddKnotMember(q DBTX, member KnotMember) error { 121 98 _, err := q.Exec( 122 99 `insert or ignore into knot_members (did, rkey, subject) values (?, ?, ?)`,
+5 -4
knotserver/migrate.go
··· 70 70 return repos 71 71 } 72 72 73 - func migrateReposOnStartup(ctx context.Context, c *config.Config, d *db.DB, e *rbac.Enforcer, n *notifier.Notifier, logger *slog.Logger) { 73 + func migrateReposOnStartup(ctx context.Context, c *config.Config, d *db.DB, e *rbac.Enforcer, n *notifier.Notifier, logger *slog.Logger) bool { 74 74 repos := scanLegacyRepos(c.Repo.ScanPath, logger) 75 75 if len(repos) == 0 { 76 76 logger.Info("no legacy repos found, migration complete") 77 - return 77 + return true 78 78 } 79 79 80 80 logger.Info("starting legacy repo migration", "count", len(repos)) ··· 90 90 select { 91 91 case <-ctx.Done(): 92 92 logger.Info("migration interrupted by shutdown", "migrated", migrated, "remaining", len(repos)-migrated) 93 - return 93 + return false 94 94 default: 95 95 } 96 96 ··· 103 103 } 104 104 105 105 logger.Info("legacy repo migration complete", "migrated", migrated, "total", len(repos), "duration", time.Since(start)) 106 + return migrated == len(repos) 106 107 } 107 108 108 109 func migrateOneRepo( ··· 131 132 } 132 133 133 134 if err := rewriteRBACPolicies(e, repo.ownerDid, repo.repoName, repoDid, l); err != nil { 134 - l.Error("RBAC rewrite failed (non-fatal)", "error", err) 135 + return fmt.Errorf("rewriting RBAC policies: %w", err) 135 136 } 136 137 137 138 newPath := filepath.Join(c.Repo.ScanPath, repoDid)
+11 -3
knotserver/server.go
··· 108 108 109 109 resolver := idresolver.DefaultResolver(c.Server.PlcUrl) 110 110 111 - if err := BackfillKnotMembers(ctx, db, e, resolver, c.Server.Hostname, logger); err != nil { 112 - logger.Warn("knot members backfill failed, continuing", "err", err) 111 + if err := BackfillKnotMembers(ctx, db, e, c.Server.Owner, logger); err != nil { 112 + logger.Warn("knot member backfill failed, continuing", "err", err) 113 + } 114 + if err := BackfillCollaborators(ctx, db, e, logger, false); err != nil { 115 + logger.Warn("collaborator backfill failed, continuing", "err", err) 113 116 } 114 117 115 118 // probe and initialise the sandbox backend. ··· 140 143 sb = &sandbox.NoopBackend{} 141 144 } 142 145 143 - go migrateReposOnStartup(ctx, c, db, e, &notifier, log.SubLogger(logger, "migrate")) 146 + go func() { 147 + migrated := migrateReposOnStartup(ctx, c, db, e, &notifier, log.SubLogger(logger, "migrate")) 148 + if err := BackfillCollaborators(ctx, db, e, logger, migrated); err != nil { 149 + logger.Warn("collaborator backfill failed, continuing", "err", err) 150 + } 151 + }() 144 152 145 153 mux, err := Setup(ctx, c, db, e, jc, &notifier, resolver, sb) 146 154 if err != nil {