| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "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 | ) |
| 16 | |
| 17 | // Capability describes an implemented protocol surface and its operational |
| 18 | // status. Status is intentionally explicit so NIP-11 or an operator UI does |
| 19 | // not advertise a feature merely because a name appears in a registry. |
| 20 | type 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 | } |
| 26 | |
| 27 | func capabilityNIPNumber(id string) (int, bool) { |
| 28 | if !strings.HasPrefix(id, "NIP-" ) { |
| 29 | return 0, false |
| 30 | } |
| 31 | number, err := strconv.Atoi(strings.TrimPrefix(id, "NIP-" )) |
| 32 | return number, err == nil |
| 33 | } |
| 34 | |
| 35 | // Capabilities returns the capability registry for a tenant. Optional |
| 36 | // operator peer liveness is passed explicitly and is never inferred by |
| 37 | // crawling public relays. |
| 38 | func (t *Tenant) Capabilities(peers *PeerMonitor) []Capability { |
| 39 | if t == nil { |
| 40 | return nil |
| 41 | } |
| 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 capabilities |
| 61 | } |
| 62 | |
| 63 | func CapabilityRegistry(p policy.Policy, gitAvailable bool, peers *PeerMonitor) []Capability { |
| 64 | return CapabilityRegistryWithServices(p, gitAvailable, false, peers) |
| 65 | } |
| 66 | |
| 67 | func 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 capabilities |
| 153 | } |
| 154 | |
| 155 | // PublishPeerStatus creates the relay's signed NIP-66 discovery record and |
| 156 | // stores it locally. Delivery is delegated to the normal durable outbox when |
| 157 | // the tenant has delivery enabled; no peer is contacted by this method. |
| 158 | func (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 err |
| 165 | } |
| 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 err |
| 170 | } |
| 171 | return t.generatedRelayRecord(ctx, e) |
| 172 | } |
| 173 | |
| 174 | func (t *Tenant) generatedRelayRecord(ctx context.Context, e event.Event) error { |
| 175 | ctx, done, err := t.beginOperation(ctx) |
| 176 | if err != nil { |
| 177 | return err |
| 178 | } |
| 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, saveErr |
| 187 | }) |
| 188 | return err |
| 189 | } |
| 190 | |
| 191 | func setCapability(capabilities []Capability, id string, value Capability) { |
| 192 | for index := range capabilities { |
| 193 | if capabilities[index].ID == id { |
| 194 | capabilities[index] = value |
| 195 | return |
| 196 | } |
| 197 | } |
| 198 | } |