| 1 | package policy |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/url" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // Succession describes the account handoff after a period of inactivity. |
| 13 | type Succession struct { |
| 14 | Heir string `json:"heir"` |
| 15 | AfterDays int `json:"afterDays"` |
| 16 | } |
| 17 | |
| 18 | // Notify selects tenant notification categories. |
| 19 | type Notify struct { |
| 20 | Reports bool `json:"reports"` |
| 21 | Jobs bool `json:"jobs"` |
| 22 | Succession bool `json:"succession"` |
| 23 | Digest bool `json:"digest"` |
| 24 | } |
| 25 | |
| 26 | // MemberInvites controls invitation depth and quota. |
| 27 | type MemberInvites struct { |
| 28 | Depth int `json:"depth"` |
| 29 | Quota int `json:"quota"` |
| 30 | } |
| 31 | |
| 32 | // Sites controls hosted site publication and mirroring. |
| 33 | type Sites struct { |
| 34 | Enabled bool `json:"enabled"` |
| 35 | Mirror bool `json:"mirror"` |
| 36 | } |
| 37 | |
| 38 | // Features selects relay protocols and tenant capabilities. |
| 39 | type Features struct { |
| 40 | Search string `json:"search"` |
| 41 | Sync bool `json:"sync"` |
| 42 | Count bool `json:"count"` |
| 43 | Discovery bool `json:"discovery"` |
| 44 | Names bool `json:"names"` |
| 45 | Files bool `json:"files"` |
| 46 | Pages bool `json:"pages"` |
| 47 | Sites Sites `json:"sites"` |
| 48 | Signer bool `json:"signer"` |
| 49 | Marmot bool `json:"marmot"` |
| 50 | Grasp bool `json:"grasp"` |
| 51 | Grasp02 bool `json:"grasp02"` |
| 52 | Grasp03 bool `json:"grasp03"` |
| 53 | Grasp05 bool `json:"grasp05"` |
| 54 | Grasp06 bool `json:"grasp06"` |
| 55 | // Grasp08 enables the private-service profile. It is a tenant boundary, |
| 56 | // not a repository visibility hint: all reads require current membership. |
| 57 | Grasp08 bool `json:"grasp08"` |
| 58 | Push bool `json:"push"` |
| 59 | // Jobs admits NIP-90 job requests, results and feedback from members |
| 60 | // and agents. It is on unless the owner switches it off. |
| 61 | Jobs bool `json:"jobs"` |
| 62 | } |
| 63 | |
| 64 | // Delivery controls event delivery services. |
| 65 | type Delivery struct { |
| 66 | Enabled bool `json:"enabled"` |
| 67 | } |
| 68 | |
| 69 | // Inbox controls targeted inbox admission. |
| 70 | type Inbox struct { |
| 71 | Targeted bool `json:"targeted"` |
| 72 | } |
| 73 | |
| 74 | // FileLimits controls a tenant's file storage allowances. Zero is unlimited. |
| 75 | type FileLimits struct { |
| 76 | MaxFileBytes int64 `json:"maxFileBytes"` |
| 77 | UserStorageBytes int64 `json:"userStorageBytes"` |
| 78 | } |
| 79 | |
| 80 | // CustomHost records a tenant-managed host and its provisioning state. |
| 81 | type CustomHost struct { |
| 82 | Host string `json:"host"` |
| 83 | ID string `json:"id"` |
| 84 | Site string `json:"site,omitempty"` |
| 85 | At int64 `json:"at"` |
| 86 | Status string `json:"status"` |
| 87 | SSLStatus string `json:"sslStatus"` |
| 88 | } |
| 89 | |
| 90 | // RetentionRule assigns a retention period to one kind; negative kinds are |
| 91 | // used as the wildcard rule by the gate. |
| 92 | type RetentionRule struct { |
| 93 | Kind int `json:"kind"` |
| 94 | Days int `json:"days"` |
| 95 | } |
| 96 | |
| 97 | // Policy is the durable policy for one tenant. It combines identity and |
| 98 | // presentation settings, access rules, feature switches and storage limits. |
| 99 | type Policy struct { |
| 100 | Owner string `json:"owner"` |
| 101 | CustomHosts []CustomHost `json:"customHosts"` |
| 102 | Succession *Succession `json:"succession"` |
| 103 | Name string `json:"name"` |
| 104 | Description string `json:"description"` |
| 105 | Icon string `json:"icon"` |
| 106 | Banner string `json:"banner"` |
| 107 | Contact string `json:"contact"` |
| 108 | PostingPolicy string `json:"postingPolicy"` |
| 109 | PrivacyPolicy string `json:"privacyPolicy"` |
| 110 | Tags []string `json:"tags"` |
| 111 | LanguageTags []string `json:"languageTags"` |
| 112 | RelayCountries []string `json:"relayCountries"` |
| 113 | Notify Notify `json:"notify"` |
| 114 | Writes string `json:"writes"` |
| 115 | OpenKinds []int `json:"openKinds"` |
| 116 | GuestReplies bool `json:"guestReplies"` |
| 117 | BlockedWords []string `json:"blockedWords"` |
| 118 | BlockedWordsInTags bool `json:"blockedWordsInTags"` |
| 119 | ReportThreshold int `json:"reportThreshold"` |
| 120 | Reads string `json:"reads"` |
| 121 | JoinTerms string `json:"joinTerms"` |
| 122 | DirectoryPublic bool `json:"directoryPublic"` |
| 123 | MinPow int `json:"minPow"` |
| 124 | MaxFuture int64 `json:"maxFuture"` |
| 125 | MemberInvites MemberInvites `json:"memberInvites"` |
| 126 | Views map[string]string `json:"views"` |
| 127 | Features Features `json:"features"` |
| 128 | PushCallbacks []string `json:"pushCallbacks"` |
| 129 | PrivatePeers []string `json:"privatePeers,omitempty"` |
| 130 | FileLimits FileLimits `json:"fileLimits"` |
| 131 | LetteredNips bool `json:"letteredNips"` |
| 132 | Delivery Delivery `json:"delivery"` |
| 133 | Inbox Inbox `json:"inbox"` |
| 134 | Dumps string `json:"dumps"` |
| 135 | DumpsKeep int `json:"dumpsKeep"` |
| 136 | // Rooms caps the chat rooms a tenant may create beside its main group. |
| 137 | // Zero switches room creation off. |
| 138 | Rooms int `json:"rooms"` |
| 139 | // Callbacks caps the event callbacks one key may register. The owner is |
| 140 | // not limited. Zero switches registration off for everyone else. |
| 141 | Callbacks int `json:"callbacks"` |
| 142 | BlockedKinds []int `json:"-"` |
| 143 | AllowedKinds []int `json:"-"` |
| 144 | Retention []RetentionRule `json:"-"` |
| 145 | } |
| 146 | |
| 147 | // DefaultRooms is the room allowance applied when a policy does not name one. |
| 148 | const DefaultRooms = 64 |
| 149 | |
| 150 | // DefaultCallbacks is the callback allowance per key when a policy does not |
| 151 | // name one. |
| 152 | const DefaultCallbacks = 4 |
| 153 | |
| 154 | // UnmarshalJSON applies the current defaults for room, callback and job |
| 155 | // settings before decoding a stored policy. |
| 156 | func (p *Policy) UnmarshalJSON(data []byte) error { |
| 157 | type plain Policy |
| 158 | decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks} |
| 159 | decoded.Features.Jobs = true |
| 160 | if err := json.Unmarshal(data, &decoded); err != nil { |
| 161 | return err |
| 162 | } |
| 163 | *p = Policy(decoded) |
| 164 | return nil |
| 165 | } |
| 166 | |
| 167 | func Defaults(owner string) Policy { |
| 168 | return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open" , Reads: "open" , DirectoryPublic: true, MaxFuture: 900, Dumps: "off" , DumpsKeep: 7, Rooms: DefaultRooms, Callbacks: DefaultCallbacks, |
| 169 | Features: Features{Search: "prose" , Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}, Jobs: true}, |
| 170 | Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}} |
| 171 | } |
| 172 | |
| 173 | // Validate checks policy enum values, URL fields, feature dependencies and |
| 174 | // numeric bounds. |
| 175 | func Validate(p Policy) error { |
| 176 | if p.FileLimits.MaxFileBytes < 0 || p.FileLimits.UserStorageBytes < 0 { |
| 177 | return errors.New("fileLimits: byte allowances cannot be negative" ) |
| 178 | } |
| 179 | if p.Writes != "open" && p.Writes != "allowlist" && p.Writes != "wot" && p.Writes != "owner" { |
| 180 | return fmt.Errorf("writes: invalid rule %q" , p.Writes) |
| 181 | } |
| 182 | if p.Reads != "open" && p.Reads != "auth" && p.Reads != "members" { |
| 183 | return fmt.Errorf("reads: invalid rule %q" , p.Reads) |
| 184 | } |
| 185 | if p.Features.Search != "full" && p.Features.Search != "prose" && p.Features.Search != "off" { |
| 186 | return fmt.Errorf("features.search: invalid mode %q" , p.Features.Search) |
| 187 | } |
| 188 | if p.Dumps != "off" && p.Dumps != "daily" && p.Dumps != "weekly" { |
| 189 | return fmt.Errorf("dumps: invalid mode %q" , p.Dumps) |
| 190 | } |
| 191 | if p.DumpsKeep < 0 { |
| 192 | return errors.New("dumpsKeep: cannot be negative" ) |
| 193 | } |
| 194 | for name, value := range map[string]string{"postingPolicy" : p.PostingPolicy, "privacyPolicy" : p.PrivacyPolicy} { |
| 195 | if value != "" { |
| 196 | u, err := url.Parse(value) |
| 197 | if err != nil || u.Scheme != "https" || u.Host == "" { |
| 198 | return fmt.Errorf("%s: must be an https URL" , name) |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | for _, peer := range p.PrivatePeers { |
| 203 | u, err := url.Parse(strings.TrimRight(strings.TrimSpace(peer), "/" )) |
| 204 | if err != nil || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss" ) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { |
| 205 | return fmt.Errorf("privatePeers: invalid peer URL %q" , peer) |
| 206 | } |
| 207 | } |
| 208 | if p.Features.Sites.Mirror && !p.Features.Sites.Enabled { |
| 209 | return errors.New("features.sites.mirror requires sites.enabled" ) |
| 210 | } |
| 211 | if (p.Features.Grasp02 || p.Features.Grasp03 || p.Features.Grasp05 || p.Features.Grasp06 || p.Features.Grasp08) && !p.Features.Grasp { |
| 212 | return errors.New("grasp subfeatures require grasp" ) |
| 213 | } |
| 214 | if (p.Features.Grasp03 || p.Features.Grasp05) && !p.Features.Grasp02 { |
| 215 | return errors.New("features.grasp03 and grasp05 require grasp02" ) |
| 216 | } |
| 217 | if p.Features.Grasp08 && p.Reads != "members" { |
| 218 | return errors.New("features.grasp08 requires reads=members" ) |
| 219 | } |
| 220 | for name, value := range p.Views { |
| 221 | if value != "off" && value != "write" && value != "hourly" && value != "daily" { |
| 222 | return fmt.Errorf("views.%s: invalid setting" , name) |
| 223 | } |
| 224 | } |
| 225 | for _, word := range p.BlockedWords { |
| 226 | if strings.HasPrefix(word, "/" ) && strings.HasSuffix(word, "/" ) { |
| 227 | if _, err := regexp.Compile(word[1 : len(word)-1]); err != nil { |
| 228 | return fmt.Errorf("blockedWords: %w" , err) |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 || p.Callbacks < 0 { |
| 233 | return errors.New("policy: numeric values cannot be negative" ) |
| 234 | } |
| 235 | if p.Succession != nil && (p.Succession.Heir == "" || p.Succession.AfterDays <= 0) { |
| 236 | return errors.New("succession: heir and positive afterDays required" ) |
| 237 | } |
| 238 | return nil |
| 239 | } |
| 240 | |
| 241 | // Patch applies only recognized fields. A missing field is unchanged; an |
| 242 | // explicit null resets pointer fields and an explicit empty array clears it. |
| 243 | func Patch(cur Policy, raw map[string]json.RawMessage) (Policy, error) { |
| 244 | b, err := json.Marshal(cur) |
| 245 | if err != nil { |
| 246 | return Policy{}, fmt.Errorf("marshal policy: %w" , err) |
| 247 | } |
| 248 | var merged map[string]json.RawMessage |
| 249 | if err := json.Unmarshal(b, &merged); err != nil { |
| 250 | return Policy{}, fmt.Errorf("decode policy: %w" , err) |
| 251 | } |
| 252 | for key, value := range raw { |
| 253 | if _, ok := merged[key]; !ok { |
| 254 | continue |
| 255 | } |
| 256 | if key == "features" || key == "notify" || key == "memberInvites" || key == "delivery" || key == "inbox" || key == "fileLimits" { |
| 257 | var patchMap, currentMap map[string]json.RawMessage |
| 258 | if json.Unmarshal(value, &patchMap) == nil && json.Unmarshal(merged[key], ¤tMap) == nil { |
| 259 | if key == "features" { |
| 260 | if sites, ok := patchMap["sites" ]; ok && string(sites) == "false" { |
| 261 | patchMap["sites" ] = json.RawMessage(`{"enabled":false}` ) |
| 262 | } |
| 263 | } |
| 264 | for nestedKey, nestedValue := range patchMap { |
| 265 | currentMap[nestedKey] = nestedValue |
| 266 | } |
| 267 | value, err = json.Marshal(currentMap) |
| 268 | if err != nil { |
| 269 | return Policy{}, fmt.Errorf("marshal %s patch: %w" , key, err) |
| 270 | } |
| 271 | } |
| 272 | } |
| 273 | merged[key] = value |
| 274 | } |
| 275 | b, err = json.Marshal(merged) |
| 276 | if err != nil { |
| 277 | return Policy{}, fmt.Errorf("marshal patch: %w" , err) |
| 278 | } |
| 279 | var out Policy |
| 280 | if err := json.Unmarshal(b, &out); err != nil { |
| 281 | return Policy{}, fmt.Errorf("decode patch: %w" , err) |
| 282 | } |
| 283 | if err := Validate(out); err != nil { |
| 284 | return Policy{}, err |
| 285 | } |
| 286 | return out, nil |
| 287 | } |