- commit
- f9b06b9bec648c98e985ca3bd7d303d5e4809ebb
- parent
- f3b260bcec34624519dda50138f1a0417da4adfd
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 34 | +++ |
| A | internal/ | 305 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 124 | ++++++++++++ |
| A | internal/ | 274 | +++++++++++++++++++++++++++ |
| A | internal/ | 171 | +++++++++++++++++ |
| A | internal/ | 173 | +++++++++++++++++ |
| M | internal/ | 278 | ++++++++++++++++++++++----- |
| A | internal/ | 25 | ++ |
| M | internal/ | 1 | - |
| M | internal/ | 4 | + |
| M | internal/ | 70 | ++++--- |
| A | internal/ | 138 | ++++++++++++++ |
| A | internal/ | 249 | ++++++++++++++++++++++++ |
| M | internal/ | 51 | +++++ |
| M | internal/ | 289 | ++++++++++++++++++++++++++++- |
| A | internal/ | 87 | +++++++++ |
@@ -0,0 +1,34 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+ "time"7+)8+9+// Network synchronization has its own lifecycle so an unavailable peer does10+// not delay local retention, view publication or upload cleanup.11+func (t *Tenant) runGitHistory(ctx context.Context) {12+ timer := time.NewTimer(time.Second)13+ defer timer.Stop()14+ for {15+ select {16+ case <-ctx.Done():17+ return18+ case <-timer.C:19+ }20+ if t.gitLiveEnabled() {21+ opCtx, done, err := t.beginOperation(ctx)22+ if err == nil {23+ passCtx, cancel := context.WithTimeout(opCtx, 5*time.Minute)24+ err = t.git.GRASPService().Tick(passCtx)25+ cancel()26+ done()27+ }28+ if err != nil && !errors.Is(err, context.Canceled) {29+ t.app.telemetry.Logger().Error("Git synchronization failed", "tenant", t.meta.Name, "error", err)30+ }31+ }32+ timer.Reset(time.Minute)33+ }34+}@@ -0,0 +1,305 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "sort"8+ "strings"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"13+ "github.com/FelineStateMachine/tinyrelay/internal/replication"14+ "github.com/FelineStateMachine/tinyrelay/internal/storage"15+)16+17+const maxGitLivePeers = 3218+19+// maxGitLiveRoots bounds the conversation roots one live subscription follows.20+const maxGitLiveRoots = 10021+22+type gitLiveWorker struct {23+ cancel context.CancelFunc24+ done chan struct{}25+ started time.Time26+}27+type gitLiveTarget struct {28+ repo gitrelay.Repository29+ target string30+}31+32+// runGitLive reconciles the current repository snapshot with live workers. A33+// worker is removed as soon as its repository, relay, feature or private-peer34+// authorization disappears. Rotation gives relays beyond the socket budget a35+// turn on every reconciliation pass.36+func (t *Tenant) runGitLive(ctx context.Context) {37+ workers := make(map[string]gitLiveWorker)38+ var cursor int39+ defer stopGitLiveWorkers(workers)40+ for {41+ if !t.gitLiveEnabled() {42+ stopGitLiveWorkers(workers)43+ workers = make(map[string]gitLiveWorker)44+ } else {45+ cursor = t.reconcileGitLive(ctx, workers, cursor)46+ }47+ timer := time.NewTimer(5 * time.Second)48+ select {49+ case <-ctx.Done():50+ timer.Stop()51+ return52+ case <-timer.C:53+ }54+ }55+}56+57+func (t *Tenant) gitLiveEnabled() bool {58+ p := t.Policy()59+ return p.Features.Grasp && p.Features.Grasp02 && t.git != nil60+}61+62+func (t *Tenant) reconcileGitLive(ctx context.Context, workers map[string]gitLiveWorker, cursor int) int {63+ desired := t.gitLiveTargets(ctx)64+ keys := make([]string, 0, len(desired))65+ for key := range desired {66+ keys = append(keys, key)67+ }68+ sort.Strings(keys)69+ if len(keys) == 0 {70+ stopGitLiveWorkers(workers)71+ return 072+ }73+ if cursor >= len(keys) {74+ cursor = 075+ }76+ allowed := gitLiveCohort(keys, cursor)77+ now := time.Now()78+ for key, worker := range workers {79+ _, wanted := desired[key]80+ _, inCohort := allowed[key]81+ if !wanted || (!inCohort && now.Sub(worker.started) >= gitLiveDwell) {82+ worker.cancel()83+ if worker.done != nil {84+ <-worker.done85+ }86+ delete(workers, key)87+ }88+ }89+ allowedKeys := make([]string, 0, len(allowed))90+ for key := range allowed {91+ allowedKeys = append(allowedKeys, key)92+ }93+ sort.Strings(allowedKeys)94+ for _, key := range allowedKeys {95+ if len(workers) >= maxGitLivePeers {96+ break97+ }98+ if _, ok := workers[key]; ok {99+ continue100+ }101+ workerCtx, cancel := context.WithCancel(ctx)102+ done := make(chan struct{})103+ workers[key] = gitLiveWorker{cancel: cancel, done: done, started: now}104+ go func(repo gitrelay.Repository, target string) {105+ defer close(done)106+ t.gitLivePeer(workerCtx, repo, target)107+ }(desired[key].repo, desired[key].target)108+ }109+ if len(keys) > maxGitLivePeers {110+ return (cursor + maxGitLivePeers) % len(keys)111+ }112+ return cursor113+}114+115+func gitLiveCohort(keys []string, cursor int) map[string]struct{} {116+ allowed := make(map[string]struct{}, maxGitLivePeers)117+ if len(keys) == 0 {118+ return allowed119+ }120+ cursor %= len(keys)121+ if cursor < 0 {122+ cursor += len(keys)123+ }124+ for i := 0; i < len(keys) && i < maxGitLivePeers; i++ {125+ allowed[keys[(cursor+i)%len(keys)]] = struct{}{}126+ }127+ return allowed128+}129+130+const gitLiveDwell = 2 * time.Minute131+132+func (t *Tenant) gitLiveTargets(ctx context.Context) map[string]gitLiveTarget {133+ result := make(map[string]gitLiveTarget)134+ for _, repo := range t.git.Repositories() {135+ targets := append([]string(nil), repo.Relays...)136+ if t.Policy().Features.Grasp03 {137+ if authors, err := t.gitLiveConversationAuthors(ctx, repo); err == nil {138+ offset := t.gitOutboxOffset(ctx, repo)139+ authors = rotateGitAuthors(authors, offset)140+ if lists, err := t.gitLocalOutboxEvents(ctx, authors); err == nil {141+ targets = append(targets, gitOutboxRelays(lists, repo.Private, t.Policy().PrivatePeers)...)142+ }143+ }144+ }145+ for _, target := range targets {146+ target = normalizeRelayGit(target)147+ if target == "" || sameRelayGit(target, t.RelayURL(), t.publicURL) || !t.gitLiveAuthorized(repo, target) {148+ continue149+ }150+ key := strings.Join([]string{repo.Owner, repo.Identifier, target, repo.EventID, fmt.Sprint(repo.Private)}, "\x00")151+ result[key] = gitLiveTarget{repo: repo, target: target}152+ }153+ }154+ return result155+}156+157+func (t *Tenant) gitLiveConversationAuthors(ctx context.Context, repo gitrelay.Repository) ([]string, error) {158+ items := make([]event.Event, 0, 256)159+ for _, filter := range gitEventFilters(repo)[1:] {160+ rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 256})161+ if err != nil {162+ return nil, err163+ }164+ items = append(items, rows.Events...)165+ }166+ return gitConversationAuthors(items, repo.Owner, repo.Maintainers), nil167+}168+169+func rotateGitAuthors(authors []string, offset int) []string {170+ if len(authors) == 0 {171+ return nil172+ }173+ offset %= len(authors)174+ if offset < 0 {175+ offset += len(authors)176+ }177+ rotated := append([]string(nil), authors[offset:]...)178+ return append(rotated, authors[:offset]...)179+}180+181+func (t *Tenant) gitLiveAuthorized(repo gitrelay.Repository, target string) bool {182+ if !repo.Private && !privatePolicy(t.Policy()) {183+ return true184+ }185+ return privatePeerMatch(target, t.Policy().PrivatePeers)186+}187+188+func stopGitLiveWorkers(workers map[string]gitLiveWorker) {189+ for key, worker := range workers {190+ worker.cancel()191+ if worker.done != nil {192+ <-worker.done193+ }194+ delete(workers, key)195+ }196+}197+198+// gitLivePeer performs bounded history overlap before opening subscriptions.199+// One subscription follows all repository and conversation filters.200+func (t *Tenant) gitLivePeer(ctx context.Context, repo gitrelay.Repository, target string) {201+ backoff := time.Second202+ for ctx.Err() == nil {203+ historyCtx, historyCancel := context.WithTimeout(ctx, 20*time.Second)204+ if err := t.gitSyncPeer(historyCtx, repo, target); err != nil && ctx.Err() == nil {205+ t.app.telemetry.Logger().Debug("Git live history overlap incomplete", "target", target, "error", err)206+ }207+ historyCancel()208+ conversation, err := t.gitConversationEvents(ctx, repo)209+ if err != nil {210+ conversation = nil211+ }212+ filters := gitLiveFiltersForRoots(repo, conversation)213+ readyCtx, readyCancel := context.WithTimeout(ctx, 5*time.Second)214+ transport, err := t.gitTargetTransport(readyCtx, repo, target)215+ readyCancel()216+ if err != nil {217+ backoff = nextGitLiveBackoff(backoff)218+ if !waitGitLive(ctx, backoff) {219+ return220+ }221+ continue222+ }223+ started := time.Now()224+ subCtx, subCancel := context.WithTimeout(ctx, 2*time.Minute)225+ err = transport.SubscribeFilters(subCtx, target, filters, func(item event.Event) error {226+ t.ingestGitLive(ctx, item)227+ return nil228+ })229+ subCancel()230+ if ctx.Err() != nil {231+ return232+ }233+ if err != nil {234+ if time.Since(started) >= 30*time.Second {235+ backoff = time.Second236+ }237+ backoff = nextGitLiveBackoff(backoff)238+ if !waitGitLive(ctx, backoff) {239+ return240+ }241+ }242+ }243+}244+245+func (t *Tenant) ingestGitLive(ctx context.Context, item event.Event) {246+ err := t.ingest(ctx, item, replication.OriginImport)247+ if errors.Is(err, storage.ErrDuplicate) || errors.Is(err, storage.ErrReplaced) {248+ return249+ }250+}251+252+func gitLiveFiltersForRoots(repo gitrelay.Repository, conversation []event.Event) []event.Filter {253+ filters := gitEventFilters(repo)254+ roots := make([]string, 0, len(conversation))255+ for _, item := range conversation {256+ if item.Kind == event.KIND_GIT_PATCH || item.Kind == event.KIND_GIT_PR || item.Kind == event.KIND_GIT_ISSUE {257+ roots = append(roots, item.ID)258+ }259+ }260+ if len(roots) == 0 {261+ return filters262+ }263+ // One subscription carries every filter, so the request stays bounded to264+ // the newest conversations. Older threads are covered by history passes.265+ if len(roots) > maxGitLiveRoots {266+ sorted := append([]event.Event(nil), conversation...)267+ sortEventsNewestFirst(sorted)268+ roots = roots[:0]269+ for _, item := range sorted {270+ if item.Kind == event.KIND_GIT_PATCH || item.Kind == event.KIND_GIT_PR || item.Kind == event.KIND_GIT_ISSUE {271+ roots = append(roots, item.ID)272+ }273+ if len(roots) == maxGitLiveRoots {274+ break275+ }276+ }277+ }278+ kinds := []int{event.KIND_GIT_PR_UPDATE, 1111, 1630, 1631, 1632, 1633}279+ return append(filters,280+ event.Filter{Kinds: kinds, Tags: map[string][]string{"e": roots}},281+ event.Filter{Kinds: kinds, Tags: map[string][]string{"E": roots}},282+ )283+}284+285+func nextGitLiveBackoff(current time.Duration) time.Duration {286+ if current >= 5*time.Minute {287+ return 5 * time.Minute288+ }289+ return current * 2290+}291+292+func waitGitLive(ctx context.Context, delay time.Duration) bool {293+ timer := time.NewTimer(delay)294+ defer timer.Stop()295+ select {296+ case <-ctx.Done():297+ return false298+ case <-timer.C:299+ return true300+ }301+}302+303+func liveFilter(repo gitrelay.Repository) event.Filter {304+ return gitEventFilters(repo)[1]305+}@@ -0,0 +1,124 @@
1+package daemon2+3+import (4+ "context"5+ "strings"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"11+)12+13+func TestGitLiveEnabledRequiresGRASP02(t *testing.T) {14+ _, tenant := testTenant(t)15+ p := tenant.Policy()16+ p.Features.Grasp = true17+ p.Features.Grasp02 = false18+ if err := tenant.applyPolicy(context.Background(), p); err != nil {19+ t.Fatal(err)20+ }21+ if tenant.gitLiveEnabled() {22+ t.Fatal("live synchronization enabled without GRASP-02")23+ }24+ p.Features.Grasp02 = true25+ if err := tenant.applyPolicy(context.Background(), p); err != nil {26+ t.Fatal(err)27+ }28+ if !tenant.gitLiveEnabled() {29+ t.Fatal("live synchronization disabled with GRASP-02")30+ }31+}32+33+func TestGitLivePrivateTargetRequiresConfiguredPeer(t *testing.T) {34+ _, tenant := testTenant(t)35+ repo := gitrelay.Repository{Private: true}36+ if tenant.gitLiveAuthorized(repo, "wss://peer.example") {37+ t.Fatal("unconfigured private target was authorized")38+ }39+ p := tenant.Policy()40+ p.Features.Grasp, p.Features.Grasp02, p.Features.Grasp08 = true, true, true41+ p.Reads = "members"42+ p.PrivatePeers = []string{"wss://peer.example"}43+ if err := tenant.applyPolicy(context.Background(), p); err != nil {44+ t.Fatal(err)45+ }46+ if !tenant.gitLiveAuthorized(repo, "wss://peer.example") {47+ t.Fatal("configured private target was rejected")48+ }49+ if tenant.gitLiveAuthorized(repo, "wss://other.example") {50+ t.Fatal("different private target was authorized")51+ }52+}53+54+func TestGitLiveFiltersIncludeConversationRootsAndReplies(t *testing.T) {55+ repo := gitrelay.Repository{Owner: strings.Repeat("a", 64), Identifier: "repo"}56+ root := event.Event{Kind: event.KIND_GIT_ISSUE, ID: strings.Repeat("b", 64)}57+ filters := gitLiveFiltersForRoots(repo, []event.Event{root})58+ if len(filters) != 5 {59+ t.Fatalf("got %d filters, want metadata, conversation and two reply filters", len(filters))60+ }61+ for _, key := range []string{"e", "E"} {62+ found := false63+ for _, filter := range filters {64+ if len(filter.Tags[key]) == 1 && filter.Tags[key][0] == root.ID {65+ found = true66+ }67+ }68+ if !found {69+ t.Fatalf("missing reply filter for %s", key)70+ }71+ }72+}73+74+func TestGitLiveCohortRespectsSocketCapAndRotates(t *testing.T) {75+ keys := make([]string, maxGitLivePeers+7)76+ for i := range keys {77+ keys[i] = strings.Repeat("a", i+1)78+ }79+ first := gitLiveCohort(keys, 0)80+ second := gitLiveCohort(keys, maxGitLivePeers)81+ if len(first) != maxGitLivePeers || len(second) != maxGitLivePeers {82+ t.Fatalf("cohort sizes = %d, %d; want %d", len(first), len(second), maxGitLivePeers)83+ }84+ _, firstHas := first[keys[maxGitLivePeers-1]]85+ _, secondHas := second[keys[maxGitLivePeers-1]]86+ if firstHas == secondHas {87+ t.Fatal("live cohort did not rotate")88+ }89+}90+91+func TestRotateGitAuthorsUsesDurableOffset(t *testing.T) {92+ authors := []string{"a", "b", "c"}93+ got := rotateGitAuthors(authors, 1)94+ if strings.Join(got, ",") != "b,c,a" {95+ t.Fatalf("rotated authors = %v", got)96+ }97+}98+99+func TestStopGitLiveWorkersCancelsAndWaits(t *testing.T) {100+ done := make(chan struct{})101+ canceled := make(chan struct{})102+ workers := map[string]gitLiveWorker{"peer": {103+ cancel: func() { close(canceled); close(done) },104+ done: done,105+ }}106+ finished := make(chan struct{})107+ go func() {108+ stopGitLiveWorkers(workers)109+ close(finished)110+ }()111+ select {112+ case <-canceled:113+ case <-time.After(time.Second):114+ t.Fatal("worker was not canceled")115+ }116+ select {117+ case <-finished:118+ case <-time.After(time.Second):119+ t.Fatal("stop returned before worker completion")120+ }121+ if len(workers) != 0 {122+ t.Fatal("stopped workers remain in the registry")123+ }124+}@@ -0,0 +1,274 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+ "net/url"7+ "sort"8+ "strconv"9+ "strings"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/replication"14+)15+16+const (17+ gitOutboxAuthorLimit = 3218+ gitOutboxRelayLimit = 2419+)20+21+// gitOutboxOptions describes the bounded, read-only part of GRASP-03. Seeds22+// are the repository's announced relays. Private repositories must also set23+// PrivatePeers; discovered public relays are never used for private work.24+type gitOutboxOptions struct {25+ Transport replication.PullTransport26+ Seeds []string27+ Private bool28+ PrivatePeers []string29+ AuthorOffset int30+ SeedOffset int31+}32+33+// discoverGitOutboxes asks announced relays for the NIP-65 lists of authors34+// who took part in a repository conversation. A failed author or seed is35+// isolated so one unavailable relay cannot prevent the remaining discussion36+// from being imported.37+func discoverGitOutboxes(ctx context.Context, options gitOutboxOptions, authors []string) ([]string, error) {38+ items, err := discoverGitOutboxEvents(ctx, options, authors)39+ result := make([]string, 0, gitOutboxRelayLimit)40+ seen := make(map[string]struct{}, gitOutboxRelayLimit)41+ for _, item := range items {42+ for _, relay := range gitOutboxRelayTags(item) {43+ if !gitOutboxAllowedRelay(relay, options.Private, options.PrivatePeers) {44+ continue45+ }46+ canonical := normalizeRelayGit(relay)47+ if _, ok := seen[canonical]; ok {48+ continue49+ }50+ seen[canonical] = struct{}{}51+ result = append(result, canonical)52+ if len(result) == gitOutboxRelayLimit {53+ return result, err54+ }55+ }56+ }57+ return result, err58+}59+60+// discoverGitOutboxEvents returns the newest valid authored list of each61+// supported kind. Callers should ingest these events with OriginImport so the62+// relay list becomes available after a restart and to normal replication.63+func discoverGitOutboxEvents(ctx context.Context, options gitOutboxOptions, authors []string) ([]event.Event, error) {64+ if options.Transport == nil {65+ return nil, errors.New("GRASP-03: outbox transport is required")66+ }67+ authors = boundedGitAuthorsFrom(authors, options.AuthorOffset)68+ seeds := boundedGitRelaysFrom(options.Seeds, options.Private, options.PrivatePeers, options.SeedOffset)69+ if len(authors) == 0 || len(seeds) == 0 {70+ return nil, nil71+ }72+ newest := make(map[string]event.Event, len(authors)*3)73+ var queryErrors []error74+ workCtx, cancel := context.WithTimeout(ctx, 5*time.Second)75+ defer cancel()76+ stop := false77+ for _, seed := range seeds {78+ for _, author := range authors {79+ for _, kind := range []int{event.KIND_PROFILE, 10002, 10317} {80+ if err := workCtx.Err(); err != nil {81+ queryErrors = append(queryErrors, err)82+ stop = true83+ break84+ }85+ items, err := options.Transport.Query(workCtx, seed, gitOutboxFilter(author, kind))86+ if err != nil {87+ if len(queryErrors) < 8 {88+ queryErrors = append(queryErrors, err)89+ }90+ continue91+ }92+ for _, item := range items {93+ if event.Validate(item) != nil || item.PubKey != author || item.Kind != kind {94+ continue95+ }96+ key := author + ":" + strconv.Itoa(item.Kind)97+ if current, ok := newest[key]; !ok || item.CreatedAt > current.CreatedAt || (item.CreatedAt == current.CreatedAt && item.ID < current.ID) {98+ newest[key] = item99+ }100+ }101+ }102+ if stop {103+ break104+ }105+ }106+ if stop {107+ break108+ }109+ }110+ result := make([]event.Event, 0, len(newest))111+ for _, item := range newest {112+ result = append(result, item)113+ }114+ sort.Slice(result, func(i, j int) bool {115+ if result[i].PubKey == result[j].PubKey {116+ return result[i].Kind < result[j].Kind117+ }118+ return result[i].PubKey < result[j].PubKey119+ })120+ return result, errors.Join(queryErrors...)121+}122+123+func gitOutboxFilter(author string, kind int) event.Filter {124+ limit := 1125+ return event.Filter{Authors: []string{author}, Kinds: []int{kind}, Tags: map[string][]string{}, Limit: &limit}126+}127+128+// gitConversationAuthors returns the repository participants whose outboxes129+// may contain replies. The caller supplies events already admitted to the130+// repository conversation, so arbitrary relay authors cannot expand the131+// discovery set.132+func gitConversationAuthors(events []event.Event, owner string, maintainers []string) []string {133+ authors := make([]string, 0, len(maintainers)+1+len(events))134+ appendAuthor := func(author string) {135+ if len(author) != 64 || strings.Trim(author, "0123456789abcdefABCDEF") != "" {136+ return137+ }138+ for _, existing := range authors {139+ if existing == author {140+ return141+ }142+ }143+ authors = append(authors, author)144+ }145+ appendAuthor(owner)146+ for _, maintainer := range maintainers {147+ appendAuthor(maintainer)148+ }149+ for _, item := range events {150+ switch item.Kind {151+ case event.KIND_GIT_PATCH, event.KIND_GIT_PR, event.KIND_GIT_PR_UPDATE, event.KIND_GIT_ISSUE, 1111, 1630, 1631, 1632, 1633:152+ appendAuthor(item.PubKey)153+ }154+ }155+ return authors156+}157+158+func boundedGitAuthors(authors []string) []string {159+ return boundedGitAuthorsFrom(authors, 0)160+}161+162+func boundedGitAuthorsFrom(authors []string, offset int) []string {163+ seen := make(map[string]struct{}, len(authors))164+ result := make([]string, 0, minGitInt(len(authors), gitOutboxAuthorLimit))165+ if offset < 0 {166+ offset = 0167+ }168+ valid := 0169+ for _, author := range authors {170+ if len(author) != 64 || strings.Trim(author, "0123456789abcdefABCDEF") != "" {171+ continue172+ }173+ if _, ok := seen[author]; ok {174+ continue175+ }176+ seen[author] = struct{}{}177+ if valid < offset {178+ valid++179+ continue180+ }181+ valid++182+ result = append(result, author)183+ if len(result) == gitOutboxAuthorLimit {184+ break185+ }186+ }187+ return result188+}189+190+func boundedGitRelays(relays []string, private bool, peers []string) []string {191+ return boundedGitRelaysFrom(relays, private, peers, 0)192+}193+194+func boundedGitRelaysFrom(relays []string, private bool, peers []string, offset int) []string {195+ seen := make(map[string]struct{}, len(relays))196+ valid := make([]string, 0, len(relays))197+ for _, relay := range relays {198+ if !gitOutboxAllowedRelay(relay, private, peers) {199+ continue200+ }201+ canonical := normalizeRelayGit(relay)202+ if _, ok := seen[canonical]; ok {203+ continue204+ }205+ seen[canonical] = struct{}{}206+ valid = append(valid, canonical)207+ }208+ if len(valid) == 0 {209+ return nil210+ }211+ start := offset % len(valid)212+ if start < 0 {213+ start = 0214+ }215+ rotated := append(append([]string(nil), valid[start:]...), valid[:start]...)216+ result := rotated217+ if len(result) > gitOutboxRelayLimit {218+ result = result[:gitOutboxRelayLimit]219+ }220+ return result221+}222+223+func gitOutboxRelayTags(item event.Event) []string {224+ name := ""225+ switch item.Kind {226+ case 10002:227+ name = "r"228+ case 10317:229+ name = "g"230+ default:231+ return nil232+ }233+ result := make([]string, 0)234+ for _, tag := range item.Tags {235+ if len(tag) < 2 || tag[0] != name {236+ continue237+ }238+ if item.Kind == 10002 && len(tag) > 2 && strings.EqualFold(tag[2], "read") {239+ continue240+ }241+ result = append(result, tag[1])242+ }243+ return result244+}245+246+func gitOutboxAllowedRelay(raw string, private bool, peers []string) bool {247+ u, err := url.Parse(strings.TrimSpace(raw))248+ if err != nil || u.User != nil || u.Fragment != "" || u.Host == "" {249+ return false250+ }251+ if u.Scheme == "http" {252+ u.Scheme = "ws"253+ } else if u.Scheme == "https" {254+ u.Scheme = "wss"255+ }256+ if (u.Scheme != "ws" && u.Scheme != "wss") || !safeGitOutboxHost(u.Hostname()) {257+ return false258+ }259+ if !private {260+ return true261+ }262+ return privatePeerMatch(u.String(), peers)263+}264+265+func safeGitOutboxHost(host string) bool {266+ return host != "" && !strings.ContainsAny(host, " \t\r\n")267+}268+269+func minGitInt(a, b int) int {270+ if a < b {271+ return a272+ }273+ return b274+}@@ -0,0 +1,171 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"11+ "github.com/FelineStateMachine/tinyrelay/internal/replication"12+ "github.com/FelineStateMachine/tinyrelay/internal/storage"13+)14+15+const gitOutboxOffsetPrefix = "grasp03.outbox-offset."16+17+type tenantGitOutboxTransport struct {18+ tenant *Tenant19+ repo gitrelay.Repository20+}21+22+func (t tenantGitOutboxTransport) Query(ctx context.Context, target string, filter event.Filter) ([]event.Event, error) {23+ requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)24+ defer cancel()25+ transport, err := t.tenant.gitTargetTransport(requestCtx, t.repo, target)26+ if err != nil {27+ return nil, err28+ }29+ return transport.Query(requestCtx, target, filter)30+}31+32+// gitOutboxSync imports participant relay lists, then asks each discovered33+// outbox for the repository conversation. List events are ingested first so34+// the discovered routing remains durable after a restart.35+func (t *Tenant) gitOutboxSync(ctx context.Context, repo gitrelay.Repository) error {36+ if t == nil || t.store == nil {37+ return errors.New("GRASP-03: tenant store is required")38+ }39+ events, err := t.gitConversationEvents(ctx, repo)40+ if err != nil {41+ return err42+ }43+ authors := gitConversationAuthors(events, repo.Owner, repo.Maintainers)44+ offset := t.gitOutboxOffset(ctx, repo)45+ selectedAuthors := boundedGitAuthorsFrom(authors, offset%maxGitInt(len(authors), 1))46+ private := repo.Private || t.PrivateServiceEnabled()47+ options := gitOutboxOptions{Transport: tenantGitOutboxTransport{tenant: t, repo: repo}, Seeds: repo.Relays, Private: private, PrivatePeers: t.Policy().PrivatePeers, AuthorOffset: 0, SeedOffset: offset}48+ listEvents, err := t.gitLocalOutboxEvents(ctx, selectedAuthors)49+ if err != nil {50+ return err51+ }52+ discovered, discoverErr := discoverGitOutboxEvents(ctx, options, selectedAuthors)53+ listEvents = mergeGitOutboxEvents(listEvents, discovered)54+ var syncErrors []error55+ if err := t.ingestGitOutboxEvents(ctx, discovered); err != nil {56+ syncErrors = append(syncErrors, err)57+ }58+ if discoverErr != nil {59+ syncErrors = append(syncErrors, discoverErr)60+ }61+ if len(authors) > 0 {62+ if err := t.store.PutSetting(ctx, gitOutboxOffsetKey(repo), (offset+gitOutboxAuthorLimit)%len(authors)); err != nil {63+ syncErrors = append(syncErrors, fmt.Errorf("GRASP-03: save outbox offset: %w", err))64+ }65+ }66+ targets := gitOutboxRelays(listEvents, options.Private, options.PrivatePeers)67+ targets = rotateGitRelays(targets, offset)68+ for _, target := range targets {69+ targetCtx, cancel := context.WithTimeout(ctx, 8*time.Second)70+ if err := t.gitSyncPeer(targetCtx, repo, target); err != nil {71+ syncErrors = append(syncErrors, fmt.Errorf("GRASP-03: sync %s: %w", target, err))72+ }73+ cancel()74+ }75+ return errors.Join(syncErrors...)76+}77+78+func (t *Tenant) gitLocalOutboxEvents(ctx context.Context, authors []string) ([]event.Event, error) {79+ result := make([]event.Event, 0, len(authors)*3)80+ for _, author := range authors {81+ for _, kind := range []int{event.KIND_PROFILE, 10002, 10317} {82+ rows, err := t.store.Query(ctx, event.Filter{Authors: []string{author}, Kinds: []int{kind}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})83+ if err != nil {84+ return nil, fmt.Errorf("GRASP-03: read cached outbox: %w", err)85+ }86+ if len(rows.Events) > 0 {87+ result = append(result, rows.Events[0])88+ }89+ }90+ }91+ return result, nil92+}93+94+func rotateGitRelays(relays []string, offset int) []string {95+ if len(relays) == 0 {96+ return nil97+ }98+ start := offset % len(relays)99+ return append(append([]string(nil), relays[start:]...), relays[:start]...)100+}101+102+func maxGitInt(a, b int) int {103+ if a > b {104+ return a105+ }106+ return b107+}108+109+func mergeGitOutboxEvents(existing, incoming []event.Event) []event.Event {110+ result := append([]event.Event(nil), existing...)111+ for _, item := range incoming {112+ found := false113+ for i := range result {114+ if result[i].PubKey != item.PubKey || result[i].Kind != item.Kind {115+ continue116+ }117+ found = true118+ if item.CreatedAt > result[i].CreatedAt || (item.CreatedAt == result[i].CreatedAt && item.ID < result[i].ID) {119+ result[i] = item120+ }121+ break122+ }123+ if !found {124+ result = append(result, item)125+ }126+ }127+ return result128+}129+130+func gitOutboxRelays(items []event.Event, private bool, peers []string) []string {131+ result := make([]string, 0, gitOutboxRelayLimit)132+ seen := make(map[string]struct{}, gitOutboxRelayLimit)133+ for _, item := range items {134+ for _, raw := range gitOutboxRelayTags(item) {135+ if !gitOutboxAllowedRelay(raw, private, peers) {136+ continue137+ }138+ canonical := normalizeRelayGit(raw)139+ if _, ok := seen[canonical]; ok {140+ continue141+ }142+ seen[canonical] = struct{}{}143+ result = append(result, canonical)144+ if len(result) == gitOutboxRelayLimit {145+ return result146+ }147+ }148+ }149+ return result150+}151+152+func (t *Tenant) ingestGitOutboxEvents(ctx context.Context, items []event.Event) error {153+ for _, item := range items {154+ if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {155+ return fmt.Errorf("GRASP-03: import outbox list: %w", err)156+ }157+ }158+ return nil159+}160+161+func (t *Tenant) gitOutboxOffset(ctx context.Context, repo gitrelay.Repository) int {162+ var offset int163+ if err := t.store.GetSetting(ctx, gitOutboxOffsetKey(repo), &offset); err != nil || offset < 0 {164+ return 0165+ }166+ return offset167+}168+169+func gitOutboxOffsetKey(repo gitrelay.Repository) string {170+ return gitOutboxOffsetPrefix + repo.Owner + ":" + repo.Identifier171+}@@ -0,0 +1,173 @@
1+package daemon2+3+import (4+ "context"5+ "fmt"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+type gitOutboxTransport struct {12+ queries map[string][]event.Event13+ errors map[string]error14+}15+16+func (t gitOutboxTransport) Query(_ context.Context, target string, _ event.Filter) ([]event.Event, error) {17+ if err := t.errors[target]; err != nil {18+ return nil, err19+ }20+ if items := t.queries[target]; items != nil {21+ return items, nil22+ }23+ return nil, nil24+}25+26+func TestGitOutboxFilterIncludesProfilesRelayListsAndRepositoryRelayLists(t *testing.T) {27+ key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"28+ filter := gitOutboxFilter(key, 10002)29+ if len(filter.Authors) != 1 || filter.Authors[0] != key {30+ t.Fatalf("authors = %#v", filter.Authors)31+ }32+ if !containsGitKind(filter.Kinds, 10002) || len(filter.Kinds) != 1 {33+ t.Fatalf("kinds = %#v", filter.Kinds)34+ }35+ if filter.Limit == nil || *filter.Limit != 1 {36+ t.Fatalf("limit = %#v", filter.Limit)37+ }38+}39+40+func TestGitOutboxAuthorsAndRelaysAreDeduplicatedAndBounded(t *testing.T) {41+ key := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"42+ authors := make([]string, 0, gitOutboxAuthorLimit+2)43+ for i := 0; i < gitOutboxAuthorLimit+2; i++ {44+ authors = append(authors, key)45+ }46+ if got := boundedGitAuthors(authors); len(got) != 1 {47+ t.Fatalf("authors = %#v", got)48+ }49+ relays := boundedGitRelays([]string{"https://Relay.EXAMPLE/", "wss://relay.example"}, false, nil)50+ if len(relays) != 1 || relays[0] != "wss://relay.example" {51+ t.Fatalf("relays = %#v", relays)52+ }53+}54+55+func TestGitOutboxPrivateDiscoveryNeverUsesUnconfiguredRelay(t *testing.T) {56+ if gitOutboxAllowedRelay("wss://other.example", true, []string{"https://peer.example/git"}) {57+ t.Fatal("unconfigured private relay was allowed")58+ }59+ if !gitOutboxAllowedRelay("wss://peer.example/git", true, []string{"https://peer.example/git"}) {60+ t.Fatal("configured private relay was rejected")61+ }62+}63+64+func TestGitOutboxRelayTagsUseWriteCapableNIP65Entries(t *testing.T) {65+ list := event.Event{Kind: 10002, Tags: [][]string{{"r", "wss://read.example", "read"}, {"r", "wss://write.example", "write"}, {"r", "wss://both.example"}}}66+ got := gitOutboxRelayTags(list)67+ if len(got) != 2 || got[0] != "wss://write.example" || got[1] != "wss://both.example" {68+ t.Fatalf("relays = %#v", got)69+ }70+}71+72+func TestGitOutboxRelayTagsUseGroupEntriesForKind10317(t *testing.T) {73+ list := event.Event{Kind: 10317, Tags: [][]string{{"r", "wss://wrong.example"}, {"g", "wss://group.example"}}}74+ got := gitOutboxRelayTags(list)75+ if len(got) != 1 || got[0] != "wss://group.example" {76+ t.Fatalf("relays = %#v", got)77+ }78+}79+80+func TestGitConversationAuthorsAreLimitedToAdmittedParticipants(t *testing.T) {81+ owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"82+ maintainer := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"83+ participant := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"84+ authors := gitConversationAuthors([]event.Event{{Kind: event.KIND_GIT_ISSUE, PubKey: participant}, {Kind: event.KIND_PROFILE, PubKey: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}}, owner, []string{maintainer})85+ if len(authors) != 3 || authors[0] != owner || authors[1] != maintainer || authors[2] != participant {86+ t.Fatalf("authors = %#v", authors)87+ }88+}89+90+func TestGitConversationAuthorsDoesNotDropParticipantsBeforeBatching(t *testing.T) {91+ events := make([]event.Event, 40)92+ for i := range events {93+ events[i] = event.Event{Kind: event.KIND_GIT_ISSUE, PubKey: fmt.Sprintf("%064x", i+1)}94+ }95+ if got := gitConversationAuthors(events, "", nil); len(got) != len(events) {96+ t.Fatalf("authors = %d, want %d", len(got), len(events))97+ }98+}99+100+func TestGitOutboxAuthorBatchRotatesPastLimit(t *testing.T) {101+ events := make([]event.Event, 40)102+ for i := range events {103+ events[i] = event.Event{Kind: event.KIND_GIT_ISSUE, PubKey: fmt.Sprintf("%064x", i+1)}104+ }105+ authors := gitConversationAuthors(events, "", nil)106+ first := boundedGitAuthorsFrom(authors, 0)107+ second := boundedGitAuthorsFrom(authors, gitOutboxAuthorLimit)108+ if len(first) != gitOutboxAuthorLimit || len(second) != len(authors)-gitOutboxAuthorLimit {109+ t.Fatalf("batch sizes = %d and %d", len(first), len(second))110+ }111+ if first[0] == second[0] {112+ t.Fatal("rotated batch repeated its first author")113+ }114+}115+116+func TestRotateGitRelaysChangesStartingPoint(t *testing.T) {117+ input := []string{"wss://one.example", "wss://two.example", "wss://three.example"}118+ got := rotateGitRelays(input, 1)119+ if len(got) != 3 || got[0] != input[1] || got[2] != input[0] {120+ t.Fatalf("rotated relays = %#v", got)121+ }122+ if input[0] != "wss://one.example" {123+ t.Fatal("rotation modified input")124+ }125+}126+127+func TestDiscoverGitOutboxesRequiresTransport(t *testing.T) {128+ _, err := discoverGitOutboxes(context.Background(), gitOutboxOptions{}, []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})129+ if err == nil || err.Error() != "GRASP-03: outbox transport is required" {130+ t.Fatalf("error = %v", err)131+ }132+}133+134+func TestDiscoverGitOutboxesIsolatesUnavailableSeeds(t *testing.T) {135+ secret, err := event.GenerateKey()136+ if err != nil {137+ t.Fatal(err)138+ }139+ item := event.Event{Kind: 10002, CreatedAt: 1, Tags: [][]string{{"r", "wss://outbox.example"}}}140+ if err := event.Sign(&item, secret); err != nil {141+ t.Fatal(err)142+ }143+ if err := event.Validate(item); err != nil {144+ t.Fatal(err)145+ }146+ if got := gitOutboxRelayTags(item); len(got) != 1 {147+ t.Fatalf("item relays = %#v", got)148+ }149+ transport := gitOutboxTransport{150+ queries: map[string][]event.Event{"wss://healthy.example": {item}},151+ errors: map[string]error{"wss://down.example": fmt.Errorf("connection refused")},152+ }153+ authors := []string{item.PubKey}154+ if got := boundedGitRelays([]string{"wss://down.example", "wss://healthy.example"}, false, nil); len(got) != 2 {155+ t.Fatalf("seed relays = %#v", got)156+ }157+ got, err := discoverGitOutboxes(context.Background(), gitOutboxOptions{Transport: transport, Seeds: []string{"wss://down.example", "wss://healthy.example"}}, authors)158+ if err == nil {159+ t.Fatal("expected isolated seed error to be reported")160+ }161+ if len(got) != 1 || got[0] != "wss://outbox.example" {162+ t.Fatalf("relays = %#v, err = %v", got, err)163+ }164+}165+166+func containsGitKind(kinds []int, wanted int) bool {167+ for _, kind := range kinds {168+ if kind == wanted {169+ return true170+ }171+ }172+ return false173+}@@ -6,83 +6,231 @@ import (
66 "net/url"77 "sort"88 "strings"9+ "time"910 1011 "github.com/FelineStateMachine/tinyrelay/internal/event"1112 "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"1213 "github.com/FelineStateMachine/tinyrelay/internal/replication"1314 "github.com/FelineStateMachine/tinyrelay/internal/storage"15+ "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"1416 )1517 16-// gitSync is the GRASP-02 callback passed to GitRelay. It repairs every17-// signed repository tip from the announcement's clone/relay sources before18-// allowing the Git relay to expose the state.18+// gitSync repairs signed branch state. Conversation history is scheduled19+// separately so a missing Git object cannot block issue or PR replication.1920 func (t *Tenant) gitSync(ctx context.Context, repo gitrelay.Repository) error {2021 if len(repo.Refs) == 0 {2122 return nil2223 }23- if len(gitrelay.RepairSources(repo)) == 0 {24- return errors.New("GRASP-02: repository has no repair source")25- }24+ repo.Private = repo.Private || t.PrivateServiceEnabled()2625 t.git.ConfigurePrivatePeers(t.Policy().PrivatePeers, t.privateHTTPAuth)2726 return t.git.FetchMissing(ctx, repo, gitrelay.RepairSources(repo), repo.Refs)2827 }2928 30-// gitEventSync is the GRASP-03 callback. It imports NIP-34 repository31-// metadata from announced relays through the hardened replication transport;32-// ordinary event policy and GitRelay admission remain the final gates.3329 func (t *Tenant) gitEventSync(ctx context.Context, repo gitrelay.Repository) error {34- if len(repo.Relays) == 0 {35- return nil30+ var result error31+ targets := boundedGitRelays(repo.Relays, repo.Private || t.PrivateServiceEnabled(), t.Policy().PrivatePeers)32+ setting := "grasp02.peer-offset." + repo.Owner + ":" + repo.Identifier33+ var offset int34+ _ = t.store.GetSetting(ctx, setting, &offset)35+ if offset < 0 {36+ offset = 03637 }37- filters := gitEventFilters(repo)38- for _, target := range repo.Relays {38+ targets = rotateGitRelays(targets, offset)39+ historyCtx, historyCancel := context.WithTimeout(ctx, 20*time.Second)40+ for index, target := range targets {41+ if historyCtx.Err() != nil {42+ break43+ }3944 if sameRelayGit(target, t.RelayURL(), t.publicURL) {4045 continue4146 }42- transport := &replication.NostrTransport{Dialer: t.gitEventDialer(ctx, target)}43- seen := make(map[string]struct{})44- roots := make([]string, 0, 16)45- for _, filter := range filters {46- items, err := transport.Query(ctx, target, filter)47- if err != nil {48- return err49- }50- sort.SliceStable(items, func(i, j int) bool {51- return metadataEventOrder(items[i].Kind) < metadataEventOrder(items[j].Kind)52- })53- for _, item := range items {54- if _, ok := seen[item.ID]; ok {55- continue56- }57- seen[item.ID] = struct{}{}58- if item.Kind == event.KIND_GIT_PATCH || item.Kind == event.KIND_GIT_PR || item.Kind == event.KIND_GIT_ISSUE {59- roots = append(roots, item.ID)60- }61- if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {62- return err63- }47+ // Persist the next starting peer before a bounded network attempt.48+ // An unavailable peer cannot monopolize every subsequent pass.49+ result = errors.Join(result, t.store.PutSetting(ctx, setting, (offset+index+1)%len(targets)))50+ peerCtx, cancel := context.WithTimeout(historyCtx, 8*time.Second)51+ result = errors.Join(result, t.gitSyncPeer(peerCtx, repo, target))52+ cancel()53+ }54+ historyCancel()55+ repairCtx, repairCancel := context.WithTimeout(ctx, 5*time.Second)56+ result = errors.Join(result, t.repairGitPullRequests(repairCtx, repo))57+ repairCancel()58+ if t.Policy().Features.Grasp03 {59+ outboxCtx, outboxCancel := context.WithTimeout(ctx, 15*time.Second)60+ result = errors.Join(result, t.gitOutboxSync(outboxCtx, repo))61+ outboxCancel()62+ }63+ return withoutBudgetDeadline(ctx, result)64+}65+66+// withoutBudgetDeadline drops deadlines from the pass's own time budgets.67+// Peer offsets and outbox offsets persist between passes, so running out of68+// time leaves the repository incomplete rather than failed and keeps it on the69+// regular cadence. Cancellation of the caller's context still propagates.70+func withoutBudgetDeadline(parent context.Context, err error) error {71+ if err == nil || parent.Err() != nil {72+ return err73+ }74+ if joined, ok := err.(interface{ Unwrap() []error }); ok {75+ var kept []error76+ for _, item := range joined.Unwrap() {77+ if item = withoutBudgetDeadline(parent, item); item != nil {78+ kept = append(kept, item)6479 }6580 }81+ return errors.Join(kept...)82+ }83+ if errors.Is(err, context.DeadlineExceeded) {84+ return nil85+ }86+ return err87+}88+89+// gitTargetTransport checks the privacy boundary before sending any filters.90+// An unverified private peer never falls back to an anonymous connection.91+func (t *Tenant) gitTargetTransport(ctx context.Context, repo gitrelay.Repository, target string) (*replication.NostrTransport, error) {92+ p := t.Policy()93+ if !p.Features.Grasp || !p.Features.Grasp02 {94+ return nil, errors.New("GRASP synchronization is disabled")95+ }96+ if !gitOutboxAllowedRelay(target, repo.Private || t.PrivateServiceEnabled(), p.PrivatePeers) {97+ return nil, errors.New("GRASP synchronization target is not allowed")98+ }99+ if repo.Private || t.PrivateServiceEnabled() {100+ if !t.PrivateServiceEnabled() || !privatePeerMatch(target, p.PrivatePeers) || !t.privatePeerReady(ctx, target) {101+ return nil, errors.New("private peer is not ready")102+ }103+ }104+ return &replication.NostrTransport{Dialer: t.gitEventDialer(repo.Private || t.PrivateServiceEnabled()), Timeout: 5 * time.Second}, nil105+}106+107+func (t *Tenant) gitSyncPeer(ctx context.Context, repo gitrelay.Repository, target string) error {108+ transport, err := t.gitTargetTransport(ctx, repo, target)109+ if err != nil {110+ return err111+ }112+ var result error113+ for _, filter := range gitEventFilters(repo) {114+ result = errors.Join(result, t.gitPullFilter(ctx, transport, target, filter))115+ }116+ events, err := t.gitConversationEvents(ctx, repo)117+ if err != nil {118+ return errors.Join(result, err)119+ }120+ for _, filter := range gitReplyFilters(events) {121+ result = errors.Join(result, t.gitPullFilter(ctx, transport, target, filter))122+ }123+ return result124+}125+126+func (t *Tenant) gitPullFilter(ctx context.Context, transport *replication.NostrTransport, target string, filter event.Filter) error {127+ local, err := t.localSyncItems(ctx, filter)128+ if err != nil {129+ return err130+ }131+ items, pullErr := transport.QuerySynchronized(ctx, target, filter, local)132+ sort.SliceStable(items, func(i, j int) bool { return metadataEventOrder(items[i].Kind) < metadataEventOrder(items[j].Kind) })133+ for _, item := range items {134+ if ctx.Err() != nil {135+ return errors.Join(pullErr, ctx.Err())136+ }137+ if !event.Matches(filter, item) || event.Validate(item) != nil {138+ continue139+ }140+ // Rejected remote events must not prevent later valid events from arriving.141+ if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {142+ t.app.telemetry.Logger().Debug("Git sync event rejected", "kind", item.Kind, "error", err)143+ }144+ }145+ return pullErr146+}147+148+func (t *Tenant) localSyncItems(ctx context.Context, filter event.Filter) ([]syncprotocol.Item, error) {149+ rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10001})150+ if err != nil {151+ return nil, err152+ }153+ if rows.More {154+ return nil, replication.ErrPullIncomplete155+ }156+ items := make([]syncprotocol.Item, 0, len(rows.Events))157+ for _, item := range rows.Events {158+ items = append(items, syncprotocol.Item{ID: item.ID, Timestamp: item.CreatedAt})159+ }160+ return items, nil161+}162+163+func (t *Tenant) gitConversationEvents(ctx context.Context, repo gitrelay.Repository) ([]event.Event, error) {164+ var items []event.Event165+ for _, filter := range gitEventFilters(repo)[1:] {166+ rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})167+ if err != nil {168+ return nil, err169+ }170+ if rows.More {171+ return nil, replication.ErrPullIncomplete172+ }173+ items = append(items, rows.Events...)174+ }175+ roots := uniqueEvents(items)176+ for _, filter := range gitReplyFilters(roots) {177+ rows, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10000})178+ if err != nil {179+ return nil, err180+ }181+ if rows.More {182+ return nil, replication.ErrPullIncomplete183+ }184+ items = append(items, rows.Events...)185+ }186+ return uniqueEvents(items), nil187+}188+189+func gitReplyFilters(items []event.Event) []event.Filter {190+ var ids []string191+ for _, item := range items {192+ if item.Kind == 1617 || item.Kind == 1618 || item.Kind == 1621 {193+ ids = append(ids, item.ID)194+ }195+ }196+ var filters []event.Filter197+ for start := 0; start < len(ids); start += 100 {198+ end := start + 100199+ if end > len(ids) {200+ end = len(ids)201+ }66202 for _, key := range []string{"e", "E"} {67- if len(roots) == 0 {68- break69- }70- items, err := transport.Query(ctx, target, event.Filter{Kinds: []int{event.KIND_GIT_PR_UPDATE, 1111, 1630, 1631, 1632, 1633}, Tags: map[string][]string{key: roots}})71- if err != nil {72- return err73- }74- for _, item := range items {75- if _, ok := seen[item.ID]; ok {76- continue77- }78- seen[item.ID] = struct{}{}79- if err := t.ingest(ctx, item, replication.OriginImport); err != nil && !errors.Is(err, storage.ErrDuplicate) && !errors.Is(err, storage.ErrReplaced) {80- return err81- }203+ filters = append(filters, event.Filter{Kinds: []int{1619, 1111, 1630, 1631, 1632, 1633}, Tags: map[string][]string{key: ids[start:end]}})204+ }205+ }206+ return filters207+}208+209+func (t *Tenant) repairGitPullRequests(ctx context.Context, repo gitrelay.Repository) error {210+ repo.Private = repo.Private || t.PrivateServiceEnabled()211+ items, err := t.gitConversationEvents(ctx, repo)212+ if err != nil {213+ return err214+ }215+ t.git.ConfigurePrivatePeers(t.Policy().PrivatePeers, t.privateHTTPAuth)216+ var result error217+ for _, root := range items {218+ if root.Kind != 1618 {219+ continue220+ }221+ set := gitrelay.PullRequestRepair{Root: root}222+ for _, update := range items {223+ if update.Kind == 1619 && update.PubKey == root.PubKey && event.Tag(update, "E") == root.ID && event.Tag(update, "P") == root.PubKey {224+ set.Updates = append(set.Updates, update)82225 }83226 }227+ err := t.git.RepairPullRequestObjects(ctx, repo, set)228+ result = errors.Join(result, err)229+ if ctx.Err() != nil {230+ return errors.Join(result, ctx.Err())231+ }84232 }85- return nil233+ return result86234 }87235 88236 func metadataEventOrder(kind int) int {@@ -92,9 +240,9 @@ func metadataEventOrder(kind int) int {
92240 return 193241 }94242 95-func (t *Tenant) gitEventDialer(ctx context.Context, target string) replication.WebsocketDialer {243+func (t *Tenant) gitEventDialer(private bool) replication.WebsocketDialer {96244 dialer := replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}97- if !t.privatePeerReady(ctx, target) {245+ if !private {98246 return dialer99247 }100248 dialer.Authenticate = func(authCtx context.Context, relayURL, challenge string) (event.Event, error) {@@ -135,5 +283,29 @@ func normalizeRelayGit(raw string) string {
135283 case "https":136284 u.Scheme = "wss"137285 }286+ u.Host = strings.ToLower(u.Host)138287 return strings.TrimRight(u.String(), "/")139288 }289+290+func uniqueEvents(rows []event.Event) []event.Event {291+ seen := make(map[string]struct{}, len(rows))292+ result := make([]event.Event, 0, len(rows))293+ for _, row := range rows {294+ if _, ok := seen[row.ID]; ok {295+ continue296+ }297+ seen[row.ID] = struct{}{}298+ result = append(result, row)299+ }300+ return result301+}302+303+// sortEventsNewestFirst matches the stable storage order used by cursors.304+func sortEventsNewestFirst(rows []event.Event) {305+ sort.Slice(rows, func(i, j int) bool {306+ if rows[i].CreatedAt == rows[j].CreatedAt {307+ return rows[i].ID < rows[j].ID308+ }309+ return rows[i].CreatedAt > rows[j].CreatedAt310+ })311+}@@ -0,0 +1,25 @@
1+package daemon2+3+import (4+ "context"5+ "testing"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"8+)9+10+func TestGitTargetTransportRequiresFeatureAndPrivatePeer(t *testing.T) {11+ _, tenant := testTenant(t)12+ repo := gitrelay.Repository{Owner: tenant.Policy().Owner, Identifier: "test"}13+ if _, err := tenant.gitTargetTransport(context.Background(), repo, "wss://unconfigured.example"); err == nil {14+ t.Fatal("disabled sync opened a transport")15+ }16+ p := tenant.Policy()17+ p.Features.Grasp, p.Features.Grasp02, p.Features.Grasp08 = true, true, true18+ p.Reads = "members"19+ if err := tenant.applyPolicy(context.Background(), p); err != nil {20+ t.Fatal(err)21+ }22+ if _, err := tenant.gitTargetTransport(context.Background(), repo, "wss://unconfigured.example"); err == nil {23+ t.Fatal("private repository could send filters to an unconfigured peer")24+ }25+}@@ -50,7 +50,6 @@ func (t *Tenant) runScheduler() {
5050 }5151 report("retention", t.sweep(ctx, now.Unix()))5252 report("restore-state", t.sweepReplicationState(ctx, now.Unix()))53- report("git-sync", t.git.GRASPService().Tick(ctx))5453 nextMaintenance = now.Add(time.Minute)5554 }5655 next = nextMaintenance@@ -137,6 +137,10 @@ func (t *Tenant) initServices(ctx context.Context) error {
137137 }()138138 t.workWG.Add(1)139139 go func() { defer t.workWG.Done(); t.runScheduler() }()140+ t.workWG.Add(1)141+ go func() { defer t.workWG.Done(); t.runGitLive(t.workCtx) }()142+ t.workWG.Add(1)143+ go func() { defer t.workWG.Done(); t.runGitHistory(t.workCtx) }()140144 return nil141145 }142146 @@ -150,7 +150,7 @@ func (g *GitRelay) ValidateImported(ctx context.Context, e event.Event) (Reposit
150150 151151 const (152152 graspRetryDelay = int64(5 * 60)153- graspSuccessDelay = int64(60 * 60)153+ graspSuccessDelay = int64(55 * 60)154154 )155155 156156 type tickProgress struct {@@ -236,54 +236,60 @@ func (s *Service) Tick(ctx context.Context) error {
236236 var last string237237 progress := tickProgress{At: time.Now().Unix(), Repos: make(map[string]tickRepository, len(repos))}238238 previousProgress, _ := s.loadProgress()239+ // Resume after the last attempted repository if the prior pass ran out240+ // of time. Preserve untouched statuses instead of marking them failed.241+ last = previousProgress.LastKey242+ for _, r := range repos {243+ if previous, ok := previousProgress.Repos[key(r.Owner, r.Identifier)]; ok {244+ progress.Repos[key(r.Owner, r.Identifier)] = previous245+ }246+ }247+ start := sort.Search(len(repos), func(i int) bool { return key(repos[i].Owner, repos[i].Identifier) > last })248+ repos = append(repos[start:], repos[:start]...)239249 var tickErr error240250 for _, r := range repos {241- last = key(r.Owner, r.Identifier)251+ if ctx.Err() != nil {252+ tickErr = errors.Join(tickErr, ctx.Err())253+ break254+ }255+ repoKey := key(r.Owner, r.Identifier)242256 status := tickRepository{AttemptedAt: progress.At, Attempts: 1}243257 profile := s.relay.policy()244258 status.Fingerprint = repoFingerprint(r, profile)245- if previous, ok := previousProgress.Repos[last]; ok {259+ if previous, ok := previousProgress.Repos[repoKey]; ok {246260 if previous.NextAt > progress.At && previous.Fingerprint == status.Fingerprint {247- progress.Repos[last] = previous261+ progress.Repos[repoKey] = previous248262 continue249263 }250264 if previous.Error != "" {251265 status.Attempts = previous.Attempts + 1252266 }253267 }268+ last = repoKey254269 features := profile.Features255- if features.Grasp02 && s.relay.gitSync != nil {256- if err := s.relay.gitSync(ctx, r); err != nil {257- status.Error = err.Error()258- status.NextAt = progress.At + graspRetryDelay259- tickErr = errors.Join(tickErr, fmt.Errorf("%s Git sync: %w", last, err))260- progress.Repos[last] = status261- continue270+ var repoErr error271+ if features.Grasp && features.Grasp02 {272+ if s.relay.eventSync != nil {273+ eventCtx, cancel := context.WithTimeout(ctx, 40*time.Second)274+ repoErr = errors.Join(repoErr, s.relay.eventSync(eventCtx, r))275+ cancel()262276 }263- } else if features.Grasp02 && len(r.Refs) > 0 && len(RepairSources(r)) > 0 {264- // Native fallback for self-hosted deployments: fetch and verify the265- // signed tips without requiring a hosted-worker callback.266- if err := s.relay.FetchMissing(ctx, r, RepairSources(r), r.Refs); err != nil {267- // A source may be temporarily unavailable; leave the persisted268- // cursor intact and let the next tick retry.269- status.Error = err.Error()270- status.NextAt = progress.At + graspRetryDelay271- tickErr = errors.Join(tickErr, fmt.Errorf("%s Git sync: %w", last, err))272- progress.Repos[last] = status273- continue277+ gitCtx, cancel := context.WithTimeout(ctx, 20*time.Second)278+ if s.relay.gitSync != nil {279+ repoErr = errors.Join(repoErr, s.relay.gitSync(gitCtx, r))280+ } else if len(r.Refs) > 0 && len(RepairSources(r)) > 0 {281+ repoErr = errors.Join(repoErr, s.relay.FetchMissing(gitCtx, r, RepairSources(r), r.Refs))274282 }283+ cancel()275284 }276- if features.Grasp03 && s.relay.eventSync != nil {277- if err := s.relay.eventSync(ctx, r); err != nil {278- status.Error = err.Error()279- status.NextAt = progress.At + graspRetryDelay280- tickErr = errors.Join(tickErr, fmt.Errorf("%s event sync: %w", last, err))281- progress.Repos[last] = status282- continue283- }285+ if repoErr != nil {286+ status.Error = repoErr.Error()287+ status.NextAt = progress.At + graspRetryDelay288+ tickErr = errors.Join(tickErr, fmt.Errorf("%s synchronization: %w", last, repoErr))289+ } else {290+ status.SucceededAt = progress.At291+ status.NextAt = progress.At + graspSuccessDelay284292 }285- status.SucceededAt = progress.At286- status.NextAt = progress.At + graspSuccessDelay287293 progress.Repos[last] = status288294 }289295 progress.LastKey = last@@ -0,0 +1,138 @@
1+package gitrelay2+3+import (4+ "context"5+ "errors"6+ "fmt"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+// PullRequestRepair groups a PR with its later tip updates. The event store12+// remains the authority for these events; this value only describes the Git13+// objects that GRASP-02 should fetch for them.14+type PullRequestRepair struct {15+ Root event.Event16+ Updates []event.Event17+}18+19+// RepairPullRequestObjects fetches the commits named by a NIP-34 PR and its20+// updates into the supplied hosted repository.21+//22+// Updates are accepted only from the PR author and must point to the root PR.23+// This prevents an unrelated event from supplying a ref to a hosted PR.24+func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Repository, set PullRequestRepair) error {25+ root, err := pullRequestTarget(set.Root, event.KIND_GIT_PR)26+ if err != nil {27+ return err28+ }29+ if !hasRepositoryCoordinate(set.Root, hosted) {30+ return errors.New("blocked: pull request targets another repository")31+ }32+ // The hosted repository owns its path and access policy. PR events only33+ // contribute temporary refs and clone sources.34+ root = hosted35+ root.Alternative = false36+ sources := cloneURLs(set.Root)37+ refs := map[string]string{"refs/nostr/" + set.Root.ID: event.Tag(set.Root, "c")}38+ for _, update := range set.Updates {39+ if err := validatePullRequestUpdate(set.Root, update); err != nil {40+ return err41+ }42+ commit := event.Tag(update, "c")43+ if commit == "" {44+ continue45+ }46+ refs["refs/nostr/"+update.ID] = commit47+ sources = append(sources, cloneURLs(update)...)48+ }49+ if len(sources) == 0 {50+ return errors.New("GRASP-02: pull request has no clone source")51+ }52+ sources = uniqueStrings(sources)53+ if err := g.FetchMissing(ctx, root, sources, refs); err != nil {54+ return fmt.Errorf("GRASP-02 pull request repair: %w", err)55+ }56+ return nil57+}58+59+func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {60+ want := "30617:" + repo.Owner + ":" + repo.Identifier61+ for _, tag := range pr.Tags {62+ if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == want {63+ return true64+ }65+ }66+ return false67+}68+69+func cloneURLs(e event.Event) []string {70+ var out []string71+ for _, tag := range e.Tags {72+ if len(tag) > 1 && tag[0] == "clone" {73+ out = append(out, tag[1:]...)74+ }75+ }76+ return out77+}78+79+func pullRequestTarget(raw event.Event, kind int) (Repository, error) {80+ if err := event.Validate(raw); err != nil {81+ return Repository{}, err82+ }83+ if raw.Kind != kind {84+ return Repository{}, fmt.Errorf("unsupported: event kind %d", raw.Kind)85+ }86+ commit := event.Tag(raw, "c")87+ if !isObjectID(commit) {88+ return Repository{}, errors.New("invalid: pull request commit is not a SHA-1")89+ }90+ return Repository{91+ Owner: raw.PubKey,92+ Identifier: raw.ID,93+ EventID: raw.ID,94+ Alternative: true,95+ Clone: cloneURLs(raw),96+ Refs: map[string]string{"refs/nostr/" + raw.ID: commit},97+ }, nil98+}99+100+func validatePullRequestUpdate(root, update event.Event) error {101+ if err := event.Validate(update); err != nil {102+ return err103+ }104+ if update.Kind != event.KIND_GIT_PR_UPDATE {105+ return fmt.Errorf("unsupported: event kind %d is not a pull request update", update.Kind)106+ }107+ parent := event.Tag(update, "E")108+ if parent == "" {109+ parent = event.Tag(update, "e")110+ }111+ if parent != root.ID {112+ return errors.New("blocked: pull request update does not reference its root")113+ }114+ declaredAuthor := event.Tag(update, "P")115+ if declaredAuthor != root.PubKey {116+ return errors.New("blocked: pull request update author tag does not match root")117+ }118+ if update.PubKey != root.PubKey {119+ return errors.New("blocked: pull request update signer is not the PR author")120+ }121+ if !isObjectID(event.Tag(update, "c")) {122+ return errors.New("invalid: pull request update commit is not a SHA-1")123+ }124+ return nil125+}126+127+func uniqueStrings(values []string) []string {128+ seen := make(map[string]struct{}, len(values))129+ out := make([]string, 0, len(values))130+ for _, value := range values {131+ if _, ok := seen[value]; ok {132+ continue133+ }134+ seen[value] = struct{}{}135+ out = append(out, value)136+ }137+ return out138+}@@ -0,0 +1,249 @@
1+package gitrelay2+3+import (4+ "context"5+ "crypto/x509"6+ "encoding/pem"7+ "net/http"8+ "net/http/httptest"9+ "os"10+ "os/exec"11+ "path/filepath"12+ "strings"13+ "testing"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/event"16+ "github.com/FelineStateMachine/tinyrelay/internal/policy"17+ "github.com/FelineStateMachine/tinyrelay/internal/storage"18+)19+20+func TestPullRequestTargetUsesEventIDForStandardNIP34(t *testing.T) {21+ secret := strings.Repeat("0", 63) + "1"22+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "change", Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}23+ if err := event.Sign(&root, secret); err != nil {24+ t.Fatal(err)25+ }26+ r, err := pullRequestTarget(root, event.KIND_GIT_PR)27+ if err != nil {28+ t.Fatal(err)29+ }30+ if r.Identifier != root.ID || r.Refs["refs/nostr/"+root.ID] != strings.Repeat("a", 40) {31+ t.Fatalf("standard PR target = %#v", r)32+ }33+}34+35+func TestPullRequestUpdateRequiresRootAuthor(t *testing.T) {36+ rootSecret := strings.Repeat("0", 63) + "1"37+ otherSecret := strings.Repeat("0", 63) + "2"38+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}39+ if err := event.Sign(&root, rootSecret); err != nil {40+ t.Fatal(err)41+ }42+ update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", root.PubKey}, {"c", strings.Repeat("b", 40)}, {"clone", "https://git.example/repo.git"}}}43+ if err := event.Sign(&update, otherSecret); err != nil {44+ t.Fatal(err)45+ }46+ if err := validatePullRequestUpdate(root, update); err == nil {47+ t.Fatal("accepted update signed by another author")48+ }49+}50+51+func TestPullRequestRepairRejectsMissingCloneBeforeGit(t *testing.T) {52+ secret := strings.Repeat("0", 63) + "1"53+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}}}54+ owner, err := event.PublicKey(secret)55+ if err != nil {56+ t.Fatal(err)57+ }58+ root.Tags[0][1] = "30617:" + owner + ":repo"59+ if err := event.Sign(&root, secret); err != nil {60+ t.Fatal(err)61+ }62+ g := &GitRelay{}63+ hosted := Repository{Owner: root.PubKey, Identifier: "repo", Refs: map[string]string{}}64+ err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})65+ if err == nil || !strings.Contains(err.Error(), "no clone source") {66+ t.Fatalf("missing clone error = %v", err)67+ }68+}69+70+func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {71+ rootDir := t.TempDir()72+ remote := filepath.Join(rootDir, "remote", "repo.git")73+ work := filepath.Join(rootDir, "work")74+ prGitRun(t, "init", "--bare", remote)75+ prGitRun(t, "init", work)76+ prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")77+ prGitRun(t, "-C", work, "config", "user.name", "PR")78+ if err := os.WriteFile(filepath.Join(work, "README"), []byte("one\n"), 0600); err != nil {79+ t.Fatal(err)80+ }81+ prGitRun(t, "-C", work, "add", "README")82+ prGitRun(t, "-C", work, "commit", "-m", "one")83+ first := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))84+ prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")85+ if err := os.WriteFile(filepath.Join(work, "NEXT"), []byte("two\n"), 0600); err != nil {86+ t.Fatal(err)87+ }88+ prGitRun(t, "-C", work, "add", "NEXT")89+ prGitRun(t, "-C", work, "commit", "-m", "two")90+ second := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))91+ prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/pr")92+93+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, filepath.Dir(remote)) }))94+ t.Cleanup(server.Close)95+ cert, err := x509.ParseCertificate(server.Certificate().Raw)96+ if err != nil {97+ t.Fatal(err)98+ }99+ ca := filepath.Join(rootDir, "ca.pem")100+ if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {101+ t.Fatal(err)102+ }103+ t.Setenv("GIT_SSL_CAINFO", ca)104+ store, err := storage.Open(context.Background(), filepath.Join(rootDir, "relay.db"))105+ if err != nil {106+ t.Fatal(err)107+ }108+ t.Cleanup(func() { _ = store.Close() })109+ p := policy.Defaults("")110+ g, err := New(Config{Store: store, Root: filepath.Join(rootDir, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})111+ if err != nil {112+ t.Fatal(err)113+ }114+ secret := strings.Repeat("0", 63) + "1"115+ owner, err := event.PublicKey(secret)116+ if err != nil {117+ t.Fatal(err)118+ }119+ clone := server.URL + "/repo.git"120+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "PR", Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", first}, {"clone", clone}}}121+ if err := event.Sign(&root, secret); err != nil {122+ t.Fatal(err)123+ }124+ update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}125+ if err := event.Sign(&update, secret); err != nil {126+ t.Fatal(err)127+ }128+ hosted := Repository{Owner: owner, Identifier: "repo", Clone: []string{clone}, Refs: map[string]string{"refs/heads/main": first}}129+ if err := g.FetchMissing(context.Background(), hosted, hosted.Clone, hosted.Refs); err != nil {130+ t.Fatal(err)131+ }132+ if g.objectsPresent(context.Background(), hosted, []string{second}) {133+ t.Fatal("PR update was already present before repair; test must exercise a real fetch")134+ }135+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root, Updates: []event.Event{update}}); err != nil {136+ t.Fatal(err)137+ }138+ gitDir := filepath.Join(rootDir, "git", owner, "repo.git")139+ for ref, want := range map[string]string{"refs/heads/main": first, "refs/nostr/" + root.ID: first, "refs/nostr/" + update.ID: second} {140+ got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", ref))141+ if got != want {142+ t.Fatalf("%s = %s, want %s", ref, got, want)143+ }144+ }145+ if _, err := os.Stat(filepath.Join(gitDir, "tinyrelay.refs")); !os.IsNotExist(err) {146+ t.Fatalf("PR repair wrote signed state file: %v", err)147+ }148+}149+150+func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {151+ var requests int152+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {153+ requests++154+ w.WriteHeader(http.StatusInternalServerError)155+ }))156+ t.Cleanup(server.Close)157+ root := t.TempDir()158+ cert, err := x509.ParseCertificate(server.Certificate().Raw)159+ if err != nil {160+ t.Fatal(err)161+ }162+ ca := filepath.Join(root, "ca.pem")163+ if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {164+ t.Fatal(err)165+ }166+ t.Setenv("GIT_SSL_CAINFO", ca)167+ store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))168+ if err != nil {169+ t.Fatal(err)170+ }171+ t.Cleanup(func() { _ = store.Close() })172+ p := policy.Defaults("")173+ p.Reads = "members"174+ g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})175+ if err != nil {176+ t.Fatal(err)177+ }178+ repo := Repository{Owner: strings.Repeat("a", 64), Identifier: "private", Clone: []string{server.URL + "/repo.git"}, Refs: map[string]string{"refs/heads/main": strings.Repeat("b", 40)}}179+ err = g.FetchMissing(context.Background(), repo, repo.Clone, repo.Refs)180+ if err == nil {181+ t.Fatal("private-policy repair unexpectedly succeeded")182+ }183+ if requests != 0 {184+ t.Fatalf("private-policy repair contacted unconfigured source %d time(s)", requests)185+ }186+}187+188+func TestGRASPTickResumesAfterCanceledRepository(t *testing.T) {189+ p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true}}190+ owner := strings.Repeat("a", 64)191+ first := Repository{Owner: owner, Identifier: "a", Refs: map[string]string{}}192+ second := Repository{Owner: owner, Identifier: "b", Refs: map[string]string{}}193+ var calls []string194+ var cancel context.CancelFunc195+ g := &GitRelay{196+ root: t.TempDir(),197+ policy: func() policy.Policy { return p },198+ repos: map[string]Repository{key(first.Owner, first.Identifier): first, key(second.Owner, second.Identifier): second},199+ gitSync: func(ctx context.Context, repo Repository) error {200+ calls = append(calls, repo.Identifier)201+ if len(calls) == 1 {202+ cancel()203+ return ctx.Err()204+ }205+ return nil206+ },207+ }208+ ctx, done := context.WithCancel(context.Background())209+ cancel = done210+ if err := g.GRASPService().Tick(ctx); err == nil {211+ t.Fatal("canceled GRASP tick unexpectedly succeeded")212+ }213+ status, err := g.GRASPService().SyncStatus(context.Background())214+ if err != nil {215+ t.Fatal(err)216+ }217+ if _, ok := status.Repositories[key(second.Owner, second.Identifier)]; ok {218+ t.Fatalf("unattempted repository received a status: %#v", status.Repositories)219+ }220+ if err := g.GRASPService().Tick(context.Background()); err != nil {221+ t.Fatal(err)222+ }223+ status, err = g.GRASPService().SyncStatus(context.Background())224+ if err != nil {225+ t.Fatal(err)226+ }227+ if status.Repositories[key(second.Owner, second.Identifier)].SucceededAt == 0 {228+ t.Fatalf("resumed repository was not attempted: %#v", status.Repositories)229+ }230+ if len(calls) != 2 || calls[1] != "b" {231+ t.Fatalf("resume order = %v, want [a b]", calls)232+ }233+}234+235+func prGitRun(t *testing.T, args ...string) {236+ t.Helper()237+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {238+ t.Fatalf("git %v: %v: %s", args, err, out)239+ }240+}241+242+func prGitOutput(t *testing.T, args ...string) string {243+ t.Helper()244+ out, err := exec.Command("git", args...).Output()245+ if err != nil {246+ t.Fatal(err)247+ }248+ return string(out)249+}@@ -182,6 +182,57 @@ func TestQueryNegentropyEqualInventoryCloseCompletesImmediately(t *testing.T) {
182182 }183183 }184184 185+type negRejectDialer struct{ calls int }186+187+type negRejectSocket struct{ responses [][]byte }188+189+func (d *negRejectDialer) Dial(context.Context, string) (Socket, *http.Response, error) {190+ d.calls++191+ return &negRejectSocket{}, nil, nil192+}193+194+func (s *negRejectSocket) Read(ctx context.Context) ([]byte, error) {195+ if len(s.responses) == 0 {196+ return nil, ctx.Err()197+ }198+ item := s.responses[0]199+ s.responses = s.responses[1:]200+ return item, nil201+}202+203+func (s *negRejectSocket) Write(_ context.Context, data []byte) error {204+ var message []json.RawMessage205+ if err := json.Unmarshal(data, &message); err != nil || len(message) < 2 {206+ return nil207+ }208+ var kind, id string209+ _ = json.Unmarshal(message[0], &kind)210+ _ = json.Unmarshal(message[1], &id)211+ var response []any212+ if kind == "NEG-OPEN" {213+ response = []any{"NEG-ERR", id, "unsupported"}214+ } else if kind == "REQ" {215+ response = []any{"EOSE", id}216+ }217+ raw, _ := json.Marshal(response)218+ s.responses = append(s.responses, raw)219+ return nil220+}221+222+func (s *negRejectSocket) Close(error) error { return nil }223+224+func TestQuerySynchronizedFallsBackWhenNegentropyIsUnsupported(t *testing.T) {225+ dialer := &negRejectDialer{}226+ transport := &NostrTransport{Dialer: dialer, Timeout: time.Second}227+ items, err := transport.QuerySynchronized(context.Background(), "ws://relay.example", event.Filter{}, nil)228+ if err != nil {229+ t.Fatal(err)230+ }231+ if len(items) != 0 || dialer.calls != 2 {232+ t.Fatalf("fallback result=%d dials=%d, want empty result and two dials", len(items), dialer.calls)233+ }234+}235+185236 func TestNegentropyNeededIDsAreFetchedInBoundedFilteredBatches(t *testing.T) {186237 const total = negentropyFetchBatch + 1187238 secret := "1111111111111111111111111111111111111111111111111111111111111111"@@ -8,6 +8,8 @@ import (
88 "net"99 "net/http"1010 "net/url"11+ "strings"12+ "sync"1113 "sync/atomic"1214 "time"1315 @@ -252,6 +254,221 @@ type NostrTransport struct {
252254 Dialer Dialer253255 Timeout time.Duration254256 sequence atomic.Uint64257+ legacyMu sync.Mutex258+ legacy map[string]struct{}259+}260+261+// Subscribe keeps a bounded NIP-01 subscription open until ctx is canceled.262+// Reconnection belongs to the lifecycle owner so it can apply peer backoff263+// and avoid multiplying sockets when a relay is unhealthy.264+func (t *NostrTransport) Subscribe(ctx context.Context, target string, filter event.Filter, onEvent func(event.Event) error) error {265+ return t.SubscribeFilters(ctx, target, []event.Filter{filter}, onEvent)266+}267+268+// SubscribeFilters opens one subscription for a set of NIP-01 filters. Relays269+// treat filters in one REQ as an OR, which lets callers follow related event270+// families without opening a socket for each family.271+func (t *NostrTransport) SubscribeFilters(ctx context.Context, target string, filters []event.Filter, onEvent func(event.Event) error) error {272+ if len(filters) == 0 {273+ return errors.New("replication: live subscription has no filters")274+ }275+ socket, _, err := t.dial(ctx, target)276+ if err != nil {277+ return err278+ }279+ defer socket.Close(nil)280+ sub := fmt.Sprintf("tiny-live-%d", t.sequence.Add(1))281+ prepared := make([]event.Filter, len(filters))282+ for i, filter := range filters {283+ filter.Tags = ensureTags(filter.Tags)284+ prepared[i] = filter285+ }286+ message, err := json.Marshal(append([]any{"REQ", sub}, filtersToAny(prepared)...))287+ if err != nil {288+ return fmt.Errorf("encode subscription: %w", err)289+ }290+ if err := socket.Write(ctx, message); err != nil {291+ return err292+ }293+ for {294+ data, err := socket.Read(ctx)295+ if err != nil {296+ return err297+ }298+ var raw []json.RawMessage299+ if json.Unmarshal(data, &raw) != nil || len(raw) < 2 {300+ continue301+ }302+ var kind, id string303+ _ = json.Unmarshal(raw[0], &kind)304+ _ = json.Unmarshal(raw[1], &id)305+ if id != sub {306+ continue307+ }308+ if kind == "CLOSED" {309+ return errors.New("replication: live subscription closed")310+ }311+ if kind != "EVENT" || len(raw) < 3 {312+ continue313+ }314+ var item event.Event315+ if err := json.Unmarshal(raw[2], &item); err != nil {316+ continue317+ }318+ if err := event.Validate(item); err != nil || !filterMatchesAny(prepared, item) {319+ continue320+ }321+ if onEvent != nil {322+ if err := onEvent(item); err != nil {323+ return err324+ }325+ }326+ }327+}328+329+func filtersToAny(filters []event.Filter) []any {330+ out := make([]any, len(filters))331+ for i := range filters {332+ out[i] = filters[i]333+ }334+ return out335+}336+337+func filterMatchesAny(filters []event.Filter, item event.Event) bool {338+ for _, filter := range filters {339+ if event.Matches(filter, item) {340+ return true341+ }342+ }343+ return false344+}345+346+const (347+ // pullPageSize keeps a compatibility REQ small enough for relays to serve348+ // without allocating an unbounded result set.349+ pullPageSize = 500350+ // pullPageLimit prevents a corrupt or very large repository announcement351+ // from turning one maintenance pass into an unbounded import.352+ pullPageLimit = 20353+ pullEventLimit = pullPageSize * pullPageLimit354+)355+356+// ErrPullIncomplete indicates that a relay returned a full bounded history357+// window but the next page cannot be represented safely by an `until` cursor.358+// Callers should retain the result and retry with a larger or more capable359+// synchronization method.360+var ErrPullIncomplete = errors.New("replication: pull history is incomplete")361+362+// ErrNegentropyUnsupported means the peer does not implement NIP-77. It is363+// deliberately distinct from authentication, protocol and transport errors:364+// only this condition is safe to downgrade to an ordinary REQ pull.365+var ErrNegentropyUnsupported = errors.New("replication: negentropy unsupported")366+367+// QuerySynchronized prefers NIP-77 negentropy and falls back to bounded,368+// descending REQ pages when the peer does not implement it. GRASP peers are369+// commonly mixed-version, so lack of NIP-77 support must not prevent the370+// ordinary NIP-01 pull from completing.371+func (t *NostrTransport) QuerySynchronized(ctx context.Context, target string, filter event.Filter, local []syncprotocol.Item) ([]event.Event, error) {372+ if t.knownLegacy(target) {373+ return t.QueryPaginated(ctx, target, filter)374+ }375+ items, err := t.QueryNegentropy(ctx, target, filter, local)376+ if err == nil {377+ return items, nil378+ }379+ if !errors.Is(err, ErrNegentropyUnsupported) {380+ return items, err381+ }382+ t.rememberLegacy(target)383+ return t.QueryPaginated(ctx, target, filter)384+}385+386+func (t *NostrTransport) knownLegacy(target string) bool {387+ t.legacyMu.Lock()388+ defer t.legacyMu.Unlock()389+ _, ok := t.legacy[target]390+ return ok391+}392+393+func (t *NostrTransport) rememberLegacy(target string) {394+ t.legacyMu.Lock()395+ defer t.legacyMu.Unlock()396+ if t.legacy == nil {397+ t.legacy = make(map[string]struct{})398+ }399+ // Bound the instance-local compatibility cache so untrusted relay URLs400+ // cannot accumulate indefinitely. Once full, an unremembered peer simply401+ // receives another bounded probe on a later sync.402+ if len(t.legacy) < 64 {403+ t.legacy[target] = struct{}{}404+ }405+}406+407+// QueryPaginated reads a bounded history window. It uses the oldest event in408+// each page as the next upper bound, deduplicating events because relays may409+// include the boundary event in their response.410+func (t *NostrTransport) QueryPaginated(ctx context.Context, target string, filter event.Filter) ([]event.Event, error) {411+ result := make([]event.Event, 0, pullPageSize)412+ seen := make(map[string]struct{})413+ pageFilter := filter414+ for page := 0; page < pullPageLimit; page++ {415+ if err := ctx.Err(); err != nil {416+ return result, err417+ }418+ limit := pullPageSize419+ pageFilter.Limit = &limit420+ items, err := t.Query(ctx, target, pageFilter)421+ added := 0422+ for _, item := range items {423+ if _, ok := seen[item.ID]; ok {424+ continue425+ }426+ seen[item.ID] = struct{}{}427+ result = append(result, item)428+ added++429+ }430+ if len(result) >= pullEventLimit {431+ return result, ErrPullIncomplete432+ }433+ if err != nil {434+ return result, err435+ }436+ if len(items) == 0 {437+ return result, nil438+ }439+ oldest := items[0].CreatedAt440+ for _, item := range items {441+ if item.CreatedAt < oldest {442+ oldest = item.CreatedAt443+ }444+ }445+ // The inclusive boundary is an overlap probe. If it yields no new446+ // events, the relay has no older matching events available through this447+ // cursor; a short response is therefore not treated as definitive by448+ // itself.449+ if page > 0 && added == 0 {450+ allAtBoundary := true451+ for _, item := range items {452+ if item.CreatedAt != oldest {453+ allAtBoundary = false454+ break455+ }456+ }457+ if allAtBoundary && len(items) >= pullPageSize {458+ return result, ErrPullIncomplete459+ }460+ return result, nil461+ }462+ if oldest <= 0 {463+ return result, nil464+ }465+ // Keep the boundary inclusive. A relay may cap every response below466+ // our requested limit, and a strict cursor would silently drop events467+ // sharing the oldest timestamp. Deduplication makes the overlap safe.468+ until := oldest469+ pageFilter.Until = &until470+ }471+ return result, ErrPullIncomplete255472 }256473 257474 // QueryNegentropy reconciles a local index with a remote relay, then fetches@@ -281,11 +498,19 @@ func (t *NostrTransport) QueryNegentropy(ctx context.Context, target string, fil
281498 return nil, err282499 }283500 var needed []string501+ progress := false284502 deadline, cancel := context.WithTimeout(ctx, t.timeout())285503 defer cancel()286504 for {287505 data, err := socket.Read(deadline)288506 if err != nil {507+ // Some legacy relays accept the websocket but silently ignore508+ // NEG-OPEN. A bounded probe timeout is the only safe downgrade; a509+ // caller cancellation or an authenticated/protocol response must510+ // remain an error.511+ if errors.Is(err, context.DeadlineExceeded) && !progress && ctx.Err() == nil {512+ return nil, ErrNegentropyUnsupported513+ }289514 return nil, err290515 }291516 var raw []json.RawMessage@@ -298,6 +523,9 @@ func (t *NostrTransport) QueryNegentropy(ctx context.Context, target string, fil
298523 if len(raw) > 2 {299524 _ = json.Unmarshal(raw[2], &body)300525 }526+ if kind == "AUTH" {527+ return nil, errors.New("replication: remote relay requires authentication")528+ }301529 if messageID != id {302530 continue303531 }@@ -306,19 +534,30 @@ func (t *NostrTransport) QueryNegentropy(ctx context.Context, target string, fil
306534 // waiting for a NEG-MSG that will never arrive turns a successful NIP-77307535 // no-op sync into a timeout.308536 if kind == "NEG-CLOSE" {537+ progress = true309538 break310539 }540+ if kind == "CLOSED" {541+ return nil, errors.New("replication: remote negentropy closed")542+ }311543 if kind == "NEG-ERR" {312- return nil, errors.New("replication: remote negentropy rejected")544+ if negentropyUnsupported(body) {545+ return nil, ErrNegentropyUnsupported546+ }547+ return nil, fmt.Errorf("replication: remote negentropy rejected: %s", body)313548 }314549 if kind != "NEG-MSG" {315550 continue316551 }552+ progress = true317553 result, err := session.Reconcile(body)318554 if err != nil {319555 return nil, err320556 }321557 needed = append(needed, result.Need...)558+ if len(needed) > pullEventLimit {559+ return nil, ErrPullIncomplete560+ }322561 if result.Done {323562 closeMessage, _ := json.Marshal([]any{"NEG-CLOSE", id})324563 _ = socket.Write(ctx, closeMessage)@@ -338,6 +577,19 @@ func (t *NostrTransport) QueryNegentropy(ctx context.Context, target string, fil
338577 return t.queryNegentropyIDs(ctx, target, filter, needed)339578 }340579 580+func negentropyUnsupported(reason string) bool {581+ reason = strings.ToLower(strings.TrimSpace(reason))582+ if strings.HasPrefix(reason, "unsupported") || strings.HasPrefix(reason, "error: unsupported") {583+ return true584+ }585+ switch reason {586+ case "unsupported", "not supported", "negentropy unsupported", "nip-77 unsupported":587+ return true588+ default:589+ return false590+ }591+}592+341593 const negentropyFetchBatch = 500342594 343595 func (t *NostrTransport) queryNegentropyIDs(ctx context.Context, target string, filter event.Filter, ids []string) ([]event.Event, error) {@@ -355,7 +607,14 @@ func (t *NostrTransport) queryNegentropyIDs(ctx context.Context, target string,
355607 request.Limit = &limit356608 events, err := t.Query(ctx, target, request)357609 if err != nil {358- return nil, err610+ for _, candidate := range events {611+ if _, ok := seen[candidate.ID]; ok {612+ continue613+ }614+ seen[candidate.ID] = struct{}{}615+ result = append(result, candidate)616+ }617+ return result, err359618 }360619 allowed := make(map[string]struct{}, len(batch))361620 for _, id := range batch {@@ -375,9 +634,20 @@ func (t *NostrTransport) queryNegentropyIDs(ctx context.Context, target string,
375634 result = append(result, candidate)376635 }377636 }637+ if len(seen) != len(uniqueIDs(ids)) {638+ return result, ErrPullIncomplete639+ }378640 return result, nil379641 }380642 643+func uniqueIDs(ids []string) map[string]struct{} {644+ unique := make(map[string]struct{}, len(ids))645+ for _, id := range ids {646+ unique[id] = struct{}{}647+ }648+ return unique649+}650+381651 func (t *NostrTransport) timeout() time.Duration {382652 if t.Timeout > 0 {383653 return t.Timeout@@ -400,7 +670,7 @@ func (t *NostrTransport) Query(ctx context.Context, target string, filter event.
400670 if err := socket.Write(ctx, message); err != nil {401671 return nil, err402672 }403- return readEvents(ctx, socket, sub, t.timeout())673+ return readEvents(ctx, socket, sub, t.timeout(), filter)404674 }405675 406676 func (t *NostrTransport) Send(ctx context.Context, target string, e event.Event) (DeliveryResult, error) {@@ -447,14 +717,14 @@ func (t *NostrTransport) dial(ctx context.Context, target string) (Socket, *http
447717 return dialer.Dial(ctx, target)448718 }449719 450-func readEvents(ctx context.Context, socket Socket, subscription string, timeout time.Duration) ([]event.Event, error) {720+func readEvents(ctx context.Context, socket Socket, subscription string, timeout time.Duration, filter event.Filter) ([]event.Event, error) {451721 deadline, cancel := context.WithTimeout(ctx, timeout)452722 defer cancel()453723 items := make([]event.Event, 0)454724 for {455725 data, err := socket.Read(deadline)456726 if err != nil {457- return nil, err727+ return items, err458728 }459729 var raw []json.RawMessage460730 if err := json.Unmarshal(data, &raw); err != nil || len(raw) < 2 {@@ -469,8 +739,11 @@ func readEvents(ctx context.Context, socket Socket, subscription string, timeout
469739 continue470740 }471741 var item event.Event472- if json.Unmarshal(raw[2], &item) == nil && event.Validate(item) == nil {742+ if json.Unmarshal(raw[2], &item) == nil && event.Validate(item) == nil && event.Matches(filter, item) {473743 items = append(items, item)744+ if len(items) > pullEventLimit {745+ return items[:pullEventLimit], ErrPullIncomplete746+ }474747 }475748 case "EOSE":476749 if id == subscription {@@ -478,10 +751,10 @@ func readEvents(ctx context.Context, socket Socket, subscription string, timeout
478751 }479752 case "CLOSED":480753 if id == subscription {481- return nil, errors.New("replication: remote query closed")754+ return items, errors.New("replication: remote query closed")482755 }483756 case "AUTH":484- return nil, errors.New("replication: remote relay requires authentication")757+ return items, errors.New("replication: remote relay requires authentication")485758 }486759 }487760 }@@ -0,0 +1,87 @@
1+package replication2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+)12+13+type silentNegentropyDialer struct{ calls int }14+15+type silentNegentropySocket struct{ responses chan []byte }16+17+func (d *silentNegentropyDialer) Dial(_ context.Context, _ string) (Socket, *http.Response, error) {18+ d.calls++19+ return &silentNegentropySocket{responses: make(chan []byte, 1)}, nil, nil20+}21+22+func (s *silentNegentropySocket) Read(ctx context.Context) ([]byte, error) {23+ select {24+ case response := <-s.responses:25+ return response, nil26+ case <-ctx.Done():27+ return nil, ctx.Err()28+ }29+}30+31+func (s *silentNegentropySocket) Write(_ context.Context, data []byte) error {32+ var message []json.RawMessage33+ if err := json.Unmarshal(data, &message); err != nil || len(message) < 2 {34+ return nil35+ }36+ var kind, id string37+ _ = json.Unmarshal(message[0], &kind)38+ _ = json.Unmarshal(message[1], &id)39+ if kind != "REQ" {40+ return nil // legacy peer silently ignores NEG-OPEN41+ }42+ response, _ := json.Marshal([]any{"EOSE", id})43+ s.responses <- response44+ return nil45+}46+47+func (s *silentNegentropySocket) Close(error) error { return nil }48+49+func TestQuerySynchronizedFallsBackAfterSilentLegacyNegentropyPeer(t *testing.T) {50+ dialer := &silentNegentropyDialer{}51+ transport := &NostrTransport{Dialer: dialer, Timeout: 10 * time.Millisecond}52+ items, err := transport.QuerySynchronized(context.Background(), "ws://relay.example", event.Filter{}, nil)53+ if err != nil {54+ t.Fatal(err)55+ }56+ if len(items) != 0 {57+ t.Fatalf("fallback returned %d events", len(items))58+ }59+ if dialer.calls != 2 {60+ t.Fatalf("transport dials = %d, want negentropy probe plus REQ fallback", dialer.calls)61+ }62+}63+64+func TestQuerySynchronizedCachesSilentLegacyPeerPerTransport(t *testing.T) {65+ dialer := &silentNegentropyDialer{}66+ transport := &NostrTransport{Dialer: dialer, Timeout: 10 * time.Millisecond}67+ for i := 0; i < 2; i++ {68+ if _, err := transport.QuerySynchronized(context.Background(), "ws://legacy.example", event.Filter{}, nil); err != nil {69+ t.Fatal(err)70+ }71+ }72+ if dialer.calls != 3 {73+ t.Fatalf("transport dials = %d, want one negentropy probe and two REQ pulls", dialer.calls)74+ }75+}76+77+func TestQueryNegentropyIDsReportsMissingRequestedEvents(t *testing.T) {78+ dialer := &batchQueryDialer{events: map[string]event.Event{}}79+ transport := &NostrTransport{Dialer: dialer, Timeout: time.Second}80+ items, err := transport.queryNegentropyIDs(context.Background(), "ws://relay.example", event.Filter{}, []string{"missing"})81+ if err != ErrPullIncomplete {82+ t.Fatalf("error = %v, want ErrPullIncomplete", err)83+ }84+ if len(items) != 0 {85+ t.Fatalf("returned %d events for missing ID", len(items))86+ }87+}