Apply source visibility and expiration checks to image routes and signed artifact records. Require current repository README content to match cached artifacts, protect nested references, and revalidate private image caches. Skip expired queued transforms and cover hidden, removed, expired and replaced sources, shared media, and recursive references.
- commit
- 73dc995c43004151a39b4e1e890d46c9e961de54
- parent
- 4cfc8b0000c7bf78a795d9c1f9037b35b3cec008
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 10 | +- |
| M | docs/ | 2 | +- |
| M | internal/ | 6 | +- |
| A | internal/ | 119 | ++++++++++++++++++ |
| A | internal/ | 203 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 15 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 32 | ++++- |
| M | internal/ | 28 | ++++ |
@@ -93,15 +93,17 @@ SVG is served under the sandbox policy below. Acceptance does not authorize arbi
9393 - One `["block","<decimal index>"]` for each source, in the same source order. These block tags follow the source tags; they are not adjacent source/block pairs.9494 - `["type","image/svg+xml"]` or `["type","image/png"]`, optional `engine`, and optional `expiration`.9595 96-If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Expiry is therefore not an unconditional immediate HTTP-route check. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive.96+If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Media and signed-record reads reject expired sources even before maintenance prunes them. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive.9797 9898 Public artifacts use `records.Generate` and enter normal event storage. Members-only artifacts use `records.Sign` and remain signed in the artifact store without being published to normal event queries. The signature attests to the relay's generated output, not endorsement by any source author.9999 100-`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts use the relay's general browse-read rule, not a per-source visibility check.100+`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts require the relay's general browse-read rule. Both audiences also require a current, unexpired source visible to the requester. A repository-state source must still contain a block matching the artifact's hash. If no source qualifies, the route returns 404.101101 102-Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` for members and `public, no-cache` for public artifacts. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go).102+Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` because access depends on the requester and current source state. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go).103103 104-**Disclosure boundary:** Audience is attached to the view/artifact, not inherited from a source's room, proposal, wiki approval or repository ACL. A public view can expose an artifact of a nonpublic or pending source; a members view can expose it to members who cannot read that source. Transformation itself uses current source presence, not author-specific read authorization. Only configure a transform/audience appropriate for everything its kinds/languages can select. Hash-based paths and relay signatures are not access control or encryption.104+Signed artifact records require access to every source named in their tags, and the tags must match the current source records. An image shared by several sources can be served through any readable source because its bytes are identical; the signed record stays hidden when it would disclose another source. Source checks also apply through nested artifact references and reject cycles.105+106+Transformation sends blocks from current, unexpired sources to the owner-configured endpoint. Reader access checks do not prevent that outbound disclosure. Configure transforms only for content you intend to send to their endpoints.105107 106108 ## Scheduling, errors and discovery107109 @@ -65,7 +65,7 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/<
6565 6666 Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced.6767 68-Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Neither audience inherits the source event's access controls. A public artifact can expose material from a private or pending source, and a members-only artifact can expose it to members who cannot read that source. Configure transforms and audiences only for content you intend to disclose. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them.68+Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Both audiences also require access to a current, unexpired source containing the block. Signed artifact records require access to every source they list. Configure transforms only for content you intend to send to their endpoints. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them.6969 7070 ## Failures7171 @@ -23,11 +23,12 @@ import (
2323 2424 "github.com/FelineStateMachine/tinyrelay/internal/community"2525 "github.com/FelineStateMachine/tinyrelay/internal/event"26- gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"26+ "github.com/FelineStateMachine/tinyrelay/internal/gates"2727 "github.com/FelineStateMachine/tinyrelay/internal/records"2828 "github.com/FelineStateMachine/tinyrelay/internal/storage"2929 "github.com/FelineStateMachine/tinyrelay/internal/telemetry"3030 "github.com/FelineStateMachine/tinyrelay/internal/views"31+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"3132 )3233 3334 // customViewService owns custom view definitions, transform execution and@@ -45,6 +46,7 @@ type customViewService struct {
4546 loopback func() bool4647 resolveActor func(*http.Request) (string, error)4748 browseRead func(context.Context, string) error49+ gate *gates.Gate4850 viewClient *http.Client4951 viewMu sync.Mutex5052 viewIndex map[int][]customView@@ -62,6 +64,7 @@ type customViewServiceConfig struct {
6264 Loopback func() bool6365 ResolveActor func(*http.Request) (string, error)6466 BrowseRead func(context.Context, string) error67+ Gate *gates.Gate6568 }6669 6770 func newCustomViewService(cfg customViewServiceConfig) *customViewService {@@ -76,6 +79,7 @@ func newCustomViewService(cfg customViewServiceConfig) *customViewService {
7679 loopback: cfg.Loopback,7780 resolveActor: cfg.ResolveActor,7881 browseRead: cfg.BrowseRead,82+ gate: cfg.Gate,7983 }8084 }8185 @@ -0,0 +1,119 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strconv"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/relay"12+ "github.com/FelineStateMachine/tinyrelay/internal/views"13+)14+15+func (t *customViewService) artifactEventVisible(ctx context.Context, e event.Event, s relay.Session) bool {16+ if t.records == nil || e.PubKey != t.records.PublicKey() {17+ return true18+ }19+ d := event.Tag(e, "d")20+ if !strings.HasPrefix(d, "bind.ws/view/") {21+ return true22+ }23+ var view, hash string24+ if err := t.store.DB().QueryRowContext(ctx, `SELECT view,hash FROM custom_view_artifacts WHERE event_id=?`, e.ID).Scan(&view, &hash); err != nil {25+ return false26+ }27+ if d != "bind.ws/view/"+view+"/"+hash {28+ return false29+ }30+ refs := map[string]bool{}31+ for _, tag := range e.Tags {32+ if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") {33+ refs[tag[1]] = true34+ }35+ }36+ if len(refs) == 0 {37+ return false38+ }39+ sources, ok := t.artifactSourceRows(ctx, view, hash)40+ if !ok {41+ return false42+ }43+ for _, source := range sources {44+ if !refs[source] || !t.sourceVisible(ctx, source, s, hash) {45+ return false46+ }47+ delete(refs, source)48+ }49+ return len(sources) > 0 && len(refs) == 050+}51+52+func (t *customViewService) artifactHTTPSourceVisible(ctx context.Context, view, hash string, s relay.Session) bool {53+ sources, ok := t.artifactSourceRows(ctx, view, hash)54+ if !ok {55+ return false56+ }57+ for _, source := range sources {58+ if t.sourceVisible(ctx, source, s, hash) {59+ return true60+ }61+ }62+ return false63+}64+65+func (t *customViewService) artifactSourceRows(ctx context.Context, view, hash string) ([]string, bool) {66+ rows, err := t.store.DB().QueryContext(ctx, `SELECT source FROM custom_view_sources WHERE view=? AND hash=?`, view, hash)67+ if err != nil {68+ return nil, false69+ }70+ defer rows.Close()71+ var sources []string72+ for rows.Next() {73+ var source string74+ if rows.Scan(&source) != nil {75+ _ = rows.Close()76+ return nil, false77+ }78+ sources = append(sources, source)79+ }80+ if rows.Err() != nil {81+ return nil, false82+ }83+ return sources, true84+}85+86+func (t *customViewService) sourceVisible(ctx context.Context, source string, s relay.Session, artifactHash string) bool {87+ if t.gate == nil {88+ return false89+ }90+ var raw string91+ if tag := viewSourceTag(source); tag[0] == "a" {92+ parts := strings.SplitN(source, ":", 3)93+ if len(parts) != 3 {94+ return false95+ }96+ if err := t.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) ORDER BY created_at DESC,id ASC LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2], time.Now().Unix()).Scan(&raw); err != nil {97+ return false98+ }99+ } else if err := t.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND (expires=0 OR expires>?)`, source, time.Now().Unix()).Scan(&raw); err != nil {100+ return false101+ }102+ var sourceEvent event.Event103+ if err := json.Unmarshal([]byte(raw), &sourceEvent); err != nil || !t.gate.CanSee(ctx, sourceEvent, s, nil) {104+ return false105+ }106+ if strings.HasPrefix(source, strconv.Itoa(event.KIND_REPO_STATE)+":") && artifactHash != "" {107+ blocks, err := t.sourceBlocks(ctx, sourceEvent)108+ if err != nil {109+ return false110+ }111+ for _, block := range blocks {112+ if views.Hash(block.Lang, block.Source) == artifactHash {113+ return true114+ }115+ }116+ return false117+ }118+ return true119+}@@ -0,0 +1,203 @@
1+package daemon2+3+import (4+ "context"5+ "fmt"6+ "net/http"7+ "net/http/httptest"8+ "os"9+ "path/filepath"10+ "strings"11+ "testing"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+ "github.com/FelineStateMachine/tinyrelay/internal/views"17+)18+19+func TestCustomViewArtifactSourceVisibilityCoversMediaAndEventReads(t *testing.T) {20+ tenant, server, _ := viewTenant(t, []int{1}, nil)21+ server.answer(http.StatusOK, svgArtifact(0))22+ first := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```")23+ if err := publishAs(t, tenant, first); err != nil {24+ t.Fatal(err)25+ }26+ runPendingView(t, tenant, "diagrams", first.ID)27+ second := signedEvent(t, testOwnerSecret, 1, time.Now().Unix()+1, nil, first.Content)28+ if err := publishAs(t, tenant, second); err != nil {29+ t.Fatal(err)30+ }31+ runPendingView(t, tenant, "diagrams", second.ID)32+ ctx := context.Background()33+ if _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')`, first.ID); err != nil {34+ t.Fatal(err)35+ }36+ _, image, ok := strings.Cut(views.Figure("diagrams", "mermaid", "graph TD; a-->b;"), `<img src="`)37+ if !ok {38+ t.Fatal("custom view has no image")39+ }40+ path, _, _ := strings.Cut(image, `"`)41+ if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK {42+ t.Fatalf("visible source should serve shared media: %d", response.Code)43+ }44+ hash := views.Hash("mermaid", "graph TD; a-->b;")45+ rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{})46+ if err != nil {47+ t.Fatal(err)48+ }49+ if len(rows) != 0 {50+ t.Fatalf("artifact event leaked a hidden source: %d", len(rows))51+ }52+}53+54+func TestCustomViewArtifactSourceLifecycleBlocksMediaAndEventReads(t *testing.T) {55+ tests := []struct {56+ name string57+ mutate func(context.Context, *Tenant, event.Event) error58+ }{59+ {name: "hidden", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error {60+ _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')`, source.ID)61+ return err62+ }},63+ {name: "deleted", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error {64+ _, err := tenant.store.DB().ExecContext(ctx, `DELETE FROM events WHERE id=?`, source.ID)65+ return err66+ }},67+ {name: "expired", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error {68+ _, err := tenant.store.DB().ExecContext(ctx, `UPDATE events SET expires=? WHERE id=?`, time.Now().Unix()-1, source.ID)69+ return err70+ }},71+ }72+ for _, tc := range tests {73+ t.Run(tc.name, func(t *testing.T) {74+ tenant, server, _ := viewTenant(t, []int{1}, nil)75+ server.answer(http.StatusOK, svgArtifact(0))76+ source := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```")77+ if err := publishAs(t, tenant, source); err != nil {78+ t.Fatal(err)79+ }80+ runPendingView(t, tenant, "diagrams", source.ID)81+ hash := views.Hash("mermaid", "A")82+ path := "/views/diagrams/" + hash + ".svg"83+ if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK {84+ t.Fatalf("baseline artifact: %d", response.Code)85+ }86+ if err := tc.mutate(context.Background(), tenant, source); err != nil {87+ t.Fatal(err)88+ }89+ if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusNotFound {90+ t.Fatalf("inaccessible source served media: %d", response.Code)91+ }92+ rows, err := tenant.Query(context.Background(), []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{})93+ if err != nil {94+ t.Fatal(err)95+ }96+ if len(rows) != 0 {97+ t.Fatalf("inaccessible source leaked artifact event: %d", len(rows))98+ }99+ })100+ }101+}102+103+func TestCustomViewDoesNotTransformExpiredQueuedSource(t *testing.T) {104+ tenant, server, _ := viewTenant(t, []int{1}, nil)105+ server.answer(http.StatusOK, svgArtifact(0))106+ now := time.Now().Unix()107+ source := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", fmt.Sprint(now + 3600)}}, "```mermaid\nA\n```")108+ if err := publishAs(t, tenant, source); err != nil {109+ t.Fatal(err)110+ }111+ queued := pendingViewIntents(t, tenant, "diagrams", source.ID)112+ if len(queued) != 1 {113+ t.Fatalf("queued intents = %d", len(queued))114+ }115+ if _, err := tenant.store.DB().ExecContext(context.Background(), `UPDATE events SET expires=? WHERE id=?`, now-1, source.ID); err != nil {116+ t.Fatal(err)117+ }118+ runViewIntent(t, tenant, queued[0])119+ if server.count() != 0 {120+ t.Fatalf("expired source was sent to transform: %d requests", server.count())121+ }122+}123+124+func TestCustomViewArtifactCoordinateRequiresCurrentMatchingSource(t *testing.T) {125+ tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil)126+ ctx := context.Background()127+ p := tenant.Policy()128+ p.Features.Grasp = true129+ if err := tenant.applyPolicy(ctx, p); err != nil {130+ t.Fatal(err)131+ }132+ announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, time.Now().Unix(), [][]string{{"d", "docs"}, {"clone", "http://127.0.0.1:8080/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git"}, {"relays", "ws://127.0.0.1:8080"}}, "")133+ if err := publishAs(t, tenant, announcement); err != nil {134+ t.Fatal(err)135+ }136+ source := filepath.Join(t.TempDir(), "source.git")137+ gitTest(t, "init", "--bare", source)138+ work := filepath.Join(t.TempDir(), "work")139+ gitTest(t, "clone", source, work)140+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")141+ gitTest(t, "-C", work, "config", "user.name", "Test")142+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\n```mermaid\ngraph LR; a-->b;\n```\n"), 0o600); err != nil {143+ t.Fatal(err)144+ }145+ gitTest(t, "-C", work, "add", "README.md")146+ gitTest(t, "-C", work, "commit", "-m", "readme")147+ gitTest(t, "-C", work, "branch", "-M", "main")148+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))149+ state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")150+ if err := publishAs(t, tenant, state); err != nil {151+ t.Fatal(err)152+ }153+ relayServer := httptest.NewServer(tenant)154+ defer relayServer.Close()155+ gitTest(t, "-C", work, "push", relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")156+ queued := pendingViewIntents(t, tenant, "diagrams", state.ID)157+ if len(queued) != 1 {158+ t.Fatalf("state intents = %d", len(queued))159+ }160+ server.answer(http.StatusOK, svgArtifact(0))161+ runViewIntent(t, tenant, queued[0])162+ hash := views.Hash("mermaid", "graph LR; a-->b;")163+ path := "/views/diagrams/" + hash + ".svg"164+ if blocks, blockErr := tenant.customViews.sourceBlocks(ctx, state); blockErr != nil {165+ t.Fatalf("initial source blocks: %v", blockErr)166+ } else if len(blocks) != 1 {167+ t.Fatalf("initial source blocks = %+v", blocks)168+ }169+ if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK {170+ t.Fatalf("artifact through current state: %d", response.Code)171+ }172+ rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{})173+ if err != nil {174+ t.Fatal(err)175+ }176+ if len(rows) != 1 {177+ t.Fatalf("artifact query through current state = %d, want 1", len(rows))178+ }179+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\nNo diagrams here.\n"), 0o600); err != nil {180+ t.Fatal(err)181+ }182+ gitTest(t, "-C", work, "add", "README.md")183+ gitTest(t, "-C", work, "commit", "-m", "remove diagram")184+ newSHA := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))185+ replacement := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix()+1, [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", newSHA}}, "")186+ if err := publishAs(t, tenant, replacement); err != nil {187+ t.Fatal(err)188+ }189+ gitTest(t, "-C", work, "push", relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")190+ if _, ok := storedArtifact(t, tenant, "diagrams", hash); !ok {191+ t.Fatal("replacement removed the artifact before the access check")192+ }193+ if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusNotFound {194+ t.Fatalf("stale coordinate served media: %d", response.Code)195+ }196+ rows, err = tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{})197+ if err != nil {198+ t.Fatal(err)199+ }200+ if len(rows) != 0 {201+ t.Fatalf("stale coordinate leaked artifact event: %d", len(rows))202+ }203+}@@ -1,8 +1,7 @@
11 package daemon22 33 // GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a4-// sandbox policy. A members-only view answers only members; a public one5-// follows the relay's read rule.4+// sandbox policy. Reads require both the view's audience and a visible source.65 76 import (87 "crypto/sha256"@@ -11,6 +10,7 @@ import (
1110 "strconv"1211 "strings"1312 13+ "github.com/FelineStateMachine/tinyrelay/internal/relay"1414 "github.com/FelineStateMachine/tinyrelay/internal/views"1515 )1616 @@ -84,6 +84,11 @@ func (t *customViewService) viewArtifactHTTP(w http.ResponseWriter, r *http.Requ
8484 outcome = "unauthorized"8585 return8686 }87+ if !t.artifactHTTPSourceVisible(ctx, name, hash, relay.Session{PubKeys: []string{actor}, RelayURL: t.publicURL}) {88+ http.NotFound(w, r)89+ outcome = "invalid"90+ return91+ }8792 body, err := record.body()8893 if err != nil {8994 http.Error(w, "artifact is unreadable", http.StatusInternalServerError)@@ -96,11 +101,7 @@ func (t *customViewService) viewArtifactHTTP(w http.ResponseWriter, r *http.Requ
96101 etagBytes := sha256.Sum256(body)97102 etag := `"` + hex.EncodeToString(etagBytes[:]) + `"`98103 w.Header().Set("ETag", etag)99- if record.Audience == "members" {100- w.Header().Set("Cache-Control", "private, no-cache")101- } else {102- w.Header().Set("Cache-Control", "public, no-cache")103- }104+ w.Header().Set("Cache-Control", "private, no-cache")104105 outcome = "ok"105106 if r.Header.Get("If-None-Match") == etag {106107 w.WriteHeader(http.StatusNotModified)@@ -419,7 +419,7 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
419419 420420 // The route serves each artifact under a sandbox, by its own type.421421 res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")422- if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, no-cache" || res.Header().Get("ETag") == "" {422+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" {423423 t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())424424 }425425 conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil)@@ -172,9 +172,9 @@ func (t *customViewService) sourceCurrent(ctx context.Context, e event.Event) (b
172172 var id string173173 var err error174174 if e.Kind == event.KIND_REPO_STATE {175- err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE kind=? AND pubkey=? AND d=?`, e.Kind, e.PubKey, event.Tag(e, "d")).Scan(&id)175+ err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) ORDER BY created_at DESC,id ASC LIMIT 1`, e.Kind, e.PubKey, event.Tag(e, "d"), time.Now().Unix()).Scan(&id)176176 } else {177- err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=?`, e.ID).Scan(&id)177+ err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=? AND (expires=0 OR expires>?)`, e.ID, time.Now().Unix()).Scan(&id)178178 }179179 if errors.Is(err, sql.ErrNoRows) {180180 return false, nil@@ -612,7 +612,7 @@ func (t *customViewService) sourcePresent(ctx context.Context, source string) (b
612612 if len(parts) != 3 {613613 return false, nil614614 }615- err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE kind=? AND pubkey=? AND d=? LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2]).Scan(&one)615+ err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2], time.Now().Unix()).Scan(&one)616616 } else {617617 err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=? AND (expires=0 OR expires>?) LIMIT 1`, source, time.Now().Unix()).Scan(&one)618618 }@@ -153,7 +153,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
153153 if err := t.reconcileAutomaticInbox(ctx, currentPolicy, currentPolicy); err != nil {154154 return err155155 }156- t.customViews = newCustomViewService(customViewServiceConfig{Store: t.store, Community: t.community, Records: t.records, Git: t.git, Telemetry: t.app.telemetry, TenantName: t.meta.Name, PublicURL: t.publicURL, Loopback: func() bool { return loopbackPublicURL(t.publicURL) }, ResolveActor: t.resolveUIActor, BrowseRead: t.browseRead})156+ t.customViews = newCustomViewService(customViewServiceConfig{Store: t.store, Community: t.community, Records: t.records, Git: t.git, Gate: t.gate, Telemetry: t.app.telemetry, TenantName: t.meta.Name, PublicURL: t.publicURL, Loopback: func() bool { return loopbackPublicURL(t.publicURL) }, ResolveActor: t.resolveUIActor, BrowseRead: t.browseRead})157157 if err := t.customViews.initCustomViews(ctx); err != nil {158158 return err159159 }@@ -104,7 +104,9 @@ func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
104104 if err := t.community.EnsureSlugRoom(ctx, t.meta.Name, community.RoomAccessFor(p.Reads)); err != nil {105105 return nil, err106106 }107- t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name})107+ t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name, ArtifactVisible: func(ctx context.Context, e event.Event, s relay.Session) bool {108+ return t.customViews != nil && t.customViews.artifactEventVisible(ctx, e, s)109+ }})108110 if err != nil {109111 return nil, err110112 }@@ -18,10 +18,11 @@ import (
1818 // Config supplies the policy and optional durable services used by Gate.1919 // Store and Community enable checks that depend on persisted relay state.2020 type Config struct {21- Store *storage.Store22- Community *community.Service23- Policy func() policy.Policy24- Slug string21+ Store *storage.Store22+ Community *community.Service23+ Policy func() policy.Policy24+ Slug string25+ ArtifactVisible func(context.Context, event.Event, relay.Session) bool2526 }2627 2728 // Gate evaluates event admission and visibility for one tenant configuration.@@ -29,6 +30,11 @@ type Gate struct {
2930 cfg Config3031 agents agentLimiter3132 }33+type artifactCheckContextKey struct{}34+type artifactCheckState struct {35+ seen map[string]struct{}36+ depth int37+}3238 3339 // agentLimiter is the per-agent sliding one-minute counter. It lives in3440 // memory: a restart forgives the last minute, which is the cheap and honest@@ -692,6 +698,24 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
692698 // ban, repository privacy, policy, room and recipient checks.693699 func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool {694700 p := g.cfg.Policy()701+ if e.Kind == event.KIND_VIEW && g.cfg.ArtifactVisible != nil {702+ state, _ := ctx.Value(artifactCheckContextKey{}).(artifactCheckState)703+ if state.seen == nil {704+ state.seen = map[string]struct{}{}705+ }706+ if _, cycle := state.seen[e.ID]; cycle || state.depth >= 32 {707+ return false708+ }709+ seen := make(map[string]struct{}, len(state.seen)+1)710+ for id := range state.seen {711+ seen[id] = struct{}{}712+ }713+ seen[e.ID] = struct{}{}714+ ctx = context.WithValue(ctx, artifactCheckContextKey{}, artifactCheckState{seen: seen, depth: state.depth + 1})715+ if !g.cfg.ArtifactVisible(ctx, e, s) {716+ return false717+ }718+ }695719 if community.IsAgentGrantRequest(e) {696720 operator := event.Tag(e, "p")697721 if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) {@@ -40,3 +40,31 @@ func TestSignerRepliesReachUnauthenticatedRecipientSubscription(t *testing.T) {
4040 })4141 }4242 }43+44+func TestArtifactVisibilityTracksNestedSourcesAndCycles(t *testing.T) {45+ p := policy.Defaults(strings.Repeat("a", 64))46+ nodes := map[string]event.Event{47+ "a": {ID: "a", Kind: event.KIND_VIEW},48+ "b": {ID: "b", Kind: event.KIND_VIEW},49+ }50+ refs := map[string][]string{"a": {"b"}, "b": {"hidden"}}51+ var gate *Gate52+ gate, _ = New(Config{Policy: func() policy.Policy { return p }, ArtifactVisible: func(ctx context.Context, e event.Event, s relay.Session) bool {53+ for _, id := range refs[e.ID] {54+ if id == "hidden" {55+ return false56+ }57+ if !gate.CanSee(ctx, nodes[id], s, nil) {58+ return false59+ }60+ }61+ return true62+ }})63+ if gate.CanSee(context.Background(), nodes["a"], relay.Session{}, nil) {64+ t.Fatal("nested artifact with hidden source became visible")65+ }66+ refs["b"] = []string{"a"}67+ if gate.CanSee(context.Background(), nodes["a"], relay.Session{}, nil) {68+ t.Fatal("cyclic artifact source became visible")69+ }70+}