tinyrelaycommit

Commit fe0e11cd305b

browse files at this commit | history

1diff --git a/internal/community/agents.go b/internal/community/agents.go
2index 1b146e6..696eef3 100644
3--- a/internal/community/agents.go
4+++ b/internal/community/agents.go
5@@ -17,12 +17,14 @@ import (
6 "strings"
7
8 "github.com/FelineStateMachine/tinyrelay/internal/event"
9+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
10 )
11
12 const (
13 AgentRateDefault = 60
14 AgentRateMax = 600
15 AgentGrantMaxDays = 365
16+ AgentSiteTTLMax = 365
17 agentNameMax = 64
18 agentRoomMax = 128
19 agentListMax = 256
20@@ -47,6 +49,16 @@ const (
21 AgentJobsBoth = "both"
22 )
23
24+// AgentSite is one static site an agent may publish. Label is a site label
25+// under the agent's own key or "*" for any of them. TTLDays, when set, bounds
26+// how long a manifest and the files behind it live; Encrypted requires the
27+// agent's uploads to be encrypted.
28+type AgentSite struct {
29+ Label string `json:"label"`
30+ TTLDays int `json:"ttl,omitempty"`
31+ Encrypted bool `json:"encrypted,omitempty"`
32+}
33+
34 // AgentScope is the part of a grant the gate checks on every event.
35 type AgentScope struct {
36 Kinds []int `json:"kinds"`
37@@ -54,6 +66,7 @@ type AgentScope struct {
38 Repos []AgentRepo `json:"repos"`
39 Wiki string `json:"wiki,omitempty"`
40 Jobs string `json:"jobs,omitempty"`
41+ Sites []AgentSite `json:"sites,omitempty"`
42 Rate int `json:"rate"`
43 }
44
45@@ -103,6 +116,9 @@ func (g AgentGrant) AllowsKind(kind int) bool {
46 if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {
47 return true
48 }
49+ if siteManifestKind(kind) && len(g.Scope.Sites) > 0 {
50+ return true
51+ }
52 // Wiki access carries its kinds: proposing means publishing a version and
53 // asking for a merge; editing adds redirects.
54 switch g.Scope.Wiki {
55@@ -114,6 +130,53 @@ func (g AgentGrant) AllowsKind(kind int) bool {
56 return false
57 }
58
59+// siteManifestKind reports whether a kind is a site manifest a grant's sites
60+// tag admits: the key's own site and named sites under it. Snapshots are
61+// left out; they are copies of a site rather than a site the agent publishes.
62+func siteManifestKind(kind int) bool {
63+ return kind == sites.KindSite || kind == sites.KindNamedSite
64+}
65+
66+// SiteEntries lists the grant's site entries that cover one label.
67+func (g AgentGrant) SiteEntries(label string) []AgentSite {
68+ var out []AgentSite
69+ for _, site := range g.Scope.Sites {
70+ if site.Label == "*" || (label != "" && site.Label == label) {
71+ out = append(out, site)
72+ }
73+ }
74+ return out
75+}
76+
77+// UploadTTLDays is the number of days the agent's uploads live: the longest
78+// ttl among its site entries, or 0 when there is none or an entry sets none.
79+func (g AgentGrant) UploadTTLDays() int { return longestTTL(g.Scope.Sites) }
80+
81+// UploadsEncrypted reports whether the agent's uploads must be encrypted.
82+// One entry with the flag applies it to every upload the agent makes, since
83+// an upload does not say which site it is for.
84+func (g AgentGrant) UploadsEncrypted() bool {
85+ for _, site := range g.Scope.Sites {
86+ if site.Encrypted {
87+ return true
88+ }
89+ }
90+ return false
91+}
92+
93+func longestTTL(entries []AgentSite) int {
94+ longest := 0
95+ for _, site := range entries {
96+ if site.TTLDays == 0 {
97+ return 0
98+ }
99+ if site.TTLDays > longest {
100+ longest = site.TTLDays
101+ }
102+ }
103+ return longest
104+}
105+
106 // RequestsJobs reports whether the jobs tag lets the agent publish job
107 // requests.
108 func (g AgentGrant) RequestsJobs() bool {
109@@ -164,6 +227,9 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
110 if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) {
111 return errors.New("restricted: agent grant does not allow room " + room)
112 }
113+ if siteManifestKind(e.Kind) {
114+ return g.checkSite(e, now)
115+ }
116 if !gitKind(e.Kind) {
117 return nil
118 }
119@@ -186,6 +252,30 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
120 return nil
121 }
122
123+// checkSite applies the sites entries to a manifest: the label must be
124+// covered, and when the covering entries carry a ttl the manifest must
125+// expire within it so NIP-40 retires the site on time.
126+func (g AgentGrant) checkSite(e event.Event, now int64) error {
127+ label := sites.SiteLabel(e)
128+ entries := g.SiteEntries(label)
129+ if len(entries) == 0 {
130+ return fmt.Errorf("restricted: agent grant does not allow site %s", label)
131+ }
132+ ttl := longestTTL(entries)
133+ if ttl == 0 {
134+ return nil
135+ }
136+ latest := now + int64(ttl)*86400
137+ expires := event.Expiration(e)
138+ if expires == 0 {
139+ return fmt.Errorf("invalid: agent grant allows site %s for %d days: add an expiration tag no later than %d", label, ttl, latest)
140+ }
141+ if expires > latest {
142+ return fmt.Errorf("invalid: agent grant allows site %s for %d days: set the expiration tag no later than %d", label, ttl, latest)
143+ }
144+ return nil
145+}
146+
147 func gitKind(kind int) bool {
148 return kind == event.KIND_GIT_PATCH || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PR_UPDATE || kind == event.KIND_GIT_ISSUE || kind == 1111 || gitStatusKind(kind)
149 }
150@@ -286,6 +376,21 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
151 default:
152 return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both")
153 }
154+ for _, tag := range e.Tags {
155+ if len(tag) == 0 || tag[0] != "sites" {
156+ continue
157+ }
158+ site, err := parseAgentSite(tag[1:], agent)
159+ if err != nil {
160+ return AgentGrant{}, err
161+ }
162+ if !siteListed(grant.Scope.Sites, site.Label) {
163+ grant.Scope.Sites = append(grant.Scope.Sites, site)
164+ }
165+ }
166+ if len(grant.Scope.Sites) > agentListMax {
167+ return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)
168+ }
169 if value := event.Tag(e, "rate"); value != "" {
170 rate, err := strconv.Atoi(strings.TrimSpace(value))
171 if err != nil || rate < 1 || rate > AgentRateMax {
172@@ -313,6 +418,49 @@ func parseAgentRepo(value string) (AgentRepo, bool) {
173 return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true
174 }
175
176+// parseAgentSite reads one sites tag: a label, then any of ttl=<days> and
177+// encrypted. The label is "*" or a site label under the agent's own key.
178+func parseAgentSite(values []string, agent string) (AgentSite, error) {
179+ if len(values) == 0 {
180+ return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")
181+ }
182+ site := AgentSite{Label: strings.TrimSpace(values[0])}
183+ if site.Label != "*" {
184+ parsed, ok := sites.ParseSite(site.Label)
185+ if !ok || parsed.Kind == sites.KindSiteSnapshot {
186+ return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")
187+ }
188+ if parsed.PubKey != agent {
189+ return AgentSite{}, errors.New("invalid: agent grant sites label must be a site under the agent's own key")
190+ }
191+ }
192+ for _, flag := range values[1:] {
193+ flag = strings.TrimSpace(flag)
194+ switch {
195+ case flag == "encrypted":
196+ site.Encrypted = true
197+ case strings.HasPrefix(flag, "ttl="):
198+ days, err := strconv.Atoi(strings.TrimPrefix(flag, "ttl="))
199+ if err != nil || days < 1 || days > AgentSiteTTLMax {
200+ return AgentSite{}, fmt.Errorf("invalid: agent grant sites ttl must be between 1 and %d days", AgentSiteTTLMax)
201+ }
202+ site.TTLDays = days
203+ default:
204+ return AgentSite{}, errors.New("invalid: agent grant sites tag allows only ttl=<days> and encrypted after the label")
205+ }
206+ }
207+ return site, nil
208+}
209+
210+func siteListed(entries []AgentSite, label string) bool {
211+ for _, site := range entries {
212+ if site.Label == label {
213+ return true
214+ }
215+ }
216+ return false
217+}
218+
219 // CanGrantAgents reports whether a role may sign agent grants.
220 func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }
221
222diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go
223index ce68153..00d5b9a 100644
224--- a/internal/gates/agents_test.go
225+++ b/internal/gates/agents_test.go
226@@ -12,6 +12,7 @@ import (
227 "github.com/FelineStateMachine/tinyrelay/internal/event"
228 "github.com/FelineStateMachine/tinyrelay/internal/policy"
229 "github.com/FelineStateMachine/tinyrelay/internal/relay"
230+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
231 "github.com/FelineStateMachine/tinyrelay/internal/storage"
232 )
233
234@@ -255,3 +256,115 @@ func TestAgentGrantShapeAndSigner(t *testing.T) {
235 t.Fatalf("parsed grant %+v %v", grant, err)
236 }
237 }
238+
239+func TestAgentGrantSitesCoverLabelsAndRequireExpiry(t *testing.T) {
240+ f := newAgentFixture(t)
241+ ctx := context.Background()
242+ session := relay.Session{PubKeys: []string{f.agent}}
243+ hash := strings.Repeat("a", 64)
244+ paths := [][]string{{"path", "/index.html", hash}}
245+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})
246+ base, _ := sites.Base36(f.agent)
247+ named := base + "docs"
248+ manifest := func(kind int, tags ...[]string) event.Event {
249+ return signed(t, agentSecret, kind, agentNow, append(append([][]string{}, paths...), tags...), "")
250+ }
251+ // Without a sites tag the manifest kinds stay closed.
252+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))
253+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "kind 15128")
254+ // One label without a ttl: that site only, no expiration needed.
255+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"sites", named}))
256+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}), session, agentNow); err != nil {
257+ t.Fatalf("covered named site rejected: %v", err)
258+ }
259+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}), session, agentNow), "site "+base+"blog")
260+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "site "+own)
261+ // A ttl requires an expiration tag within it; the reason says what to add.
262+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"sites", own, "ttl=2"}, []string{"sites", named, "ttl=1", "encrypted"}))
263+ latest := strconv.FormatInt(agentNow+2*86400, 10)
264+ err := f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow)
265+ if err == nil || !strings.HasPrefix(err.Error(), "invalid: agent grant allows site "+own+" for 2 days: add an expiration tag no later than "+latest) {
266+ t.Fatalf("manifest without expiration: %v", err)
267+ }
268+ err = f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", strconv.FormatInt(agentNow+3*86400, 10)}), session, agentNow)
269+ if err == nil || !strings.Contains(err.Error(), "set the expiration tag no later than "+latest) {
270+ t.Fatalf("manifest expiring too late: %v", err)
271+ }
272+ if err := f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow); err != nil {
273+ t.Fatalf("manifest within the ttl rejected: %v", err)
274+ }
275+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+86400, 10)}), session, agentNow); err != nil {
276+ t.Fatalf("named manifest within its own ttl rejected: %v", err)
277+ }
278+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", latest}), session, agentNow); err == nil {
279+ t.Fatal("named manifest beyond its own ttl accepted")
280+ }
281+ // A star covers every label under the key and the longest ttl applies.
282+ grant := f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"sites", "*", "ttl=5"}, []string{"sites", named, "ttl=1"}))
283+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {
284+ t.Fatalf("star label rejected: %v", err)
285+ }
286+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {
287+ t.Fatalf("longest ttl not applied: %v", err)
288+ }
289+ parsed, err := community.ParseAgentGrant(grant, agentNow)
290+ if err != nil || parsed.UploadTTLDays() != 5 || parsed.UploadsEncrypted() {
291+ t.Fatalf("parsed sites %+v %v", parsed.Scope.Sites, err)
292+ }
293+ // Snapshots are not admitted by the sites tag.
294+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSiteSnapshot), session, agentNow), "kind 5128")
295+ // Paused and revoked grants stop manifests like every other event.
296+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {
297+ t.Fatal(err)
298+ }
299+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "paused")
300+ if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {
301+ t.Fatal(err)
302+ }
303+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "revoked")
304+}
305+
306+func TestAgentGrantSitesShape(t *testing.T) {
307+ f := newAgentFixture(t)
308+ ctx := context.Background()
309+ other, _ := event.PublicKey(agentOtherSecret)
310+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})
311+ foreign := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: other})
312+ for _, tc := range []struct {
313+ name string
314+ tag []string
315+ want string
316+ }{
317+ {"empty", []string{"sites"}, "invalid: agent grant sites tag needs a site label"},
318+ {"bad label", []string{"sites", "not-a-site"}, "invalid: agent grant sites tag needs a site label"},
319+ {"other key", []string{"sites", foreign}, "invalid: agent grant sites label must be a site under the agent's own key"},
320+ {"snapshot", []string{"sites", "v" + strings.Repeat("0", 50)}, "invalid: agent grant sites tag needs a site label"},
321+ {"ttl zero", []string{"sites", "*", "ttl=0"}, "invalid: agent grant sites ttl must be between 1 and 365"},
322+ {"ttl long", []string{"sites", "*", "ttl=366"}, "invalid: agent grant sites ttl must be between 1 and 365"},
323+ {"ttl text", []string{"sites", "*", "ttl=soon"}, "invalid: agent grant sites ttl must be between 1 and 365"},
324+ {"unknown flag", []string{"sites", own, "public"}, "invalid: agent grant sites tag allows only ttl=<days> and encrypted"},
325+ } {
326+ t.Run(tc.name, func(t *testing.T) {
327+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, tc.tag), "")
328+ err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow)
329+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) {
330+ t.Fatalf("want %q, got %v", tc.want, err)
331+ }
332+ })
333+ }
334+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"sites", own, "ttl=30", "encrypted"}, []string{"sites", own, "ttl=7"}, []string{"sites", "*"}), "")
335+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {
336+ t.Fatalf("valid sites grant rejected: %v", err)
337+ }
338+ grant, err := community.ParseAgentGrant(e, agentNow)
339+ if err != nil {
340+ t.Fatal(err)
341+ }
342+ want := []community.AgentSite{{Label: own, TTLDays: 30, Encrypted: true}, {Label: "*"}}
343+ if len(grant.Scope.Sites) != len(want) || grant.Scope.Sites[0] != want[0] || grant.Scope.Sites[1] != want[1] {
344+ t.Fatalf("sites = %+v, want %+v", grant.Scope.Sites, want)
345+ }
346+ if !grant.UploadsEncrypted() || grant.UploadTTLDays() != 0 {
347+ t.Fatalf("an entry without a ttl should leave uploads unlimited: ttl=%d encrypted=%v", grant.UploadTTLDays(), grant.UploadsEncrypted())
348+ }
349+}

About

ownerc01503e8035f
visibilitypublic
headfe0e11cd305b

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs