Subject: [PATCH links] Adding getTags query to the GraphQL API.

From: Peter Sanchez <[email-masked]>
Date: Thu, 31 Jul 2025 20:11:09 -0600

Adding tag support to the Pinboard bridge API as well. Calls to edit
tags will simply return an unsupported error.

Changelog-added: getTags query to GraphQL api.
Changelog-added: /v1/tags/* calls to Pinboard api bridge.
---
 api/api_test.go                    | 161 +++++++++--
 api/graph/generated.go             | 418 +++++++++++++++++++++++++++++
 api/graph/model/models_gen.go      |  13 +
 api/graph/pagination.go            |  27 +-
 api/graph/schema.graphqls          |  16 ++
 api/graph/schema.resolvers.go      |  84 +++++-
 migrations/test_migration.down.sql |   4 +
 migrations/test_migration.up.sql   |  27 ++
 models/user.go                     |   4 +-
 pinboard/input.go                  |  44 +--
 pinboard/middleware.go             |  12 +-
 pinboard/responses.go              |  14 +-
 pinboard/routes.go                 |  68 ++++-
 pinboard/routes_test.go            | 254 ++++++++++++++++--
 14 files changed, 1071 insertions(+), 75 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index 26cf221..50fb214 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -489,12 +489,12 @@ func TestAPI(t *testing.T) {
 
 		orgLinks, err := models.GetOrgLinks(dbCtx, &database.FilterOptions{})
 		c.NoError(err)
-		c.Equal(3, len(orgLinks))
-		c.Equal(result.Link.ID, orgLinks[2].ID)
+		c.Equal(5, len(orgLinks)) // 4 from test data + 1 created
+		c.Equal(result.Link.ID, orgLinks[4].ID)
 
 		tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
 		c.NoError(err)
-		c.Equal(2, len(tags))
+		c.Equal(7, len(tags)) // 5 from test data + 2 created ("one", "two")
 
 		tagLinks, err := models.GetTagLinks(dbCtx,
 			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -503,8 +503,8 @@ func TestAPI(t *testing.T) {
 
 		// We create a second link to check that tags are not created twice
 		op = gqlclient.NewOperation(q)
-		op.Var("title", "testing link")
-		op.Var("url", "https://netlandish.com")
+		op.Var("title", "testing link 2")
+		op.Var("url", "https://netlandish.com/about")
 		op.Var("visibility", models.OrgLinkVisibilityPublic)
 		op.Var("tags", "one, two, three")
 		op.Var("slug", "personal-org")
@@ -516,11 +516,11 @@ func TestAPI(t *testing.T) {
 
 		orgLinks, err = models.GetOrgLinks(dbCtx, &database.FilterOptions{})
 		c.NoError(err)
-		c.Equal(3, len(orgLinks))
+		c.Equal(6, len(orgLinks)) // 4 from test data + 2 created
 
 		tags, err = models.GetTags(dbCtx, &database.FilterOptions{})
 		c.NoError(err)
-		c.Equal(3, len(tags))
+		c.Equal(8, len(tags)) // 5 from test data + 3 total unique ("one", "two", "three")
 
 		tagLinks, err = models.GetTagLinks(dbCtx,
 			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -539,7 +539,7 @@ func TestAPI(t *testing.T) {
 		// We know this has already been saved earlier. We need the hash to fetch it so
 		// let's query the db
 		ols, err := models.GetOrgLinks(dbCtx,
-			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
 		)
 		c.NoError(err)
 		ol := ols[0]
@@ -594,7 +594,7 @@ func TestAPI(t *testing.T) {
 		// We know this has already been saved earlier. We need the hash to fetch it so
 		// let's query the db
 		ols, err := models.GetOrgLinks(dbCtx,
-			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
 		)
 		c.NoError(err)
 		ol := ols[0]
@@ -617,19 +617,21 @@ func TestAPI(t *testing.T) {
 		op := gqlclient.NewOperation(q)
 		op.Var("title", "New title")
 		op.Var("hash", ol.Hash)
-		op.Var("url", "https://netlandish.com")
+		op.Var("url", "https://example.com/updated")
 		op.Var("visibility", models.OrgLinkVisibilityPrivate)
 		// We remove a tag and add a new one
 		op.Var("tags", "one, three, four")
 		err = links.Execute(ctx, op, &result)
 		c.NoError(err)
 		c.Equal("New title", result.Link.Title)
-		c.Equal("https://netlandish.com", result.Link.URL)
+		c.Equal("https://example.com/updated", result.Link.URL)
 		c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)
 
 		tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
 		c.NoError(err)
-		c.Equal(4, len(tags))
+		// When run in isolation: 5 from test data + 3 new ("one", "three", "four")
+		// When run in full suite: may have additional tags from previous tests
+		c.GreaterOrEqual(len(tags), 8)
 
 		tagLinks, err := models.GetTagLinks(dbCtx,
 			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -639,14 +641,14 @@ func TestAPI(t *testing.T) {
 
 	t.Run("org link detail", func(t *testing.T) {
 		orgs, err := models.GetOrganizations(dbCtx,
-			&database.FilterOptions{Filter: sq.Expr("o.slug = ?", "personal-org"), Limit: 1})
+			&database.FilterOptions{Filter: sq.Expr("o.slug = ?", "business_org"), Limit: 1})
 		c.NoError(err)
 		org := orgs[0]
 
 		// We know this has already been saved earlier. We need the hash to fetch it so
 		// let's query the db
 		ols, err := models.GetOrgLinks(dbCtx,
-			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+			&database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
 		)
 		c.NoError(err)
 		ol := ols[0]
@@ -678,7 +680,7 @@ func TestAPI(t *testing.T) {
 		err = links.Execute(ctx, op, &result)
 		c.NoError(err)
 		c.Equal("New title", result.Link.Title)
-		c.Equal("https://netlandish.com", result.Link.URL)
+		c.Equal("https://example.com/updated", result.Link.URL)
 		c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)
 		c.Equal(org.ID, result.Link.OrgID)
 		c.Equal(1, result.Link.UserID)
@@ -739,7 +741,7 @@ func TestAPI(t *testing.T) {
 		op.Var("slug", "personal-org")
 		err := links.Execute(ctx, op, &result)
 		c.NoError(err)
-		c.Equal(1, len(result.OrgLinks.Result))
+		c.Equal(4, len(result.OrgLinks.Result)) // 2 from test data + 2 created by tests
 
 		op = gqlclient.NewOperation(q)
 		op.Var("slug", "business_org")
@@ -2380,4 +2382,131 @@ func TestAPI(t *testing.T) {
 		c.Error(err)
 		c.Equal("gqlclient: server failure: Only members with write perm are allowed to perform this action", err.Error())
 	})
+
+	t.Run("get tags without service filter", func(t *testing.T) {
+		type GraphQLResponse struct {
+			GetTags struct {
+				Result []models.Tag `json:"result"`
+			} `json:"getTags"`
+		}
+
+		var result GraphQLResponse
+		op := gqlclient.NewOperation(
+			`query GetTags($orgSlug: String!) {
+				getTags(input: {orgSlug: $orgSlug}) {
+					result {
+						id
+						name
+						slug
+					}
+				}
+			}`)
+		op.Var("orgSlug", "personal-org")
+		err := links.Execute(ctx, op, &result)
+		c.NoError(err)
+		
+		// Should return all tags for personal-org
+		// At minimum we have 5 from test data, but previous tests may create more
+		c.GreaterOrEqual(len(result.GetTags.Result), 5)
+		
+		// Verify expected test data tags are present
+		tagNames := make(map[string]bool)
+		for _, tag := range result.GetTags.Result {
+			tagNames[tag.Slug] = true
+		}
+		// Check for test data tags that should be associated with personal-org
+		c.True(tagNames["common-tag"])
+		c.True(tagNames["links-only"])
+		c.True(tagNames["short-only"])
+		c.True(tagNames["list-only"])
+		c.True(tagNames["links-short"])
+	})
+
+	t.Run("get tags with service filter", func(t *testing.T) {
+		type GraphQLResponse struct {
+			GetTags struct {
+				Result []models.Tag `json:"result"`
+			} `json:"getTags"`
+		}
+
+		// Test LINKS service
+		var linksResult GraphQLResponse
+		linksOp := gqlclient.NewOperation(
+			`query GetTags($orgSlug: String!, $service: DomainService) {
+				getTags(input: {orgSlug: $orgSlug, service: $service}) {
+					result {
+						id
+						name
+						slug
+					}
+				}
+			}`)
+		linksOp.Var("orgSlug", "personal-org")
+		linksOp.Var("service", "LINKS")
+		err := links.Execute(ctx, linksOp, &linksResult)
+		c.NoError(err)
+		
+		// Should return at least 3 tags for LINKS service (may have more from previous tests)
+		c.GreaterOrEqual(len(linksResult.GetTags.Result), 3)
+		linksTags := make(map[string]bool)
+		for _, tag := range linksResult.GetTags.Result {
+			linksTags[tag.Slug] = true
+		}
+		c.True(linksTags["common-tag"])
+		c.True(linksTags["links-only"])
+		c.True(linksTags["links-short"])
+
+		// Test SHORT service
+		var shortResult GraphQLResponse
+		shortOp := gqlclient.NewOperation(
+			`query GetTags($orgSlug: String!, $service: DomainService) {
+				getTags(input: {orgSlug: $orgSlug, service: $service}) {
+					result {
+						id
+						name
+						slug
+					}
+				}
+			}`)
+		shortOp.Var("orgSlug", "personal-org")
+		shortOp.Var("service", "SHORT")
+		err = links.Execute(ctx, shortOp, &shortResult)
+		c.NoError(err)
+		
+		// Should return at least 3 tags for SHORT service (may have more from previous tests)
+		c.GreaterOrEqual(len(shortResult.GetTags.Result), 3)
+		shortTags := make(map[string]bool)
+		for _, tag := range shortResult.GetTags.Result {
+			shortTags[tag.Slug] = true
+		}
+		c.True(shortTags["common-tag"])
+		c.True(shortTags["short-only"])
+		c.True(shortTags["links-short"])
+
+		// Test LIST service
+		var listResult GraphQLResponse
+		listOp := gqlclient.NewOperation(
+			`query GetTags($orgSlug: String!, $service: DomainService) {
+				getTags(input: {orgSlug: $orgSlug, service: $service}) {
+					result {
+						id
+						name
+						slug
+					}
+				}
+			}`)
+		listOp.Var("orgSlug", "personal-org")
+		listOp.Var("service", "LIST")
+		err = links.Execute(ctx, listOp, &listResult)
+		c.NoError(err)
+		
+		// Should return at least 2 tags for LIST service (may have more from previous tests)
+		c.GreaterOrEqual(len(listResult.GetTags.Result), 2)
+		listTags := make(map[string]bool)
+		for _, tag := range listResult.GetTags.Result {
+			listTags[tag.Slug] = true
+		}
+		c.True(listTags["common-tag"])
+		c.True(listTags["list-only"])
+	})
 }
diff --git a/api/graph/generated.go b/api/graph/generated.go
index df74f0a..33c8990 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -425,6 +425,7 @@ type ComplexityRoot struct {
 		GetPopularLinks       func(childComplexity int, input *model.PopularLinksInput) int
 		GetQRDetail           func(childComplexity int, hashID string, orgSlug *string) int
 		GetQRList             func(childComplexity int, orgSlug string, codeType model.QRCodeType, elementID int) int
+		GetTags               func(childComplexity int, input model.GetTagsInput) int
 		GetUser               func(childComplexity int, id int) int
 		GetUsers              func(childComplexity int, input *model.GetUserInput) int
 		Me                    func(childComplexity int) int
@@ -453,6 +454,11 @@ type ComplexityRoot struct {
 		Slug      func(childComplexity int) int
 	}
 
+	TagCursor struct {
+		PageInfo func(childComplexity int) int
+		Result   func(childComplexity int) int
+	}
+
 	User struct {
 		CreatedOn       func(childComplexity int) int
 		Email           func(childComplexity int) int
@@ -554,6 +560,7 @@ type QueryResolver interface {
 	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
 	GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error)
 	GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
+	GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error)
 	GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error)
 	GetDomains(ctx context.Context, orgSlug *string, service *model.DomainService) ([]*models.Domain, error)
 	GetDomain(ctx context.Context, id int) (*models.Domain, error)
@@ -2621,6 +2628,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Query.GetQRList(childComplexity, args["orgSlug"].(string), args["codeType"].(model.QRCodeType), args["elementId"].(int)), true
 
+	case "Query.getTags":
+		if e.complexity.Query.GetTags == nil {
+			break
+		}
+
+		args, err := ec.field_Query_getTags_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Query.GetTags(childComplexity, args["input"].(model.GetTagsInput)), true
+
 	case "Query.getUser":
 		if e.complexity.Query.GetUser == nil {
 			break
@@ -2750,6 +2769,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Tag.Slug(childComplexity), true
 
+	case "TagCursor.pageInfo":
+		if e.complexity.TagCursor.PageInfo == nil {
+			break
+		}
+
+		return e.complexity.TagCursor.PageInfo(childComplexity), true
+
+	case "TagCursor.result":
+		if e.complexity.TagCursor.Result == nil {
+			break
+		}
+
+		return e.complexity.TagCursor.Result(childComplexity), true
+
 	case "User.createdOn":
 		if e.complexity.User.CreatedOn == nil {
 			break
@@ -2868,6 +2901,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 		ec.unmarshalInputGetListingInput,
 		ec.unmarshalInputGetOrganizationsInput,
 		ec.unmarshalInputGetPaymentInput,
+		ec.unmarshalInputGetTagsInput,
 		ec.unmarshalInputGetUserInput,
 		ec.unmarshalInputLinkInput,
 		ec.unmarshalInputLinkShortInput,
@@ -3699,6 +3733,17 @@ func (ec *executionContext) field_Query_getQRList_args(ctx context.Context, rawA
 	return args, nil
 }
 
+func (ec *executionContext) field_Query_getTags_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput)
+	if err != nil {
+		return nil, err
+	}
+	args["input"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Query_getUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
@@ -18271,6 +18316,99 @@ func (ec *executionContext) fieldContext_Query_getOrgLinks(ctx context.Context,
 	return fc, nil
 }
 
+func (ec *executionContext) _Query_getTags(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Query_getTags(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		directive0 := func(rctx context.Context) (any, error) {
+			ctx = rctx // use context from middleware stack in children
+			return ec.resolvers.Query().GetTags(rctx, fc.Args["input"].(model.GetTagsInput))
+		}
+
+		directive1 := func(ctx context.Context) (any, error) {
+			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")
+			if err != nil {
+				var zeroVal *model.TagCursor
+				return zeroVal, err
+			}
+			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+			if err != nil {
+				var zeroVal *model.TagCursor
+				return zeroVal, err
+			}
+			if ec.directives.Access == nil {
+				var zeroVal *model.TagCursor
+				return zeroVal, errors.New("directive access is not implemented")
+			}
+			return ec.directives.Access(ctx, nil, directive0, scope, kind)
+		}
+
+		tmp, err := directive1(rctx)
+		if err != nil {
+			return nil, graphql.ErrorOnPath(ctx, err)
+		}
+		if tmp == nil {
+			return nil, nil
+		}
+		if data, ok := tmp.(*model.TagCursor); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.TagCursor`, tmp)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*model.TagCursor)
+	fc.Result = res
+	return ec.marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Query_getTags(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Query",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "result":
+				return ec.fieldContext_TagCursor_result(ctx, field)
+			case "pageInfo":
+				return ec.fieldContext_TagCursor_pageInfo(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type TagCursor", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Query_getTags_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Query_getOrgMembers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Query_getOrgMembers(ctx, field)
 	if err != nil {
@@ -20903,6 +21041,111 @@ func (ec *executionContext) fieldContext_Tag_count(_ context.Context, field grap
 	return fc, nil
 }
 
+func (ec *executionContext) _TagCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_TagCursor_result(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Result, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.Tag)
+	fc.Result = res
+	return ec.marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_TagCursor_result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "TagCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Tag_id(ctx, field)
+			case "name":
+				return ec.fieldContext_Tag_name(ctx, field)
+			case "slug":
+				return ec.fieldContext_Tag_slug(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_Tag_createdOn(ctx, field)
+			case "count":
+				return ec.fieldContext_Tag_count(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _TagCursor_pageInfo(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_TagCursor_pageInfo(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.PageInfo, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*model.PageInfo)
+	fc.Result = res
+	return ec.marshalOPageInfo2ᚖlinksᚋapiᚋgraphᚋmodelᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_TagCursor_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "TagCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "cursor":
+				return ec.fieldContext_PageInfo_cursor(ctx, field)
+			case "hasNextPage":
+				return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+			case "hasPrevPage":
+				return ec.fieldContext_PageInfo_hasPrevPage(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_User_id(ctx, field)
 	if err != nil {
@@ -24820,6 +25063,61 @@ func (ec *executionContext) unmarshalInputGetPaymentInput(ctx context.Context, o
 	return it, nil
 }
 
+func (ec *executionContext) unmarshalInputGetTagsInput(ctx context.Context, obj any) (model.GetTagsInput, error) {
+	var it model.GetTagsInput
+	asMap := map[string]any{}
+	for k, v := range obj.(map[string]any) {
+		asMap[k] = v
+	}
+
+	fieldsInOrder := [...]string{"orgSlug", "service", "after", "before", "limit"}
+	for _, k := range fieldsInOrder {
+		v, ok := asMap[k]
+		if !ok {
+			continue
+		}
+		switch k {
+		case "orgSlug":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("orgSlug"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.OrgSlug = data
+		case "service":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("service"))
+			data, err := ec.unmarshalODomainService2ᚖlinksᚋapiᚋgraphᚋmodelᚐDomainService(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Service = data
+		case "after":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.After = data
+		case "before":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
+			data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Before = data
+		case "limit":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit"))
+			data, err := ec.unmarshalOInt2ᚖint(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Limit = data
+		}
+	}
+
+	return it, nil
+}
+
 func (ec *executionContext) unmarshalInputGetUserInput(ctx context.Context, obj any) (model.GetUserInput, error) {
 	var it model.GetUserInput
 	asMap := map[string]any{}
@@ -28946,6 +29244,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 			}
 
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
+		case "getTags":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_getTags(ctx, field)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			rrm := func(ctx context.Context) graphql.Marshaler {
+				return ec.OperationContext.RootResolverMiddleware(ctx,
+					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+			}
+
 			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
 		case "getOrgMembers":
 			field := field
@@ -29561,6 +29881,47 @@ func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj
 	return out
 }
 
+var tagCursorImplementors = []string{"TagCursor"}
+
+func (ec *executionContext) _TagCursor(ctx context.Context, sel ast.SelectionSet, obj *model.TagCursor) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, tagCursorImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("TagCursor")
+		case "result":
+			out.Values[i] = ec._TagCursor_result(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "pageInfo":
+			out.Values[i] = ec._TagCursor_pageInfo(ctx, field, obj)
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
 var userImplementors = []string{"User"}
 
 func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler {
@@ -30471,6 +30832,11 @@ func (ec *executionContext) marshalNFollowPayload2ᚖlinksᚋapiᚋgraphᚋmodel
 	return ec._FollowPayload(ctx, sel, v)
 }
 
+func (ec *executionContext) unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput(ctx context.Context, v any) (model.GetTagsInput, error) {
+	res, err := ec.unmarshalInputGetTagsInput(ctx, v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
 func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) {
 	res, err := graphql.UnmarshalID(v)
 	return res, graphql.ErrorOnPath(ctx, err)
@@ -31192,6 +31558,58 @@ func (ec *executionContext) marshalNTag2ᚕlinksᚋmodelsᚐTag(ctx context.Cont
 	return ret
 }
 
+func (ec *executionContext) marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx context.Context, sel ast.SelectionSet, v []*models.Tag) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	var wg sync.WaitGroup
+	isLen1 := len(v) == 1
+	if !isLen1 {
+		wg.Add(len(v))
+	}
+	for i := range v {
+		i := i
+		fc := &graphql.FieldContext{
+			Index:  &i,
+			Result: &v[i],
+		}
+		ctx := graphql.WithFieldContext(ctx, fc)
+		f := func(i int) {
+			defer func() {
+				if r := recover(); r != nil {
+					ec.Error(ctx, ec.Recover(ctx, r))
+					ret = nil
+				}
+			}()
+			if !isLen1 {
+				defer wg.Done()
+			}
+			ret[i] = ec.marshalOTag2ᚖlinksᚋmodelsᚐTag(ctx, sel, v[i])
+		}
+		if isLen1 {
+			f(i)
+		} else {
+			go f(i)
+		}
+
+	}
+	wg.Wait()
+
+	return ret
+}
+
+func (ec *executionContext) marshalNTagCursor2linksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v model.TagCursor) graphql.Marshaler {
+	return ec._TagCursor(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v *model.TagCursor) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._TagCursor(ctx, sel, v)
+}
+
 func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v any) (time.Time, error) {
 	res, err := graphql.UnmarshalTime(v)
 	return res, graphql.ErrorOnPath(ctx, err)
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 860a6d5..0fbf74d 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -257,6 +257,14 @@ type GetPaymentInput struct {
 	Filter    *bool   `json:"filter,omitempty"`
 }
 
+type GetTagsInput struct {
+	OrgSlug string         `json:"orgSlug"`
+	Service *DomainService `json:"service,omitempty"`
+	After   *Cursor        `json:"after,omitempty"`
+	Before  *Cursor        `json:"before,omitempty"`
+	Limit   *int           `json:"limit,omitempty"`
+}
+
 type GetUserInput struct {
 	Limit  *int    `json:"limit,omitempty"`
 	After  *Cursor `json:"after,omitempty"`
@@ -423,6 +431,11 @@ type RegisterInvitation struct {
 	Email string `json:"email"`
 }
 
+type TagCursor struct {
+	Result   []*models.Tag `json:"result"`
+	PageInfo *PageInfo     `json:"pageInfo,omitempty"`
+}
+
 type UpdateAdminDomainInput struct {
 	ID           int           `json:"id"`
 	Name         string        `json:"name"`
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
index 6312c5a..0c16595 100644
--- a/api/graph/pagination.go
+++ b/api/graph/pagination.go
@@ -8,6 +8,26 @@ import (
 	"netlandish.com/x/gobwebs/database"
 )
 
+var paginationCtxKey = &contextKey{"pagination"}
+
+type contextKey struct {
+	name string
+}
+
+// PaginationContext is used to override `model.PaginationMax` value
+func PaginationContext(ctx context.Context, limit int) context.Context {
+	return context.WithValue(ctx, paginationCtxKey, limit)
+}
+
+// ForPaginationContext pulls max pagination value for context
+func ForPaginationContext(ctx context.Context) int {
+	limit, ok := ctx.Value(paginationCtxKey).(int)
+	if !ok {
+		return model.PaginationMax
+	}
+	return limit
+}
+
 func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
 	getID func(T) int) ([]T, *model.PageInfo) {
 	overFetched := len(items) > limit
@@ -89,8 +109,11 @@ func QueryModel[T any](
 	if limit != nil && *limit > 0 {
 		numElements = *limit
 	}
-	if numElements > model.PaginationMax {
-		numElements = model.PaginationMax
+
+	// maxLimit will default to model.PaginationMax
+	maxLimit := ForPaginationContext(ctx)
+	if numElements > maxLimit {
+		numElements = maxLimit
 	}
 
 	opts.Limit = numElements + 1
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 02f9db8..54aeffa 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -402,6 +402,11 @@ type OrgLinkCursor {
     tagCloud: [Tag]
 }
 
+type TagCursor {
+    result: [Tag]!
+    pageInfo: PageInfo
+}
+
 type PopularLinkCursor {
     result: [BaseURL]!
     tagCloud: [Tag]
@@ -783,6 +788,14 @@ input AddQRCodeInput {
     image: Upload
 }
 
+input GetTagsInput {
+    orgSlug: String!
+    service: DomainService
+    after: Cursor
+    before: Cursor
+    limit: Int
+}
+
 type DeletePayload {
     success: Boolean!
     objectId: ID!
@@ -842,6 +855,9 @@ type Query {
     "Returns an array of organization links"
     getOrgLinks(input: GetLinkInput): OrgLinkCursor! @access(scope: LINKS, kind: RO)
 
+    "Returns tags of an organization. Can be by service or all services"
+    getTags(input: GetTagsInput!): TagCursor! @access(scope: ORGS, kind: RO)
+
     "Returns members of an organization"
     getOrgMembers(orgSlug: String!): [User]! @access(scope: ORGS, kind: RO)
 
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index a111149..547d662 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -40,8 +40,8 @@ import (
 	"golang.org/x/image/draw"
 	"golang.org/x/net/idna"
 	"netlandish.com/x/gobwebs"
-	"netlandish.com/x/gobwebs-auditlog"
-	"netlandish.com/x/gobwebs-oauth2"
+	auditlog "netlandish.com/x/gobwebs-auditlog"
+	oauth2 "netlandish.com/x/gobwebs-oauth2"
 	gaccounts "netlandish.com/x/gobwebs/accounts"
 	gcore "netlandish.com/x/gobwebs/core"
 	"netlandish.com/x/gobwebs/crypto"
@@ -5434,6 +5434,86 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
 	}, nil
 }
 
+// GetTags is the resolver for the getTags field.
+func (r *queryResolver) GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error) {
+	tokenUser := oauth2.ForContext(ctx)
+	if tokenUser == nil {
+		return nil, valid.ErrAuthorization
+	}
+	user := tokenUser.User.(*models.User)
+	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
+	lt := localizer.GetLocalizer(lang)
+
+	ctx = timezone.Context(ctx, links.GetUserTZ(user))
+	validator := valid.New(ctx)
+
+	if input.After != nil && input.Before != nil {
+		validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
+			WithCode(valid.ErrValidationGlobalCode)
+		return nil, nil
+	}
+
+	org, err := user.GetOrgsSlug(ctx, models.OrgUserPermissionRead, input.OrgSlug)
+	if err != nil {
+		return nil, err
+	}
+	if org == nil {
+		validator.Error("%s", lt.Translate("Organization not found.")).
+			WithField("orgSlug").
+			WithCode(valid.ErrNotFoundCode)
+		return nil, nil
+	}
+
+	var fq sq.Sqlizer
+	if input.Service != nil {
+		switch *input.Service {
+		case model.DomainServiceLinks:
+			fq = sq.Eq{"ol.org_id": org.ID}
+		case model.DomainServiceShort:
+			fq = sq.Eq{"s.org_id": org.ID}
+		case model.DomainServiceList:
+			fq = sq.Eq{"ll.org_id": org.ID}
+		}
+	} else {
+		// No service specified. Give tags for all services
+		fq = sq.Or{
+			sq.Eq{"ol.org_id": org.ID},
+			sq.Eq{"ll.org_id": org.ID},
+			sq.Eq{"s.org_id": org.ID},
+		}
+	}
+
+	opts := &database.FilterOptions{
+		Filter:  fq,
+		Limit:   500,
+		OrderBy: "name ASC",
+	}
+
+	// Set pagination limit to 250 for tag pagination
+	ctx = PaginationContext(ctx, 250)
+	tags, pageInfo, err := QueryModel(
+		ctx,
+		opts,
+		"t.id",
+		"ASC",
+		input.Limit,
+		input.Before,
+		input.After,
+		models.GetTags,
+		func(tag *models.Tag) int {
+			return tag.ID
+		},
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &model.TagCursor{
+		Result:   tags,
+		PageInfo: pageInfo,
+	}, nil
+}
+
 // GetOrgMembers is the resolver for the getOrgMembers field.
 func (r *queryResolver) GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error) {
 	tokenUser := oauth2.ForContext(ctx)
diff --git a/migrations/test_migration.down.sql b/migrations/test_migration.down.sql
index e69de29..e879b88 100644
--- a/migrations/test_migration.down.sql
+++ b/migrations/test_migration.down.sql
@@ -0,0 +1,4 @@
+DELETE FROM tag_listings;
+DELETE FROM tag_link_shorts;
+DELETE FROM tag_links;
+DELETE FROM tags;
\ No newline at end of file
diff --git a/migrations/test_migration.up.sql b/migrations/test_migration.up.sql
index 4b9d26f..13f75f0 100644
--- a/migrations/test_migration.up.sql
+++ b/migrations/test_migration.up.sql
@@ -17,6 +17,12 @@ INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, has
 INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
     ('Private Business url', 'http://base2.com?vis=private', 2, 1, 2, 'PRIVATE', 'hash2');
 
+INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
+    ('Personal org link 1', 'http://base.com?personal=1', 1, 1, 1, 'PUBLIC', 'hash3');
+
+INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
+    ('Personal org link 2', 'http://base2.com?personal=2', 2, 1, 1, 'PUBLIC', 'hash4');
+
 INSERT INTO domains (name, lookup_name, org_id, level, service, status) VALUES ('short domain', 'short.domain.org', 1, 'SYSTEM', 'SHORT', 'APPROVED');
 INSERT INTO domains (name, lookup_name, org_id, service, status, level) VALUES ('listing domain', 'list.domain.org', 1, 'LIST', 'APPROVED', 'USER');
 
@@ -29,3 +35,24 @@ INSERT INTO qr_codes (id, hash_id, url, org_id, code_type, image_path, user_id,
 
 INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (1, 'Personal', 'plan_personal', 'price_personal', 1000, 'PERSONAL');
 INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (2, 'Business', 'plan_business', 'price_business', 2000, 'BUSINESS');
+
+INSERT INTO tags (name, slug) VALUES 
+  ('common-tag', 'common-tag'),
+  ('links-only', 'links-only'),
+  ('short-only', 'short-only'),
+  ('list-only', 'list-only'),
+  ('links-short', 'links-short');
+
+INSERT INTO tag_links (org_link_id, tag_id) VALUES
+  ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'common-tag')),
+  ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'links-only')),
+  ((SELECT id FROM org_links WHERE hash = 'hash4'), (SELECT id FROM tags WHERE slug = 'links-short'));
+
+INSERT INTO tag_link_shorts (link_short_id, tag_id) VALUES
+  (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
+  (100, (SELECT id FROM tags WHERE slug = 'short-only')),
+  (100, (SELECT id FROM tags WHERE slug = 'links-short'));
+
+INSERT INTO tag_listings (listing_id, tag_id) VALUES
+  (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
+  (100, (SELECT id FROM tags WHERE slug = 'list-only'));
diff --git a/models/user.go b/models/user.go
index 6f36ea9..e25653f 100644
--- a/models/user.go
+++ b/models/user.go
@@ -34,7 +34,7 @@ func (u UserSettings) Value() (driver.Value, error) {
 }
 
 // Scan ...
-func (u *UserSettings) Scan(value interface{}) error {
+func (u *UserSettings) Scan(value any) error {
 	b, ok := value.([]byte)
 	if !ok {
 		return errors.New("type assertion to []byte failed")
@@ -260,7 +260,7 @@ func (u *User) GetOrgsSlug(ctx context.Context, perm string, slug string) (*Orga
 		return nil, err
 	}
 	for _, o := range orgs {
-		if strings.ToLower(o.Slug) == strings.ToLower(slug) {
+		if strings.EqualFold(o.Slug, slug) {
 			return o, nil
 		}
 	}
diff --git a/pinboard/input.go b/pinboard/input.go
index 55a9374..1a72e47 100644
--- a/pinboard/input.go
+++ b/pinboard/input.go
@@ -2,46 +2,46 @@ package pinboard
 
 // AddPostInput represents the input for /v1/posts/add
 type AddPostInput struct {
-	URL         string `form:"url" validate:"required,url"`
-	Description string `form:"description" validate:"required"`
-	Extended    string `form:"extended"`
-	Tags        string `form:"tags"`
-	Dt          string `form:"dt"`
-	Replace     string `form:"replace"`
-	Shared      string `form:"shared"`
-	Toread      string `form:"toread"`
+	URL         string `query:"url" validate:"required,url"`
+	Description string `query:"description" validate:"required"`
+	Extended    string `query:"extended"`
+	Tags        string `query:"tags"`
+	Dt          string `query:"dt"`
+	Replace     string `query:"replace"`
+	Shared      string `query:"shared"`
+	Toread      string `query:"toread"`
 }
 
 // DeletePostInput represents the input for /v1/posts/delete
 type DeletePostInput struct {
-	URL string `form:"url" validate:"required,url"`
+	URL string `query:"url" validate:"required,url"`
 }
 
 // GetPostInput represents the input for /v1/posts/get
 type GetPostInput struct {
-	Tag  string `form:"tag"`
-	Dt   string `form:"dt"`
-	URL  string `form:"url"`
-	Meta string `form:"meta"`
+	Tag  string `query:"tag"`
+	Dt   string `query:"dt"`
+	URL  string `query:"url"`
+	Meta string `query:"meta"`
 }
 
 // RecentPostInput represents the input for /v1/posts/recent
 type RecentPostInput struct {
-	Tag   string `form:"tag"`
-	Count int    `form:"count"`
+	Tag   string `query:"tag"`
+	Count int    `query:"count"`
 }
 
 // DatesPostInput represents the input for /v1/posts/dates
 type DatesPostInput struct {
-	Tag string `form:"tag"`
+	Tag string `query:"tag"`
 }
 
 // AllPostInput represents the input for /v1/posts/all
 type AllPostInput struct {
-	Tag     string `form:"tag"`
-	Start   int    `form:"start"`
-	Results int    `form:"results"`
-	Fromdt  string `form:"fromdt"`
-	Todt    string `form:"todt"`
-	Meta    string `form:"meta"`
+	Tag     string `query:"tag"`
+	Start   int    `query:"start"`
+	Results int    `query:"results"`
+	Fromdt  string `query:"fromdt"`
+	Todt    string `query:"todt"`
+	Meta    string `query:"meta"`
 }
\ No newline at end of file
diff --git a/pinboard/middleware.go b/pinboard/middleware.go
index 5675543..f02507d 100644
--- a/pinboard/middleware.go
+++ b/pinboard/middleware.go
@@ -16,7 +16,17 @@ func PinboardAuthMiddleware() echo.MiddlewareFunc {
 			// 1. Check for auth_token parameter
 			authToken := c.QueryParam("auth_token")
 			if authToken != "" {
-				token = authToken
+				// Pinboard docs say value should be in form of "username:TOKEN"
+				parts := strings.SplitN(authToken, ":", 2)
+				if len(parts) == 2 {
+					// Allow specification of the org to use in the username field
+					c.Set("use_org", parts[0])
+					// Use password as token (Pinboard uses username:token format)
+					token = parts[1]
+				} else {
+					// Fallback with just token and fall back to default user org
+					token = authToken
+				}
 			} else {
 				// 2. Check for HTTP Basic Auth
 				auth := c.Request().Header.Get("Authorization")
diff --git a/pinboard/responses.go b/pinboard/responses.go
index cdc1338..b96ee97 100644
--- a/pinboard/responses.go
+++ b/pinboard/responses.go
@@ -82,10 +82,22 @@ type PinboardNoteContent struct {
 	Text      string   `xml:",chardata" json:"text"`
 }
 
+// PinboardTag represents a single tag in Pinboard format
+type PinboardTag struct {
+	Tag   string `xml:"tag,attr" json:"tag"`
+	Count int    `xml:"count,attr" json:"count"`
+}
+
+// PinboardTags represents the tags response
+type PinboardTags struct {
+	XMLName xml.Name      `xml:"tags" json:"-"`
+	Tags    []PinboardTag `xml:"tag" json:"tags"`
+}
+
 // Helper functions
 
 // formatResponse returns the response in XML or JSON based on format parameter
-func formatResponse(c echo.Context, data interface{}) error {
+func formatResponse(c echo.Context, data any) error {
 	// Check both query parameter and form value
 	format := c.QueryParam("format")
 	if format == "" {
diff --git a/pinboard/routes.go b/pinboard/routes.go
index 17b1d89..69c1311 100644
--- a/pinboard/routes.go
+++ b/pinboard/routes.go
@@ -34,8 +34,8 @@ func (s *Service) RegisterRoutes() {
 	v1 := s.Group.Group("/v1")
 
 	// Posts endpoints
-	v1.POST("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
-	v1.POST("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
+	v1.GET("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
+	v1.GET("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
 	v1.GET("/posts/get", s.PostsGet).Name = s.RouteName("posts_get")
 	v1.GET("/posts/recent", s.PostsRecent).Name = s.RouteName("posts_recent")
 	v1.GET("/posts/dates", s.PostsDates).Name = s.RouteName("posts_dates")
@@ -44,6 +44,11 @@ func (s *Service) RegisterRoutes() {
 	// Notes endpoints
 	v1.GET("/notes/list", s.NotesList).Name = s.RouteName("notes_list")
 	v1.GET("/notes/:id", s.NotesGet).Name = s.RouteName("notes_get")
+
+	// Tags endpoints
+	v1.GET("/tags/get", s.TagsGet).Name = s.RouteName("tags_get")
+	v1.GET("/tags/rename", s.TagsRename).Name = s.RouteName("tags_rename")
+	v1.GET("/tags/delete", s.TagsDelete).Name = s.RouteName("tags_delete")
 }
 
 // NewService creates a new Pinboard API service
@@ -764,3 +769,62 @@ func (s *Service) NotesGet(c echo.Context) error {
 
 	return formatResponse(c, &response)
 }
+
+// TagsGet handles /v1/tags/get
+func (s *Service) TagsGet(c echo.Context) error {
+	// Get user's default organization
+	org, err := s.getUserOrg(c)
+	if err != nil {
+		return formatError(c, "Failed to get organization")
+	}
+
+	// Prepare GraphQL query
+	type GraphQLResponse struct {
+		GetTags struct {
+			Result []*models.Tag `json:"result"`
+		} `json:"getTags"`
+	}
+
+	var result GraphQLResponse
+	op := gqlclient.NewOperation(
+		`query GetTags($orgSlug: String!) {
+			getTags(input: {orgSlug: $orgSlug}) {
+				result {
+					name
+					count
+				}
+			}
+		}`)
+
+	op.Var("orgSlug", org.Slug)
+
+	err = links.Execute(c.Request().Context(), op, &result)
+	if err != nil {
+		return formatError(c, err.Error())
+	}
+
+	// Convert to Pinboard format
+	tags := make([]PinboardTag, len(result.GetTags.Result))
+	for i, tag := range result.GetTags.Result {
+		tags[i] = PinboardTag{
+			Tag:   tag.Name,
+			Count: tag.Count,
+		}
+	}
+
+	response := &PinboardTags{
+		Tags: tags,
+	}
+
+	return formatResponse(c, response)
+}
+
+// TagsRename handles /v1/tags/rename
+func (s *Service) TagsRename(c echo.Context) error {
+	return formatError(c, "Tag renaming is unsupported")
+}
+
+// TagsDelete handles /v1/tags/delete
+func (s *Service) TagsDelete(c echo.Context) error {
+	return formatError(c, "Tag deletion is unsupported")
+}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
index 60b9e42..b08294b 100644
--- a/pinboard/routes_test.go
+++ b/pinboard/routes_test.go
@@ -114,7 +114,7 @@ func TestPinboardHandlers(t *testing.T) {
 				},
 			}))
 
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com"},
 			"description": {"Example Title"},
 			"extended":    {"Extended description"},
@@ -123,8 +123,7 @@ func TestPinboardHandlers(t *testing.T) {
 			"toread":      {"no"},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		recorder := httptest.NewRecorder()
 
 		ctx := createAuthContext(request, recorder)
@@ -140,13 +139,12 @@ func TestPinboardHandlers(t *testing.T) {
 	})
 
 	t.Run("posts/add missing required fields", func(t *testing.T) {
-		formData := url.Values{
+		params := url.Values{
 			"url": {"https://example.com"},
 			// Missing description
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		recorder := httptest.NewRecorder()
 
 		ctx := createAuthContext(request, recorder)
@@ -173,14 +171,13 @@ func TestPinboardHandlers(t *testing.T) {
 				},
 			}))
 
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com"},
 			"description": {"Example Title"},
 			"format":      {"json"},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		recorder := httptest.NewRecorder()
 
 		ctx := createAuthContext(request, recorder)
@@ -229,12 +226,11 @@ func TestPinboardHandlers(t *testing.T) {
 				})
 			})
 
-		formData := url.Values{
+		params := url.Values{
 			"url": {"https://example.com"},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
 		recorder := httptest.NewRecorder()
 
 		ctx := createAuthContext(request, recorder)
@@ -262,12 +258,11 @@ func TestPinboardHandlers(t *testing.T) {
 				},
 			}))
 
-		formData := url.Values{
+		params := url.Values{
 			"url": {"https://notfound.com"},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
 		recorder := httptest.NewRecorder()
 
 		ctx := createAuthContext(request, recorder)
@@ -704,14 +699,13 @@ func TestTagConversion(t *testing.T) {
 				})
 			})
 
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com"},
 			"description": {"Example"},
 			"tags":        {`simple "tag with spaces" another`},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		request.Header.Set("Authorization", "Internal testtoken")
 		recorder := httptest.NewRecorder()
 
@@ -1153,13 +1147,12 @@ func TestEdgeCases(t *testing.T) {
 			}))
 
 		longDesc := strings.Repeat("a", 1000)
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com"},
 			"description": {longDesc},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		request.Header.Set("Authorization", "Internal testtoken")
 		recorder := httptest.NewRecorder()
 
@@ -1187,13 +1180,12 @@ func TestEdgeCases(t *testing.T) {
 				},
 			}))
 
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com/path?query=value&foo=bar#anchor"},
 			"description": {"URL with params"},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		request.Header.Set("Authorization", "Internal testtoken")
 		recorder := httptest.NewRecorder()
 
@@ -1221,14 +1213,13 @@ func TestEdgeCases(t *testing.T) {
 				},
 			}))
 
-		formData := url.Values{
+		params := url.Values{
 			"url":         {"https://example.com"},
 			"description": {"No tags"},
 			"tags":        {""},
 		}
 
-		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
-		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
 		request.Header.Set("Authorization", "Internal testtoken")
 		recorder := httptest.NewRecorder()
 
@@ -1316,3 +1307,212 @@ func TestEdgeCases(t *testing.T) {
 		}
 	})
 }
+
+func TestTagEndpoints(t *testing.T) {
+	c := require.New(t)
+	srv, e := test.NewWebTestServer(t)
+	cmd.RunMigrations(t, srv.DB)
+
+	// Use existing test user ID 1 which has 'personal-org' from test migration
+	user := test.NewTestUser(1, false, false, true, true)
+
+	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+	defer srv.Shutdown()
+	go srv.Run()
+
+	// Helper function to create authenticated context
+	createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context {
+		ctx := &server.Context{
+			Server:  srv,
+			Context: e.NewContext(request, recorder),
+			User:    user,
+		}
+		return ctx
+	}
+
+	// Helper function to check XML response structure
+	checkXMLResponse := func(body string, rootElement string) {
+		c.True(strings.HasPrefix(body, "<?xml"))
+		c.True(strings.Contains(body, "<"+rootElement))
+		c.True(strings.Contains(body, "</"+rootElement+">"))
+	}
+
+	// Helper function to check JSON response structure
+	checkJSONResponse := func(body string) {
+		var js json.RawMessage
+		err := json.Unmarshal([]byte(body), &js)
+		c.NoError(err, "Response should be valid JSON")
+	}
+
+	t.Run("tags/get success", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+				"data": map[string]interface{}{
+					"getTags": map[string]interface{}{
+						"result": []map[string]interface{}{
+							{
+								"name":  "golang",
+								"count": 42,
+							},
+							{
+								"name":  "web",
+								"count": 23,
+							},
+							{
+								"name":  "api",
+								"count": 15,
+							},
+						},
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/get")
+
+		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "tags")
+		c.True(strings.Contains(body, "<tag"))
+		c.True(strings.Contains(body, `tag="golang"`))
+		c.True(strings.Contains(body, `count="42"`))
+		c.True(strings.Contains(body, `tag="web"`))
+		c.True(strings.Contains(body, `count="23"`))
+	})
+
+	t.Run("tags/get with JSON format", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+				"data": map[string]interface{}{
+					"getTags": map[string]interface{}{
+						"result": []map[string]interface{}{
+							{
+								"name":  "test",
+								"count": 10,
+							},
+						},
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get?format=json", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/get")
+
+		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkJSONResponse(body)
+		c.True(strings.Contains(body, `"tags":`))
+		c.True(strings.Contains(body, `"tag":"test"`))
+		c.True(strings.Contains(body, `"count":10`))
+	})
+
+	t.Run("tags/get empty response", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+				"data": map[string]interface{}{
+					"getTags": map[string]interface{}{
+						"result": []map[string]interface{}{},
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/get")
+
+		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "tags")
+		// Should contain tags element but no individual tag elements
+		c.False(strings.Contains(body, "<tag "))
+	})
+
+	t.Run("tags/rename unsupported", func(t *testing.T) {
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/rename")
+
+		err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "Tag renaming is unsupported"))
+	})
+
+	t.Run("tags/delete unsupported", func(t *testing.T) {
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/delete")
+
+		err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "Tag deletion is unsupported"))
+	})
+
+	t.Run("tags/get with GraphQL error", func(t *testing.T) {
+		httpmock.Activate()
+		defer httpmock.DeactivateAndReset()
+
+		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+				"errors": []map[string]interface{}{
+					{
+						"message": "getTags query failed",
+						"extensions": map[string]interface{}{
+							"code": "INTERNAL_SERVER_ERROR",
+						},
+					},
+				},
+			}))
+
+		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+		recorder := httptest.NewRecorder()
+
+		ctx := createAuthContext(request, recorder)
+		ctx.SetPath("/pinboard/v1/tags/get")
+
+		err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+		c.NoError(err)
+		c.Equal(http.StatusOK, recorder.Code)
+
+		body := recorder.Body.String()
+		checkXMLResponse(body, "result")
+		c.True(strings.Contains(body, "something went wrong"))
+	})
+}
-- 
2.49.1

---

From: "Peter Sanchez" <[email-masked]>
Date: Fri, 01 Aug 2025 08:15:09 -0600

Applied.

To [email-masked]:~netlandish/links
   3ca09cc..a22df5a  master -> master