clone

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

manage custom views over MCP and cover them with tests

Dami committed c2bdcb3
commit
c2bdcb3f3d21194488a643ccb262b20a083076ce
parent
cd6f6d0eca9accf4d7e5a60bf842c97f1d3064ef
author
Dami <dami.etoile@gmail.com>
6 files changed, +1167 -22
Minternal/daemon/custom_views.go14+-
Ainternal/daemon/custom_views_test.go944++++++++++++++++++++++++++++++
Minternal/daemon/custom_views_transform.go37+-
Minternal/daemon/mcp_http.go4+-
Minternal/daemon/mcp_tools.go18+
Ainternal/webui/custom_views_test.go172+++++
M internal/daemon/custom_views.go +11 -3
@@ -422,7 +422,7 @@ func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customV
422422 query += ` WHERE name=?`423423 args = append(args, names[0])424424 }425- query += ` ORDER BY created_at,name`425+ query += ` ORDER BY created_at,rowid`426426 rows, err := t.store.DB().QueryContext(ctx, query, args...)427427 if err != nil {428428 return nil, fmt.Errorf("list custom views: %w", err)
@@ -505,7 +505,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
505505 if len(candidates) == 0 {506506 return507507 }508- if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) {508+ if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.statePending(ctx, e.ID)) {509509 // The README is read from the objects, which have not arrived yet;510510 // releaseGit queues the state once they have.511511 return
@@ -545,6 +545,14 @@ func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.
545545 return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)}546546 }547547 548+// statePending reports whether a repository state still waits for its549+// objects.550+func (t *Tenant) statePending(ctx context.Context, id string) bool {551+ var one int552+ err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM pending_events WHERE id=?`, id).Scan(&one)553+ return err == nil || t.git.IsPending(id)554+}555+548556 // queueCustomViewBackfill queues the newest sources of a view's kinds, at549557 // most 500, written since the given time. Repository states are queued550558 // whether or not their README has blocks; the handler reads it.
@@ -559,7 +567,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s
559567 continue560568 }561569 if e.Kind == event.KIND_REPO_STATE {562- if t.git == nil || t.git.IsPending(e.ID) {570+ if t.git == nil || t.statePending(ctx, e.ID) {563571 continue564572 }565573 } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
A internal/daemon/custom_views_test.go +944 -0
@@ -0,0 +1,944 @@
1+package daemon2+3+import (4+ "context"5+ "crypto/hmac"6+ "crypto/sha256"7+ "encoding/base64"8+ "encoding/hex"9+ "encoding/json"10+ "io"11+ "net/http"12+ "net/http/httptest"13+ "os"14+ "path/filepath"15+ "strconv"16+ "strings"17+ "sync"18+ "testing"19+ "time"20+21+ "github.com/FelineStateMachine/tinyrelay/internal/community"22+ "github.com/FelineStateMachine/tinyrelay/internal/event"23+ "github.com/FelineStateMachine/tinyrelay/internal/views"24+ "github.com/FelineStateMachine/tinyrelay/internal/work"25+)26+27+const (28+ testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>`29+ testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"30+)31+32+// transformServer stands in for a view transform: it records every request33+// and answers with the artifacts it is told to.34+type transformServer struct {35+ mu sync.Mutex36+ status int37+ body string38+ headers []http.Header39+ requests [][]byte40+}41+42+func (s *transformServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {43+ body, _ := io.ReadAll(r.Body)44+ s.mu.Lock()45+ s.headers = append(s.headers, r.Header.Clone())46+ s.requests = append(s.requests, body)47+ status, answer := s.status, s.body48+ s.mu.Unlock()49+ w.Header().Set("Content-Type", "application/json")50+ w.WriteHeader(status)51+ _, _ = w.Write([]byte(answer))52+}53+54+func (s *transformServer) count() int {55+ s.mu.Lock()56+ defer s.mu.Unlock()57+ return len(s.requests)58+}59+60+func (s *transformServer) answer(status int, artifacts ...map[string]any) {61+ s.mu.Lock()62+ defer s.mu.Unlock()63+ s.status = status64+ raw, _ := json.Marshal(map[string]any{"artifacts": artifacts, "errors": []map[string]any{{"block": 9, "error": "unknown"}}})65+ s.body = string(raw)66+}67+68+func svgArtifact(block int) map[string]any {69+ return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"}70+}71+72+func viewParams(values map[string]any) []json.RawMessage {73+ raw, _ := json.Marshal(values)74+ return []json.RawMessage{raw}75+}76+77+func addView(t *testing.T, tenant *Tenant, values map[string]any) map[string]any {78+ t.Helper()79+ result, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "addcustomview", viewParams(values))80+ if err != nil {81+ t.Fatalf("addcustomview: %v", err)82+ }83+ return result.(map[string]any)84+}85+86+// viewTenant is a loopback tenant with a transform server and one view that87+// watches the given kinds.88+func viewTenant(t *testing.T, kinds []int, extra map[string]any) (*Tenant, *transformServer, map[string]any) {89+ t.Helper()90+ _, tenant := loopbackTenant(t)91+ server := &transformServer{}92+ server.answer(http.StatusOK)93+ service := httptest.NewTLSServer(server)94+ t.Cleanup(service.Close)95+ tenant.viewClient = service.Client()96+ values := map[string]any{"name": "diagrams", "kinds": kinds, "transform": service.URL + "/render?token=t0k3n", "languages": []string{"mermaid", "dot"}}97+ for key, value := range extra {98+ values[key] = value99+ }100+ return tenant, server, addView(t, tenant, values)101+}102+103+// pendingViewIntents lists queued transforms of one source for one view,104+// first attempt first.105+func pendingViewIntents(t *testing.T, tenant *Tenant, name, eventID string) []work.Intent {106+ t.Helper()107+ rows, err := tenant.store.DB().QueryContext(context.Background(), "SELECT id,event_id,payload,next_at FROM work_intents WHERE kind=? AND target=? AND state='pending' AND (event_id=? OR event_id LIKE ? OR event_id LIKE ?) ORDER BY next_at,created_at,id", viewTransform, name, eventID, eventID+"#%", eventID+"@%")108+ if err != nil {109+ t.Fatal(err)110+ }111+ defer rows.Close()112+ var out []work.Intent113+ for rows.Next() {114+ var item work.Intent115+ var next int64116+ if err := rows.Scan(&item.ID, &item.EventID, &item.Payload, &next); err != nil {117+ t.Fatal(err)118+ }119+ item.Kind, item.Target, item.NextAt = viewTransform, name, time.Unix(next, 0)120+ out = append(out, item)121+ }122+ return out123+}124+125+// runViewIntent drives one intent the way the worker would and marks it126+// complete.127+func runViewIntent(t *testing.T, tenant *Tenant, intent work.Intent) {128+ t.Helper()129+ if err := tenant.handleViewTransform(context.Background(), intent); err != nil {130+ t.Fatalf("handleViewTransform: %v", err)131+ }132+ if _, err := tenant.store.DB().ExecContext(context.Background(), "UPDATE work_intents SET state='completed' WHERE id=?", intent.ID); err != nil {133+ t.Fatal(err)134+ }135+}136+137+func runPendingView(t *testing.T, tenant *Tenant, name, eventID string) {138+ t.Helper()139+ queued := pendingViewIntents(t, tenant, name, eventID)140+ if len(queued) != 1 {141+ t.Fatalf("queued intents for %s = %d", eventID, len(queued))142+ }143+ runViewIntent(t, tenant, queued[0])144+}145+146+func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) {147+ t.Helper()148+ var raw string149+ err := tenant.store.DB().QueryRowContext(context.Background(), "SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=?", event.KIND_VIEW, tenant.records.PublicKey(), "bind.ws/view/"+name+"/"+hash).Scan(&raw)150+ if err != nil {151+ return event.Event{}, false152+ }153+ e, err := event.Parse([]byte(raw))154+ if err != nil {155+ t.Fatal(err)156+ }157+ return e, true158+}159+160+func artifactTags(e event.Event, name string) []string {161+ var out []string162+ for _, tag := range e.Tags {163+ if len(tag) >= 2 && tag[0] == name {164+ out = append(out, tag[1])165+ }166+ }167+ return out168+}169+170+func signRequestAs(t *testing.T, r *http.Request, secret string) {171+ t.Helper()172+ hash := sha256.Sum256(nil)173+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}174+ if err := event.Sign(&e, secret); err != nil {175+ t.Fatal(err)176+ }177+ raw, _ := json.Marshal(e)178+ r.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(raw))179+}180+181+func getArtifact(t *testing.T, tenant *Tenant, path, secret string) *httptest.ResponseRecorder {182+ t.Helper()183+ req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080"+path, nil)184+ if secret != "" {185+ signRequestAs(t, req, secret)186+ }187+ res := httptest.NewRecorder()188+ tenant.ServeHTTP(res, req)189+ return res190+}191+192+func TestCustomViewManagementIsOwnerOnlyAndValidated(t *testing.T) {193+ _, tenant := testTenant(t)194+ ctx := context.Background()195+ owner := tenant.Policy().Owner196+ member, _ := event.PublicKey(testMemberSecret)197+ moderator, _ := event.PublicKey(testModSecret)198+ guest := strings.Repeat("e", 64)199+ setRole(t, tenant, member, "member")200+ setRole(t, tenant, moderator, "moderator")201+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)202+ if err != nil {203+ t.Fatal(err)204+ }205+ all := []string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}206+ for _, name := range all {207+ if !containsString(methods.([]string), name) || !containsString(ManagementMethods(), name) {208+ t.Fatalf("registry lacks %s", name)209+ }210+ }211+ base := map[string]any{"name": "diagrams", "kinds": []int{1, 30023}, "transform": "https://render.example/tiny", "languages": []string{"mermaid"}}212+ for _, actor := range []string{guest, member, moderator} {213+ for _, method := range all {214+ params := []json.RawMessage{json.RawMessage(`"diagrams"`)}215+ if method == "addcustomview" {216+ params = viewParams(base)217+ }218+ if _, err := tenant.Execute(ctx, actor, method, params); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {219+ t.Fatalf("%s by non-owner: %v", method, err)220+ }221+ }222+ }223+ with := func(changes map[string]any) map[string]any {224+ out := map[string]any{}225+ for key, value := range base {226+ out[key] = value227+ }228+ for key, value := range changes {229+ out[key] = value230+ }231+ return out232+ }233+ many := make([]int, viewKindMax+1)234+ for _, tc := range []struct {235+ name string236+ values map[string]any237+ want string238+ }{239+ {"bad name", with(map[string]any{"name": "Diagrams!"}), "invalid: name"},240+ {"long name", with(map[string]any{"name": strings.Repeat("a", 33)}), "invalid: name"},241+ {"no kinds", with(map[string]any{"kinds": []int{}}), "invalid: kinds"},242+ {"too many kinds", with(map[string]any{"kinds": many}), "invalid: kinds"},243+ {"bad kind", with(map[string]any{"kinds": []int{70000}}), "invalid: kinds must be between"},244+ {"http transform", with(map[string]any{"transform": "http://render.example/tiny"}), "invalid: transform"},245+ {"private transform", with(map[string]any{"transform": "https://10.0.0.1/tiny"}), "invalid: transform"},246+ {"bad trigger", with(map[string]any{"trigger": "daily"}), "invalid: trigger"},247+ {"bad audience", with(map[string]any{"audience": "owner"}), "invalid: audience"},248+ {"no languages", with(map[string]any{"languages": []string{}}), "invalid: languages"},249+ {"bad language", with(map[string]any{"languages": []string{"mer maid"}}), "invalid: languages"},250+ {"max bytes cap", with(map[string]any{"max_bytes": viewMaxBytesCap + 1}), "invalid: max_bytes"},251+ {"zero max bytes", with(map[string]any{"max_bytes": 0}), "invalid: max_bytes"},252+ {"short secret", with(map[string]any{"secret": "short"}), "invalid: secret"},253+ {"control in secret", with(map[string]any{"secret": strings.Repeat("a", 15) + "\n"}), "invalid: secret"},254+ } {255+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(tc.values)); err == nil || !strings.HasPrefix(err.Error(), tc.want) {256+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)257+ }258+ }259+ // A plain form sends kinds and languages as text; defaults fill trigger,260+ // audience and the size limit; the secret is generated and shown once.261+ added := addView(t, tenant, map[string]any{"name": "diagrams", "kinds": "30023, 1, 1", "transform": "https://render.example/tiny", "languages": "Mermaid, dot"})262+ secret, _ := added["secret"].(string)263+ if len(secret) != 64 || added["trigger"] != "write" || added["audience"] != "public" || added["maxBytes"] != viewDefaultMaxBytes || added["host"] != "render.example" {264+ t.Fatalf("added = %v", added)265+ }266+ if kinds, _ := json.Marshal(added["kinds"]); string(kinds) != "[1,30023]" {267+ t.Fatalf("kinds = %s", kinds)268+ }269+ if languages, _ := json.Marshal(added["languages"]); string(languages) != `["mermaid","dot"]` {270+ t.Fatalf("languages = %s", languages)271+ }272+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(base)); err == nil || !strings.Contains(err.Error(), "exists") {273+ t.Fatalf("duplicate name: %v", err)274+ }275+ // An owner may paste the secret the transform already holds.276+ pasted := addView(t, tenant, with(map[string]any{"name": "charts", "trigger": "hourly", "audience": "members", "max_bytes": 2048, "secret": "shared-secret-value-1234"}))277+ if pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != 2048 {278+ t.Fatalf("pasted = %v", pasted)279+ }280+ var stored string281+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT secret FROM custom_views WHERE name='charts'").Scan(&stored); err != nil || stored != "shared-secret-value-1234" {282+ t.Fatalf("stored secret %q %v", stored, err)283+ }284+ listed, err := tenant.Execute(ctx, owner, "listcustomviews", nil)285+ if err != nil {286+ t.Fatal(err)287+ }288+ rows := listed.([]map[string]any)289+ if len(rows) != 2 || rows[0]["name"] != "diagrams" || rows[1]["name"] != "charts" {290+ t.Fatalf("listing = %v", rows)291+ }292+ for _, row := range rows {293+ if _, leaked := row["secret"]; leaked || row["state"] != "active" {294+ t.Fatalf("listing row = %v", row)295+ }296+ }297+ paused, err := tenant.Execute(ctx, owner, "pausecustomview", []json.RawMessage{json.RawMessage(`{"name":"diagrams"}`)})298+ if err != nil || paused.(map[string]any)["enabled"] != false || paused.(map[string]any)["state"] != "paused" {299+ t.Fatalf("pause: %v %v", paused, err)300+ }301+ if _, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err == nil || !strings.Contains(err.Error(), "resume") {302+ t.Fatalf("run while paused: %v", err)303+ }304+ for _, view := range tenant.customViewIndex(ctx)[1] {305+ if view.Name == "diagrams" {306+ t.Fatal("paused view stayed in the kind index")307+ }308+ }309+ resumed, err := tenant.Execute(ctx, owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})310+ if err != nil || resumed.(map[string]any)["enabled"] != true {311+ t.Fatalf("resume: %v %v", resumed, err)312+ }313+ ran, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})314+ if err != nil || ran.(map[string]any)["queued"] != 0 {315+ t.Fatalf("run: %v %v", ran, err)316+ }317+ if _, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {318+ t.Fatalf("remove missing: %v", err)319+ }320+ removed, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"charts"`)})321+ if err != nil || removed.(map[string]any)["removed"] != true {322+ t.Fatalf("remove: %v %v", removed, err)323+ }324+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)325+ if err != nil {326+ t.Fatal(err)327+ }328+ seen := map[string]bool{}329+ for _, row := range audit.([]community.AuditRow) {330+ seen[row.Action] = true331+ if strings.Contains(row.Detail, "render.example") || strings.Contains(row.Target, "render.example") || strings.Contains(row.Detail, secret) {332+ t.Fatalf("audit carries a transform URL or secret: %+v", row)333+ }334+ }335+ for _, action := range all {336+ if action != "listcustomviews" && !seen[action] {337+ t.Fatalf("audit lacks %s: %v", action, seen)338+ }339+ }340+}341+342+func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {343+ tenant, server, added := viewTenant(t, []int{1}, nil)344+ ctx := context.Background()345+ secret := added["secret"].(string)346+ server.answer(http.StatusOK, svgArtifact(0), map[string]any{"block": 2, "type": "image/png", "body": base64.StdEncoding.EncodeToString([]byte(testPNGRaw))}, map[string]any{"block": 1, "type": "image/svg+xml", "body": testSVG})347+ now := time.Now().Unix()348+ content := "Look:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\n```go\nfunc main() {}\n```\n\n~~~ dot\ndigraph { a -> b }\n~~~\n"349+ note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", strconv.FormatInt(now+86400, 10)}}, content)350+ if err := publishAs(t, tenant, note); err != nil {351+ t.Fatal(err)352+ }353+ plain := signedEvent(t, testOwnerSecret, 1, now+1, nil, "no blocks here\n```go\nx\n```")354+ if err := publishAs(t, tenant, plain); err != nil {355+ t.Fatal(err)356+ }357+ if len(pendingViewIntents(t, tenant, "diagrams", plain.ID)) != 0 {358+ t.Fatal("an event without a matching block was queued")359+ }360+ runPendingView(t, tenant, "diagrams", note.ID)361+ if server.count() != 1 {362+ t.Fatalf("requests = %d", server.count())363+ }364+ headers, body := server.headers[0], server.requests[0]365+ mac := hmac.New(sha256.New, []byte(secret))366+ mac.Write(body)367+ if headers.Get("Content-Type") != "application/json" || headers.Get("X-Tiny-View") != "diagrams" || headers.Get("X-Tiny-Relay") != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {368+ t.Fatalf("headers = %v", headers)369+ }370+ var request map[string]any371+ if err := json.Unmarshal(body, &request); err != nil {372+ t.Fatal(err)373+ }374+ if _, leaked := request["event"]; leaked || request["relay"] != "http://127.0.0.1:8080" || request["view"] != "diagrams" {375+ t.Fatalf("request = %s", body)376+ }377+ if source := request["source"].(map[string]any); source["id"] != note.ID || source["kind"] != float64(1) || len(source) != 2 {378+ t.Fatalf("source = %v", source)379+ }380+ if strings.Contains(string(body), "Look:") || strings.Contains(string(body), tenant.Policy().Owner) {381+ t.Fatalf("request carries event content or author: %s", body)382+ }383+ blocks := request["blocks"].([]any)384+ if len(blocks) != 2 {385+ t.Fatalf("blocks = %v", blocks)386+ }387+ first, third := blocks[0].(map[string]any), blocks[1].(map[string]any)388+ if first["index"] != float64(0) || first["lang"] != "mermaid" || first["source"] != "graph TD;\n A-->B;" || third["index"] != float64(2) || third["lang"] != "dot" || third["source"] != "digraph { a -> b }" {389+ t.Fatalf("blocks = %v", blocks)390+ }391+ // The SVG artifact is a stored relay-signed record attached to the note.392+ svgHash := views.Hash("mermaid", "graph TD;\n A-->B;")393+ artifact, ok := storedArtifact(t, tenant, "diagrams", svgHash)394+ if !ok {395+ t.Fatal("svg artifact was not stored")396+ }397+ if artifact.Content != testSVG || artifact.PubKey != tenant.records.PublicKey() || event.Tag(artifact, "view") != "diagrams" || event.Tag(artifact, "e") != note.ID || event.Tag(artifact, "block") != "0" || event.Tag(artifact, "type") != "image/svg+xml" || event.Tag(artifact, "engine") != "mermaid" || event.Tag(artifact, "expiration") != strconv.FormatInt(now+86400, 10) || len(artifact.Tags[0]) != 1 || artifact.Tags[0][0] != "-" {398+ t.Fatalf("artifact = %+v", artifact)399+ }400+ pngHash := views.Hash("dot", "digraph { a -> b }")401+ png, ok := storedArtifact(t, tenant, "diagrams", pngHash)402+ if !ok || png.Content != base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) || event.Tag(png, "type") != "image/png" || event.Tag(png, "block") != "2" || event.Tag(png, "engine") != "" {403+ t.Fatalf("png artifact = %+v %v", png, ok)404+ }405+ var count int406+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 2 {407+ t.Fatalf("artifacts = %d %v (an artifact for an unsent block was kept)", count, err)408+ }409+ var lastStatus string410+ var failures int411+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT last_status,failures FROM custom_views WHERE name='diagrams'").Scan(&lastStatus, &failures); err != nil || lastStatus != "ok" || failures != 0 {412+ t.Fatalf("view state %q %d %v", lastStatus, failures, err)413+ }414+415+ // The route serves each artifact under a sandbox, by its own type.416+ res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")417+ 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, max-age=31536000, immutable" {418+ t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())419+ }420+ res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")421+ if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {422+ t.Fatalf("png route: %d %v", res.Code, res.Header())423+ }424+ for _, path := range []string{"/views/diagrams/" + pngHash + ".svg", "/views/diagrams/" + strings.Repeat("0", 64) + ".svg", "/views/other/" + svgHash + ".svg", "/views/diagrams/" + svgHash + ".gif", "/views/diagrams/" + svgHash, "/views/diagrams"} {425+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {426+ t.Fatalf("%s: %d", path, res.Code)427+ }428+ }429+430+ // A second note with the same block reuses the artifact: no POST, a431+ // fresh record with both sources.432+ second := signedEvent(t, testOwnerSecret, 1, now+2, nil, "Again:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n")433+ if err := publishAs(t, tenant, second); err != nil {434+ t.Fatal(err)435+ }436+ runPendingView(t, tenant, "diagrams", second.ID)437+ if server.count() != 1 {438+ t.Fatalf("requests after reuse = %d", server.count())439+ }440+ artifact, _ = storedArtifact(t, tenant, "diagrams", svgHash)441+ if sources := artifactTags(artifact, "e"); len(sources) != 2 || sources[0] != note.ID || sources[1] != second.ID {442+ t.Fatalf("sources after reuse = %v", sources)443+ }444+ if event.Tag(artifact, "expiration") != "" {445+ t.Fatal("artifact kept an expiration while a source without one is attached")446+ }447+ if blocks := artifactTags(artifact, "block"); len(blocks) != 2 || blocks[0] != "0" || blocks[1] != "0" {448+ t.Fatalf("block tags = %v", blocks)449+ }450+451+ // Deleting the first note detaches it; deleting the second removes the452+ // artifact and its record.453+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+3, [][]string{{"e", note.ID}}, "")454+ if err := publishAs(t, tenant, deletion); err != nil {455+ t.Fatal(err)456+ }457+ artifact, ok = storedArtifact(t, tenant, "diagrams", svgHash)458+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {459+ t.Fatalf("artifact after first deletion = %+v %v", artifact, ok)460+ }461+ if _, ok := storedArtifact(t, tenant, "diagrams", pngHash); ok {462+ t.Fatal("png artifact survived the deletion of its only source")463+ }464+ if res := getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", ""); res.Code != http.StatusNotFound {465+ t.Fatalf("png route after deletion: %d", res.Code)466+ }467+ deletion = signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+4, [][]string{{"e", second.ID}}, "")468+ if err := publishAs(t, tenant, deletion); err != nil {469+ t.Fatal(err)470+ }471+ if _, ok := storedArtifact(t, tenant, "diagrams", svgHash); ok {472+ t.Fatal("svg artifact survived the deletion of its last source")473+ }474+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 0 {475+ t.Fatalf("artifacts after deletions = %d %v", count, err)476+ }477+478+ // Expiry: an expired source loses its artifacts at the sweep.479+ expiring := signedEvent(t, testOwnerSecret, 1, now+5, [][]string{{"expiration", strconv.FormatInt(now+30, 10)}}, "```dot\nx\n```")480+ if err := publishAs(t, tenant, expiring); err != nil {481+ t.Fatal(err)482+ }483+ server.answer(http.StatusOK, svgArtifact(0))484+ runPendingView(t, tenant, "diagrams", expiring.ID)485+ expiringHash := views.Hash("dot", "x")486+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); !ok {487+ t.Fatal("expiring artifact was not stored")488+ }489+ if err := tenant.sweep(ctx, now+60); err != nil {490+ t.Fatal(err)491+ }492+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); ok {493+ t.Fatal("artifact survived its source's expiry")494+ }495+ if res := getArtifact(t, tenant, "/views/diagrams/"+expiringHash+".svg", ""); res.Code != http.StatusNotFound {496+ t.Fatalf("expired route: %d", res.Code)497+ }498+499+ // Removing the view removes what is left.500+ last := signedEvent(t, testOwnerSecret, 1, now+6, nil, "```dot\ny\n```")501+ if err := publishAs(t, tenant, last); err != nil {502+ t.Fatal(err)503+ }504+ runPendingView(t, tenant, "diagrams", last.ID)505+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "removecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {506+ t.Fatal(err)507+ }508+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE kind=? AND pubkey=?", event.KIND_VIEW, tenant.records.PublicKey()).Scan(&count); err != nil || count != 0 {509+ t.Fatalf("records after removal = %d %v", count, err)510+ }511+}512+513+func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {514+ tenant, server, _ := viewTenant(t, []int{30023}, nil)515+ ctx := context.Background()516+ now := time.Now().Unix()517+ server.answer(http.StatusOK, svgArtifact(0), svgArtifact(1))518+ first := signedEvent(t, testOwnerSecret, 30023, now, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n\n```mermaid\nB\n```\n")519+ if err := publishAs(t, tenant, first); err != nil {520+ t.Fatal(err)521+ }522+ runPendingView(t, tenant, "diagrams", first.ID)523+ hashA, hashB := views.Hash("mermaid", "A"), views.Hash("mermaid", "B")524+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); !ok {525+ t.Fatal("A missing")526+ }527+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); !ok {528+ t.Fatal("B missing")529+ }530+ // The replacement keeps A and drops B; A is reused without a POST and531+ // carries only the new version.532+ second := signedEvent(t, testOwnerSecret, 30023, now+1, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n")533+ if err := publishAs(t, tenant, second); err != nil {534+ t.Fatal(err)535+ }536+ runPendingView(t, tenant, "diagrams", second.ID)537+ if server.count() != 1 {538+ t.Fatalf("requests = %d", server.count())539+ }540+ artifact, ok := storedArtifact(t, tenant, "diagrams", hashA)541+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {542+ t.Fatalf("A after replacement = %+v %v", artifact, ok)543+ }544+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); ok {545+ t.Fatal("B survived the replacement")546+ }547+ // A stale intent for the replaced version renders nothing.548+ stale := tenant.viewIntent(customView{Name: "diagrams"}, first, "old")549+ if err := tenant.handleViewTransform(ctx, work.Intent{ID: "stale", Kind: stale.Kind, EventID: stale.EventID, Target: stale.Target, Payload: stale.Payload}); err != nil {550+ t.Fatal(err)551+ }552+ if server.count() != 1 {553+ t.Fatal("a replaced version was sent to the transform")554+ }555+ // Deleting the address removes the artifact.556+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+2, [][]string{{"a", "30023:" + tenant.Policy().Owner + ":post"}}, "")557+ if err := publishAs(t, tenant, deletion); err != nil {558+ t.Fatal(err)559+ }560+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); ok {561+ t.Fatal("A survived the address deletion")562+ }563+}564+565+func TestCustomViewTransformRetriesThenPauses(t *testing.T) {566+ tenant, server, _ := viewTenant(t, []int{1}, nil)567+ ctx := context.Background()568+ now := time.Now().Unix()569+ server.answer(http.StatusInternalServerError)570+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")571+ if err := publishAs(t, tenant, note); err != nil {572+ t.Fatal(err)573+ }574+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)575+ if len(queued) != 1 {576+ t.Fatalf("queued = %d", len(queued))577+ }578+ runViewIntent(t, tenant, queued[0])579+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)580+ if len(queued) != 1 || queued[0].EventID != note.ID+"#2" || queued[0].NextAt.Unix() < now+55 || queued[0].NextAt.Unix() > now+70 {581+ t.Fatalf("second attempt = %+v", queued)582+ }583+ runViewIntent(t, tenant, queued[0])584+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)585+ if len(queued) != 1 || queued[0].EventID != note.ID+"#3" || queued[0].NextAt.Unix() < now+295 || queued[0].NextAt.Unix() > now+310 {586+ t.Fatalf("third attempt = %+v", queued)587+ }588+ runViewIntent(t, tenant, queued[0])589+ if queued = pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {590+ t.Fatalf("fourth attempt queued: %+v", queued)591+ }592+ var failures, enabled int593+ var status string594+ state := func() {595+ t.Helper()596+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'").Scan(&failures, &enabled, &status); err != nil {597+ t.Fatal(err)598+ }599+ }600+ state()601+ if failures != 3 || enabled != 1 || status != "HTTP 500" {602+ t.Fatalf("after three failures: %d %d %q", failures, enabled, status)603+ }604+ if server.count() != 3 {605+ t.Fatalf("requests = %d", server.count())606+ }607+ // A 200 that is not JSON is a failure too.608+ server.mu.Lock()609+ server.status, server.body = http.StatusOK, "<html>"610+ server.mu.Unlock()611+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```mermaid\nB\n```")612+ if err := publishAs(t, tenant, other); err != nil {613+ t.Fatal(err)614+ }615+ runPendingView(t, tenant, "diagrams", other.ID)616+ state()617+ if failures != 4 || status != "invalid response" {618+ t.Fatalf("after bad json: %d %q", failures, status)619+ }620+ // The twentieth failure in a row pauses the view; nothing new is queued.621+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE custom_views SET failures=19 WHERE name='diagrams'"); err != nil {622+ t.Fatal(err)623+ }624+ server.answer(http.StatusBadGateway)625+ third := signedEvent(t, testOwnerSecret, 1, now+2, nil, "```mermaid\nC\n```")626+ if err := publishAs(t, tenant, third); err != nil {627+ t.Fatal(err)628+ }629+ runPendingView(t, tenant, "diagrams", third.ID)630+ state()631+ if failures != 20 || enabled != 0 || status != "paused after 20 failures: HTTP 502" {632+ t.Fatalf("after twenty failures: %d %d %q", failures, enabled, status)633+ }634+ if queued := pendingViewIntents(t, tenant, "diagrams", third.ID); len(queued) != 0 {635+ t.Fatalf("paused view queued a retry: %+v", queued)636+ }637+ fourth := signedEvent(t, testOwnerSecret, 1, now+3, nil, "```mermaid\nD\n```")638+ if err := publishAs(t, tenant, fourth); err != nil {639+ t.Fatal(err)640+ }641+ if queued := pendingViewIntents(t, tenant, "diagrams", fourth.ID); len(queued) != 0 {642+ t.Fatal("paused view queued a transform")643+ }644+ // Resuming clears the count; a success resets it after a failure.645+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {646+ t.Fatal(err)647+ }648+ server.answer(http.StatusOK, svgArtifact(0))649+ fifth := signedEvent(t, testOwnerSecret, 1, now+4, nil, "```mermaid\nE\n```")650+ if err := publishAs(t, tenant, fifth); err != nil {651+ t.Fatal(err)652+ }653+ runPendingView(t, tenant, "diagrams", fifth.ID)654+ state()655+ if failures != 0 || enabled != 1 || status != "ok" {656+ t.Fatalf("after success: %d %d %q", failures, enabled, status)657+ }658+}659+660+func TestCustomViewArtifactValidation(t *testing.T) {661+ png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw))662+ for _, tc := range []struct {663+ name string664+ artifact viewArtifact665+ max int666+ want string667+ }{668+ {"svg", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 1024, ""},669+ {"svg fragment href", viewArtifact{Type: "image/svg+xml", Body: `<svg><use href="#a"/><a xlink:href='#b'/></svg>`}, 1024, ""},670+ {"png", viewArtifact{Type: "image/png", Body: png}, 1024, ""},671+ {"png raw base64", viewArtifact{Type: "image/png", Body: strings.TrimRight(png, "=")}, 1024, ""},672+ {"type", viewArtifact{Type: "text/html", Body: "<p>"}, 1024, "invalid: artifact type"},673+ {"svg size", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 10, "invalid: artifact exceeds"},674+ {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"},675+ {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"},676+ {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"},677+ {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"},678+ {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"},679+ {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"},680+ {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"},681+ {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"},682+ {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"},683+ {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"},684+ {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg references an external"},685+ } {686+ _, err := checkArtifact(tc.artifact, tc.max)687+ if tc.want == "" && err != nil {688+ t.Errorf("%s: unexpected %v", tc.name, err)689+ }690+ if tc.want != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.want)) {691+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)692+ }693+ }694+ if body, err := checkArtifact(viewArtifact{Type: "image/png", Body: " " + png + "\n"}, 1024); err != nil || body != png {695+ t.Fatalf("png normalization: %q %v", body, err)696+ }697+}698+699+func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {700+ tenant, server, added := viewTenant(t, []int{1}, map[string]any{"audience": "members"})701+ ctx := context.Background()702+ member, _ := event.PublicKey(testMemberSecret)703+ setRole(t, tenant, member, "member")704+ now := time.Now().Unix()705+ server.answer(http.StatusOK, svgArtifact(0))706+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")707+ if err := publishAs(t, tenant, note); err != nil {708+ t.Fatal(err)709+ }710+ runPendingView(t, tenant, "diagrams", note.ID)711+ hash := views.Hash("mermaid", "A")712+ if _, ok := storedArtifact(t, tenant, "diagrams", hash); ok {713+ t.Fatal("a members-only artifact became a stored record")714+ }715+ var raw string716+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT raw FROM custom_view_artifacts WHERE view='diagrams' AND hash=?", hash).Scan(&raw); err != nil {717+ t.Fatal(err)718+ }719+ signed, err := event.Parse([]byte(raw))720+ if err != nil || signed.PubKey != tenant.records.PublicKey() || event.Tag(signed, "d") != "bind.ws/view/diagrams/"+hash {721+ t.Fatalf("members artifact record = %+v %v", signed, err)722+ }723+ path := "/views/diagrams/" + hash + ".svg"724+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusUnauthorized {725+ t.Fatalf("guest: %d", res.Code)726+ }727+ if res := getArtifact(t, tenant, path, strings.Repeat("7", 64)); res.Code != http.StatusForbidden {728+ t.Fatalf("outsider: %d", res.Code)729+ }730+ for _, secret := range []string{testMemberSecret, testOwnerSecret} {731+ res := getArtifact(t, tenant, path, secret)732+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" {733+ t.Fatalf("member: %d %v", res.Code, res.Header())734+ }735+ }736+ // A public view on a members-only relay follows the read rule.737+ p := tenant.Policy()738+ p.Reads = "members"739+ if err := tenant.applyPolicy(ctx, p); err != nil {740+ t.Fatal(err)741+ }742+ addView(t, tenant, map[string]any{"name": "open", "kinds": []int{1}, "transform": added["transform"], "languages": []string{"dot"}})743+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```dot\nB\n```")744+ if err := publishAs(t, tenant, other); err != nil {745+ t.Fatal(err)746+ }747+ runPendingView(t, tenant, "open", other.ID)748+ openPath := "/views/open/" + views.Hash("dot", "B") + ".svg"749+ if res := getArtifact(t, tenant, openPath, ""); res.Code != http.StatusUnauthorized && res.Code != http.StatusForbidden {750+ t.Fatalf("guest on a members relay: %d", res.Code)751+ }752+ if res := getArtifact(t, tenant, openPath, testMemberSecret); res.Code != http.StatusOK {753+ t.Fatalf("member on a members relay: %d %s", res.Code, res.Body.String())754+ }755+}756+757+func TestCustomViewHourlyTickQueuesRecentSources(t *testing.T) {758+ tenant, server, _ := viewTenant(t, []int{1}, map[string]any{"trigger": "hourly"})759+ ctx := context.Background()760+ now := time.Now().Unix()761+ server.answer(http.StatusOK, svgArtifact(0))762+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")763+ if err := publishAs(t, tenant, note); err != nil {764+ t.Fatal(err)765+ }766+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {767+ t.Fatal("an hourly view queued at write time")768+ }769+ if err := tenant.tickCustomViews(ctx, now); err != nil {770+ t.Fatal(err)771+ }772+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)773+ if len(queued) != 1 || !strings.HasPrefix(queued[0].EventID, note.ID+"@") {774+ t.Fatalf("hourly queue = %+v", queued)775+ }776+ runViewIntent(t, tenant, queued[0])777+ if _, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "A")); !ok {778+ t.Fatal("hourly artifact missing")779+ }780+ // Not due again for an hour; a backfill through runcustomview queues now.781+ if err := tenant.tickCustomViews(ctx, now+60); err != nil {782+ t.Fatal(err)783+ }784+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {785+ t.Fatal("hourly view queued again within the hour")786+ }787+ ran, err := tenant.Execute(ctx, tenant.Policy().Owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})788+ if err != nil || ran.(map[string]any)["queued"] != 1 {789+ t.Fatalf("runcustomview: %v %v", ran, err)790+ }791+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)792+ if len(queued) != 1 {793+ t.Fatalf("backfill queue = %+v", queued)794+ }795+ runViewIntent(t, tenant, queued[0])796+ if server.count() != 1 {797+ t.Fatalf("backfill re-rendered a block with an artifact: %d requests", server.count())798+ }799+}800+801+func TestCustomViewRendersTheReadmeAtTheStateHead(t *testing.T) {802+ tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil)803+ ctx := context.Background()804+ p := tenant.Policy()805+ p.Features.Grasp = true806+ if err := tenant.applyPolicy(ctx, p); err != nil {807+ t.Fatal(err)808+ }809+ owner := tenant.Policy().Owner810+ 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"}}, "")811+ if err := publishAs(t, tenant, announcement); err != nil {812+ t.Fatal(err)813+ }814+ source := filepath.Join(t.TempDir(), "source.git")815+ gitTest(t, "init", "--bare", source)816+ work := filepath.Join(t.TempDir(), "work")817+ gitTest(t, "clone", source, work)818+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")819+ gitTest(t, "-C", work, "config", "user.name", "Test")820+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\n```go\nx\n```\n\n```mermaid\ngraph LR; a-->b;\n```\n"), 0o600); err != nil {821+ t.Fatal(err)822+ }823+ gitTest(t, "-C", work, "add", "README.md")824+ gitTest(t, "-C", work, "commit", "-m", "readme")825+ gitTest(t, "-C", work, "branch", "-M", "main")826+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))827+ state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")828+ if err := publishAs(t, tenant, state); err != nil {829+ t.Fatal(err)830+ }831+ if queued := pendingViewIntents(t, tenant, "diagrams", state.ID); len(queued) != 0 {832+ t.Fatal("a state was queued before its objects arrived")833+ }834+ relay := httptest.NewServer(tenant)835+ defer relay.Close()836+ gitTest(t, "-C", work, "push", relay.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")837+ blocks, err := tenant.sourceBlocks(ctx, state)838+ if err != nil {839+ t.Fatal(err)840+ }841+ if len(blocks) != 2 || blocks[1].Index != 1 || blocks[1].Lang != "mermaid" || blocks[1].Source != "graph LR; a-->b;" {842+ t.Fatalf("readme blocks = %+v", blocks)843+ }844+ queued := pendingViewIntents(t, tenant, "diagrams", state.ID)845+ if len(queued) != 1 {846+ t.Fatalf("state intents after push = %d", len(queued))847+ }848+ server.answer(http.StatusOK, svgArtifact(1))849+ runViewIntent(t, tenant, queued[0])850+ var request map[string]any851+ if err := json.Unmarshal(server.requests[0], &request); err != nil {852+ t.Fatal(err)853+ }854+ coordinate := "30618:" + owner + ":docs"855+ if source := request["source"].(map[string]any); source["id"] != coordinate || source["kind"] != float64(30618) {856+ t.Fatalf("state source = %v", source)857+ }858+ if blocks := request["blocks"].([]any); len(blocks) != 1 || blocks[0].(map[string]any)["index"] != float64(1) {859+ t.Fatalf("state blocks = %v", blocks)860+ }861+ artifact, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "graph LR; a-->b;"))862+ if !ok || event.Tag(artifact, "a") != coordinate || event.Tag(artifact, "e") != "" || event.Tag(artifact, "block") != "1" {863+ t.Fatalf("state artifact = %+v %v", artifact, ok)864+ }865+}866+867+func TestMCPCustomViewTools(t *testing.T) {868+ app, tenant := testTenant(t)869+ member, _ := event.PublicKey(testMemberSecret)870+ setRole(t, tenant, member, "member")871+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {872+ t.Helper()873+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")874+ if w.Code != http.StatusOK {875+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())876+ }877+ return mcpToolResult(t, response)878+ }879+ structured := func(result map[string]any) any {880+ value, _ := result["structuredContent"].(map[string]any)881+ return value["result"]882+ }883+ text := func(result map[string]any) string {884+ content, _ := result["content"].([]any)885+ if len(content) == 0 {886+ return ""887+ }888+ return content[0].(map[string]any)["text"].(string)889+ }890+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")891+ names := map[string]bool{}892+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {893+ names[tool.(map[string]any)["name"].(string)] = true894+ }895+ for _, want := range []string{"list_custom_views", "add_custom_view", "remove_custom_view", "pause_custom_view", "resume_custom_view", "run_custom_view"} {896+ if !names[want] {897+ t.Fatalf("missing tool %s", want)898+ }899+ }900+ definition := map[string]any{"name": "diagrams", "kinds": []any{1.0, 30023.0}, "transform": "https://render.example/tiny", "languages": []any{"mermaid"}}901+ result, isError := call("add_custom_view", definition, testMemberSecret)902+ if !isError || !strings.Contains(text(result), "restricted") {903+ t.Fatalf("add_custom_view as member: %v %s", isError, text(result))904+ }905+ result, isError = call("add_custom_view", definition, "")906+ added, _ := structured(result).(map[string]any)907+ secret, _ := added["secret"].(string)908+ if isError || len(secret) != 64 || added["name"] != "diagrams" || added["trigger"] != "write" {909+ t.Fatalf("add_custom_view: %v %s", isError, text(result))910+ }911+ definition["name"], definition["secret"], definition["trigger"], definition["audience"], definition["max_bytes"] = "charts", "shared-secret-value-1234", "hourly", "members", 4096.0912+ result, isError = call("add_custom_view", definition, "")913+ if pasted, _ := structured(result).(map[string]any); isError || pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != float64(4096) {914+ t.Fatalf("add_custom_view with a secret: %v %s", isError, text(result))915+ }916+ result, isError = call("list_custom_views", nil, "")917+ rows, _ := structured(result).([]any)918+ if isError || len(rows) != 2 || strings.Contains(text(result), secret) || strings.Contains(text(result), "shared-secret-value-1234") {919+ t.Fatalf("list_custom_views: %v %s", isError, text(result))920+ }921+ if result, isError = call("list_custom_views", nil, testMemberSecret); !isError {922+ t.Fatalf("list_custom_views as member: %s", text(result))923+ }924+ result, isError = call("pause_custom_view", map[string]any{"name": "diagrams"}, "")925+ if paused, _ := structured(result).(map[string]any); isError || paused["enabled"] != false {926+ t.Fatalf("pause_custom_view: %v %s", isError, text(result))927+ }928+ result, isError = call("resume_custom_view", map[string]any{"name": "diagrams"}, "")929+ if resumed, _ := structured(result).(map[string]any); isError || resumed["enabled"] != true {930+ t.Fatalf("resume_custom_view: %v %s", isError, text(result))931+ }932+ result, isError = call("run_custom_view", map[string]any{"name": "diagrams"}, "")933+ if ran, _ := structured(result).(map[string]any); isError || ran["queued"] != float64(0) {934+ t.Fatalf("run_custom_view: %v %s", isError, text(result))935+ }936+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")937+ if removed, _ := structured(result).(map[string]any); isError || removed["removed"] != true {938+ t.Fatalf("remove_custom_view: %v %s", isError, text(result))939+ }940+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")941+ if !isError || !strings.Contains(text(result), "not found") {942+ t.Fatalf("remove_custom_view twice: %v %s", isError, text(result))943+ }944+}
M internal/daemon/custom_views_transform.go +20 -17
@@ -323,9 +323,11 @@ func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, pay
323323 return err324324 }325325 now := time.Now().Unix()326- key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))326+ // The intent id names the source and run; earlier attempts add "#n".327+ source, _, _ := strings.Cut(intent.EventID, "#")328+ key := sha256.Sum256([]byte(viewTransform + "\x00" + source + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))327329 return t.store.WithTx(ctx, func(tx *sql.Tx) error {328- _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, intent.EventID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)330+ _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, source+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)329331 return err330332 })331333 }
@@ -686,12 +688,13 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
686688 if err != nil {687689 return nil688690 }689- rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name)691+ rows, err := t.store.DB().QueryContext(ctx, `SELECT hash, raw FROM custom_view_artifacts WHERE view=?`, name)690692 if err != nil {691693 return err692694 }693- type stale struct{ hash string }694- var refresh []stale695+ // Read the rows out before asking about sources: the store lends one696+ // connection at a time.697+ tagged := map[string][]string{}695698 for rows.Next() {696699 var hash, raw string697700 if err := rows.Scan(&hash, &raw); err != nil {
@@ -702,28 +705,28 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
702705 if json.Unmarshal([]byte(raw), &signed) != nil {703706 continue704707 }705- var tagged []string708+ sources := []string{}706709 for _, tag := range signed.Tags {707710 if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") {708- tagged = append(tagged, tag[1])711+ sources = append(sources, tag[1])709712 }710713 }714+ tagged[hash] = sources715+ }716+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {717+ return err718+ }719+ for hash, sources := range tagged {711720 current, err := t.artifactSources(ctx, name, hash)712721 if err != nil {713- rows.Close()714722 return err715723 }716- sort.Strings(tagged)724+ sort.Strings(sources)717725 sort.Strings(current)718- if strings.Join(tagged, "\n") != strings.Join(current, "\n") {719- refresh = append(refresh, stale{hash})726+ if strings.Join(sources, "\n") == strings.Join(current, "\n") {727+ continue720728 }721- }722- if err := errors.Join(rows.Err(), rows.Close()); err != nil {723- return err724- }725- for _, item := range refresh {726- if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil {729+ if err := t.signArtifact(ctx, view, hash, time.Now().Unix()); err != nil {727730 return err728731 }729732 }
M internal/daemon/mcp_http.go +2 -2
@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9191 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",9292 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",9393 "- Methods: server/discover, tools/list and tools/call.",94- "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_join_requests, list_jobs and read_job.",95- "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback and resume_callback. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",94+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",95+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",9696 "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",9797 "",9898 "## Files",
M internal/daemon/mcp_tools.go +18 -0
@@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
133133 add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {134134 return t.mcpManage(ctx, call, "listcallbacks")135135 })136+ add("list_custom_views", "List the owner's custom views: name, kinds, languages, transform host, trigger, audience, state, failures and last run. The secret is never listed. Owner only.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {137+ return t.mcpManage(ctx, call, "listcustomviews")138+ })136139 add("list_join_requests", "List access requests from people who asked to join without an invite: pubkey, reason, requested_at, status (pending, approved or denied), decided_by and decided_at. Pending requests come first. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {137140 return t.mcpManage(ctx, call, "listjoinrequests")138141 })
@@ -164,6 +167,21 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
164167 add("add_callback", "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default.", mcp.Object(map[string]any{"url": map[string]any{"type": "string", "description": "An https URL on a public host."}, "filter": map[string]any{"type": "object", "description": "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h.", "minProperties": 1}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "url", "filter"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {165168 return t.mcpManage(ctx, call, "addcallback", call.Arguments)166169 })170+ add("add_custom_view", "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Tiny-Signature header; keep it. Owner only.", mcp.Object(map[string]any{"name": map[string]any{"type": "string", "pattern": "^[a-z0-9-]{1,32}$", "description": "View name: 1 to 32 lowercase letters, digits or hyphens."}, "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "minItems": 1, "maxItems": viewKindMax, "description": "Event kinds the view watches; 30618 sends the repository README at its head."}, "transform": map[string]any{"type": "string", "description": "An https URL on a public host that renders the blocks."}, "trigger": map[string]any{"type": "string", "enum": []string{"write", "hourly"}, "description": "write sends each matching event as it arrives; hourly sends the newest ones every hour."}, "audience": map[string]any{"type": "string", "enum": []string{"public", "members"}, "description": "Who may see the artifacts."}, "languages": map[string]any{"type": "array", "items": map[string]any{"type": "string", "minLength": 1, "maxLength": viewLanguageLength}, "minItems": 1, "maxItems": viewLanguageMax, "description": "Fenced block languages the view renders, lowercase."}, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": viewMaxBytesCap, "description": "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most."}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "name", "kinds", "transform", "languages"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {171+ return t.mcpManage(ctx, call, "addcustomview", call.Arguments)172+ })173+ add("remove_custom_view", "Delete a custom view and every artifact it produced. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {174+ return t.mcpManage(ctx, call, "removecustomview", call.String("name"))175+ })176+ add("pause_custom_view", "Stop a custom view: no transform requests until it is resumed. Its artifacts stay. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {177+ return t.mcpManage(ctx, call, "pausecustomview", call.String("name"))178+ })179+ add("resume_custom_view", "Resume a paused custom view and clear its failure count. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {180+ return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))181+ })182+ add("run_custom_view", "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {183+ return t.mcpManage(ctx, call, "runcustomview", call.String("name"))184+ })167185 add("remove_callback", "Delete a callback by id. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {168186 return t.mcpManage(ctx, call, "removecallback", call.String("id"))169187 })
A internal/webui/custom_views_test.go +172 -0
@@ -0,0 +1,172 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "net/url"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+ "github.com/FelineStateMachine/tinyrelay/internal/views"14+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"15+)16+17+// viewsBackend reports two custom views and lists them for the owner.18+type viewsBackend struct {19+ fakeBackend20+ views []views.View21+ rows []map[string]any22+}23+24+func (b *viewsBackend) CustomViews() []views.View { return b.views }25+26+func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {27+ if method == "listcustomviews" {28+ if actor != b.policy.Owner {29+ return nil, context.Canceled30+ }31+ return b.rows, nil32+ }33+ if method == "browserepo" {34+ var request map[string]any35+ if len(params) == 1 {36+ _ = json.Unmarshal(params[0], &request)37+ }38+ if request["view"] == "file" {39+ return map[string]any{"content": "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n"}, nil40+ }41+ return map[string]any{"identifier": "notes", "entries": []any{}}, nil42+ }43+ return b.fakeBackend.Query(ctx, method, params, actor)44+}45+46+const mermaidSource = "graph TD;\n A-->B & <C>;"47+48+func mermaidFigure(view string) string {49+ return `<figure data-view="` + view + `"><object data="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A--&gt;B &amp; &lt;C&gt;;</code></pre></object></figure>`50+}51+52+func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {53+ renderer := views.NewRenderer([]views.View{{Name: "diagrams", Languages: []string{"mermaid"}}})54+ source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n"55+ got := string(renderMarkdownWith(source, renderer.Block))56+ wantAll(t, "markdown", got, mermaidFigure("diagrams"), `<pre><code data-lang="go">func main() {}</code></pre>`)57+ if strings.Contains(got, "class=") {58+ t.Fatal("markdown carries a class attribute")59+ }60+ // Without a renderer, every fenced block stays code.61+ plain := string(renderMarkdown(source))62+ wantAll(t, "plain markdown", plain, `<pre><code data-lang="mermaid">graph TD;`)63+ wantNone(t, "plain markdown", plain, "<figure", "<object")64+ // Chat keeps the figure and links the bare URL outside it.65+ chat := string(renderChatMarkdownWith(source, renderer.Block))66+ wantAll(t, "chat", chat, mermaidFigure("diagrams"), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>`)67+ // README rendering maps relative links and keeps the figure.68+ query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}}69+ repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block))70+ wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repo?owner=alice&amp;path=docs%2Fguide.md&amp;ref=main&amp;repo=notes&amp;view=file"`)71+ // The wiki renderer takes the same renderer through its options.72+ article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block}))73+ wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>")74+ if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n")); strings.Contains(plainWiki, "<figure") || !strings.Contains(plainWiki, `<code data-lang="mermaid">`) {75+ t.Fatalf("wiki without options: %s", plainWiki)76+ }77+}78+79+func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {80+ owner := strings.Repeat("a", 64)81+ backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}82+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})83+ if err != nil {84+ t.Fatal(err)85+ }86+ source := "```mermaid\n" + mermaidSource + "\n```"87+ for name, got := range map[string]string{88+ "markdown": string(app.markdown(source)),89+ "chat": string(app.chatMarkdown(source)),90+ "wiki": string(app.wikiHTML(source)),91+ } {92+ wantAll(t, name, got, mermaidFigure("diagrams"))93+ }94+ // The repository home renders the README through the same renderer,95+ // and the tenant prefix reaches the object address.96+ recorder := httptest.NewRecorder()97+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner="+owner+"&repo=notes&view=home", nil))98+ body := recorder.Body.String()99+ if recorder.Code != http.StatusOK {100+ t.Fatalf("repo home: %d %s", recorder.Code, body)101+ }102+ hash := views.Hash("mermaid", "graph TD; a-->b;")103+ figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a--&gt;b;</code></pre></object></figure>`104+ wantAll(t, "repo home", body, figure, `href="/repo?owner=`)105+ if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) {106+ t.Fatalf("tenant prefix missed the object address: %s", prefixed)107+ }108+ // A backend without the summary keeps code blocks as code.109+ plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})110+ if err != nil {111+ t.Fatal(err)112+ }113+ if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure") {114+ t.Fatalf("figure without a summary: %s", got)115+ }116+ backend.views = nil117+ if got := string(app.markdown(source)); strings.Contains(got, "<figure") {118+ t.Fatalf("figure without views: %s", got)119+ }120+}121+122+func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {123+ owner := strings.Repeat("a", 64)124+ backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{125+ {"name": "diagrams", "kinds": []any{1.0, 30023.0}, "languages": []any{"mermaid", "dot"}, "trigger": "write", "audience": "public", "enabled": true, "state": "active", "lastRun": 1700000000.0, "lastStatus": "ok", "transform": "https://render.example/tiny?token=secret-path"},126+ {"name": "charts", "kinds": []any{30618.0}, "languages": []any{"vega"}, "trigger": "hourly", "audience": "members", "enabled": false, "state": "paused", "lastRun": 0.0, "lastStatus": "paused after 20 failures: HTTP 500"},127+ }}128+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})129+ if err != nil {130+ t.Fatal(err)131+ }132+ get := func(actor string) string {133+ t.Helper()134+ request := httptest.NewRequest(http.MethodGet, "/manage/views", nil)135+ request.Header.Set("X-Actor", actor)136+ recorder := httptest.NewRecorder()137+ app.ServeHTTP(recorder, request)138+ if recorder.Code != http.StatusOK {139+ t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String())140+ }141+ body := recorder.Body.String()142+ if strings.Contains(body, "class=") {143+ t.Fatal("views page carries a class attribute")144+ }145+ if strings.Contains(body, "ยท") {146+ t.Fatal("views page contains a middle dot")147+ }148+ return body149+ }150+ body := get(owner)151+ wantAll(t, "/manage/views", body,152+ `<rpc-form method="listviews">`, `<rpc-form method="publishview">`,153+ `<table id="custom-views">`, `<tr id="view-diagrams">`, `<code>diagrams</code>`, `<td>1, 30023</td>`, `<td>mermaid, dot</td>`, `<td>write</td>`, `<td>public</td>`,154+ `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`,155+ `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`,156+ `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`,157+ `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`,158+ `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`,159+ `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`,160+ `<view-form method="addcustomview">`, `<input name="view-name" pattern="[a-z0-9-]{1,32}"`, `<input name="view-kinds"`, `<input name="view-languages"`, `<input name="view-transform" type="url"`,161+ `<select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select>`, `<select name="view-audience">`, `<input name="view-max-bytes" type="number" min="1" max="4194304"`, `<input name="view-secret" minlength="16" maxlength="128"`, `<button>Add view</button>`)162+ wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`)163+ // A member keeps the built-in controls and sees no custom view section.164+ member := get(strings.Repeat("b", 64))165+ wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`)166+ wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form")167+ // The owner without views sees the empty note and the form.168+ backend.rows = nil169+ empty := get(owner)170+ wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`)171+ wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`)172+}