diff --git a/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md new file mode 100644 index 000000000..8af7904e0 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/badge/place-stream-badge-getvalidbadges.md @@ -0,0 +1,76 @@ +--- +title: place.stream.badge.getValidBadges +description: Reference for the place.stream.badge.getValidBadges lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| ---------- | -------- | ----- | ------------------------------------------------------------------------ | ------------- | +| `streamer` | `string` | ❌ | Optional DID of the streamer for context-specific badges (mod, vip, etc) | Format: `did` | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| -------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- | +| `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | ✅ | | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.badge.getValidBadges", + "defs": { + "main": { + "type": "query", + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 7efe3598f..59f433370 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -1870,6 +1870,47 @@ } } }, + "/xrpc/place.stream.badge.getValidBadges": { + "get": { + "summary": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "operationId": "place.stream.badge.getValidBadges", + "tags": ["place.stream.badge"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "badges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/place.stream.badge.defs_badgeView" + } + } + }, + "required": ["badges"] + } + } + } + } + }, + "parameters": [ + { + "name": "streamer", + "in": "query", + "required": false, + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", + "schema": { + "type": "string", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)", + "format": "did" + } + } + ] + } + }, "/xrpc/com.atproto.sync.getRecord": { "get": { "summary": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", @@ -3429,6 +3470,30 @@ }, "required": ["key", "mimeType"] }, + "place.stream.badge.defs_badgeView": { + "type": "object", + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", + "properties": { + "badgeType": { + "type": "string" + }, + "issuer": { + "type": "string", + "description": "DID of the badge issuer.", + "format": "did" + }, + "recipient": { + "type": "string", + "description": "DID of the badge recipient.", + "format": "did" + }, + "signature": { + "type": "string", + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." + } + }, + "required": ["badgeType", "issuer", "recipient"] + }, "com.atproto.sync.listRepos_repo": { "type": "object", "properties": { diff --git a/lexicons/place/stream/badge/getValidBadges.json b/lexicons/place/stream/badge/getValidBadges.json new file mode 100644 index 000000000..12ff6556c --- /dev/null +++ b/lexicons/place/stream/badge/getValidBadges.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "place.stream.badge.getValidBadges", + "defs": { + "main": { + "type": "query", + "description": "Get valid badges for the authenticated user, optionally in the context of a specific streamer's chat", + "parameters": { + "type": "params", + "properties": { + "streamer": { + "type": "string", + "format": "did", + "description": "Optional DID of the streamer for context-specific badges (mod, vip, etc)" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["badges"], + "properties": { + "badges": { + "type": "array", + "items": { + "type": "ref", + "ref": "place.stream.badge.defs#badgeView" + } + } + } + } + } + } + } +} diff --git a/pkg/atproto/badges.go b/pkg/atproto/badges.go index 1f0521824..a45f7ca0f 100644 --- a/pkg/atproto/badges.go +++ b/pkg/atproto/badges.go @@ -4,8 +4,7 @@ import ( "context" "fmt" - "stream.place/streamplace/pkg/constants" - "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/badges" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/streamplace" ) @@ -21,39 +20,18 @@ func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_ authorDID := message.Author.Did - var badge *streamplace.BadgeDefs_BadgeView - - // Check if author is the streamer - if authorDID == streamerDID { - badge = &streamplace.BadgeDefs_BadgeView{ - BadgeType: constants.BadgeTypeStreamer, - Issuer: issuerDID, - Recipient: authorDID, - } - } else { - // Check if author has any moderation permissions for the streamer - delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) - if err != nil { - log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) - return err - } - - // If the author has any delegations (meaning they're a moderator), add a mod badge - if len(delegations) > 0 { - badge = &streamplace.BadgeDefs_BadgeView{ - BadgeType: constants.BadgeTypeMod, - Issuer: issuerDID, - Recipient: authorDID, - } - } + // Get valid badges for this user + validBadges, err := badges.GetValidBadges(ctx, authorDID, streamerDID, issuerDID, m) + if err != nil { + return err } - // Prepend the badge if one was created (server-controlled badge is first) - if badge != nil { + // Prepend server-controlled badges (first badge slot is reserved for server) + if len(validBadges) > 0 { if message.Badges == nil { - message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} + message.Badges = validBadges } else { - message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) + message.Badges = append(validBadges, message.Badges...) } } diff --git a/pkg/badges/badges.go b/pkg/badges/badges.go new file mode 100644 index 000000000..dc2330d12 --- /dev/null +++ b/pkg/badges/badges.go @@ -0,0 +1,54 @@ +package badges + +import ( + "context" + + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +// GetValidBadges returns valid badges for a user in the context of a streamer's chat. +// Returns server-controlled badges (streamer, mod) based on permissions. +func GetValidBadges(ctx context.Context, userDID, streamerDID, issuerDID string, m model.Model) ([]*streamplace.BadgeDefs_BadgeView, error) { + badges := []*streamplace.BadgeDefs_BadgeView{} + + // If no streamer context, return empty badges + if streamerDID == "" { + return badges, nil + } + + // Check if user is the streamer + if userDID == streamerDID { + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeStreamer, + Issuer: issuerDID, + Recipient: userDID, + }) + return badges, nil + } + + // Check if user has moderation permissions for this streamer + delegations, err := m.GetModerationDelegations(ctx, streamerDID, userDID) + if err != nil { + log.Error(ctx, "failed to get moderation delegations", "err", err, "userDID", userDID, "streamerDID", streamerDID) + return nil, err + } + + // If user has any delegations, they're a moderator + if len(delegations) > 0 { + badges = append(badges, &streamplace.BadgeDefs_BadgeView{ + BadgeType: constants.BadgeTypeMod, + Issuer: issuerDID, + Recipient: userDID, + }) + } + + // TODO: Add badge issuance records when implemented + // - Query place.stream.badge.issuance records for this user + // - Verify signatures if issuer is not the current node + // - Add VIP badges, subscriber badges, etc. + + return badges, nil +} diff --git a/pkg/badges/badges_test.go b/pkg/badges/badges_test.go new file mode 100644 index 000000000..715d5f353 --- /dev/null +++ b/pkg/badges/badges_test.go @@ -0,0 +1,77 @@ +package badges + +import ( + "context" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/util" + "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" +) + +func TestGetValidBadges(t *testing.T) { + ctx := context.Background() + + mod, err := model.MakeDB(":memory:") + require.NoError(t, err) + + issuerDID := "did:web:node.example.com" + streamerDID := "did:plc:streamer" + moderatorDID := "did:plc:moderator" + regularUserDID := "did:plc:regular" + + t.Run("returns empty when no streamer context", func(t *testing.T) { + badges, err := GetValidBadges(ctx, regularUserDID, "", issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("returns streamer badge for streamer", func(t *testing.T) { + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) + require.Equal(t, issuerDID, badges[0].Issuer) + require.Equal(t, streamerDID, badges[0].Recipient) + }) + + t.Run("returns no badges for regular user", func(t *testing.T) { + badges, err := GetValidBadges(ctx, regularUserDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Empty(t, badges) + }) + + t.Run("returns mod badge for moderator", func(t *testing.T) { + // Grant moderation permissions + perm := &streamplace.ModerationPermission{ + LexiconTypeID: "place.stream.moderation.permission", + Moderator: moderatorDID, + Permissions: []string{"ban", "hide"}, + CreatedAt: time.Now().Format(util.ISO8601), + } + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") + require.NoError(t, err) + + err = mod.CreateModerationDelegation(ctx, perm, aturi) + require.NoError(t, err) + + badges, err := GetValidBadges(ctx, moderatorDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeMod, badges[0].BadgeType) + require.Equal(t, issuerDID, badges[0].Issuer) + require.Equal(t, moderatorDID, badges[0].Recipient) + }) + + t.Run("streamer badge takes priority over mod", func(t *testing.T) { + // Even if streamer has mod permissions for themselves, they get streamer badge + badges, err := GetValidBadges(ctx, streamerDID, streamerDID, issuerDID, mod) + require.NoError(t, err) + require.Len(t, badges, 1) + require.Equal(t, constants.BadgeTypeStreamer, badges[0].BadgeType) + }) +} diff --git a/pkg/spxrpc/place_stream_badge.go b/pkg/spxrpc/place_stream_badge.go new file mode 100644 index 000000000..19167e446 --- /dev/null +++ b/pkg/spxrpc/place_stream_badge.go @@ -0,0 +1,29 @@ +package spxrpc + +import ( + "context" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/badges" + placestream "stream.place/streamplace/pkg/streamplace" +) + +func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context, streamer string) (*placestream.BadgeGetValidBadges_Output, error) { + // Get authenticated user's DID from OAuth session + session, _ := oatproxy.GetOAuthSession(ctx) + if session == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") + } + + // Get valid badges using shared badge logic + badgeList, err := badges.GetValidBadges(ctx, session.DID, streamer, s.cli.MyDID(), s.model) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "Failed to get valid badges") + } + + return &placestream.BadgeGetValidBadges_Output{ + Badges: badgeList, + }, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 026f74c42..5490dc1a5 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -278,6 +278,7 @@ func (s *Server) HandleComAtprotoSyncListRepos(c echo.Context) error { } func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { + e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges) e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) @@ -308,6 +309,20 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { return nil } +func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBadgeGetValidBadges") + defer span.End() + streamer := c.QueryParam("streamer") + var out *placestream.BadgeGetValidBadges_Output + var handleErr error + // func (s *Server) handlePlaceStreamBadgeGetValidBadges(ctx context.Context,streamer string) (*placestream.BadgeGetValidBadges_Output, error) + out, handleErr = s.handlePlaceStreamBadgeGetValidBadges(ctx, streamer) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob") defer span.End() diff --git a/pkg/streamplace/badgegetValidBadges.go b/pkg/streamplace/badgegetValidBadges.go new file mode 100644 index 000000000..6a877a6a3 --- /dev/null +++ b/pkg/streamplace/badgegetValidBadges.go @@ -0,0 +1,33 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.badge.getValidBadges + +package streamplace + +import ( + "context" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// BadgeGetValidBadges_Output is the output of a place.stream.badge.getValidBadges call. +type BadgeGetValidBadges_Output struct { + Badges []*BadgeDefs_BadgeView `json:"badges" cborgen:"badges"` +} + +// BadgeGetValidBadges calls the XRPC method "place.stream.badge.getValidBadges". +// +// streamer: Optional DID of the streamer for context-specific badges (mod, vip, etc) +func BadgeGetValidBadges(ctx context.Context, c lexutil.LexClient, streamer string) (*BadgeGetValidBadges_Output, error) { + var out BadgeGetValidBadges_Output + + params := map[string]interface{}{} + if streamer != "" { + params["streamer"] = streamer + } + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.badge.getValidBadges", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +}