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 long-task requests, answers and cancels (kinds 43001 to60 // 43006) from members and agents. It is on unless the owner switches it61 // off.62 Jobs bool `json:"jobs"`63}6465// Delivery controls event delivery services.66type Delivery struct {67 Enabled bool `json:"enabled"`68}6970// Inbox controls targeted inbox admission.71type Inbox struct {72 Targeted bool `json:"targeted"`73}7475// FileLimits controls a tenant's file storage allowances. Zero is unlimited.76type FileLimits struct {77 MaxFileBytes int64 `json:"maxFileBytes"`78 UserStorageBytes int64 `json:"userStorageBytes"`79}8081// CustomHost records a tenant-managed host and its provisioning state.82type CustomHost struct {83 Host string `json:"host"`84 ID string `json:"id"`85 Site string `json:"site,omitempty"`86 At int64 `json:"at"`87 Status string `json:"status"`88 SSLStatus string `json:"sslStatus"`89}9091// RetentionRule assigns a retention period to one kind; negative kinds are92// used as the wildcard rule by the gate.93type RetentionRule struct {94 Kind int `json:"kind"`95 Days int `json:"days"`96}9798// Policy is the durable policy for one tenant. It combines identity and99// presentation settings, access rules, feature switches and storage limits.100type Policy struct {101 Owner string `json:"owner"`102 CustomHosts []CustomHost `json:"customHosts"`103 Succession *Succession `json:"succession"`104 Name string `json:"name"`105 Description string `json:"description"`106 Icon string `json:"icon"`107 Banner string `json:"banner"`108 Contact string `json:"contact"`109 PostingPolicy string `json:"postingPolicy"`110 PrivacyPolicy string `json:"privacyPolicy"`111 Tags []string `json:"tags"`112 LanguageTags []string `json:"languageTags"`113 RelayCountries []string `json:"relayCountries"`114 Notify Notify `json:"notify"`115 Writes string `json:"writes"`116 OpenKinds []int `json:"openKinds"`117 GuestReplies bool `json:"guestReplies"`118 BlockedWords []string `json:"blockedWords"`119 BlockedWordsInTags bool `json:"blockedWordsInTags"`120 ReportThreshold int `json:"reportThreshold"`121 Reads string `json:"reads"`122 JoinTerms string `json:"joinTerms"`123 DirectoryPublic bool `json:"directoryPublic"`124 MinPow int `json:"minPow"`125 MaxFuture int64 `json:"maxFuture"`126 MemberInvites MemberInvites `json:"memberInvites"`127 Views map[string]string `json:"views"`128 Features Features `json:"features"`129 PushCallbacks []string `json:"pushCallbacks"`130 PrivatePeers []string `json:"privatePeers,omitempty"`131 FileLimits FileLimits `json:"fileLimits"`132 LetteredNips bool `json:"letteredNips"`133 Delivery Delivery `json:"delivery"`134 Inbox Inbox `json:"inbox"`135 Dumps string `json:"dumps"`136 DumpsKeep int `json:"dumpsKeep"`137 // Rooms caps the chat rooms a tenant may create beside its main group.138 // Zero switches room creation off.139 Rooms int `json:"rooms"`140 // Callbacks caps the event callbacks one key may register. The owner is141 // not limited. Zero switches registration off for everyone else.142 Callbacks int `json:"callbacks"`143 BlockedKinds []int `json:"-"`144 AllowedKinds []int `json:"-"`145 Retention []RetentionRule `json:"-"`146}147148// DefaultRooms is the room allowance applied when a policy does not name one.149const DefaultRooms = 64150151// DefaultCallbacks is the callback allowance per key when a policy does not152// name one.153const DefaultCallbacks = 4154155// UnmarshalJSON applies the current defaults for room, callback and job156// settings before decoding a stored policy.157func (p *Policy) UnmarshalJSON(data []byte) error {158 type plain Policy159 decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks}160 decoded.Features.Jobs = true161 if err := json.Unmarshal(data, &decoded); err != nil {162 return err163 }164 *p = Policy(decoded)165 return nil166}167168func Defaults(owner string) Policy {169 return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms, Callbacks: DefaultCallbacks,170 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},171 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}172}173174// Validate checks policy enum values, URL fields, feature dependencies and175// numeric bounds.176func Validate(p Policy) error {177 if p.FileLimits.MaxFileBytes < 0 || p.FileLimits.UserStorageBytes < 0 {178 return errors.New("fileLimits: byte allowances cannot be negative")179 }180 if p.Writes != "open" && p.Writes != "allowlist" && p.Writes != "wot" && p.Writes != "owner" {181 return fmt.Errorf("writes: invalid rule %q", p.Writes)182 }183 if p.Reads != "open" && p.Reads != "auth" && p.Reads != "members" {184 return fmt.Errorf("reads: invalid rule %q", p.Reads)185 }186 if p.Features.Search != "full" && p.Features.Search != "prose" && p.Features.Search != "off" {187 return fmt.Errorf("features.search: invalid mode %q", p.Features.Search)188 }189 if p.Dumps != "off" && p.Dumps != "daily" && p.Dumps != "weekly" {190 return fmt.Errorf("dumps: invalid mode %q", p.Dumps)191 }192 if p.DumpsKeep < 0 {193 return errors.New("dumpsKeep: cannot be negative")194 }195 for name, value := range map[string]string{"postingPolicy": p.PostingPolicy, "privacyPolicy": p.PrivacyPolicy} {196 if value != "" {197 u, err := url.Parse(value)198 if err != nil || u.Scheme != "https" || u.Host == "" {199 return fmt.Errorf("%s: must be an https URL", name)200 }201 }202 }203 for _, peer := range p.PrivatePeers {204 u, err := url.Parse(strings.TrimRight(strings.TrimSpace(peer), "/"))205 if err != nil || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {206 return fmt.Errorf("privatePeers: invalid peer URL %q", peer)207 }208 }209 if p.Features.Sites.Mirror && !p.Features.Sites.Enabled {210 return errors.New("features.sites.mirror requires sites.enabled")211 }212 if (p.Features.Grasp02 || p.Features.Grasp03 || p.Features.Grasp05 || p.Features.Grasp06 || p.Features.Grasp08) && !p.Features.Grasp {213 return errors.New("grasp subfeatures require grasp")214 }215 if (p.Features.Grasp03 || p.Features.Grasp05) && !p.Features.Grasp02 {216 return errors.New("features.grasp03 and grasp05 require grasp02")217 }218 if p.Features.Grasp08 && p.Reads != "members" {219 return errors.New("features.grasp08 requires reads=members")220 }221 for name, value := range p.Views {222 if value != "off" && value != "write" && value != "hourly" && value != "daily" {223 return fmt.Errorf("views.%s: invalid setting", name)224 }225 }226 for _, word := range p.BlockedWords {227 if strings.HasPrefix(word, "/") && strings.HasSuffix(word, "/") {228 if _, err := regexp.Compile(word[1 : len(word)-1]); err != nil {229 return fmt.Errorf("blockedWords: %w", err)230 }231 }232 }233 if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 || p.Callbacks < 0 {234 return errors.New("policy: numeric values cannot be negative")235 }236 if p.Succession != nil && (p.Succession.Heir == "" || p.Succession.AfterDays <= 0) {237 return errors.New("succession: heir and positive afterDays required")238 }239 return nil240}241242// Patch applies only recognized fields. A missing field is unchanged; an243// explicit null resets pointer fields and an explicit empty array clears it.244func Patch(cur Policy, raw map[string]json.RawMessage) (Policy, error) {245 b, err := json.Marshal(cur)246 if err != nil {247 return Policy{}, fmt.Errorf("marshal policy: %w", err)248 }249 var merged map[string]json.RawMessage250 if err := json.Unmarshal(b, &merged); err != nil {251 return Policy{}, fmt.Errorf("decode policy: %w", err)252 }253 for key, value := range raw {254 if _, ok := merged[key]; !ok {255 continue256 }257 if key == "features" || key == "notify" || key == "memberInvites" || key == "delivery" || key == "inbox" || key == "fileLimits" {258 var patchMap, currentMap map[string]json.RawMessage259 if json.Unmarshal(value, &patchMap) == nil && json.Unmarshal(merged[key], ¤tMap) == nil {260 if key == "features" {261 if sites, ok := patchMap["sites"]; ok && string(sites) == "false" {262 patchMap["sites"] = json.RawMessage(`{"enabled":false}`)263 }264 }265 for nestedKey, nestedValue := range patchMap {266 currentMap[nestedKey] = nestedValue267 }268 value, err = json.Marshal(currentMap)269 if err != nil {270 return Policy{}, fmt.Errorf("marshal %s patch: %w", key, err)271 }272 }273 }274 merged[key] = value275 }276 b, err = json.Marshal(merged)277 if err != nil {278 return Policy{}, fmt.Errorf("marshal patch: %w", err)279 }280 var out Policy281 if err := json.Unmarshal(b, &out); err != nil {282 return Policy{}, fmt.Errorf("decode patch: %w", err)283 }284 if err := Validate(out); err != nil {285 return Policy{}, err286 }287 return out, nil288}