- commit
- 0921e2e8e5816f1484742a0311eeae5786dd2dc0
- parent
- 66e4007b240540b0f73ef363b24aa64151cdc7fe
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 5 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 1 | + |
| A | internal/ | 341 | ++++++++++++++++++++++++++++ |
| A | internal/ | 346 | ++++++++++++++++++++++++++++ |
| A | internal/ | 369 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
@@ -30,7 +30,7 @@ type clientBrowseRequest struct {
3030 }3131 3232 func clientBrowseMethod(method string) bool {33- return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)33+ return wikiBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)3434 }3535 3636 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -54,6 +54,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
5454 if err := t.browseRead(ctx, actor); err != nil {5555 return nil, err5656 }57+ if wikiBrowseMethod(method) {58+ return t.executeWiki(ctx, actor, method, params)59+ }5760 q := clientBrowseRequest{}5861 if len(params) > 0 {5962 if err := json.Unmarshal(params[0], &q); err != nil {@@ -659,6 +659,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
659659 _, err := t.store.Save(ctx, e, opts)660660 if err == nil {661661 t.notifyDevices(ctx, e)662+ t.notifyWikiMerge(ctx, e)662663 }663664 return err664665 }@@ -370,6 +370,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
370370 return "", err371371 }372372 t.notifyDevices(ctx, e)373+ t.notifyWikiMerge(ctx, e)373374 // Stage Git metadata before acknowledging the event. This closes the374375 // publish-ACK/receive-pack race: the signed pending refs and hook exist375376 // before a client can push objects for the state.@@ -0,0 +1,341 @@
1+package daemon2+3+// NIP-54 wiki rules that sit on top of stored events: how a merge request is4+// read, how its answer is derived from the destination author's reactions,5+// and the device notice sent when one arrives. The relay never merges on6+// its own; an accepted merge request is applied when the destination author7+// publishes a new version of their article.8+9+import (10+ "context"11+ "net/url"12+ "sort"13+ "strconv"14+ "strings"15+ "time"16+17+ "github.com/FelineStateMachine/tinyrelay/internal/event"18+ "github.com/FelineStateMachine/tinyrelay/internal/storage"19+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"20+)21+22+const (23+ kindWikiArticle = 3081824+ kindWikiMerge = 81825+ kindWikiRedirect = 3081926+ kindReaction = 727+ wikiQueryLimit = 200028+)29+30+const (31+ wikiMergeOpen = "open"32+ wikiMergeAccepted = "accepted"33+ wikiMergeRejected = "rejected"34+)35+36+type wikiRef struct {37+ Coordinate string `json:"a,omitempty"`38+ Event string `json:"e,omitempty"`39+}40+41+type wikiVersion struct {42+ ID string `json:"id"`43+ Author string `json:"author"`44+ CreatedAt int64 `json:"created_at"`45+ D string `json:"d"`46+ Title string `json:"title"`47+ Summary string `json:"summary,omitempty"`48+ Coordinate string `json:"coordinate"`49+ Likes int `json:"likes"`50+ Fork *wikiRef `json:"fork,omitempty"`51+ Defer *wikiRef `json:"defer,omitempty"`52+ Content string `json:"content,omitempty"`53+ Links []string `json:"links,omitempty"`54+}55+56+type wikiAnswer struct {57+ Status string `json:"status"`58+ AnsweredAt int64 `json:"answered_at,omitempty"`59+ Reaction string `json:"reaction,omitempty"`60+}61+62+type wikiMerge struct {63+ ID string `json:"id"`64+ Author string `json:"author"`65+ CreatedAt int64 `json:"created_at"`66+ Content string `json:"content"`67+ Target string `json:"target"`68+ TargetD string `json:"target_d"`69+ Destination string `json:"destination"`70+ Base string `json:"base,omitempty"`71+ Source string `json:"source,omitempty"`72+ wikiAnswer73+}74+75+type wikiRedirect struct {76+ ID string `json:"id"`77+ Author string `json:"author"`78+ CreatedAt int64 `json:"created_at"`79+ D string `json:"d"`80+ Target string `json:"target"`81+ TargetD string `json:"target_d"`82+ TargetAuthor string `json:"target_author"`83+}84+85+// wikiCoordinate splits a "30818:<pubkey>:<d>" reference.86+func wikiCoordinate(value string) (author, d string, ok bool) {87+ parts := strings.SplitN(value, ":", 3)88+ if len(parts) != 3 || parts[0] != strconv.Itoa(kindWikiArticle) || len(parts[1]) != 64 || parts[2] == "" {89+ return "", "", false90+ }91+ return parts[1], parts[2], true92+}93+94+func wikiVersionFrom(e event.Event, full bool) wikiVersion {95+ d := event.Tag(e, "d")96+ v := wikiVersion{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: d, Title: event.Tag(e, "title"), Summary: event.Tag(e, "summary"), Coordinate: strconv.Itoa(kindWikiArticle) + ":" + e.PubKey + ":" + d}97+ if v.Title == "" {98+ v.Title = d99+ }100+ for _, tag := range e.Tags {101+ if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") || (tag[0] != "a" && tag[0] != "e") {102+ continue103+ }104+ ref := &v.Fork105+ if tag[3] == "defer" {106+ ref = &v.Defer107+ }108+ if *ref == nil {109+ *ref = &wikiRef{}110+ }111+ if tag[0] == "a" {112+ (*ref).Coordinate = tag[1]113+ } else {114+ (*ref).Event = tag[1]115+ }116+ }117+ if full {118+ v.Content = e.Content119+ v.Links = wiki.Links(e.Content)120+ }121+ return v122+}123+124+// wikiMergeFrom reads a kind 818 request. The proposed version is the e tag125+// with the source marker, or a source tag, or the only plain e tag; when two126+// plain e tags are present the first is the base and the last the proposal.127+func wikiMergeFrom(e event.Event) wikiMerge {128+ m := wikiMerge{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, Content: e.Content, Target: event.Tag(e, "a"), wikiAnswer: wikiAnswer{Status: wikiMergeOpen}}129+ m.Destination, m.TargetD, _ = wikiCoordinate(m.Target)130+ if p := event.Tag(e, "p"); len(p) == 64 {131+ m.Destination = p132+ }133+ var plain []string134+ for _, tag := range e.Tags {135+ if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {136+ continue137+ }138+ if len(tag) >= 4 && tag[3] == "source" {139+ m.Source = tag[1]140+ } else {141+ plain = append(plain, tag[1])142+ }143+ }144+ if m.Source == "" {145+ if source := event.Tag(e, "source"); len(source) == 64 {146+ m.Source = source147+ }148+ }149+ switch {150+ case m.Source == "" && len(plain) == 1:151+ m.Source = plain[0]152+ case m.Source == "" && len(plain) > 1:153+ m.Base, m.Source = plain[0], plain[len(plain)-1]154+ case len(plain) > 0:155+ m.Base = plain[0]156+ }157+ return m158+}159+160+func wikiRedirectFrom(e event.Event) wikiRedirect {161+ r := wikiRedirect{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: event.Tag(e, "d"), Target: event.Tag(e, "a")}162+ r.TargetAuthor, r.TargetD, _ = wikiCoordinate(r.Target)163+ return r164+}165+166+// wikiMergeStatus reports how the destination author answered a merge167+// request: a NIP-25 reaction from that pubkey tagging the request with "+"168+// accepts it and "-" rejects it. The newest reaction wins.169+func (t *Tenant) wikiMergeStatus(ctx context.Context, mergeRequestID, destination string) (wikiAnswer, error) {170+ answers, err := t.wikiMergeStatuses(ctx, map[string]string{mergeRequestID: destination})171+ if err != nil {172+ return wikiAnswer{}, err173+ }174+ return answers[mergeRequestID], nil175+}176+177+// wikiMergeStatuses resolves a set of merge requests, keyed by request id178+// with the destination pubkey as value, in one query.179+func (t *Tenant) wikiMergeStatuses(ctx context.Context, destinations map[string]string) (map[string]wikiAnswer, error) {180+ answers := make(map[string]wikiAnswer, len(destinations))181+ ids := make([]string, 0, len(destinations))182+ authors := make([]string, 0, len(destinations))183+ for id, destination := range destinations {184+ answers[id] = wikiAnswer{Status: wikiMergeOpen}185+ ids = append(ids, id)186+ authors = append(authors, destination)187+ }188+ if len(ids) == 0 {189+ return answers, nil190+ }191+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})192+ if err != nil {193+ return nil, err194+ }195+ reactions := rows.Events196+ sortEventsNewestFirst(reactions)197+ for _, reaction := range reactions {198+ status := wikiReactionStatus(reaction.Content)199+ if status == "" {200+ continue201+ }202+ for _, id := range event.TagValues(reaction, "e") {203+ if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen {204+ continue205+ }206+ answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID}207+ }208+ }209+ return answers, nil210+}211+212+func wikiReactionStatus(content string) string {213+ switch strings.TrimSpace(content) {214+ case "+", "":215+ return wikiMergeAccepted216+ case "-":217+ return wikiMergeRejected218+ }219+ return ""220+}221+222+// wikiMemberLikes counts "+" reactions from the owner and members for each223+// version, matched by event id or article coordinate.224+func (t *Tenant) wikiMemberLikes(ctx context.Context, versions []wikiVersion) (map[string]int, error) {225+ likes := make(map[string]int, len(versions))226+ if len(versions) == 0 {227+ return likes, nil228+ }229+ ids := make([]string, 0, len(versions))230+ coordinates := make([]string, 0, len(versions))231+ byCoordinate := make(map[string]string, len(versions))232+ for _, v := range versions {233+ likes[v.ID] = 0234+ ids = append(ids, v.ID)235+ coordinates = append(coordinates, v.Coordinate)236+ byCoordinate[v.Coordinate] = v.ID237+ }238+ var reactions []event.Event239+ for _, tags := range []map[string][]string{{"e": ids}, {"a": coordinates}} {240+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: tags}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})241+ if err != nil {242+ return nil, err243+ }244+ reactions = append(reactions, rows.Events...)245+ }246+ members := map[string]bool{t.Policy().Owner: true}247+ counted := map[string]bool{}248+ for _, reaction := range uniqueEvents(reactions) {249+ if wikiReactionStatus(reaction.Content) != wikiMergeAccepted {250+ continue251+ }252+ if _, ok := members[reaction.PubKey]; !ok {253+ member, err := t.community.IsMember(ctx, reaction.PubKey)254+ if err != nil {255+ return nil, err256+ }257+ members[reaction.PubKey] = member258+ }259+ if !members[reaction.PubKey] {260+ continue261+ }262+ targets := event.TagValues(reaction, "e")263+ for _, coordinate := range event.TagValues(reaction, "a") {264+ if id, ok := byCoordinate[coordinate]; ok {265+ targets = append(targets, id)266+ }267+ }268+ for _, id := range targets {269+ key := reaction.PubKey + "\x00" + id270+ if _, known := likes[id]; !known || counted[key] {271+ continue272+ }273+ counted[key] = true274+ likes[id]++275+ }276+ }277+ return likes, nil278+}279+280+// wikiPreferred picks the version to show: the requested author, else the281+// tenant owner, else the most member likes, else the newest. Versions must282+// already be sorted newest first.283+func wikiPreferred(versions []wikiVersion, author, owner string) (wikiVersion, string) {284+ for _, v := range versions {285+ if author != "" && v.Author == author {286+ return v, "author"287+ }288+ }289+ for _, v := range versions {290+ if v.Author == owner {291+ return v, "owner"292+ }293+ }294+ best := versions[0]295+ for _, v := range versions[1:] {296+ if v.Likes > best.Likes {297+ best = v298+ }299+ }300+ if best.Likes > 0 {301+ return best, "reactions"302+ }303+ return versions[0], "newest"304+}305+306+func sortWikiVersions(versions []wikiVersion) {307+ sort.Slice(versions, func(i, j int) bool {308+ if versions[i].CreatedAt == versions[j].CreatedAt {309+ return versions[i].ID < versions[j].ID310+ }311+ return versions[i].CreatedAt > versions[j].CreatedAt312+ })313+}314+315+// notifyWikiMerge wakes the destination author's devices when a merge316+// request for one of their articles arrives from someone else.317+func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {318+ if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) {319+ return320+ }321+ m := wikiMergeFrom(e)322+ if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" {323+ return324+ }325+ role, err := t.community.Role(ctx, m.Destination)326+ if err != nil || role == "" {327+ return328+ }329+ if !t.pushCoalesced(m.Destination, pushReplies) {330+ return331+ }332+ title := m.TargetD333+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{m.Destination}, Tags: map[string][]string{"d": {m.TargetD}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})334+ if err == nil && len(rows.Events) == 1 {335+ title = wikiVersionFrom(rows.Events[0], false).Title336+ }337+ notice := pushNotice{recipient: m.Destination, category: pushReplies, body: "Merge request for " + excerpt(title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(m.TargetD) + "?merge=" + e.ID}338+ if err := t.enqueuePushNotice(ctx, notice); err != nil {339+ t.app.telemetry.Logger().Debug("wiki merge notification not queued", "error", err)340+ }341+}@@ -0,0 +1,346 @@
1+package daemon2+3+// Wiki browsing: the page list, one page with its versions, merge requests4+// and redirects, and one merge request. Every read passes the tenant gate5+// with the caller's session, so a members-only relay shows nothing to6+// guests, and each event is checked with CanSee before it is returned.7+8+import (9+ "context"10+ "encoding/json"11+ "errors"12+ "fmt"13+ "sort"14+ "strings"15+ "time"16+17+ "github.com/FelineStateMachine/tinyrelay/internal/event"18+ "github.com/FelineStateMachine/tinyrelay/internal/relay"19+ "github.com/FelineStateMachine/tinyrelay/internal/storage"20+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"21+)22+23+type wikiBrowseRequest struct {24+ D string `json:"d"`25+ Author string `json:"author"`26+ Version string `json:"version"`27+ ID string `json:"id"`28+ Event string `json:"event"`29+ Query string `json:"q"`30+ Cursor string `json:"cursor"`31+ Limit int `json:"limit"`32+}33+34+type wikiPageItem struct {35+ D string `json:"d"`36+ Title string `json:"title"`37+ Summary string `json:"summary,omitempty"`38+ Version wikiVersion `json:"version"`39+ Versions int `json:"versions"`40+ OpenMerges int `json:"open_merges"`41+}42+43+type wikiPage struct {44+ D string `json:"d"`45+ Title string `json:"title"`46+ Version *wikiVersion `json:"version"`47+ PreferredBy string `json:"preferred_by,omitempty"`48+ Versions []wikiVersion `json:"versions"`49+ Merges []wikiMerge `json:"merges"`50+ RedirectsTo []wikiRedirect `json:"redirects_to"`51+ RedirectsFrom []wikiRedirect `json:"redirects_from"`52+}53+54+type wikiMergeDetail struct {55+ Merge wikiMerge `json:"merge"`56+ Proposed *wikiVersion `json:"proposed"`57+ Target *wikiVersion `json:"target"`58+}59+60+func wikiBrowseMethod(method string) bool {61+ return method == "browsewiki" || method == "browsewikipage" || method == "browsewikimerge"62+}63+64+func (t *Tenant) executeWiki(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {65+ q := wikiBrowseRequest{}66+ if len(params) > 0 {67+ if err := json.Unmarshal(params[0], &q); err != nil {68+ return nil, fmt.Errorf("invalid: wiki parameters: %w", err)69+ }70+ }71+ if q.Limit <= 0 || q.Limit > 100 {72+ q.Limit = 5073+ }74+ if q.Author != "" && len(q.Author) != 64 {75+ return nil, errors.New("invalid: wiki author")76+ }77+ switch method {78+ case "browsewiki":79+ return t.browseWiki(ctx, actor, q)80+ case "browsewikipage":81+ return t.browseWikiPage(ctx, actor, q)82+ case "browsewikimerge":83+ if q.Event == "" {84+ q.Event = q.ID85+ }86+ return t.browseWikiMerge(ctx, actor, q)87+ }88+ return nil, errors.New("unsupported: browse operation")89+}90+91+// wikiQuery reads visible events for the caller's session.92+func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters ...event.Filter) ([]event.Event, error) {93+ if _, err := t.gate.Read(ctx, filters, session); err != nil {94+ return nil, err95+ }96+ var rows []event.Event97+ for _, filter := range filters {98+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: wikiQueryLimit})99+ if err != nil {100+ return nil, err101+ }102+ for _, row := range page.Events {103+ if t.gate.CanSee(ctx, row, session, nil) {104+ rows = append(rows, row)105+ }106+ }107+ }108+ rows = uniqueEvents(rows)109+ sortEventsNewestFirst(rows)110+ return rows, nil111+}112+113+func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter event.Filter) ([]wikiVersion, error) {114+ filter.Kinds = []int{kindWikiArticle}115+ rows, err := t.wikiQuery(ctx, session, filter)116+ if err != nil {117+ return nil, err118+ }119+ versions := make([]wikiVersion, 0, len(rows))120+ for _, row := range rows {121+ if event.Tag(row, "d") != "" {122+ versions = append(versions, wikiVersionFrom(row, false))123+ }124+ }125+ return versions, nil126+}127+128+// wikiMerges lists the merge requests aimed at any of the coordinates with129+// their answers, newest first.130+func (t *Tenant) wikiMerges(ctx context.Context, session relay.Session, coordinates []string) ([]wikiMerge, error) {131+ if len(coordinates) == 0 {132+ return []wikiMerge{}, nil133+ }134+ rows, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiMerge}, Tags: map[string][]string{"a": coordinates}})135+ if err != nil {136+ return nil, err137+ }138+ merges := make([]wikiMerge, 0, len(rows))139+ destinations := make(map[string]string, len(rows))140+ for _, row := range rows {141+ m := wikiMergeFrom(row)142+ if m.Destination == "" {143+ continue144+ }145+ merges = append(merges, m)146+ destinations[m.ID] = m.Destination147+ }148+ answers, err := t.wikiMergeStatuses(ctx, destinations)149+ if err != nil {150+ return nil, err151+ }152+ for i := range merges {153+ merges[i].wikiAnswer = answers[merges[i].ID]154+ }155+ return merges, nil156+}157+158+func wikiMatches(v wikiVersion, query string) bool {159+ query = strings.ToLower(strings.TrimSpace(query))160+ return query == "" || strings.Contains(strings.ToLower(v.Title), query) || strings.Contains(strings.ToLower(v.Summary), query) || strings.Contains(v.D, query)161+}162+163+func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {164+ session := browseSession(t, actor)165+ versions, err := t.wikiVersions(ctx, session, event.Filter{})166+ if err != nil {167+ return nil, err168+ }169+ pages := map[string][]wikiVersion{}170+ for _, v := range versions {171+ if v.D <= q.Cursor || !wikiMatches(v, q.Query) {172+ continue173+ }174+ pages[v.D] = append(pages[v.D], v)175+ }176+ names := make([]string, 0, len(pages))177+ for d := range pages {178+ names = append(names, d)179+ }180+ sort.Strings(names)181+ next := ""182+ if len(names) > q.Limit {183+ names = names[:q.Limit]184+ next = names[len(names)-1]185+ }186+ owner := t.Policy().Owner187+ items := make([]wikiPageItem, 0, len(names))188+ for _, d := range names {189+ page := pages[d]190+ if err := t.wikiRankVersions(ctx, page, q.Author, owner); err != nil {191+ return nil, err192+ }193+ preferred, _ := wikiPreferred(page, q.Author, owner)194+ merges, err := t.wikiMerges(ctx, session, wikiCoordinates(page))195+ if err != nil {196+ return nil, err197+ }198+ open := 0199+ for _, m := range merges {200+ if m.Status == wikiMergeOpen {201+ open++202+ }203+ }204+ items = append(items, wikiPageItem{D: d, Title: preferred.Title, Summary: preferred.Summary, Version: preferred, Versions: len(page), OpenMerges: open})205+ }206+ return map[string]any{"items": items, "next_cursor": next}, nil207+}208+209+// wikiRankVersions fills in member likes when they decide the preferred210+// version, which is only when neither an explicit author nor the owner has211+// one.212+func (t *Tenant) wikiRankVersions(ctx context.Context, versions []wikiVersion, author, owner string) error {213+ for _, v := range versions {214+ if v.Author == owner || (author != "" && v.Author == author) {215+ return nil216+ }217+ }218+ likes, err := t.wikiMemberLikes(ctx, versions)219+ if err != nil {220+ return err221+ }222+ for i := range versions {223+ versions[i].Likes = likes[versions[i].ID]224+ }225+ return nil226+}227+228+func wikiCoordinates(versions []wikiVersion) []string {229+ coordinates := make([]string, 0, len(versions))230+ for _, v := range versions {231+ coordinates = append(coordinates, v.Coordinate)232+ }233+ return coordinates234+}235+236+func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {237+ d := wiki.Normalize(q.D)238+ if d == "" {239+ return nil, errors.New("invalid: wiki page name")240+ }241+ session := browseSession(t, actor)242+ versions, err := t.wikiVersions(ctx, session, event.Filter{Tags: map[string][]string{"d": {d}}})243+ if err != nil {244+ return nil, err245+ }246+ redirects, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"d": {d}}}, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"a": wikiCoordinates(versions)}})247+ if err != nil {248+ return nil, err249+ }250+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}}251+ for _, row := range redirects {252+ r := wikiRedirectFrom(row)253+ if r.D == d && r.TargetD != "" && r.TargetD != d {254+ page.RedirectsFrom = append(page.RedirectsFrom, r)255+ } else if r.TargetD == d && r.D != d {256+ page.RedirectsTo = append(page.RedirectsTo, r)257+ }258+ }259+ if len(versions) == 0 && len(page.RedirectsFrom) == 0 {260+ return nil, errors.New("not found: wiki page")261+ }262+ if len(versions) == 0 {263+ return page, nil264+ }265+ likes, err := t.wikiMemberLikes(ctx, versions)266+ if err != nil {267+ return nil, err268+ }269+ for i := range versions {270+ versions[i].Likes = likes[versions[i].ID]271+ }272+ owner := t.Policy().Owner273+ preferred, by := wikiPreferred(versions, q.Author, owner)274+ if q.Version != "" {275+ by = "version"276+ found := false277+ for _, v := range versions {278+ if v.ID == q.Version {279+ preferred, found = v, true280+ }281+ }282+ if !found {283+ return nil, errors.New("not found: wiki version")284+ }285+ }286+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})287+ if err != nil {288+ return nil, err289+ }290+ if len(rows) == 1 {291+ full := wikiVersionFrom(rows[0], true)292+ full.Likes = preferred.Likes293+ preferred = full294+ }295+ page.Title = preferred.Title296+ page.Version = &preferred297+ page.PreferredBy = by298+ page.Merges, err = t.wikiMerges(ctx, session, wikiCoordinates(versions))299+ if err != nil {300+ return nil, err301+ }302+ return page, nil303+}304+305+func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {306+ if len(q.Event) != 64 {307+ return nil, errors.New("invalid: wiki merge request")308+ }309+ session := browseSession(t, actor)310+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{q.Event}, Kinds: []int{kindWikiMerge}, Limit: intPtr(1)})311+ if err != nil {312+ return nil, err313+ }314+ if len(rows) != 1 {315+ return nil, errors.New("not found: wiki merge request")316+ }317+ detail := wikiMergeDetail{Merge: wikiMergeFrom(rows[0])}318+ if detail.Merge.Destination == "" {319+ return nil, errors.New("not found: wiki merge request has no destination")320+ }321+ detail.Merge.wikiAnswer, err = t.wikiMergeStatus(ctx, detail.Merge.ID, detail.Merge.Destination)322+ if err != nil {323+ return nil, err324+ }325+ if detail.Merge.Source != "" {326+ proposed, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{detail.Merge.Source}, Kinds: []int{kindWikiArticle}, Limit: intPtr(1)})327+ if err != nil {328+ return nil, err329+ }330+ if len(proposed) == 1 {331+ v := wikiVersionFrom(proposed[0], true)332+ detail.Proposed = &v333+ }334+ }335+ if detail.Merge.TargetD != "" {336+ target, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{detail.Merge.Destination}, Tags: map[string][]string{"d": {detail.Merge.TargetD}}, Limit: intPtr(1)})337+ if err != nil {338+ return nil, err339+ }340+ if len(target) == 1 {341+ v := wikiVersionFrom(target[0], true)342+ detail.Target = &v343+ }344+ }345+ return detail, nil346+}@@ -0,0 +1,369 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/relay"13+)14+15+const (16+ wikiOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"17+ wikiMemberSecret = "2222222222222222222222222222222222222222222222222222222222222222"18+ wikiOtherSecret = "3333333333333333333333333333333333333333333333333333333333333333"19+ wikiThirdSecret = "4444444444444444444444444444444444444444444444444444444444444444"20+)21+22+func wikiKey(t *testing.T, secret string) string {23+ t.Helper()24+ pk, err := event.PublicKey(secret)25+ if err != nil {26+ t.Fatal(err)27+ }28+ return pk29+}30+31+func wikiPublish(t *testing.T, tenant *Tenant, secret string, kind int, createdAt int64, content string, tags ...[]string) event.Event {32+ t.Helper()33+ e := event.Event{Kind: kind, CreatedAt: createdAt, Content: content, Tags: tags}34+ if err := event.Sign(&e, secret); err != nil {35+ t.Fatal(err)36+ }37+ if _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: tenant.RelayURL()}); err != nil {38+ t.Fatalf("publish kind %d: %v", kind, err)39+ }40+ return e41+}42+43+func wikiArticle(t *testing.T, tenant *Tenant, secret, title string, createdAt int64, extra ...[]string) event.Event {44+ t.Helper()45+ tags := append([][]string{{"d", strings.ToLower(strings.ReplaceAll(title, " ", "-"))}, {"title", title}, {"summary", "Summary about " + title}}, extra...)46+ return wikiPublish(t, tenant, secret, kindWikiArticle, createdAt, "Content of "+title+" with a [[Wiki Link]].", tags...)47+}48+49+func wikiMember(t *testing.T, tenant *Tenant, pubkey string) {50+ t.Helper()51+ if _, err := tenant.community.Execute(context.Background(), tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf("%q", pubkey)), json.RawMessage(fmt.Sprintf(`{"name":"member-%s"}`, pubkey[:8]))}); err != nil {52+ t.Fatal(err)53+ }54+}55+56+func wikiCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) map[string]any {57+ t.Helper()58+ raw, err := json.Marshal(params)59+ if err != nil {60+ t.Fatal(err)61+ }62+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})63+ if err != nil {64+ t.Fatalf("%s %s: %v", method, raw, err)65+ }66+ encoded, err := json.Marshal(value)67+ if err != nil {68+ t.Fatal(err)69+ }70+ var result map[string]any71+ if err := json.Unmarshal(encoded, &result); err != nil {72+ t.Fatal(err)73+ }74+ return result75+}76+77+func wikiItems(result map[string]any, key string) []map[string]any {78+ var items []map[string]any79+ for _, item := range result[key].([]any) {80+ items = append(items, item.(map[string]any))81+ }82+ return items83+}84+85+func TestWikiPreferenceOrder(t *testing.T) {86+ _, tenant := testTenant(t)87+ owner := tenant.Policy().Owner88+ member, other, third := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret), wikiKey(t, wikiThirdSecret)89+ wikiMember(t, tenant, member)90+ wikiMember(t, tenant, third)91+ now := time.Now().Unix()92+93+ ownerBitcoin := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)94+ memberBitcoin := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)95+ otherBitcoin := wikiArticle(t, tenant, wikiOtherSecret, "Bitcoin", now-100)96+97+ memberLightning := wikiArticle(t, tenant, wikiMemberSecret, "Lightning", now-300)98+ otherLightning := wikiArticle(t, tenant, wikiOtherSecret, "Lightning", now-200)99+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-150, "+", []string{"e", memberLightning.ID})100+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-140, "", []string{"a", "30818:" + member + ":lightning"})101+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-130, "-", []string{"e", memberLightning.ID})102+ // Non-members do not count, and one member counts once per version.103+ wikiPublish(t, tenant, wikiOtherSecret, kindReaction, now-120, "+", []string{"e", otherLightning.ID})104+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-110, "+", []string{"e", memberLightning.ID})105+106+ wikiArticle(t, tenant, wikiMemberSecret, "Nostr", now-300)107+ otherNostr := wikiArticle(t, tenant, wikiOtherSecret, "Nostr", now-200)108+109+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})110+ version := page["version"].(map[string]any)111+ if version["author"] != owner || page["preferred_by"] != "owner" || version["id"] != ownerBitcoin.ID {112+ t.Fatalf("owner preference %v", page)113+ }114+ if version["content"] == "" || fmt.Sprint(version["links"]) != "[wiki-link]" {115+ t.Fatalf("preferred version content %v", version)116+ }117+ versions := wikiItems(page, "versions")118+ if len(versions) != 3 || versions[0]["id"] != otherBitcoin.ID || versions[1]["id"] != memberBitcoin.ID || versions[2]["id"] != ownerBitcoin.ID {119+ t.Fatalf("versions newest first %v", versions)120+ }121+ if _, ok := versions[0]["content"]; ok {122+ t.Fatalf("version list carries content %v", versions[0])123+ }124+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "Bitcoin", "author": member})125+ if page["version"].(map[string]any)["id"] != memberBitcoin.ID || page["preferred_by"] != "author" {126+ t.Fatalf("author preference %v", page)127+ }128+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": otherBitcoin.ID})129+ if page["version"].(map[string]any)["id"] != otherBitcoin.ID || page["preferred_by"] != "version" {130+ t.Fatalf("version selection %v", page)131+ }132+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + strings.Repeat("f", 64) + `"}`)}); err == nil {133+ t.Fatal("unknown version selected")134+ }135+136+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "lightning"})137+ version = page["version"].(map[string]any)138+ if version["id"] != memberLightning.ID || page["preferred_by"] != "reactions" || version["likes"].(float64) != 2 {139+ t.Fatalf("reaction preference %v", page)140+ }141+ for _, v := range wikiItems(page, "versions") {142+ if v["id"] == otherLightning.ID && v["likes"].(float64) != 0 {143+ t.Fatalf("non-member like counted %v", v)144+ }145+ }146+147+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "nostr"})148+ if page["version"].(map[string]any)["id"] != otherNostr.ID || page["preferred_by"] != "newest" {149+ t.Fatalf("newest preference %v", page)150+ }151+152+ list := wikiCall(t, tenant, "", "browsewiki", nil)153+ items := wikiItems(list, "items")154+ if len(items) != 3 || items[0]["d"] != "bitcoin" || items[1]["d"] != "lightning" || items[2]["d"] != "nostr" || list["next_cursor"] != "" {155+ t.Fatalf("page list %v", list)156+ }157+ if items[0]["versions"].(float64) != 3 || items[0]["version"].(map[string]any)["author"] != owner || items[1]["version"].(map[string]any)["id"] != memberLightning.ID {158+ t.Fatalf("page list preference %v", items)159+ }160+ list = wikiCall(t, tenant, "", "browsewiki", map[string]any{"author": other})161+ if wikiItems(list, "items")[0]["version"].(map[string]any)["author"] != other {162+ t.Fatalf("author preference in list %v", list)163+ }164+}165+166+func TestWikiSearchAndPagination(t *testing.T) {167+ _, tenant := testTenant(t)168+ now := time.Now().Unix()169+ names := []string{"Alpha", "Beta", "Delta", "Epsilon", "Gamma"}170+ for i, name := range names {171+ wikiArticle(t, tenant, wikiOwnerSecret, name, now-int64(i))172+ }173+ var got []string174+ cursor := ""175+ for page := 0; page < 5; page++ {176+ result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"limit": 2, "cursor": cursor})177+ for _, item := range wikiItems(result, "items") {178+ got = append(got, item["d"].(string))179+ }180+ cursor = result["next_cursor"].(string)181+ if cursor == "" {182+ break183+ }184+ }185+ if fmt.Sprint(got) != "[alpha beta delta epsilon gamma]" {186+ t.Fatalf("paginated pages = %v", got)187+ }188+ result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "about BETA"})189+ if items := wikiItems(result, "items"); len(items) != 1 || items[0]["d"] != "beta" {190+ t.Fatalf("summary search %v", result)191+ }192+ result = wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "gamm"})193+ if items := wikiItems(result, "items"); len(items) != 1 || items[0]["title"] != "Gamma" {194+ t.Fatalf("title search %v", result)195+ }196+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "zeta"}), "items"); len(items) != 0 {197+ t.Fatalf("unmatched search %v", items)198+ }199+}200+201+func TestWikiMergeRequestStatusFollowsDestinationReactions(t *testing.T) {202+ _, tenant := testTenant(t)203+ ctx := context.Background()204+ owner := tenant.Policy().Owner205+ member := wikiKey(t, wikiMemberSecret)206+ wikiMember(t, tenant, member)207+ now := time.Now().Unix()208+ original := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)209+ coordinate := "30818:" + owner + ":bitcoin"210+ fork := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-400, []string{"a", coordinate, "", "fork"}, []string{"e", original.ID, "", "fork"})211+ merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-300, "Added the block size limit", []string{"a", coordinate}, []string{"e", original.ID}, []string{"p", owner}, []string{"e", fork.ID, "", "source"})212+213+ detail := wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"id": merge.ID})214+ m := detail["merge"].(map[string]any)215+ if m["status"] != "open" || m["source"] != fork.ID || m["base"] != original.ID || m["destination"] != owner || m["target_d"] != "bitcoin" {216+ t.Fatalf("merge detail %v", m)217+ }218+ if detail["proposed"].(map[string]any)["id"] != fork.ID || detail["target"].(map[string]any)["id"] != original.ID {219+ t.Fatalf("merge versions %v", detail)220+ }221+ if proposed := detail["proposed"].(map[string]any); proposed["fork"].(map[string]any)["e"] != original.ID || proposed["fork"].(map[string]any)["a"] != coordinate {222+ t.Fatalf("fork reference %v", proposed)223+ }224+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})225+ if merges := wikiItems(page, "merges"); len(merges) != 1 || merges[0]["id"] != merge.ID || merges[0]["status"] != "open" {226+ t.Fatalf("page merges %v", page)227+ }228+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 1 {229+ t.Fatalf("open merge count %v", items)230+ }231+232+ // Only the destination author's reaction answers the request.233+ wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", merge.ID}, []string{"p", owner})234+ if answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner); err != nil || answer.Status != wikiMergeOpen {235+ t.Fatalf("foreign reaction %+v %v", answer, err)236+ }237+ accept := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", merge.ID}, []string{"p", member})238+ answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner)239+ if err != nil || answer.Status != wikiMergeAccepted || answer.AnsweredAt != now-200 || answer.Reaction != accept.ID {240+ t.Fatalf("accepted %+v %v", answer, err)241+ }242+ reject := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-100, "-", []string{"e", merge.ID})243+ answer, err = tenant.wikiMergeStatus(ctx, merge.ID, owner)244+ if err != nil || answer.Status != wikiMergeRejected || answer.Reaction != reject.ID {245+ t.Fatalf("rejected %+v %v", answer, err)246+ }247+ // Other reaction content does not answer.248+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "🤔", []string{"e", merge.ID})249+ if answer, _ := tenant.wikiMergeStatus(ctx, merge.ID, owner); answer.Status != wikiMergeRejected {250+ t.Fatalf("emoji reaction changed the answer %+v", answer)251+ }252+ detail = wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"event": merge.ID})253+ if detail["merge"].(map[string]any)["status"] != "rejected" {254+ t.Fatalf("merge detail status %v", detail)255+ }256+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 0 {257+ t.Fatalf("answered merge still open %v", items)258+ }259+ if _, err := tenant.Execute(ctx, "", "browsewikimerge", []json.RawMessage{json.RawMessage(`{"id":"` + original.ID + `"}`)}); err == nil {260+ t.Fatal("article served as a merge request")261+ }262+}263+264+func TestWikiRedirects(t *testing.T) {265+ _, tenant := testTenant(t)266+ owner := tenant.Policy().Owner267+ now := time.Now().Unix()268+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-100)269+ redirect := wikiPublish(t, tenant, wikiMemberSecret, kindWikiRedirect, now-50, "", []string{"d", "btc"}, []string{"a", "30818:" + owner + ":bitcoin"})270+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})271+ if to := wikiItems(page, "redirects_to"); len(to) != 1 || to[0]["d"] != "btc" || to[0]["id"] != redirect.ID || len(wikiItems(page, "redirects_from")) != 0 {272+ t.Fatalf("redirects to %v", page)273+ }274+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "BTC"})275+ if from := wikiItems(page, "redirects_from"); page["version"] != nil || len(from) != 1 || from[0]["target_d"] != "bitcoin" || from[0]["target_author"] != owner {276+ t.Fatalf("redirects from %v", page)277+ }278+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"missing"}`)}); err == nil || !strings.Contains(err.Error(), "not found") {279+ t.Fatalf("missing page %v", err)280+ }281+}282+283+func TestWikiMembersOnlyHidesPagesFromGuests(t *testing.T) {284+ _, tenant := testTenant(t)285+ ctx := context.Background()286+ member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)287+ wikiMember(t, tenant, member)288+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", time.Now().Unix()-10)289+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); len(items) != 1 {290+ t.Fatalf("open relay list %v", items)291+ }292+ p := tenant.Policy()293+ p.Reads = "members"294+ if err := tenant.applyPolicy(ctx, p); err != nil {295+ t.Fatal(err)296+ }297+ for _, actor := range []string{"", other} {298+ for _, method := range []string{"browsewiki", "browsewikipage", "browsewikimerge"} {299+ if _, err := tenant.Execute(ctx, actor, method, []json.RawMessage{json.RawMessage(`{"d":"bitcoin","id":"` + strings.Repeat("a", 64) + `"}`)}); err == nil {300+ t.Fatalf("%s exposed to %q", method, actor)301+ }302+ }303+ }304+ if items := wikiItems(wikiCall(t, tenant, member, "browsewiki", nil), "items"); len(items) != 1 {305+ t.Fatalf("member list %v", items)306+ }307+ if page := wikiCall(t, tenant, tenant.Policy().Owner, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"] == nil {308+ t.Fatalf("owner page %v", page)309+ }310+}311+312+func TestWikiMergeRequestWakesDestinationDevices(t *testing.T) {313+ _, tenant := testTenant(t)314+ ctx := context.Background()315+ owner := tenant.Policy().Owner316+ member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)317+ wikiMember(t, tenant, member)318+ now := time.Now().Unix()319+ for _, pubkey := range []string{owner, other} {320+ if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil {321+ t.Fatal(err)322+ }323+ }324+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin Basics", now-100)325+ // A request aimed at someone who is not a member, and one the owner326+ // sends to themselves, wake nobody.327+ wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-90, "", []string{"a", "30818:" + other + ":bitcoin-basics"}, []string{"p", other}, []string{"e", strings.Repeat("a", 64), "", "source"})328+ wikiPublish(t, tenant, wikiOwnerSecret, kindWikiMerge, now-80, "", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("b", 64), "", "source"})329+ if got := countIntents(t, tenant, notificationPush); got != 0 {330+ t.Fatalf("unexpected notifications %d", got)331+ }332+ merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-70, "Please merge", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("c", 64), "", "source"})333+ var payload string334+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {335+ t.Fatalf("notification intent: %v", err)336+ }337+ var notice pushPayload338+ if err := json.Unmarshal([]byte(payload), ¬ice); err != nil {339+ t.Fatal(err)340+ }341+ if notice.Kind != pushReplies || notice.Text != "Merge request for Bitcoin Basics" || notice.URL != "http://relay.test/wiki/bitcoin-basics?merge="+merge.ID {342+ t.Fatalf("notice %+v", notice)343+ }344+}345+346+func TestWikiMergeFromReadsEveryTagShape(t *testing.T) {347+ owner := strings.Repeat("a", 64)348+ base, source := strings.Repeat("b", 64), strings.Repeat("c", 64)349+ coordinate := "30818:" + owner + ":bitcoin"350+ cases := []struct {351+ name string352+ tags [][]string353+ base, source string354+ }{355+ {"source marker", [][]string{{"a", coordinate}, {"e", base}, {"p", owner}, {"e", source, "", "source"}}, base, source},356+ {"source tag", [][]string{{"a", coordinate}, {"e", base}, {"source", source}}, base, source},357+ {"single e", [][]string{{"a", coordinate}, {"e", source}}, "", source},358+ {"two plain e", [][]string{{"a", coordinate}, {"e", base}, {"e", source}}, base, source},359+ }360+ for _, tc := range cases {361+ m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: tc.tags})362+ if m.Base != tc.base || m.Source != tc.source || m.Destination != owner || m.TargetD != "bitcoin" || m.Status != wikiMergeOpen {363+ t.Errorf("%s: %+v", tc.name, m)364+ }365+ }366+ if m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: [][]string{{"a", "30617:" + owner + ":repo"}}}); m.Destination != "" || m.TargetD != "" {367+ t.Errorf("foreign coordinate accepted %+v", m)368+ }369+}@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
2626 return2727 }2828 }29- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true}29+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true}3030 if !allowed[method] {3131 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})3232 return