clone

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

feat/standalone-relay
tinyrelay / internal / policy / policy.go
policy.go 10.9K
1package policy23import (4 "encoding/json"5 "errors"6 "fmt"7 "net/url"8 "regexp"9 "strings"10)1112// Succession describes the account handoff after a period of inactivity.13type Succession struct {14 Heir string `json:"heir"`15 AfterDays int `json:"afterDays"`16}1718// Notify selects tenant notification categories.19type Notify struct {20 Reports bool `json:"reports"`21 Jobs bool `json:"jobs"`22 Succession bool `json:"succession"`23 Digest bool `json:"digest"`24}2526// MemberInvites controls invitation depth and quota.27type MemberInvites struct {28 Depth int `json:"depth"`29 Quota int `json:"quota"`30}3132// Sites controls hosted site publication and mirroring.33type Sites struct {34 Enabled bool `json:"enabled"`35 Mirror bool `json:"mirror"`36}3738// Features selects relay protocols and tenant capabilities.39type 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 members60 // and agents. It is on unless the owner switches it off.61 Jobs bool `json:"jobs"`62}6364// Delivery controls event delivery services.65type Delivery struct {66 Enabled bool `json:"enabled"`67}6869// Inbox controls targeted inbox admission.70type Inbox struct {71 Targeted bool `json:"targeted"`72}7374// FileLimits controls a tenant's file storage allowances. Zero is unlimited.75type FileLimits struct {76 MaxFileBytes int64 `json:"maxFileBytes"`77 UserStorageBytes int64 `json:"userStorageBytes"`78}7980// CustomHost records a tenant-managed host and its provisioning state.81type 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}8990// RetentionRule assigns a retention period to one kind; negative kinds are91// used as the wildcard rule by the gate.92type RetentionRule struct {93 Kind int `json:"kind"`94 Days int `json:"days"`95}9697// Policy is the durable policy for one tenant. It combines identity and98// presentation settings, access rules, feature switches and storage limits.99type 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 is140 // 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}146147// DefaultRooms is the room allowance applied when a policy does not name one.148const DefaultRooms = 64149150// DefaultCallbacks is the callback allowance per key when a policy does not151// name one.152const DefaultCallbacks = 4153154// UnmarshalJSON applies the current defaults for room, callback and job155// settings before decoding a stored policy.156func (p *Policy) UnmarshalJSON(data []byte) error {157 type plain Policy158 decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks}159 decoded.Features.Jobs = true160 if err := json.Unmarshal(data, &decoded); err != nil {161 return err162 }163 *p = Policy(decoded)164 return nil165}166167func 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}172173// Validate checks policy enum values, URL fields, feature dependencies and174// numeric bounds.175func 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 nil239}240241// Patch applies only recognized fields. A missing field is unchanged; an242// explicit null resets pointer fields and an explicit empty array clears it.243func 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.RawMessage249 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 continue255 }256 if key == "features" || key == "notify" || key == "memberInvites" || key == "delivery" || key == "inbox" || key == "fileLimits" {257 var patchMap, currentMap map[string]json.RawMessage258 if json.Unmarshal(value, &patchMap) == nil && json.Unmarshal(merged[key], &currentMap) == 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] = nestedValue266 }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] = value274 }275 b, err = json.Marshal(merged)276 if err != nil {277 return Policy{}, fmt.Errorf("marshal patch: %w", err)278 }279 var out Policy280 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{}, err285 }286 return out, nil287}