tinyrelayfile
tinyrelay/internal/policy/policy.go/

internal/policy/policy.go raw
1package policy
2
3import (
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.
13type Succession struct {
14 Heir string `json:"heir"`
15 AfterDays int `json:"afterDays"`
16}
17
18// 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}
25
26// MemberInvites controls invitation depth and quota.
27type MemberInvites struct {
28 Depth int `json:"depth"`
29 Quota int `json:"quota"`
30}
31
32// Sites controls hosted site publication and mirroring.
33type Sites struct {
34 Enabled bool `json:"enabled"`
35 Mirror bool `json:"mirror"`
36}
37
38// 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 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.
65type Delivery struct {
66 Enabled bool `json:"enabled"`
67}
68
69// Inbox controls targeted inbox admission.
70type Inbox struct {
71 Targeted bool `json:"targeted"`
72}
73
74// 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}
79
80// 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}
89
90// RetentionRule assigns a retention period to one kind; negative kinds are
91// used as the wildcard rule by the gate.
92type 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.
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 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.
148const DefaultRooms = 64
149
150// DefaultCallbacks is the callback allowance per key when a policy does not
151// name one.
152const DefaultCallbacks = 4
153
154// UnmarshalJSON applies the current defaults for room, callback and job
155// settings before decoding a stored policy.
156func (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
167func 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.
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 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.
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.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], &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] = 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs