| 1 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 2 | index 9f0976f..6ecf63b 100644 |
| 3 | --- a/internal/daemon/custom_views.go |
| 4 | +++ b/internal/daemon/custom_views.go |
| 5 | @@ -422,7 +422,7 @@ func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customV |
| 6 | query += ` WHERE name=?` |
| 7 | args = append(args, names[0]) |
| 8 | } |
| 9 | - query += ` ORDER BY created_at,name` |
| 10 | + query += ` ORDER BY created_at,rowid` |
| 11 | rows, err := t.store.DB().QueryContext(ctx, query, args...) |
| 12 | if err != nil { |
| 13 | return nil, fmt.Errorf("list custom views: %w", err) |
| 14 | @@ -505,7 +505,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) { |
| 15 | if len(candidates) == 0 { |
| 16 | return |
| 17 | } |
| 18 | - if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) { |
| 19 | + if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.statePending(ctx, e.ID)) { |
| 20 | // The README is read from the objects, which have not arrived yet; |
| 21 | // releaseGit queues the state once they have. |
| 22 | return |
| 23 | @@ -545,6 +545,14 @@ func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage. |
| 24 | return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)} |
| 25 | } |
| 26 | |
| 27 | +// statePending reports whether a repository state still waits for its |
| 28 | +// objects. |
| 29 | +func (t *Tenant) statePending(ctx context.Context, id string) bool { |
| 30 | + var one int |
| 31 | + err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM pending_events WHERE id=?`, id).Scan(&one) |
| 32 | + return err == nil || t.git.IsPending(id) |
| 33 | +} |
| 34 | + |
| 35 | // queueCustomViewBackfill queues the newest sources of a view's kinds, at |
| 36 | // most 500, written since the given time. Repository states are queued |
| 37 | // whether or not their README has blocks; the handler reads it. |
| 38 | @@ -559,7 +567,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s |
| 39 | continue |
| 40 | } |
| 41 | if e.Kind == event.KIND_REPO_STATE { |
| 42 | - if t.git == nil || t.git.IsPending(e.ID) { |
| 43 | + if t.git == nil || t.statePending(ctx, e.ID) { |
| 44 | continue |
| 45 | } |
| 46 | } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 { |
| 47 | diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go |
| 48 | new file mode 100644 |
| 49 | index 0000000..57d155e |
| 50 | --- /dev/null |
| 51 | +++ b/internal/daemon/custom_views_test.go |
| 52 | @@ -0,0 +1,944 @@ |
| 53 | +package daemon |
| 54 | + |
| 55 | +import ( |
| 56 | + "context" |
| 57 | + "crypto/hmac" |
| 58 | + "crypto/sha256" |
| 59 | + "encoding/base64" |
| 60 | + "encoding/hex" |
| 61 | + "encoding/json" |
| 62 | + "io" |
| 63 | + "net/http" |
| 64 | + "net/http/httptest" |
| 65 | + "os" |
| 66 | + "path/filepath" |
| 67 | + "strconv" |
| 68 | + "strings" |
| 69 | + "sync" |
| 70 | + "testing" |
| 71 | + "time" |
| 72 | + |
| 73 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 74 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 75 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 76 | + "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 77 | +) |
| 78 | + |
| 79 | +const ( |
| 80 | + testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>` |
| 81 | + testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" |
| 82 | +) |
| 83 | + |
| 84 | +// transformServer stands in for a view transform: it records every request |
| 85 | +// and answers with the artifacts it is told to. |
| 86 | +type transformServer struct { |
| 87 | + mu sync.Mutex |
| 88 | + status int |
| 89 | + body string |
| 90 | + headers []http.Header |
| 91 | + requests [][]byte |
| 92 | +} |
| 93 | + |
| 94 | +func (s *transformServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 95 | + body, _ := io.ReadAll(r.Body) |
| 96 | + s.mu.Lock() |
| 97 | + s.headers = append(s.headers, r.Header.Clone()) |
| 98 | + s.requests = append(s.requests, body) |
| 99 | + status, answer := s.status, s.body |
| 100 | + s.mu.Unlock() |
| 101 | + w.Header().Set("Content-Type", "application/json") |
| 102 | + w.WriteHeader(status) |
| 103 | + _, _ = w.Write([]byte(answer)) |
| 104 | +} |
| 105 | + |
| 106 | +func (s *transformServer) count() int { |
| 107 | + s.mu.Lock() |
| 108 | + defer s.mu.Unlock() |
| 109 | + return len(s.requests) |
| 110 | +} |
| 111 | + |
| 112 | +func (s *transformServer) answer(status int, artifacts ...map[string]any) { |
| 113 | + s.mu.Lock() |
| 114 | + defer s.mu.Unlock() |
| 115 | + s.status = status |
| 116 | + raw, _ := json.Marshal(map[string]any{"artifacts": artifacts, "errors": []map[string]any{{"block": 9, "error": "unknown"}}}) |
| 117 | + s.body = string(raw) |
| 118 | +} |
| 119 | + |
| 120 | +func svgArtifact(block int) map[string]any { |
| 121 | + return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"} |
| 122 | +} |
| 123 | + |
| 124 | +func viewParams(values map[string]any) []json.RawMessage { |
| 125 | + raw, _ := json.Marshal(values) |
| 126 | + return []json.RawMessage{raw} |
| 127 | +} |
| 128 | + |
| 129 | +func addView(t *testing.T, tenant *Tenant, values map[string]any) map[string]any { |
| 130 | + t.Helper() |
| 131 | + result, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "addcustomview", viewParams(values)) |
| 132 | + if err != nil { |
| 133 | + t.Fatalf("addcustomview: %v", err) |
| 134 | + } |
| 135 | + return result.(map[string]any) |
| 136 | +} |
| 137 | + |
| 138 | +// viewTenant is a loopback tenant with a transform server and one view that |
| 139 | +// watches the given kinds. |
| 140 | +func viewTenant(t *testing.T, kinds []int, extra map[string]any) (*Tenant, *transformServer, map[string]any) { |
| 141 | + t.Helper() |
| 142 | + _, tenant := loopbackTenant(t) |
| 143 | + server := &transformServer{} |
| 144 | + server.answer(http.StatusOK) |
| 145 | + service := httptest.NewTLSServer(server) |
| 146 | + t.Cleanup(service.Close) |
| 147 | + tenant.viewClient = service.Client() |
| 148 | + values := map[string]any{"name": "diagrams", "kinds": kinds, "transform": service.URL + "/render?token=t0k3n", "languages": []string{"mermaid", "dot"}} |
| 149 | + for key, value := range extra { |
| 150 | + values[key] = value |
| 151 | + } |
| 152 | + return tenant, server, addView(t, tenant, values) |
| 153 | +} |
| 154 | + |
| 155 | +// pendingViewIntents lists queued transforms of one source for one view, |
| 156 | +// first attempt first. |
| 157 | +func pendingViewIntents(t *testing.T, tenant *Tenant, name, eventID string) []work.Intent { |
| 158 | + t.Helper() |
| 159 | + 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+"@%") |
| 160 | + if err != nil { |
| 161 | + t.Fatal(err) |
| 162 | + } |
| 163 | + defer rows.Close() |
| 164 | + var out []work.Intent |
| 165 | + for rows.Next() { |
| 166 | + var item work.Intent |
| 167 | + var next int64 |
| 168 | + if err := rows.Scan(&item.ID, &item.EventID, &item.Payload, &next); err != nil { |
| 169 | + t.Fatal(err) |
| 170 | + } |
| 171 | + item.Kind, item.Target, item.NextAt = viewTransform, name, time.Unix(next, 0) |
| 172 | + out = append(out, item) |
| 173 | + } |
| 174 | + return out |
| 175 | +} |
| 176 | + |
| 177 | +// runViewIntent drives one intent the way the worker would and marks it |
| 178 | +// complete. |
| 179 | +func runViewIntent(t *testing.T, tenant *Tenant, intent work.Intent) { |
| 180 | + t.Helper() |
| 181 | + if err := tenant.handleViewTransform(context.Background(), intent); err != nil { |
| 182 | + t.Fatalf("handleViewTransform: %v", err) |
| 183 | + } |
| 184 | + if _, err := tenant.store.DB().ExecContext(context.Background(), "UPDATE work_intents SET state='completed' WHERE id=?", intent.ID); err != nil { |
| 185 | + t.Fatal(err) |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +func runPendingView(t *testing.T, tenant *Tenant, name, eventID string) { |
| 190 | + t.Helper() |
| 191 | + queued := pendingViewIntents(t, tenant, name, eventID) |
| 192 | + if len(queued) != 1 { |
| 193 | + t.Fatalf("queued intents for %s = %d", eventID, len(queued)) |
| 194 | + } |
| 195 | + runViewIntent(t, tenant, queued[0]) |
| 196 | +} |
| 197 | + |
| 198 | +func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) { |
| 199 | + t.Helper() |
| 200 | + var raw string |
| 201 | + 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) |
| 202 | + if err != nil { |
| 203 | + return event.Event{}, false |
| 204 | + } |
| 205 | + e, err := event.Parse([]byte(raw)) |
| 206 | + if err != nil { |
| 207 | + t.Fatal(err) |
| 208 | + } |
| 209 | + return e, true |
| 210 | +} |
| 211 | + |
| 212 | +func artifactTags(e event.Event, name string) []string { |
| 213 | + var out []string |
| 214 | + for _, tag := range e.Tags { |
| 215 | + if len(tag) >= 2 && tag[0] == name { |
| 216 | + out = append(out, tag[1]) |
| 217 | + } |
| 218 | + } |
| 219 | + return out |
| 220 | +} |
| 221 | + |
| 222 | +func signRequestAs(t *testing.T, r *http.Request, secret string) { |
| 223 | + t.Helper() |
| 224 | + hash := sha256.Sum256(nil) |
| 225 | + e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}} |
| 226 | + if err := event.Sign(&e, secret); err != nil { |
| 227 | + t.Fatal(err) |
| 228 | + } |
| 229 | + raw, _ := json.Marshal(e) |
| 230 | + r.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(raw)) |
| 231 | +} |
| 232 | + |
| 233 | +func getArtifact(t *testing.T, tenant *Tenant, path, secret string) *httptest.ResponseRecorder { |
| 234 | + t.Helper() |
| 235 | + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080"+path, nil) |
| 236 | + if secret != "" { |
| 237 | + signRequestAs(t, req, secret) |
| 238 | + } |
| 239 | + res := httptest.NewRecorder() |
| 240 | + tenant.ServeHTTP(res, req) |
| 241 | + return res |
| 242 | +} |
| 243 | + |
| 244 | +func TestCustomViewManagementIsOwnerOnlyAndValidated(t *testing.T) { |
| 245 | + _, tenant := testTenant(t) |
| 246 | + ctx := context.Background() |
| 247 | + owner := tenant.Policy().Owner |
| 248 | + member, _ := event.PublicKey(testMemberSecret) |
| 249 | + moderator, _ := event.PublicKey(testModSecret) |
| 250 | + guest := strings.Repeat("e", 64) |
| 251 | + setRole(t, tenant, member, "member") |
| 252 | + setRole(t, tenant, moderator, "moderator") |
| 253 | + methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil) |
| 254 | + if err != nil { |
| 255 | + t.Fatal(err) |
| 256 | + } |
| 257 | + all := []string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"} |
| 258 | + for _, name := range all { |
| 259 | + if !containsString(methods.([]string), name) || !containsString(ManagementMethods(), name) { |
| 260 | + t.Fatalf("registry lacks %s", name) |
| 261 | + } |
| 262 | + } |
| 263 | + base := map[string]any{"name": "diagrams", "kinds": []int{1, 30023}, "transform": "https://render.example/tiny", "languages": []string{"mermaid"}} |
| 264 | + for _, actor := range []string{guest, member, moderator} { |
| 265 | + for _, method := range all { |
| 266 | + params := []json.RawMessage{json.RawMessage(`"diagrams"`)} |
| 267 | + if method == "addcustomview" { |
| 268 | + params = viewParams(base) |
| 269 | + } |
| 270 | + if _, err := tenant.Execute(ctx, actor, method, params); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { |
| 271 | + t.Fatalf("%s by non-owner: %v", method, err) |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | + with := func(changes map[string]any) map[string]any { |
| 276 | + out := map[string]any{} |
| 277 | + for key, value := range base { |
| 278 | + out[key] = value |
| 279 | + } |
| 280 | + for key, value := range changes { |
| 281 | + out[key] = value |
| 282 | + } |
| 283 | + return out |
| 284 | + } |
| 285 | + many := make([]int, viewKindMax+1) |
| 286 | + for _, tc := range []struct { |
| 287 | + name string |
| 288 | + values map[string]any |
| 289 | + want string |
| 290 | + }{ |
| 291 | + {"bad name", with(map[string]any{"name": "Diagrams!"}), "invalid: name"}, |
| 292 | + {"long name", with(map[string]any{"name": strings.Repeat("a", 33)}), "invalid: name"}, |
| 293 | + {"no kinds", with(map[string]any{"kinds": []int{}}), "invalid: kinds"}, |
| 294 | + {"too many kinds", with(map[string]any{"kinds": many}), "invalid: kinds"}, |
| 295 | + {"bad kind", with(map[string]any{"kinds": []int{70000}}), "invalid: kinds must be between"}, |
| 296 | + {"http transform", with(map[string]any{"transform": "http://render.example/tiny"}), "invalid: transform"}, |
| 297 | + {"private transform", with(map[string]any{"transform": "https://10.0.0.1/tiny"}), "invalid: transform"}, |
| 298 | + {"bad trigger", with(map[string]any{"trigger": "daily"}), "invalid: trigger"}, |
| 299 | + {"bad audience", with(map[string]any{"audience": "owner"}), "invalid: audience"}, |
| 300 | + {"no languages", with(map[string]any{"languages": []string{}}), "invalid: languages"}, |
| 301 | + {"bad language", with(map[string]any{"languages": []string{"mer maid"}}), "invalid: languages"}, |
| 302 | + {"max bytes cap", with(map[string]any{"max_bytes": viewMaxBytesCap + 1}), "invalid: max_bytes"}, |
| 303 | + {"zero max bytes", with(map[string]any{"max_bytes": 0}), "invalid: max_bytes"}, |
| 304 | + {"short secret", with(map[string]any{"secret": "short"}), "invalid: secret"}, |
| 305 | + {"control in secret", with(map[string]any{"secret": strings.Repeat("a", 15) + "\n"}), "invalid: secret"}, |
| 306 | + } { |
| 307 | + if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(tc.values)); err == nil || !strings.HasPrefix(err.Error(), tc.want) { |
| 308 | + t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want) |
| 309 | + } |
| 310 | + } |
| 311 | + // A plain form sends kinds and languages as text; defaults fill trigger, |
| 312 | + // audience and the size limit; the secret is generated and shown once. |
| 313 | + added := addView(t, tenant, map[string]any{"name": "diagrams", "kinds": "30023, 1, 1", "transform": "https://render.example/tiny", "languages": "Mermaid, dot"}) |
| 314 | + secret, _ := added["secret"].(string) |
| 315 | + if len(secret) != 64 || added["trigger"] != "write" || added["audience"] != "public" || added["maxBytes"] != viewDefaultMaxBytes || added["host"] != "render.example" { |
| 316 | + t.Fatalf("added = %v", added) |
| 317 | + } |
| 318 | + if kinds, _ := json.Marshal(added["kinds"]); string(kinds) != "[1,30023]" { |
| 319 | + t.Fatalf("kinds = %s", kinds) |
| 320 | + } |
| 321 | + if languages, _ := json.Marshal(added["languages"]); string(languages) != `["mermaid","dot"]` { |
| 322 | + t.Fatalf("languages = %s", languages) |
| 323 | + } |
| 324 | + if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(base)); err == nil || !strings.Contains(err.Error(), "exists") { |
| 325 | + t.Fatalf("duplicate name: %v", err) |
| 326 | + } |
| 327 | + // An owner may paste the secret the transform already holds. |
| 328 | + pasted := addView(t, tenant, with(map[string]any{"name": "charts", "trigger": "hourly", "audience": "members", "max_bytes": 2048, "secret": "shared-secret-value-1234"})) |
| 329 | + if pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != 2048 { |
| 330 | + t.Fatalf("pasted = %v", pasted) |
| 331 | + } |
| 332 | + var stored string |
| 333 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT secret FROM custom_views WHERE name='charts'").Scan(&stored); err != nil || stored != "shared-secret-value-1234" { |
| 334 | + t.Fatalf("stored secret %q %v", stored, err) |
| 335 | + } |
| 336 | + listed, err := tenant.Execute(ctx, owner, "listcustomviews", nil) |
| 337 | + if err != nil { |
| 338 | + t.Fatal(err) |
| 339 | + } |
| 340 | + rows := listed.([]map[string]any) |
| 341 | + if len(rows) != 2 || rows[0]["name"] != "diagrams" || rows[1]["name"] != "charts" { |
| 342 | + t.Fatalf("listing = %v", rows) |
| 343 | + } |
| 344 | + for _, row := range rows { |
| 345 | + if _, leaked := row["secret"]; leaked || row["state"] != "active" { |
| 346 | + t.Fatalf("listing row = %v", row) |
| 347 | + } |
| 348 | + } |
| 349 | + paused, err := tenant.Execute(ctx, owner, "pausecustomview", []json.RawMessage{json.RawMessage(`{"name":"diagrams"}`)}) |
| 350 | + if err != nil || paused.(map[string]any)["enabled"] != false || paused.(map[string]any)["state"] != "paused" { |
| 351 | + t.Fatalf("pause: %v %v", paused, err) |
| 352 | + } |
| 353 | + if _, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err == nil || !strings.Contains(err.Error(), "resume") { |
| 354 | + t.Fatalf("run while paused: %v", err) |
| 355 | + } |
| 356 | + for _, view := range tenant.customViewIndex(ctx)[1] { |
| 357 | + if view.Name == "diagrams" { |
| 358 | + t.Fatal("paused view stayed in the kind index") |
| 359 | + } |
| 360 | + } |
| 361 | + resumed, err := tenant.Execute(ctx, owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}) |
| 362 | + if err != nil || resumed.(map[string]any)["enabled"] != true { |
| 363 | + t.Fatalf("resume: %v %v", resumed, err) |
| 364 | + } |
| 365 | + ran, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}) |
| 366 | + if err != nil || ran.(map[string]any)["queued"] != 0 { |
| 367 | + t.Fatalf("run: %v %v", ran, err) |
| 368 | + } |
| 369 | + if _, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") { |
| 370 | + t.Fatalf("remove missing: %v", err) |
| 371 | + } |
| 372 | + removed, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"charts"`)}) |
| 373 | + if err != nil || removed.(map[string]any)["removed"] != true { |
| 374 | + t.Fatalf("remove: %v %v", removed, err) |
| 375 | + } |
| 376 | + audit, err := tenant.Execute(ctx, owner, "listaudit", nil) |
| 377 | + if err != nil { |
| 378 | + t.Fatal(err) |
| 379 | + } |
| 380 | + seen := map[string]bool{} |
| 381 | + for _, row := range audit.([]community.AuditRow) { |
| 382 | + seen[row.Action] = true |
| 383 | + if strings.Contains(row.Detail, "render.example") || strings.Contains(row.Target, "render.example") || strings.Contains(row.Detail, secret) { |
| 384 | + t.Fatalf("audit carries a transform URL or secret: %+v", row) |
| 385 | + } |
| 386 | + } |
| 387 | + for _, action := range all { |
| 388 | + if action != "listcustomviews" && !seen[action] { |
| 389 | + t.Fatalf("audit lacks %s: %v", action, seen) |
| 390 | + } |
| 391 | + } |
| 392 | +} |
| 393 | + |
| 394 | +func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) { |
| 395 | + tenant, server, added := viewTenant(t, []int{1}, nil) |
| 396 | + ctx := context.Background() |
| 397 | + secret := added["secret"].(string) |
| 398 | + 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}) |
| 399 | + now := time.Now().Unix() |
| 400 | + 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" |
| 401 | + note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", strconv.FormatInt(now+86400, 10)}}, content) |
| 402 | + if err := publishAs(t, tenant, note); err != nil { |
| 403 | + t.Fatal(err) |
| 404 | + } |
| 405 | + plain := signedEvent(t, testOwnerSecret, 1, now+1, nil, "no blocks here\n```go\nx\n```") |
| 406 | + if err := publishAs(t, tenant, plain); err != nil { |
| 407 | + t.Fatal(err) |
| 408 | + } |
| 409 | + if len(pendingViewIntents(t, tenant, "diagrams", plain.ID)) != 0 { |
| 410 | + t.Fatal("an event without a matching block was queued") |
| 411 | + } |
| 412 | + runPendingView(t, tenant, "diagrams", note.ID) |
| 413 | + if server.count() != 1 { |
| 414 | + t.Fatalf("requests = %d", server.count()) |
| 415 | + } |
| 416 | + headers, body := server.headers[0], server.requests[0] |
| 417 | + mac := hmac.New(sha256.New, []byte(secret)) |
| 418 | + mac.Write(body) |
| 419 | + 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)) { |
| 420 | + t.Fatalf("headers = %v", headers) |
| 421 | + } |
| 422 | + var request map[string]any |
| 423 | + if err := json.Unmarshal(body, &request); err != nil { |
| 424 | + t.Fatal(err) |
| 425 | + } |
| 426 | + if _, leaked := request["event"]; leaked || request["relay"] != "http://127.0.0.1:8080" || request["view"] != "diagrams" { |
| 427 | + t.Fatalf("request = %s", body) |
| 428 | + } |
| 429 | + if source := request["source"].(map[string]any); source["id"] != note.ID || source["kind"] != float64(1) || len(source) != 2 { |
| 430 | + t.Fatalf("source = %v", source) |
| 431 | + } |
| 432 | + if strings.Contains(string(body), "Look:") || strings.Contains(string(body), tenant.Policy().Owner) { |
| 433 | + t.Fatalf("request carries event content or author: %s", body) |
| 434 | + } |
| 435 | + blocks := request["blocks"].([]any) |
| 436 | + if len(blocks) != 2 { |
| 437 | + t.Fatalf("blocks = %v", blocks) |
| 438 | + } |
| 439 | + first, third := blocks[0].(map[string]any), blocks[1].(map[string]any) |
| 440 | + 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 }" { |
| 441 | + t.Fatalf("blocks = %v", blocks) |
| 442 | + } |
| 443 | + // The SVG artifact is a stored relay-signed record attached to the note. |
| 444 | + svgHash := views.Hash("mermaid", "graph TD;\n A-->B;") |
| 445 | + artifact, ok := storedArtifact(t, tenant, "diagrams", svgHash) |
| 446 | + if !ok { |
| 447 | + t.Fatal("svg artifact was not stored") |
| 448 | + } |
| 449 | + 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] != "-" { |
| 450 | + t.Fatalf("artifact = %+v", artifact) |
| 451 | + } |
| 452 | + pngHash := views.Hash("dot", "digraph { a -> b }") |
| 453 | + png, ok := storedArtifact(t, tenant, "diagrams", pngHash) |
| 454 | + if !ok || png.Content != base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) || event.Tag(png, "type") != "image/png" || event.Tag(png, "block") != "2" || event.Tag(png, "engine") != "" { |
| 455 | + t.Fatalf("png artifact = %+v %v", png, ok) |
| 456 | + } |
| 457 | + var count int |
| 458 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 2 { |
| 459 | + t.Fatalf("artifacts = %d %v (an artifact for an unsent block was kept)", count, err) |
| 460 | + } |
| 461 | + var lastStatus string |
| 462 | + var failures int |
| 463 | + 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 { |
| 464 | + t.Fatalf("view state %q %d %v", lastStatus, failures, err) |
| 465 | + } |
| 466 | + |
| 467 | + // The route serves each artifact under a sandbox, by its own type. |
| 468 | + res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "") |
| 469 | + 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" { |
| 470 | + t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String()) |
| 471 | + } |
| 472 | + res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "") |
| 473 | + if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" { |
| 474 | + t.Fatalf("png route: %d %v", res.Code, res.Header()) |
| 475 | + } |
| 476 | + 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"} { |
| 477 | + if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound { |
| 478 | + t.Fatalf("%s: %d", path, res.Code) |
| 479 | + } |
| 480 | + } |
| 481 | + |
| 482 | + // A second note with the same block reuses the artifact: no POST, a |
| 483 | + // fresh record with both sources. |
| 484 | + second := signedEvent(t, testOwnerSecret, 1, now+2, nil, "Again:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n") |
| 485 | + if err := publishAs(t, tenant, second); err != nil { |
| 486 | + t.Fatal(err) |
| 487 | + } |
| 488 | + runPendingView(t, tenant, "diagrams", second.ID) |
| 489 | + if server.count() != 1 { |
| 490 | + t.Fatalf("requests after reuse = %d", server.count()) |
| 491 | + } |
| 492 | + artifact, _ = storedArtifact(t, tenant, "diagrams", svgHash) |
| 493 | + if sources := artifactTags(artifact, "e"); len(sources) != 2 || sources[0] != note.ID || sources[1] != second.ID { |
| 494 | + t.Fatalf("sources after reuse = %v", sources) |
| 495 | + } |
| 496 | + if event.Tag(artifact, "expiration") != "" { |
| 497 | + t.Fatal("artifact kept an expiration while a source without one is attached") |
| 498 | + } |
| 499 | + if blocks := artifactTags(artifact, "block"); len(blocks) != 2 || blocks[0] != "0" || blocks[1] != "0" { |
| 500 | + t.Fatalf("block tags = %v", blocks) |
| 501 | + } |
| 502 | + |
| 503 | + // Deleting the first note detaches it; deleting the second removes the |
| 504 | + // artifact and its record. |
| 505 | + deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+3, [][]string{{"e", note.ID}}, "") |
| 506 | + if err := publishAs(t, tenant, deletion); err != nil { |
| 507 | + t.Fatal(err) |
| 508 | + } |
| 509 | + artifact, ok = storedArtifact(t, tenant, "diagrams", svgHash) |
| 510 | + if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID { |
| 511 | + t.Fatalf("artifact after first deletion = %+v %v", artifact, ok) |
| 512 | + } |
| 513 | + if _, ok := storedArtifact(t, tenant, "diagrams", pngHash); ok { |
| 514 | + t.Fatal("png artifact survived the deletion of its only source") |
| 515 | + } |
| 516 | + if res := getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", ""); res.Code != http.StatusNotFound { |
| 517 | + t.Fatalf("png route after deletion: %d", res.Code) |
| 518 | + } |
| 519 | + deletion = signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+4, [][]string{{"e", second.ID}}, "") |
| 520 | + if err := publishAs(t, tenant, deletion); err != nil { |
| 521 | + t.Fatal(err) |
| 522 | + } |
| 523 | + if _, ok := storedArtifact(t, tenant, "diagrams", svgHash); ok { |
| 524 | + t.Fatal("svg artifact survived the deletion of its last source") |
| 525 | + } |
| 526 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 0 { |
| 527 | + t.Fatalf("artifacts after deletions = %d %v", count, err) |
| 528 | + } |
| 529 | + |
| 530 | + // Expiry: an expired source loses its artifacts at the sweep. |
| 531 | + expiring := signedEvent(t, testOwnerSecret, 1, now+5, [][]string{{"expiration", strconv.FormatInt(now+30, 10)}}, "```dot\nx\n```") |
| 532 | + if err := publishAs(t, tenant, expiring); err != nil { |
| 533 | + t.Fatal(err) |
| 534 | + } |
| 535 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 536 | + runPendingView(t, tenant, "diagrams", expiring.ID) |
| 537 | + expiringHash := views.Hash("dot", "x") |
| 538 | + if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); !ok { |
| 539 | + t.Fatal("expiring artifact was not stored") |
| 540 | + } |
| 541 | + if err := tenant.sweep(ctx, now+60); err != nil { |
| 542 | + t.Fatal(err) |
| 543 | + } |
| 544 | + if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); ok { |
| 545 | + t.Fatal("artifact survived its source's expiry") |
| 546 | + } |
| 547 | + if res := getArtifact(t, tenant, "/views/diagrams/"+expiringHash+".svg", ""); res.Code != http.StatusNotFound { |
| 548 | + t.Fatalf("expired route: %d", res.Code) |
| 549 | + } |
| 550 | + |
| 551 | + // Removing the view removes what is left. |
| 552 | + last := signedEvent(t, testOwnerSecret, 1, now+6, nil, "```dot\ny\n```") |
| 553 | + if err := publishAs(t, tenant, last); err != nil { |
| 554 | + t.Fatal(err) |
| 555 | + } |
| 556 | + runPendingView(t, tenant, "diagrams", last.ID) |
| 557 | + if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "removecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil { |
| 558 | + t.Fatal(err) |
| 559 | + } |
| 560 | + 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 { |
| 561 | + t.Fatalf("records after removal = %d %v", count, err) |
| 562 | + } |
| 563 | +} |
| 564 | + |
| 565 | +func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) { |
| 566 | + tenant, server, _ := viewTenant(t, []int{30023}, nil) |
| 567 | + ctx := context.Background() |
| 568 | + now := time.Now().Unix() |
| 569 | + server.answer(http.StatusOK, svgArtifact(0), svgArtifact(1)) |
| 570 | + first := signedEvent(t, testOwnerSecret, 30023, now, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n\n```mermaid\nB\n```\n") |
| 571 | + if err := publishAs(t, tenant, first); err != nil { |
| 572 | + t.Fatal(err) |
| 573 | + } |
| 574 | + runPendingView(t, tenant, "diagrams", first.ID) |
| 575 | + hashA, hashB := views.Hash("mermaid", "A"), views.Hash("mermaid", "B") |
| 576 | + if _, ok := storedArtifact(t, tenant, "diagrams", hashA); !ok { |
| 577 | + t.Fatal("A missing") |
| 578 | + } |
| 579 | + if _, ok := storedArtifact(t, tenant, "diagrams", hashB); !ok { |
| 580 | + t.Fatal("B missing") |
| 581 | + } |
| 582 | + // The replacement keeps A and drops B; A is reused without a POST and |
| 583 | + // carries only the new version. |
| 584 | + second := signedEvent(t, testOwnerSecret, 30023, now+1, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n") |
| 585 | + if err := publishAs(t, tenant, second); err != nil { |
| 586 | + t.Fatal(err) |
| 587 | + } |
| 588 | + runPendingView(t, tenant, "diagrams", second.ID) |
| 589 | + if server.count() != 1 { |
| 590 | + t.Fatalf("requests = %d", server.count()) |
| 591 | + } |
| 592 | + artifact, ok := storedArtifact(t, tenant, "diagrams", hashA) |
| 593 | + if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID { |
| 594 | + t.Fatalf("A after replacement = %+v %v", artifact, ok) |
| 595 | + } |
| 596 | + if _, ok := storedArtifact(t, tenant, "diagrams", hashB); ok { |
| 597 | + t.Fatal("B survived the replacement") |
| 598 | + } |
| 599 | + // A stale intent for the replaced version renders nothing. |
| 600 | + stale := tenant.viewIntent(customView{Name: "diagrams"}, first, "old") |
| 601 | + if err := tenant.handleViewTransform(ctx, work.Intent{ID: "stale", Kind: stale.Kind, EventID: stale.EventID, Target: stale.Target, Payload: stale.Payload}); err != nil { |
| 602 | + t.Fatal(err) |
| 603 | + } |
| 604 | + if server.count() != 1 { |
| 605 | + t.Fatal("a replaced version was sent to the transform") |
| 606 | + } |
| 607 | + // Deleting the address removes the artifact. |
| 608 | + deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+2, [][]string{{"a", "30023:" + tenant.Policy().Owner + ":post"}}, "") |
| 609 | + if err := publishAs(t, tenant, deletion); err != nil { |
| 610 | + t.Fatal(err) |
| 611 | + } |
| 612 | + if _, ok := storedArtifact(t, tenant, "diagrams", hashA); ok { |
| 613 | + t.Fatal("A survived the address deletion") |
| 614 | + } |
| 615 | +} |
| 616 | + |
| 617 | +func TestCustomViewTransformRetriesThenPauses(t *testing.T) { |
| 618 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 619 | + ctx := context.Background() |
| 620 | + now := time.Now().Unix() |
| 621 | + server.answer(http.StatusInternalServerError) |
| 622 | + note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```") |
| 623 | + if err := publishAs(t, tenant, note); err != nil { |
| 624 | + t.Fatal(err) |
| 625 | + } |
| 626 | + queued := pendingViewIntents(t, tenant, "diagrams", note.ID) |
| 627 | + if len(queued) != 1 { |
| 628 | + t.Fatalf("queued = %d", len(queued)) |
| 629 | + } |
| 630 | + runViewIntent(t, tenant, queued[0]) |
| 631 | + queued = pendingViewIntents(t, tenant, "diagrams", note.ID) |
| 632 | + if len(queued) != 1 || queued[0].EventID != note.ID+"#2" || queued[0].NextAt.Unix() < now+55 || queued[0].NextAt.Unix() > now+70 { |
| 633 | + t.Fatalf("second attempt = %+v", queued) |
| 634 | + } |
| 635 | + runViewIntent(t, tenant, queued[0]) |
| 636 | + queued = pendingViewIntents(t, tenant, "diagrams", note.ID) |
| 637 | + if len(queued) != 1 || queued[0].EventID != note.ID+"#3" || queued[0].NextAt.Unix() < now+295 || queued[0].NextAt.Unix() > now+310 { |
| 638 | + t.Fatalf("third attempt = %+v", queued) |
| 639 | + } |
| 640 | + runViewIntent(t, tenant, queued[0]) |
| 641 | + if queued = pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 { |
| 642 | + t.Fatalf("fourth attempt queued: %+v", queued) |
| 643 | + } |
| 644 | + var failures, enabled int |
| 645 | + var status string |
| 646 | + state := func() { |
| 647 | + t.Helper() |
| 648 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'").Scan(&failures, &enabled, &status); err != nil { |
| 649 | + t.Fatal(err) |
| 650 | + } |
| 651 | + } |
| 652 | + state() |
| 653 | + if failures != 3 || enabled != 1 || status != "HTTP 500" { |
| 654 | + t.Fatalf("after three failures: %d %d %q", failures, enabled, status) |
| 655 | + } |
| 656 | + if server.count() != 3 { |
| 657 | + t.Fatalf("requests = %d", server.count()) |
| 658 | + } |
| 659 | + // A 200 that is not JSON is a failure too. |
| 660 | + server.mu.Lock() |
| 661 | + server.status, server.body = http.StatusOK, "<html>" |
| 662 | + server.mu.Unlock() |
| 663 | + other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```mermaid\nB\n```") |
| 664 | + if err := publishAs(t, tenant, other); err != nil { |
| 665 | + t.Fatal(err) |
| 666 | + } |
| 667 | + runPendingView(t, tenant, "diagrams", other.ID) |
| 668 | + state() |
| 669 | + if failures != 4 || status != "invalid response" { |
| 670 | + t.Fatalf("after bad json: %d %q", failures, status) |
| 671 | + } |
| 672 | + // The twentieth failure in a row pauses the view; nothing new is queued. |
| 673 | + if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE custom_views SET failures=19 WHERE name='diagrams'"); err != nil { |
| 674 | + t.Fatal(err) |
| 675 | + } |
| 676 | + server.answer(http.StatusBadGateway) |
| 677 | + third := signedEvent(t, testOwnerSecret, 1, now+2, nil, "```mermaid\nC\n```") |
| 678 | + if err := publishAs(t, tenant, third); err != nil { |
| 679 | + t.Fatal(err) |
| 680 | + } |
| 681 | + runPendingView(t, tenant, "diagrams", third.ID) |
| 682 | + state() |
| 683 | + if failures != 20 || enabled != 0 || status != "paused after 20 failures: HTTP 502" { |
| 684 | + t.Fatalf("after twenty failures: %d %d %q", failures, enabled, status) |
| 685 | + } |
| 686 | + if queued := pendingViewIntents(t, tenant, "diagrams", third.ID); len(queued) != 0 { |
| 687 | + t.Fatalf("paused view queued a retry: %+v", queued) |
| 688 | + } |
| 689 | + fourth := signedEvent(t, testOwnerSecret, 1, now+3, nil, "```mermaid\nD\n```") |
| 690 | + if err := publishAs(t, tenant, fourth); err != nil { |
| 691 | + t.Fatal(err) |
| 692 | + } |
| 693 | + if queued := pendingViewIntents(t, tenant, "diagrams", fourth.ID); len(queued) != 0 { |
| 694 | + t.Fatal("paused view queued a transform") |
| 695 | + } |
| 696 | + // Resuming clears the count; a success resets it after a failure. |
| 697 | + if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil { |
| 698 | + t.Fatal(err) |
| 699 | + } |
| 700 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 701 | + fifth := signedEvent(t, testOwnerSecret, 1, now+4, nil, "```mermaid\nE\n```") |
| 702 | + if err := publishAs(t, tenant, fifth); err != nil { |
| 703 | + t.Fatal(err) |
| 704 | + } |
| 705 | + runPendingView(t, tenant, "diagrams", fifth.ID) |
| 706 | + state() |
| 707 | + if failures != 0 || enabled != 1 || status != "ok" { |
| 708 | + t.Fatalf("after success: %d %d %q", failures, enabled, status) |
| 709 | + } |
| 710 | +} |
| 711 | + |
| 712 | +func TestCustomViewArtifactValidation(t *testing.T) { |
| 713 | + png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) |
| 714 | + for _, tc := range []struct { |
| 715 | + name string |
| 716 | + artifact viewArtifact |
| 717 | + max int |
| 718 | + want string |
| 719 | + }{ |
| 720 | + {"svg", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 1024, ""}, |
| 721 | + {"svg fragment href", viewArtifact{Type: "image/svg+xml", Body: `<svg><use href="#a"/><a xlink:href='#b'/></svg>`}, 1024, ""}, |
| 722 | + {"png", viewArtifact{Type: "image/png", Body: png}, 1024, ""}, |
| 723 | + {"png raw base64", viewArtifact{Type: "image/png", Body: strings.TrimRight(png, "=")}, 1024, ""}, |
| 724 | + {"type", viewArtifact{Type: "text/html", Body: "<p>"}, 1024, "invalid: artifact type"}, |
| 725 | + {"svg size", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 10, "invalid: artifact exceeds"}, |
| 726 | + {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"}, |
| 727 | + {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"}, |
| 728 | + {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"}, |
| 729 | + {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"}, |
| 730 | + {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"}, |
| 731 | + {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"}, |
| 732 | + {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"}, |
| 733 | + {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"}, |
| 734 | + {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"}, |
| 735 | + {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"}, |
| 736 | + {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg references an external"}, |
| 737 | + } { |
| 738 | + _, err := checkArtifact(tc.artifact, tc.max) |
| 739 | + if tc.want == "" && err != nil { |
| 740 | + t.Errorf("%s: unexpected %v", tc.name, err) |
| 741 | + } |
| 742 | + if tc.want != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.want)) { |
| 743 | + t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want) |
| 744 | + } |
| 745 | + } |
| 746 | + if body, err := checkArtifact(viewArtifact{Type: "image/png", Body: " " + png + "\n"}, 1024); err != nil || body != png { |
| 747 | + t.Fatalf("png normalization: %q %v", body, err) |
| 748 | + } |
| 749 | +} |
| 750 | + |
| 751 | +func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) { |
| 752 | + tenant, server, added := viewTenant(t, []int{1}, map[string]any{"audience": "members"}) |
| 753 | + ctx := context.Background() |
| 754 | + member, _ := event.PublicKey(testMemberSecret) |
| 755 | + setRole(t, tenant, member, "member") |
| 756 | + now := time.Now().Unix() |
| 757 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 758 | + note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```") |
| 759 | + if err := publishAs(t, tenant, note); err != nil { |
| 760 | + t.Fatal(err) |
| 761 | + } |
| 762 | + runPendingView(t, tenant, "diagrams", note.ID) |
| 763 | + hash := views.Hash("mermaid", "A") |
| 764 | + if _, ok := storedArtifact(t, tenant, "diagrams", hash); ok { |
| 765 | + t.Fatal("a members-only artifact became a stored record") |
| 766 | + } |
| 767 | + var raw string |
| 768 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT raw FROM custom_view_artifacts WHERE view='diagrams' AND hash=?", hash).Scan(&raw); err != nil { |
| 769 | + t.Fatal(err) |
| 770 | + } |
| 771 | + signed, err := event.Parse([]byte(raw)) |
| 772 | + if err != nil || signed.PubKey != tenant.records.PublicKey() || event.Tag(signed, "d") != "bind.ws/view/diagrams/"+hash { |
| 773 | + t.Fatalf("members artifact record = %+v %v", signed, err) |
| 774 | + } |
| 775 | + path := "/views/diagrams/" + hash + ".svg" |
| 776 | + if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusUnauthorized { |
| 777 | + t.Fatalf("guest: %d", res.Code) |
| 778 | + } |
| 779 | + if res := getArtifact(t, tenant, path, strings.Repeat("7", 64)); res.Code != http.StatusForbidden { |
| 780 | + t.Fatalf("outsider: %d", res.Code) |
| 781 | + } |
| 782 | + for _, secret := range []string{testMemberSecret, testOwnerSecret} { |
| 783 | + res := getArtifact(t, tenant, path, secret) |
| 784 | + if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" { |
| 785 | + t.Fatalf("member: %d %v", res.Code, res.Header()) |
| 786 | + } |
| 787 | + } |
| 788 | + // A public view on a members-only relay follows the read rule. |
| 789 | + p := tenant.Policy() |
| 790 | + p.Reads = "members" |
| 791 | + if err := tenant.applyPolicy(ctx, p); err != nil { |
| 792 | + t.Fatal(err) |
| 793 | + } |
| 794 | + addView(t, tenant, map[string]any{"name": "open", "kinds": []int{1}, "transform": added["transform"], "languages": []string{"dot"}}) |
| 795 | + other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```dot\nB\n```") |
| 796 | + if err := publishAs(t, tenant, other); err != nil { |
| 797 | + t.Fatal(err) |
| 798 | + } |
| 799 | + runPendingView(t, tenant, "open", other.ID) |
| 800 | + openPath := "/views/open/" + views.Hash("dot", "B") + ".svg" |
| 801 | + if res := getArtifact(t, tenant, openPath, ""); res.Code != http.StatusUnauthorized && res.Code != http.StatusForbidden { |
| 802 | + t.Fatalf("guest on a members relay: %d", res.Code) |
| 803 | + } |
| 804 | + if res := getArtifact(t, tenant, openPath, testMemberSecret); res.Code != http.StatusOK { |
| 805 | + t.Fatalf("member on a members relay: %d %s", res.Code, res.Body.String()) |
| 806 | + } |
| 807 | +} |
| 808 | + |
| 809 | +func TestCustomViewHourlyTickQueuesRecentSources(t *testing.T) { |
| 810 | + tenant, server, _ := viewTenant(t, []int{1}, map[string]any{"trigger": "hourly"}) |
| 811 | + ctx := context.Background() |
| 812 | + now := time.Now().Unix() |
| 813 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 814 | + note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```") |
| 815 | + if err := publishAs(t, tenant, note); err != nil { |
| 816 | + t.Fatal(err) |
| 817 | + } |
| 818 | + if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 { |
| 819 | + t.Fatal("an hourly view queued at write time") |
| 820 | + } |
| 821 | + if err := tenant.tickCustomViews(ctx, now); err != nil { |
| 822 | + t.Fatal(err) |
| 823 | + } |
| 824 | + queued := pendingViewIntents(t, tenant, "diagrams", note.ID) |
| 825 | + if len(queued) != 1 || !strings.HasPrefix(queued[0].EventID, note.ID+"@") { |
| 826 | + t.Fatalf("hourly queue = %+v", queued) |
| 827 | + } |
| 828 | + runViewIntent(t, tenant, queued[0]) |
| 829 | + if _, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "A")); !ok { |
| 830 | + t.Fatal("hourly artifact missing") |
| 831 | + } |
| 832 | + // Not due again for an hour; a backfill through runcustomview queues now. |
| 833 | + if err := tenant.tickCustomViews(ctx, now+60); err != nil { |
| 834 | + t.Fatal(err) |
| 835 | + } |
| 836 | + if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 { |
| 837 | + t.Fatal("hourly view queued again within the hour") |
| 838 | + } |
| 839 | + ran, err := tenant.Execute(ctx, tenant.Policy().Owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}) |
| 840 | + if err != nil || ran.(map[string]any)["queued"] != 1 { |
| 841 | + t.Fatalf("runcustomview: %v %v", ran, err) |
| 842 | + } |
| 843 | + queued = pendingViewIntents(t, tenant, "diagrams", note.ID) |
| 844 | + if len(queued) != 1 { |
| 845 | + t.Fatalf("backfill queue = %+v", queued) |
| 846 | + } |
| 847 | + runViewIntent(t, tenant, queued[0]) |
| 848 | + if server.count() != 1 { |
| 849 | + t.Fatalf("backfill re-rendered a block with an artifact: %d requests", server.count()) |
| 850 | + } |
| 851 | +} |
| 852 | + |
| 853 | +func TestCustomViewRendersTheReadmeAtTheStateHead(t *testing.T) { |
| 854 | + tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil) |
| 855 | + ctx := context.Background() |
| 856 | + p := tenant.Policy() |
| 857 | + p.Features.Grasp = true |
| 858 | + if err := tenant.applyPolicy(ctx, p); err != nil { |
| 859 | + t.Fatal(err) |
| 860 | + } |
| 861 | + owner := tenant.Policy().Owner |
| 862 | + 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"}}, "") |
| 863 | + if err := publishAs(t, tenant, announcement); err != nil { |
| 864 | + t.Fatal(err) |
| 865 | + } |
| 866 | + source := filepath.Join(t.TempDir(), "source.git") |
| 867 | + gitTest(t, "init", "--bare", source) |
| 868 | + work := filepath.Join(t.TempDir(), "work") |
| 869 | + gitTest(t, "clone", source, work) |
| 870 | + gitTest(t, "-C", work, "config", "user.email", "test@example.com") |
| 871 | + gitTest(t, "-C", work, "config", "user.name", "Test") |
| 872 | + 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 { |
| 873 | + t.Fatal(err) |
| 874 | + } |
| 875 | + gitTest(t, "-C", work, "add", "README.md") |
| 876 | + gitTest(t, "-C", work, "commit", "-m", "readme") |
| 877 | + gitTest(t, "-C", work, "branch", "-M", "main") |
| 878 | + sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD")) |
| 879 | + state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "") |
| 880 | + if err := publishAs(t, tenant, state); err != nil { |
| 881 | + t.Fatal(err) |
| 882 | + } |
| 883 | + if queued := pendingViewIntents(t, tenant, "diagrams", state.ID); len(queued) != 0 { |
| 884 | + t.Fatal("a state was queued before its objects arrived") |
| 885 | + } |
| 886 | + relay := httptest.NewServer(tenant) |
| 887 | + defer relay.Close() |
| 888 | + gitTest(t, "-C", work, "push", relay.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main") |
| 889 | + blocks, err := tenant.sourceBlocks(ctx, state) |
| 890 | + if err != nil { |
| 891 | + t.Fatal(err) |
| 892 | + } |
| 893 | + if len(blocks) != 2 || blocks[1].Index != 1 || blocks[1].Lang != "mermaid" || blocks[1].Source != "graph LR; a-->b;" { |
| 894 | + t.Fatalf("readme blocks = %+v", blocks) |
| 895 | + } |
| 896 | + queued := pendingViewIntents(t, tenant, "diagrams", state.ID) |
| 897 | + if len(queued) != 1 { |
| 898 | + t.Fatalf("state intents after push = %d", len(queued)) |
| 899 | + } |
| 900 | + server.answer(http.StatusOK, svgArtifact(1)) |
| 901 | + runViewIntent(t, tenant, queued[0]) |
| 902 | + var request map[string]any |
| 903 | + if err := json.Unmarshal(server.requests[0], &request); err != nil { |
| 904 | + t.Fatal(err) |
| 905 | + } |
| 906 | + coordinate := "30618:" + owner + ":docs" |
| 907 | + if source := request["source"].(map[string]any); source["id"] != coordinate || source["kind"] != float64(30618) { |
| 908 | + t.Fatalf("state source = %v", source) |
| 909 | + } |
| 910 | + if blocks := request["blocks"].([]any); len(blocks) != 1 || blocks[0].(map[string]any)["index"] != float64(1) { |
| 911 | + t.Fatalf("state blocks = %v", blocks) |
| 912 | + } |
| 913 | + artifact, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "graph LR; a-->b;")) |
| 914 | + if !ok || event.Tag(artifact, "a") != coordinate || event.Tag(artifact, "e") != "" || event.Tag(artifact, "block") != "1" { |
| 915 | + t.Fatalf("state artifact = %+v %v", artifact, ok) |
| 916 | + } |
| 917 | +} |
| 918 | + |
| 919 | +func TestMCPCustomViewTools(t *testing.T) { |
| 920 | + app, tenant := testTenant(t) |
| 921 | + member, _ := event.PublicKey(testMemberSecret) |
| 922 | + setRole(t, tenant, member, "member") |
| 923 | + call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) { |
| 924 | + t.Helper() |
| 925 | + w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp") |
| 926 | + if w.Code != http.StatusOK { |
| 927 | + t.Fatalf("%s: %d %s", name, w.Code, w.Body.String()) |
| 928 | + } |
| 929 | + return mcpToolResult(t, response) |
| 930 | + } |
| 931 | + structured := func(result map[string]any) any { |
| 932 | + value, _ := result["structuredContent"].(map[string]any) |
| 933 | + return value["result"] |
| 934 | + } |
| 935 | + text := func(result map[string]any) string { |
| 936 | + content, _ := result["content"].([]any) |
| 937 | + if len(content) == 0 { |
| 938 | + return "" |
| 939 | + } |
| 940 | + return content[0].(map[string]any)["text"].(string) |
| 941 | + } |
| 942 | + _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp") |
| 943 | + names := map[string]bool{} |
| 944 | + for _, tool := range response.Result.(map[string]any)["tools"].([]any) { |
| 945 | + names[tool.(map[string]any)["name"].(string)] = true |
| 946 | + } |
| 947 | + for _, want := range []string{"list_custom_views", "add_custom_view", "remove_custom_view", "pause_custom_view", "resume_custom_view", "run_custom_view"} { |
| 948 | + if !names[want] { |
| 949 | + t.Fatalf("missing tool %s", want) |
| 950 | + } |
| 951 | + } |
| 952 | + definition := map[string]any{"name": "diagrams", "kinds": []any{1.0, 30023.0}, "transform": "https://render.example/tiny", "languages": []any{"mermaid"}} |
| 953 | + result, isError := call("add_custom_view", definition, testMemberSecret) |
| 954 | + if !isError || !strings.Contains(text(result), "restricted") { |
| 955 | + t.Fatalf("add_custom_view as member: %v %s", isError, text(result)) |
| 956 | + } |
| 957 | + result, isError = call("add_custom_view", definition, "") |
| 958 | + added, _ := structured(result).(map[string]any) |
| 959 | + secret, _ := added["secret"].(string) |
| 960 | + if isError || len(secret) != 64 || added["name"] != "diagrams" || added["trigger"] != "write" { |
| 961 | + t.Fatalf("add_custom_view: %v %s", isError, text(result)) |
| 962 | + } |
| 963 | + definition["name"], definition["secret"], definition["trigger"], definition["audience"], definition["max_bytes"] = "charts", "shared-secret-value-1234", "hourly", "members", 4096.0 |
| 964 | + result, isError = call("add_custom_view", definition, "") |
| 965 | + if pasted, _ := structured(result).(map[string]any); isError || pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != float64(4096) { |
| 966 | + t.Fatalf("add_custom_view with a secret: %v %s", isError, text(result)) |
| 967 | + } |
| 968 | + result, isError = call("list_custom_views", nil, "") |
| 969 | + rows, _ := structured(result).([]any) |
| 970 | + if isError || len(rows) != 2 || strings.Contains(text(result), secret) || strings.Contains(text(result), "shared-secret-value-1234") { |
| 971 | + t.Fatalf("list_custom_views: %v %s", isError, text(result)) |
| 972 | + } |
| 973 | + if result, isError = call("list_custom_views", nil, testMemberSecret); !isError { |
| 974 | + t.Fatalf("list_custom_views as member: %s", text(result)) |
| 975 | + } |
| 976 | + result, isError = call("pause_custom_view", map[string]any{"name": "diagrams"}, "") |
| 977 | + if paused, _ := structured(result).(map[string]any); isError || paused["enabled"] != false { |
| 978 | + t.Fatalf("pause_custom_view: %v %s", isError, text(result)) |
| 979 | + } |
| 980 | + result, isError = call("resume_custom_view", map[string]any{"name": "diagrams"}, "") |
| 981 | + if resumed, _ := structured(result).(map[string]any); isError || resumed["enabled"] != true { |
| 982 | + t.Fatalf("resume_custom_view: %v %s", isError, text(result)) |
| 983 | + } |
| 984 | + result, isError = call("run_custom_view", map[string]any{"name": "diagrams"}, "") |
| 985 | + if ran, _ := structured(result).(map[string]any); isError || ran["queued"] != float64(0) { |
| 986 | + t.Fatalf("run_custom_view: %v %s", isError, text(result)) |
| 987 | + } |
| 988 | + result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "") |
| 989 | + if removed, _ := structured(result).(map[string]any); isError || removed["removed"] != true { |
| 990 | + t.Fatalf("remove_custom_view: %v %s", isError, text(result)) |
| 991 | + } |
| 992 | + result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "") |
| 993 | + if !isError || !strings.Contains(text(result), "not found") { |
| 994 | + t.Fatalf("remove_custom_view twice: %v %s", isError, text(result)) |
| 995 | + } |
| 996 | +} |
| 997 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 998 | index 9b33471..6767a26 100644 |
| 999 | --- a/internal/daemon/custom_views_transform.go |
| 1000 | +++ b/internal/daemon/custom_views_transform.go |
| 1001 | @@ -323,9 +323,11 @@ func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, pay |
| 1002 | return err |
| 1003 | } |
| 1004 | now := time.Now().Unix() |
| 1005 | - key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt))) |
| 1006 | + // The intent id names the source and run; earlier attempts add "#n". |
| 1007 | + source, _, _ := strings.Cut(intent.EventID, "#") |
| 1008 | + key := sha256.Sum256([]byte(viewTransform + "\x00" + source + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt))) |
| 1009 | return t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 1010 | - _, 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) |
| 1011 | + _, 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) |
| 1012 | return err |
| 1013 | }) |
| 1014 | } |
| 1015 | @@ -686,12 +688,13 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error { |
| 1016 | if err != nil { |
| 1017 | return nil |
| 1018 | } |
| 1019 | - rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name) |
| 1020 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT hash, raw FROM custom_view_artifacts WHERE view=?`, name) |
| 1021 | if err != nil { |
| 1022 | return err |
| 1023 | } |
| 1024 | - type stale struct{ hash string } |
| 1025 | - var refresh []stale |
| 1026 | + // Read the rows out before asking about sources: the store lends one |
| 1027 | + // connection at a time. |
| 1028 | + tagged := map[string][]string{} |
| 1029 | for rows.Next() { |
| 1030 | var hash, raw string |
| 1031 | if err := rows.Scan(&hash, &raw); err != nil { |
| 1032 | @@ -702,28 +705,28 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error { |
| 1033 | if json.Unmarshal([]byte(raw), &signed) != nil { |
| 1034 | continue |
| 1035 | } |
| 1036 | - var tagged []string |
| 1037 | + sources := []string{} |
| 1038 | for _, tag := range signed.Tags { |
| 1039 | if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") { |
| 1040 | - tagged = append(tagged, tag[1]) |
| 1041 | + sources = append(sources, tag[1]) |
| 1042 | } |
| 1043 | } |
| 1044 | + tagged[hash] = sources |
| 1045 | + } |
| 1046 | + if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1047 | + return err |
| 1048 | + } |
| 1049 | + for hash, sources := range tagged { |
| 1050 | current, err := t.artifactSources(ctx, name, hash) |
| 1051 | if err != nil { |
| 1052 | - rows.Close() |
| 1053 | return err |
| 1054 | } |
| 1055 | - sort.Strings(tagged) |
| 1056 | + sort.Strings(sources) |
| 1057 | sort.Strings(current) |
| 1058 | - if strings.Join(tagged, "\n") != strings.Join(current, "\n") { |
| 1059 | - refresh = append(refresh, stale{hash}) |
| 1060 | + if strings.Join(sources, "\n") == strings.Join(current, "\n") { |
| 1061 | + continue |
| 1062 | } |
| 1063 | - } |
| 1064 | - if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1065 | - return err |
| 1066 | - } |
| 1067 | - for _, item := range refresh { |
| 1068 | - if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil { |
| 1069 | + if err := t.signArtifact(ctx, view, hash, time.Now().Unix()); err != nil { |
| 1070 | return err |
| 1071 | } |
| 1072 | } |
| 1073 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 1074 | index c19d55e..d71c16c 100644 |
| 1075 | --- a/internal/daemon/mcp_http.go |
| 1076 | +++ b/internal/daemon/mcp_http.go |
| 1077 | @@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) { |
| 1078 | "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.", |
| 1079 | "- 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+"`.", |
| 1080 | "- Methods: server/discover, tools/list and tools/call.", |
| 1081 | - "- 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.", |
| 1082 | - "- 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.", |
| 1083 | + "- 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.", |
| 1084 | + "- 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.", |
| 1085 | "- 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.", |
| 1086 | "", |
| 1087 | "## Files", |
| 1088 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 1089 | index cddea04..19a9244 100644 |
| 1090 | --- a/internal/daemon/mcp_tools.go |
| 1091 | +++ b/internal/daemon/mcp_tools.go |
| 1092 | @@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 1093 | 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) { |
| 1094 | return t.mcpManage(ctx, call, "listcallbacks") |
| 1095 | }) |
| 1096 | + 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) { |
| 1097 | + return t.mcpManage(ctx, call, "listcustomviews") |
| 1098 | + }) |
| 1099 | 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) { |
| 1100 | return t.mcpManage(ctx, call, "listjoinrequests") |
| 1101 | }) |
| 1102 | @@ -164,6 +167,21 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 1103 | 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) { |
| 1104 | return t.mcpManage(ctx, call, "addcallback", call.Arguments) |
| 1105 | }) |
| 1106 | + 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) { |
| 1107 | + return t.mcpManage(ctx, call, "addcustomview", call.Arguments) |
| 1108 | + }) |
| 1109 | + 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) { |
| 1110 | + return t.mcpManage(ctx, call, "removecustomview", call.String("name")) |
| 1111 | + }) |
| 1112 | + 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) { |
| 1113 | + return t.mcpManage(ctx, call, "pausecustomview", call.String("name")) |
| 1114 | + }) |
| 1115 | + 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) { |
| 1116 | + return t.mcpManage(ctx, call, "resumecustomview", call.String("name")) |
| 1117 | + }) |
| 1118 | + 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) { |
| 1119 | + return t.mcpManage(ctx, call, "runcustomview", call.String("name")) |
| 1120 | + }) |
| 1121 | 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) { |
| 1122 | return t.mcpManage(ctx, call, "removecallback", call.String("id")) |
| 1123 | }) |
| 1124 | diff --git a/internal/webui/custom_views_test.go b/internal/webui/custom_views_test.go |
| 1125 | new file mode 100644 |
| 1126 | index 0000000..f6e5d73 |
| 1127 | --- /dev/null |
| 1128 | +++ b/internal/webui/custom_views_test.go |
| 1129 | @@ -0,0 +1,172 @@ |
| 1130 | +package webui |
| 1131 | + |
| 1132 | +import ( |
| 1133 | + "context" |
| 1134 | + "encoding/json" |
| 1135 | + "net/http" |
| 1136 | + "net/http/httptest" |
| 1137 | + "net/url" |
| 1138 | + "strings" |
| 1139 | + "testing" |
| 1140 | + |
| 1141 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 1142 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 1143 | + "github.com/FelineStateMachine/tinyrelay/internal/wiki" |
| 1144 | +) |
| 1145 | + |
| 1146 | +// viewsBackend reports two custom views and lists them for the owner. |
| 1147 | +type viewsBackend struct { |
| 1148 | + fakeBackend |
| 1149 | + views []views.View |
| 1150 | + rows []map[string]any |
| 1151 | +} |
| 1152 | + |
| 1153 | +func (b *viewsBackend) CustomViews() []views.View { return b.views } |
| 1154 | + |
| 1155 | +func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 1156 | + if method == "listcustomviews" { |
| 1157 | + if actor != b.policy.Owner { |
| 1158 | + return nil, context.Canceled |
| 1159 | + } |
| 1160 | + return b.rows, nil |
| 1161 | + } |
| 1162 | + if method == "browserepo" { |
| 1163 | + var request map[string]any |
| 1164 | + if len(params) == 1 { |
| 1165 | + _ = json.Unmarshal(params[0], &request) |
| 1166 | + } |
| 1167 | + if request["view"] == "file" { |
| 1168 | + return map[string]any{"content": "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n"}, nil |
| 1169 | + } |
| 1170 | + return map[string]any{"identifier": "notes", "entries": []any{}}, nil |
| 1171 | + } |
| 1172 | + return b.fakeBackend.Query(ctx, method, params, actor) |
| 1173 | +} |
| 1174 | + |
| 1175 | +const mermaidSource = "graph TD;\n A-->B & <C>;" |
| 1176 | + |
| 1177 | +func mermaidFigure(view string) string { |
| 1178 | + 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-->B & <C>;</code></pre></object></figure>` |
| 1179 | +} |
| 1180 | + |
| 1181 | +func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) { |
| 1182 | + renderer := views.NewRenderer([]views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}) |
| 1183 | + source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n" |
| 1184 | + got := string(renderMarkdownWith(source, renderer.Block)) |
| 1185 | + wantAll(t, "markdown", got, mermaidFigure("diagrams"), `<pre><code data-lang="go">func main() {}</code></pre>`) |
| 1186 | + if strings.Contains(got, "class=") { |
| 1187 | + t.Fatal("markdown carries a class attribute") |
| 1188 | + } |
| 1189 | + // Without a renderer, every fenced block stays code. |
| 1190 | + plain := string(renderMarkdown(source)) |
| 1191 | + wantAll(t, "plain markdown", plain, `<pre><code data-lang="mermaid">graph TD;`) |
| 1192 | + wantNone(t, "plain markdown", plain, "<figure", "<object") |
| 1193 | + // Chat keeps the figure and links the bare URL outside it. |
| 1194 | + chat := string(renderChatMarkdownWith(source, renderer.Block)) |
| 1195 | + wantAll(t, "chat", chat, mermaidFigure("diagrams"), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>`) |
| 1196 | + // README rendering maps relative links and keeps the figure. |
| 1197 | + query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}} |
| 1198 | + repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block)) |
| 1199 | + wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repo?owner=alice&path=docs%2Fguide.md&ref=main&repo=notes&view=file"`) |
| 1200 | + // The wiki renderer takes the same renderer through its options. |
| 1201 | + article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block})) |
| 1202 | + wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>") |
| 1203 | + if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n")); strings.Contains(plainWiki, "<figure") || !strings.Contains(plainWiki, `<code data-lang="mermaid">`) { |
| 1204 | + t.Fatalf("wiki without options: %s", plainWiki) |
| 1205 | + } |
| 1206 | +} |
| 1207 | + |
| 1208 | +func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) { |
| 1209 | + owner := strings.Repeat("a", 64) |
| 1210 | + backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}} |
| 1211 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }}) |
| 1212 | + if err != nil { |
| 1213 | + t.Fatal(err) |
| 1214 | + } |
| 1215 | + source := "```mermaid\n" + mermaidSource + "\n```" |
| 1216 | + for name, got := range map[string]string{ |
| 1217 | + "markdown": string(app.markdown(source)), |
| 1218 | + "chat": string(app.chatMarkdown(source)), |
| 1219 | + "wiki": string(app.wikiHTML(source)), |
| 1220 | + } { |
| 1221 | + wantAll(t, name, got, mermaidFigure("diagrams")) |
| 1222 | + } |
| 1223 | + // The repository home renders the README through the same renderer, |
| 1224 | + // and the tenant prefix reaches the object address. |
| 1225 | + recorder := httptest.NewRecorder() |
| 1226 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner="+owner+"&repo=notes&view=home", nil)) |
| 1227 | + body := recorder.Body.String() |
| 1228 | + if recorder.Code != http.StatusOK { |
| 1229 | + t.Fatalf("repo home: %d %s", recorder.Code, body) |
| 1230 | + } |
| 1231 | + hash := views.Hash("mermaid", "graph TD; a-->b;") |
| 1232 | + figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></object></figure>` |
| 1233 | + wantAll(t, "repo home", body, figure, `href="/repo?owner=`) |
| 1234 | + if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) { |
| 1235 | + t.Fatalf("tenant prefix missed the object address: %s", prefixed) |
| 1236 | + } |
| 1237 | + // A backend without the summary keeps code blocks as code. |
| 1238 | + plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }}) |
| 1239 | + if err != nil { |
| 1240 | + t.Fatal(err) |
| 1241 | + } |
| 1242 | + if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure") { |
| 1243 | + t.Fatalf("figure without a summary: %s", got) |
| 1244 | + } |
| 1245 | + backend.views = nil |
| 1246 | + if got := string(app.markdown(source)); strings.Contains(got, "<figure") { |
| 1247 | + t.Fatalf("figure without views: %s", got) |
| 1248 | + } |
| 1249 | +} |
| 1250 | + |
| 1251 | +func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) { |
| 1252 | + owner := strings.Repeat("a", 64) |
| 1253 | + backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{ |
| 1254 | + {"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"}, |
| 1255 | + {"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"}, |
| 1256 | + }} |
| 1257 | + app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }}) |
| 1258 | + if err != nil { |
| 1259 | + t.Fatal(err) |
| 1260 | + } |
| 1261 | + get := func(actor string) string { |
| 1262 | + t.Helper() |
| 1263 | + request := httptest.NewRequest(http.MethodGet, "/manage/views", nil) |
| 1264 | + request.Header.Set("X-Actor", actor) |
| 1265 | + recorder := httptest.NewRecorder() |
| 1266 | + app.ServeHTTP(recorder, request) |
| 1267 | + if recorder.Code != http.StatusOK { |
| 1268 | + t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String()) |
| 1269 | + } |
| 1270 | + body := recorder.Body.String() |
| 1271 | + if strings.Contains(body, "class=") { |
| 1272 | + t.Fatal("views page carries a class attribute") |
| 1273 | + } |
| 1274 | + if strings.Contains(body, "ยท") { |
| 1275 | + t.Fatal("views page contains a middle dot") |
| 1276 | + } |
| 1277 | + return body |
| 1278 | + } |
| 1279 | + body := get(owner) |
| 1280 | + wantAll(t, "/manage/views", body, |
| 1281 | + `<rpc-form method="listviews">`, `<rpc-form method="publishview">`, |
| 1282 | + `<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>`, |
| 1283 | + `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`, |
| 1284 | + `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`, |
| 1285 | + `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`, |
| 1286 | + `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`, |
| 1287 | + `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`, |
| 1288 | + `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`, |
| 1289 | + `<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"`, |
| 1290 | + `<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>`) |
| 1291 | + wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`) |
| 1292 | + // A member keeps the built-in controls and sees no custom view section. |
| 1293 | + member := get(strings.Repeat("b", 64)) |
| 1294 | + wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`) |
| 1295 | + wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form") |
| 1296 | + // The owner without views sees the empty note and the form. |
| 1297 | + backend.rows = nil |
| 1298 | + empty := get(owner) |
| 1299 | + wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`) |
| 1300 | + wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`) |
| 1301 | +} |