clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / daemon / capabilities.go
capabilities.go 8.5K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "strconv"8 "strings"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12 "github.com/FelineStateMachine/tinyrelay/internal/policy"13 "github.com/FelineStateMachine/tinyrelay/internal/replication"14 "github.com/FelineStateMachine/tinyrelay/internal/storage"15)1617// Capability describes an implemented protocol surface and its operational18// status. Status is intentionally explicit so NIP-11 or an operator UI does19// not advertise a feature merely because a name appears in a registry.20type Capability struct {21 ID string `json:"id"`22 Status string `json:"status"`23 Reason string `json:"reason,omitempty"`24 Profiles []string `json:"profiles,omitempty"`25}2627func capabilityNIPNumber(id string) (int, bool) {28 if !strings.HasPrefix(id, "NIP-") {29 return 0, false30 }31 number, err := strconv.Atoi(strings.TrimPrefix(id, "NIP-"))32 return number, err == nil33}3435// Capabilities returns the capability registry for a tenant. Optional36// operator peer liveness is passed explicitly and is never inferred by37// crawling public relays.38func (t *Tenant) Capabilities(peers *PeerMonitor) []Capability {39 if t == nil {40 return nil41 }42 p := t.Policy()43 capabilities := CapabilityRegistryWithServices(p, t.git != nil, t.blobs != nil && p.Features.Files, peers)44 if t.community != nil {45 setCapability(capabilities, "NIP-43", Capability{ID: "NIP-43", Status: "enabled", Reason: "membership protocol"})46 setCapability(capabilities, "NIP-56", Capability{ID: "NIP-56", Status: "enabled", Reason: "moderation reports"})47 if active, err := t.community.ActiveAgentCount(context.Background(), time.Now().Unix()); err == nil && active > 0 {48 capabilities = append(capabilities, Capability{ID: "agents", Status: "enabled", Reason: "agent identities under owner-signed grants"})49 }50 }51 if t.blobs != nil && p.Features.Files {52 setCapability(capabilities, "NIP-94", Capability{ID: "NIP-94", Status: "enabled", Reason: "file metadata descriptors"})53 setCapability(capabilities, "NIP-96", Capability{ID: "NIP-96", Status: "enabled", Reason: "HTTP file storage"})54 }55 for index := range capabilities {56 if capabilities[index].ID == "GRASP" && t.git != nil {57 capabilities[index].Profiles = t.git.SupportedGRASPs()58 }59 }60 return capabilities61}6263func CapabilityRegistry(p policy.Policy, gitAvailable bool, peers *PeerMonitor) []Capability {64 return CapabilityRegistryWithServices(p, gitAvailable, false, peers)65}6667func CapabilityRegistryWithServices(p policy.Policy, gitAvailable, blobsAvailable bool, peers *PeerMonitor) []Capability {68 capabilities := []Capability{69 {ID: "NIP-01", Status: "enabled", Reason: "relay protocol"},70 {ID: "NIP-09", Status: "enabled", Reason: "event deletion"},71 {ID: "NIP-11", Status: "enabled", Reason: "relay information document"},72 {ID: "NIP-13", Status: "enabled", Reason: "proof of work policy"},73 {ID: "NIP-17", Status: "enabled", Reason: "private direct messages"},74 {ID: "NIP-40", Status: "enabled", Reason: "expiration tags"},75 {ID: "NIP-42", Status: "enabled", Reason: "authenticated relay sessions"},76 {ID: "NIP-62", Status: "enabled", Reason: "request to vanish"},77 {ID: "NIP-67", Status: "enabled", Reason: "EOSE completeness hints"},78 {ID: "NIP-70", Status: "enabled", Reason: "protected event policy"},79 {ID: "NIP-86", Status: "enabled", Reason: "operator management methods"},80 {ID: "NIP-98", Status: "enabled", Reason: "HTTP authentication"},81 {ID: "NIP-43", Status: "disabled", Reason: "membership service is not mounted"},82 {ID: "NIP-56", Status: "disabled", Reason: "moderation report service is not mounted"},83 {ID: "NIP-94", Status: "disabled", Reason: "file service is not mounted"},84 {ID: "NIP-96", Status: "disabled", Reason: "file service is not mounted"},85 {ID: "NIP-34", Status: "disabled", Reason: "Git repository support is not enabled"},86 {ID: "GRASP", Status: "disabled", Reason: "GRASP is not enabled"},87 {ID: "BUD-01", Status: "enabled", Reason: "blob retrieval and basic server behavior"},88 {ID: "BUD-02", Status: "enabled", Reason: "blob upload and management"},89 {ID: "BUD-04", Status: "enabled", Reason: "blob mirroring"},90 {ID: "BUD-06", Status: "enabled", Reason: "upload preflight and storage allowances"},91 {ID: "BUD-08", Status: "enabled", Reason: "Nostr file metadata in blob descriptors"},92 {ID: "BUD-09", Status: "enabled", Reason: "blob reports"},93 {ID: "BUD-11", Status: "enabled", Reason: "Nostr authorization"},94 {ID: "BUD-12", Status: "enabled", Reason: "cursor-paginated blob listing"},95 {ID: "BUD-13", Status: "enabled", Reason: "draft hash-addressed uploads and remote sources"},96 {ID: "BUD-14", Status: "enabled", Reason: "draft multipart uploads with bounded partial storage"},97 {ID: "NIP-66", Status: "disabled", Reason: "operator peer liveness is not configured"},98 }99 if p.Features.Count {100 capabilities = append(capabilities, Capability{ID: "NIP-45", Status: "enabled", Reason: "count queries"})101 }102 if p.Features.Search != "off" {103 capabilities = append(capabilities, Capability{ID: "NIP-50", Status: "enabled", Reason: "search mode " + p.Features.Search})104 }105 if p.Features.Sync {106 capabilities = append(capabilities, Capability{ID: "NIP-77", Status: "enabled", Reason: "negentropy synchronization"})107 }108 if p.Features.Names {109 capabilities = append(capabilities, Capability{ID: "NIP-05", Status: "enabled", Reason: "name resolution"})110 }111 if p.Features.Signer {112 capabilities = append(capabilities, Capability{ID: "NIP-46", Status: "enabled", Reason: "remote signer"})113 }114 if gitAvailable && p.Features.Grasp {115 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "enabled", Reason: "Git repository support"})116 profiles := []string{"GRASP-01"}117 if p.Features.Grasp02 {118 profiles = append(profiles, "GRASP-02")119 if p.Features.Grasp03 {120 profiles = append(profiles, "GRASP-03")121 }122 }123 if p.Features.Grasp06 {124 profiles = append(profiles, "GRASP-06")125 }126 if privatePolicy(p) {127 profiles = append(profiles, "GRASP-08")128 }129 setCapability(capabilities, "GRASP", Capability{ID: "GRASP", Status: "enabled", Reason: "configured GRASP profiles", Profiles: profiles})130 } else if gitAvailable {131 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "configured", Reason: "Git support is available; GRASP policy is disabled"})132 }133 if peers != nil {134 status := "configured"135 reason := "configured peers are monitored; signed publication is not configured"136 if peers.PublicationEnabled() {137 status, reason = "enabled", "configured peer liveness publication is enabled"138 }139 setCapability(capabilities, "NIP-66", Capability{ID: "NIP-66", Status: status, Reason: reason})140 }141 if !blobsAvailable {142 for index := range capabilities {143 if strings.HasPrefix(capabilities[index].ID, "BUD-") {144 capabilities[index].Status = "disabled"145 capabilities[index].Reason = "file service is not mounted"146 }147 }148 } else {149 setCapability(capabilities, "NIP-94", Capability{ID: "NIP-94", Status: "enabled", Reason: "file metadata descriptors"})150 setCapability(capabilities, "NIP-96", Capability{ID: "NIP-96", Status: "enabled", Reason: "HTTP file storage"})151 }152 return capabilities153}154155// PublishPeerStatus creates the relay's signed NIP-66 discovery record and156// stores it locally. Delivery is delegated to the normal durable outbox when157// the tenant has delivery enabled; no peer is contacted by this method.158func (t *Tenant) PublishPeerStatus(ctx context.Context, statuses []PeerStatus) error {159 if t == nil || t.records == nil {160 return errors.New("daemon: tenant records are not ready")161 }162 content, err := json.Marshal(map[string]any{"relay": t.RelayURL(), "peers": statuses})163 if err != nil {164 return err165 }166 now := time.Now().Unix()167 e, err := t.records.GenerateEphemeral(ctx, event.KIND_RELAY_DISCOVERY, [][]string{{"d", t.RelayURL()}}, string(content), now)168 if err != nil {169 return err170 }171 return t.generatedRelayRecord(ctx, e)172}173174func (t *Tenant) generatedRelayRecord(ctx context.Context, e event.Event) error {175 ctx, done, err := t.beginOperation(ctx)176 if err != nil {177 return err178 }179 defer done()180 _, err = t.router.CommitGenerated(ctx, func(ctx context.Context) (event.Event, error) {181 opts := storage.SaveOptions{Now: e.CreatedAt, SearchMode: t.Policy().Features.Search}182 if t.replication != nil {183 opts.Intents = t.replication.Prepare(e, replication.OriginServer)184 }185 _, saveErr := t.store.Save(ctx, e, opts)186 return e, saveErr187 })188 return err189}190191func setCapability(capabilities []Capability, id string, value Capability) {192 for index := range capabilities {193 if capabilities[index].ID == id {194 capabilities[index] = value195 return196 }197 }198}