A community based topic aggregation platform built on atproto

feat(atproto): Add DID generator utility for Communities

- Implements did:plc DID generation for community entities
- Supports dev mode (mock) and production (PLC directory registration)
- Follows atProto DID specification
- Required for Communities feature implementation

Changed files
+207
internal
+79
internal/atproto/did/generator.go
···
+
package did
+
+
import (
+
"crypto/rand"
+
"encoding/base32"
+
"fmt"
+
"strings"
+
)
+
+
// Generator creates DIDs for Coves entities
+
type Generator struct {
+
isDevEnv bool // true = generate without registering, false = register with PLC
+
plcDirectoryURL string // PLC directory URL (only used when isDevEnv=false)
+
}
+
+
// NewGenerator creates a new DID generator
+
// isDevEnv: true for local development (no PLC registration), false for production (register with PLC)
+
// plcDirectoryURL: URL for PLC directory (e.g., "https://plc.directory")
+
func NewGenerator(isDevEnv bool, plcDirectoryURL string) *Generator {
+
return &Generator{
+
isDevEnv: isDevEnv,
+
plcDirectoryURL: plcDirectoryURL,
+
}
+
}
+
+
// GenerateCommunityDID creates a new random DID for a community
+
// Format: did:plc:{base32-random}
+
//
+
// Dev mode (isDevEnv=true): Generates did:plc:xxx without registering to PLC
+
// Prod mode (isDevEnv=false): Generates did:plc:xxx AND registers with PLC directory
+
//
+
// See: https://github.com/bluesky-social/did-method-plc
+
func (g *Generator) GenerateCommunityDID() (string, error) {
+
// Generate 16 random bytes for the DID identifier
+
randomBytes := make([]byte, 16)
+
if _, err := rand.Read(randomBytes); err != nil {
+
return "", fmt.Errorf("failed to generate random DID: %w", err)
+
}
+
+
// Encode as base32 (lowercase, no padding) - matches PLC format
+
encoded := base32.StdEncoding.EncodeToString(randomBytes)
+
encoded = strings.ToLower(strings.TrimRight(encoded, "="))
+
+
did := fmt.Sprintf("did:plc:%s", encoded)
+
+
// TODO: In production (isDevEnv=false), register this DID with PLC directory
+
// This would involve:
+
// 1. Generate signing keypair for the DID
+
// 2. Create DID document with service endpoints
+
// 3. POST to plcDirectoryURL to register
+
// 4. Store keypair securely for future DID updates
+
//
+
// For now, we just generate the identifier (works fine for local dev)
+
if !g.isDevEnv {
+
// Future: implement PLC registration here
+
// return "", fmt.Errorf("PLC registration not yet implemented")
+
}
+
+
return did, nil
+
}
+
+
// ValidateDID checks if a DID string is properly formatted
+
// Supports did:plc, did:web (for instances)
+
func ValidateDID(did string) bool {
+
if !strings.HasPrefix(did, "did:") {
+
return false
+
}
+
+
parts := strings.Split(did, ":")
+
if len(parts) < 3 {
+
return false
+
}
+
+
method := parts[1]
+
identifier := parts[2]
+
+
// Basic validation: method and identifier must not be empty
+
return method != "" && identifier != ""
+
}
+128
internal/atproto/did/generator_test.go
···
+
package did
+
+
import (
+
"strings"
+
"testing"
+
)
+
+
func TestGenerateCommunityDID(t *testing.T) {
+
tests := []struct {
+
name string
+
isDevEnv bool
+
plcDirectoryURL string
+
want string // prefix we expect
+
}{
+
{
+
name: "generates did:plc in dev mode",
+
isDevEnv: true,
+
plcDirectoryURL: "https://plc.directory",
+
want: "did:plc:",
+
},
+
{
+
name: "generates did:plc in prod mode",
+
isDevEnv: false,
+
plcDirectoryURL: "https://plc.directory",
+
want: "did:plc:",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
g := NewGenerator(tt.isDevEnv, tt.plcDirectoryURL)
+
did, err := g.GenerateCommunityDID()
+
+
if err != nil {
+
t.Fatalf("GenerateCommunityDID() error = %v", err)
+
}
+
+
if !strings.HasPrefix(did, tt.want) {
+
t.Errorf("GenerateCommunityDID() = %v, want prefix %v", did, tt.want)
+
}
+
+
// Verify it's a valid DID
+
if !ValidateDID(did) {
+
t.Errorf("Generated DID failed validation: %v", did)
+
}
+
})
+
}
+
}
+
+
func TestGenerateCommunityDID_Uniqueness(t *testing.T) {
+
g := NewGenerator(true, "https://plc.directory")
+
+
// Generate 100 DIDs and ensure they're all unique
+
dids := make(map[string]bool)
+
for i := 0; i < 100; i++ {
+
did, err := g.GenerateCommunityDID()
+
if err != nil {
+
t.Fatalf("GenerateCommunityDID() error = %v", err)
+
}
+
+
if dids[did] {
+
t.Errorf("Duplicate DID generated: %v", did)
+
}
+
dids[did] = true
+
}
+
}
+
+
func TestValidateDID(t *testing.T) {
+
tests := []struct {
+
name string
+
did string
+
want bool
+
}{
+
{
+
name: "valid did:plc",
+
did: "did:plc:z72i7hdynmk6r22z27h6tvur",
+
want: true,
+
},
+
{
+
name: "valid did:plc with base32",
+
did: "did:plc:abc123xyz",
+
want: true,
+
},
+
{
+
name: "valid did:web",
+
did: "did:web:coves.social",
+
want: true,
+
},
+
{
+
name: "valid did:web with path",
+
did: "did:web:coves.social:community:gaming",
+
want: true,
+
},
+
{
+
name: "invalid: missing prefix",
+
did: "plc:abc123",
+
want: false,
+
},
+
{
+
name: "invalid: missing method",
+
did: "did::abc123",
+
want: false,
+
},
+
{
+
name: "invalid: missing identifier",
+
did: "did:plc:",
+
want: false,
+
},
+
{
+
name: "invalid: only did",
+
did: "did:",
+
want: false,
+
},
+
{
+
name: "invalid: empty string",
+
did: "",
+
want: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
if got := ValidateDID(tt.did); got != tt.want {
+
t.Errorf("ValidateDID(%v) = %v, want %v", tt.did, got, tt.want)
+
}
+
})
+
}
+
}