clone

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

refactor: give tinygit owned storage and policy contracts

Dami committed aff3977
Replace internal type aliases with a typed event store interface and a narrow Git policy. Keep SQLite access in internal/gitstore and adapt the tenant store at host assembly without changing transaction ownership.

Share private peer URL matching internally. Verify external consumers can supply a store and preserve pending metadata, duplicate errors and private-service capability requirements.
commit
aff3977dcd4ab365bd4e41ccf5a911d9fbb88e88
parent
73dc995c43004151a39b4e1e890d46c9e961de54
author
Dami <dami.etoile@gmail.com>
31 files changed, +383 -185
Mdocs/architecture.md6+++
Mdocs/module-contracts.md2+-
Minternal/daemon/services.go7+++-
Ainternal/gitstore/store.go69++++++++++++++++++++++++++++++
Ainternal/gitstore/store_test.go50++++++++++++++++++++++
Ainternal/peerurl/peerurl.go43+++++++++++++++++++
Minternal/policy/privacy.go38+---------------
Mtinygit/README.md5+-
Mtinygit/admission.go61+++++++++++----------------
Mtinygit/api.go52+++++++++++++----------
Mtinygit/archive.go13++----
Mtinygit/archive_test.go4+-
Mtinygit/capabilities_test.go15++++++-
Mtinygit/doc.go12++---
Mtinygit/external_test.go11++++-
Mtinygit/gitrelay.go15+++---
Mtinygit/gitrelay_test.go34+++++++-------
Mtinygit/journal.go6+--
Mtinygit/maintainers_test.go2+-
Mtinygit/metadata_admission_test.go21+++++----
Mtinygit/pr_repair.go6+-
Mtinygit/pr_repair_test.go6+-
Mtinygit/private_repair_acceptance_test.go2+-
Mtinygit/private_transport.go4+-
Mtinygit/public_test.go3+-
Mtinygit/repair.go2+-
Mtinygit/repository.go13+++---
Mtinygit/scheduler.go7+--
Mtinygit/server.go7+--
Atinygit/store.go39+++++++++++++++++
Atinygit/test_policy_test.go13++++++
M docs/architecture.md +6 -0
M docs/module-contracts.md +1 -1
@@ -53,7 +53,7 @@ Use small, consumer-owned interfaces for access decisions, persistence and optio
5353 5454 `tinygit.Event` and the frontend room event type now originate in public `protocol/nostr`. Public `protocol/auth` depends on those primitives, not host services. Internal aliases preserve one implementation, type identity, verifier replay state and error sentinels. Feature kind constants, room-reply helpers and private-kind search exclusions remain internal rather than becoming generic protocol rules.5555 56-`tinygit` still shares internal policy and SQLite storage with the host. A narrow persistence contract is a later seam, not completed by moving event types. External-consumer and transitive dependency tests guard the public protocol packages. `tinygit.ParseMetadata` validates metadata shape without asserting signature validity or authority; its admission entry points combine these checks before consulting host policy.56+`tinygit` owns its small policy and persistence contracts. Its default SQLite implementation lives in `internal/gitstore`, and tenant assembly adapts the existing host store and policy. The host retains transaction ownership; the engine does not close a supplied store. External-consumer tests exercise the public store interface, while dependency tests guard the protocol packages. `tinygit.ParseMetadata` validates metadata shape without asserting signature validity or authority; its admission entry points combine these checks before consulting host policy.5757 5858 ## Describe standards and project extensions separately5959
M internal/daemon/services.go +6 -1
@@ -20,6 +20,7 @@ import (
2020 "github.com/FelineStateMachine/tinyrelay/internal/community"2121 "github.com/FelineStateMachine/tinyrelay/internal/configport"2222 "github.com/FelineStateMachine/tinyrelay/internal/event"23+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"2324 "github.com/FelineStateMachine/tinyrelay/internal/policy"2425 "github.com/FelineStateMachine/tinyrelay/internal/records"2526 "github.com/FelineStateMachine/tinyrelay/internal/relay"
@@ -33,6 +34,10 @@ import (
3334 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"3435 )3536 37+func gitrelayPolicy(p policy.Policy) gitrelay.Policy {38+ return gitrelay.Policy{Owner: p.Owner, Reads: p.Reads, PrivatePeers: append([]string(nil), p.PrivatePeers...), Features: gitrelay.PolicyFeatures{Grasp: p.Features.Grasp, Grasp02: p.Features.Grasp02, Grasp03: p.Features.Grasp03, Grasp05: p.Features.Grasp05, Grasp06: p.Features.Grasp06, Grasp08: p.Features.Grasp08}}39+}40+3641 // initServices constructs feature services around the tenant store and policy3742 // reader. Custom-view storage is initialized before Git can promote events;3843 // the resulting Git service is bound before handlers and workers start.
@@ -158,7 +163,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
158163 return err159164 }160165 t.followups = &eventFollowups{store: t.store, callbacks: t.callbacks, views: t.customViews, telemetry: t.app.telemetry, push: t.planReplicationPushFollowup}161- t.git, err = gitrelay.New(gitrelay.Config{Store: t.store, Root: t.meta.Paths.Git, Policy: t.Policy, PublicURL: t.publicURL, AllowPrivateRelays: t.app.cfg.AllowPrivateRelays, PrivatePeers: t.Policy().PrivatePeers, HTTPAuth: t.privateHTTPAuth, GitSync: t.gitSync, EventSync: t.gitEventSync, AuthorizeHTTP: t.authorizeGit, Maintainers: t, OnPromote: func(ctx context.Context, id string, _ gitrelay.Repository) error {166+ t.git, err = gitrelay.New(gitrelay.Config{Store: gitstore.Wrap(t.store), Root: t.meta.Paths.Git, Policy: func() gitrelay.Policy { return gitrelayPolicy(t.Policy()) }, PublicURL: t.publicURL, AllowPrivateRelays: t.app.cfg.AllowPrivateRelays, PrivatePeers: t.Policy().PrivatePeers, HTTPAuth: t.privateHTTPAuth, GitSync: t.gitSync, EventSync: t.gitEventSync, AuthorizeHTTP: t.authorizeGit, Maintainers: t, OnPromote: func(ctx context.Context, id string, _ gitrelay.Repository) error {162167 return t.releaseGit(ctx, id)163168 }})164169 if err != nil {
A internal/gitstore/store.go +69 -0
@@ -0,0 +1,69 @@
1+package gitstore2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "path/filepath"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/storage"10+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"11+)12+13+// ErrDuplicate indicates that an event was already persisted.14+var ErrDuplicate = errors.New("duplicate event")15+16+// Store adapts the shared SQLite event store to the Git engine contract.17+type Store struct{ store *storage.Store }18+19+// Open creates a caller-owned SQLite store.20+func Open(ctx context.Context, path string) (*Store, error) {21+ path, err := filepath.Abs(path)22+ if err != nil {23+ return nil, err24+ }25+ store, err := storage.Open(ctx, path)26+ if err != nil {27+ return nil, err28+ }29+ return &Store{store: store}, nil30+}31+32+// Wrap adapts an existing host store without taking lifecycle ownership.33+func Wrap(store *storage.Store) *Store { return &Store{store: store} }34+35+func (s *Store) Query(ctx context.Context, f nostr.Filter, now int64, limit int) ([]nostr.Event, error) {36+ result, err := s.store.Query(ctx, f, storage.QueryOptions{Now: now, Limit: limit, Access: storage.Access{All: true}})37+ return result.Events, err38+}39+func (s *Store) Save(ctx context.Context, e nostr.Event, now int64) error {40+ _, err := s.store.Save(ctx, e, storage.SaveOptions{Now: now})41+ if errors.Is(err, storage.ErrDuplicate) {42+ return ErrDuplicate43+ }44+ return err45+}46+func (s *Store) Latest(ctx context.Context, kind int, pubkey, identifier string) (nostr.Event, bool, error) {47+ var raw string48+ err := s.store.DB().QueryRowContext(ctx, "SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=? ORDER BY created_at DESC,id ASC LIMIT 1", kind, pubkey, identifier).Scan(&raw)49+ if errors.Is(err, sql.ErrNoRows) {50+ return nostr.Event{}, false, nil51+ }52+ if err != nil {53+ return nostr.Event{}, false, err54+ }55+ event, err := nostr.Parse([]byte(raw))56+ if err != nil {57+ return nostr.Event{}, false, err58+ }59+ return event, true, nil60+}61+func (s *Store) Exists(ctx context.Context, id string, kind int) (bool, error) {62+ var found int63+ err := s.store.DB().QueryRowContext(ctx, "SELECT 1 FROM events WHERE id=? AND kind=?", id, kind).Scan(&found)64+ if errors.Is(err, sql.ErrNoRows) {65+ return false, nil66+ }67+ return err == nil, err68+}69+func (s *Store) Close() error { return s.store.Close() }
A internal/gitstore/store_test.go +50 -0
@@ -0,0 +1,50 @@
1+package gitstore2+3+import (4+ "context"5+ "errors"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"11+)12+13+func TestStorePreservesDurableMetadataAndReadVisibility(t *testing.T) {14+ ctx := context.Background()15+ store, err := Open(ctx, filepath.Join(t.TempDir(), "events.db"))16+ if err != nil {17+ t.Fatal(err)18+ }19+ t.Cleanup(func() { _ = store.Close() })20+ e := nostr.Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d", "repo"}}}21+ if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {22+ t.Fatal(err)23+ }24+ if err := store.Save(ctx, e, 1); err != nil {25+ t.Fatal(err)26+ }27+ if err := store.Save(ctx, e, 1); !errors.Is(err, ErrDuplicate) {28+ t.Fatalf("duplicate save: %v", err)29+ }30+ if found, err := store.Exists(ctx, e.ID, 30618); err != nil || !found {31+ t.Fatalf("exists=%v, error=%v", found, err)32+ }33+ if _, err := store.store.DB().ExecContext(ctx, `INSERT INTO pending_events(id,reason) VALUES(?,'git')`, e.ID); err != nil {34+ t.Fatal(err)35+ }36+ rows, err := store.Query(ctx, nostr.Filter{Kinds: []int{30618}}, 1, 0)37+ if err != nil || len(rows) != 0 {38+ t.Fatalf("pending query=%v, error=%v", rows, err)39+ }40+ latest, found, err := store.Latest(ctx, 30618, e.PubKey, "repo")41+ if err != nil || !found || latest.ID != e.ID {42+ t.Fatalf("latest=%v, found=%v, error=%v", latest, found, err)43+ }44+ if _, err := store.store.DB().ExecContext(ctx, `UPDATE events SET raw='{}' WHERE id=?`, e.ID); err != nil {45+ t.Fatal(err)46+ }47+ if _, _, err := store.Latest(ctx, 30618, e.PubKey, "repo"); err == nil {48+ t.Fatal("accepted corrupt stored metadata")49+ }50+}
A internal/peerurl/peerurl.go +43 -0
M internal/policy/privacy.go +3 -35
@@ -2,10 +2,9 @@ package policy
22 33 import (44 "context"5- "net/url"6- "strings"75 86 "github.com/FelineStateMachine/tinyrelay/internal/event"7+ "github.com/FelineStateMachine/tinyrelay/internal/peerurl"98 )109 1110 // PrivateRepositoryLookup supplies the stored repository privacy marker for
@@ -33,38 +32,7 @@ func PrivateRepository(ctx context.Context, lookup PrivateRepositoryLookup, e ev
3332 return lookup.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))3433 }3534 36-// PrivatePeerBase returns the normalized configured base for a source URL.37-// WebSocket schemes are mapped to their HTTP equivalents because private Git38-// transport probes and signs HTTP repository roots.35+// PrivatePeerBase returns the configured HTTP base for a private peer URL.3936 func PrivatePeerBase(source string, peers []string) string {40- u, err := url.Parse(source)41- if err != nil {42- return ""43- }44- for _, configured := range peers {45- peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))46- if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {47- continue48- }49- base := strings.TrimRight(peer.Path, "/")50- if base != "" && u.Path != base && !strings.HasPrefix(u.Path, base+"/") {51- continue52- }53- peer.Scheme = normalizePeerScheme(peer.Scheme)54- peer.Path = base55- peer.RawPath = ""56- return strings.TrimRight(peer.String(), "/")57- }58- return ""59-}60-61-func normalizePeerScheme(scheme string) string {62- switch scheme {63- case "ws":64- return "http"65- case "wss":66- return "https"67- default:68- return scheme69- }37+ return peerurl.Match(source, peers)7038 }
M tinygit/README.md +3 -2
@@ -50,11 +50,12 @@ Import `github.com/FelineStateMachine/tinyrelay/tinygit`.
5050 5151 - `OpenServer` owns the standalone service's store and returns an HTTP handler. Stop serving requests before calling `Close`.5252 - `New` constructs the lower-level engine using `Config`. The caller owns its store, policy, access decisions, synchronization transports and lifecycle.53-- `OpenStore`, `Event`, `Policy` and `Store` make the public API usable from another Go module.53+- `Store` is a persistence interface over public Nostr types. Supply an implementation or use `OpenStore` for the standard SQLite store. The caller closes the store after stopping the engine.54+- `Policy` contains only the Git capabilities, owner, read rule and private peers used by the engine. Adapt richer host policy in the host assembly.5455 - `ParseMetadata` parses repository claims and validates their supported shape without checking signatures, host authority or Git objects. Call `nostr.Validate` separately before trusting a claimed author.5556 - `Validate`, `CommitAfterStore` and the promotion callback separate admission, durable event persistence and visible Git state. Integration code must preserve that ordering and its transaction guarantees.5657 57-The engine uses public `protocol/nostr` event types and `protocol/auth` proof verification. It still shares internal policy, feature helpers and SQLite implementations with tinyrelay. It is a public package in the root Go module, not an independent dependency-free Go module. See [Module boundaries and protocol contracts](../docs/module-contracts.md) for the next dependency seams.58+The engine uses public `protocol/nostr` event types and `protocol/auth` proof verification. The standard store adapts SQLite through `internal/gitstore`; the integrated host uses that adapter with its existing tenant store. SQL and host policy stay behind these boundaries. The package belongs to the root Go module. See [Module boundaries and protocol contracts](../docs/module-contracts.md).5859 5960 ## Verify6061
M tinygit/admission.go +25 -36
@@ -2,7 +2,6 @@ package tinygit
22 33 import (44 "context"5- "database/sql"65 "encoding/json"76 "errors"87 "fmt"
@@ -13,8 +12,7 @@ import (
1312 "strings"1413 "time"1514 16- "github.com/FelineStateMachine/tinyrelay/internal/event"17- "github.com/FelineStateMachine/tinyrelay/internal/storage"15+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1816 )1917 2018 // ValidateEvent checks the wire shape, canonical ID and signature, not
@@ -98,16 +96,11 @@ func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repo
9896 if e.Kind == 30617 {9997 // Announcements and ref state are separate addressable events. An10098 // announcement update must retain the current signed refs and HEAD.101- var raw string102- err := g.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE kind=30618 AND pubkey=? AND d=? ORDER BY created_at DESC,id ASC LIMIT 1`, e.PubKey, event.Tag(e, "d")).Scan(&raw)103- if err != nil && !errors.Is(err, sql.ErrNoRows) {99+ state, found, err := g.store.Latest(ctx, 30618, e.PubKey, event.Tag(e, "d"))100+ if err != nil {104101 return err105102 }106- if err == nil {107- state, err := event.Parse([]byte(raw))108- if err != nil {109- return err110- }103+ if found {111104 stateRepo, err := g.parseRepository(state)112105 if err != nil {113106 return err
@@ -154,15 +147,11 @@ func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, er
154147 if e.Kind != 30617 && e.Kind != 30618 {155148 return true, nil156149 }157- var id string158- err := g.store.DB().QueryRowContext(ctx, `SELECT events.id FROM events JOIN tags ON tags.event_id=events.id AND tags.name='d' AND tags.value=? WHERE events.pubkey=? AND events.kind=? ORDER BY events.created_at DESC,events.id ASC LIMIT 1`, event.Tag(e, "d"), e.PubKey, e.Kind).Scan(&id)159- if errors.Is(err, sql.ErrNoRows) {160- return false, nil161- }162- if err != nil {150+ current, found, err := g.store.Latest(ctx, e.Kind, e.PubKey, event.Tag(e, "d"))151+ if err != nil || !found {163152 return false, err164153 }165- return id == e.ID, nil154+ return current.ID == e.ID, nil166155 }167156 168157 // IsPending lets the event admission/read layer hide a metadata event until
@@ -268,11 +257,11 @@ func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error {
268257 }269258 270259 func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) {271- var current int272- if err := g.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM events WHERE id=? AND kind=30618`, repo.EventID).Scan(&current); err != nil {260+ found, err := g.store.Exists(ctx, repo.EventID, 30618)261+ if err != nil {273262 return false, err274263 }275- if current == 0 {264+ if !found {276265 return false, nil277266 }278267 if !g.stateObjectsPresent(ctx, repo) {
@@ -307,7 +296,7 @@ func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
307296 return err308297 }309298 if e.Kind != 30617 && e.Kind != 30618 {310- if !((g.grasp06 || g.policy().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {299+ if !((g.grasp06 || g.policyValue().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {311300 return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)312301 }313302 g.commitMu.Lock()
@@ -318,7 +307,7 @@ func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
318307 if err != nil {319308 return err320309 }321- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {310+ if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {322311 return err323312 }324313 return g.CommitAfterStore(ctx, e, repo)
@@ -344,14 +333,14 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
344333 ann := g.repos[key(e.PubKey, id)]345334 g.mu.RUnlock()346335 if ann.Owner == "" {347- q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})336+ q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 0)348337 if err != nil {349338 return Repository{}, err350339 }351- if len(q.Events) == 0 {340+ if len(q) == 0 {352341 return Repository{}, errors.New("blocked: repository announcement is missing")353342 }354- for _, candidate := range q.Events {343+ for _, candidate := range q {355344 ann, err = g.parseRepository(candidate)356345 if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {357346 break
@@ -383,7 +372,7 @@ var ErrIncomplete = errors.New("synchronization incomplete")
383372 384373 // Capabilities returns only protocol surfaces that this instance can serve.385374 func (g *GitRelay) Capabilities() []string {386- p := g.policy()375+ p := g.policyValue()387376 c := []string{"GRASP-01"}388377 if p.Features.Grasp02 && g.eventSync != nil {389378 c = append(c, "GRASP-02")
@@ -458,12 +447,12 @@ func (g *GitRelay) Reload(ctx context.Context) error {
458447 if err := g.recoverJournals(); err != nil {459448 return err460449 }461- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})450+ q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, time.Now().Unix(), 0)462451 if err != nil {463452 return err464453 }465454 repos := make(map[string]Repository)466- for _, raw := range q.Events {455+ for _, raw := range q {467456 r, parseErr := g.parseRepository(raw)468457 if parseErr == nil {469458 repos[key(r.Owner, r.Identifier)] = r
@@ -500,25 +489,25 @@ func (g *GitRelay) Reload(ctx context.Context) error {
500489 } else if !errors.Is(err, os.ErrNotExist) {501490 return err502491 }503- states, stateErr := g.store.Query(ctx, event.Filter{Authors: append([]string{announcement.Owner}, announcement.Maintainers...), Kinds: []int{30618}, Tags: map[string][]string{"d": {announcement.Identifier}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})492+ states, stateErr := g.store.Query(ctx, event.Filter{Authors: append([]string{announcement.Owner}, announcement.Maintainers...), Kinds: []int{30618}, Tags: map[string][]string{"d": {announcement.Identifier}}}, time.Now().Unix(), 0)504493 if stateErr != nil {505494 return stateErr506495 }507- if len(states.Events) == 0 {496+ if len(states) == 0 {508497 continue509498 }510- sort.Slice(states.Events, func(i, j int) bool { return states.Events[i].CreatedAt > states.Events[j].CreatedAt })511- state, parseErr := g.parseRepository(states.Events[0])499+ sort.Slice(states, func(i, j int) bool { return states[i].CreatedAt > states[j].CreatedAt })500+ state, parseErr := g.parseRepository(states[0])512501 if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) {513502 continue514503 }515504 repos[coordinate] = state516505 }517- prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})506+ prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, time.Now().Unix(), 0)518507 if prErr != nil {519508 return prErr520509 }521- for _, raw := range prs.Events {510+ for _, raw := range prs {522511 identifier := event.Tag(raw, "d")523512 if identifier == "" {524513 identifier = event.Tag(raw, "a")
@@ -587,7 +576,7 @@ func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error {
587576 if err := g.writeJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head}); err != nil {588577 return err589578 }590- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {579+ if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {591580 return err592581 }593582 if err := g.ensureRepo(r); err != nil {
M tinygit/api.go +30 -22
@@ -1,11 +1,6 @@
11 package tinygit22 33 import (4- "context"5- "path/filepath"6-7- "github.com/FelineStateMachine/tinyrelay/internal/policy"8- "github.com/FelineStateMachine/tinyrelay/internal/storage"94 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"105 )116
@@ -13,24 +8,37 @@ import (
138 // sign events themselves; the server never needs their private keys.149 type Event = nostr.Event1510 16-// Policy configures the embedded engine. It currently shares tinyrelay's17-// policy model; DefaultPolicy provides the initial values.18-type Policy = policy.Policy11+// Policy contains the policy values used by the Git engine. Hosts may keep a12+// richer policy internally and adapt these values at the integration boundary.13+type Policy struct {14+ Owner string15+ Reads string16+ PrivatePeers []string17+ Features PolicyFeatures18+}1919 20-// Store is the SQLite event store used by the engine. This alias preserves21-// shared transactions with tinyrelay; it is not a pluggable storage interface.22-type Store = storage.Store20+// PolicyFeatures selects optional Git protocol capabilities.21+type PolicyFeatures struct {22+ Grasp bool23+ Grasp02 bool24+ Grasp03 bool25+ Grasp05 bool26+ Grasp06 bool27+ Grasp08 bool28+}2329 24-// OpenStore opens the shared event schema without starting any relay services.25-// The caller owns the returned store and must close it after stopping requests.26-func OpenStore(ctx context.Context, path string) (*Store, error) {27- path, err := filepath.Abs(path)28- if err != nil {29- return nil, err30- }31- return storage.Open(ctx, path)30+func (p Policy) PrivateServiceEnabled() bool {31+ return p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members"3232 }3333 34-// DefaultPolicy returns the embedded engine's default host policy. Hosting35-// admission and private HTTP authorization still require Config callbacks.36-func DefaultPolicy(owner string) Policy { return policy.Defaults(owner) }34+// DefaultPolicy returns the defaults for an embedded engine.35+func DefaultPolicy(owner string) Policy {36+ return Policy{Owner: owner, Reads: "open"}37+}38+39+func (g *GitRelay) policyValue() Policy {40+ if g.policy == nil {41+ return DefaultPolicy("")42+ }43+ return g.policy()44+}
M tinygit/archive.go +4 -9
M tinygit/archive_test.go +2 -2
M tinygit/capabilities_test.go +13 -2
@@ -10,7 +10,7 @@ import (
1010 1111 func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {1212 p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}13- g := &GitRelay{policy: func() policy.Policy { return p }}13+ g := &GitRelay{policy: func() Policy { return fromInternalPolicy(p) }}1414 if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {1515 t.Fatalf("without event transport: %v", got)1616 }
@@ -26,9 +26,20 @@ func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
2626 2727 func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {2828 p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}29- g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}29+ g := &GitRelay{policy: func() Policy { return fromInternalPolicy(p) }, eventSync: func(context.Context, Repository) error { return nil }}3030 want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}3131 if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {3232 t.Fatalf("private profiles: %v", got)3333 }3434 }35+36+func TestPolicyPrivateServiceRequiresGRASP(t *testing.T) {37+ p := Policy{Reads: "members", Features: PolicyFeatures{Grasp08: true}}38+ if p.PrivateServiceEnabled() {39+ t.Fatal("private service enabled without GRASP")40+ }41+ p.Features.Grasp = true42+ if !p.PrivateServiceEnabled() {43+ t.Fatal("private service not enabled with complete profile")44+ }45+}
M tinygit/doc.go +5 -7
M tinygit/external_test.go +9 -2
@@ -29,17 +29,24 @@ import (
2929 "strings"3030 "testing"3131 "github.com/FelineStateMachine/tinyrelay/tinygit"32+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"3233 )34+type fakeStore struct{}35+func (fakeStore) Query(context.Context, nostr.Filter, int64, int) ([]tinygit.Event, error) { return nil, nil }36+func (fakeStore) Save(context.Context, tinygit.Event, int64) error { return nil }37+func (fakeStore) Latest(context.Context, int, string, string) (tinygit.Event, bool, error) { return tinygit.Event{}, false, nil }38+func (fakeStore) Exists(context.Context, string, int) (bool, error) { return false, nil }39+func (fakeStore) Close() error { return nil }3340 func TestUse(t *testing.T) {3441 ctx := context.Background()3542 dir := t.TempDir()36- var store *tinygit.Store43+ var store tinygit.Store3744 var err error3845 store, err = tinygit.OpenStore(ctx, filepath.Join(dir, "events.db"))3946 if err != nil { t.Fatal(err) }4047 defer store.Close()4148 p := tinygit.DefaultPolicy("")42- g, err := tinygit.New(tinygit.Config{Store: store, Root: filepath.Join(dir, "git"), Policy: func() tinygit.Policy { return p }, Authorize: func(context.Context, tinygit.Event, tinygit.Repository) error { return nil }})49+ g, err := tinygit.New(tinygit.Config{Store: fakeStore{}, Root: filepath.Join(dir, "git"), Policy: func() tinygit.Policy { return p }, Authorize: func(context.Context, tinygit.Event, tinygit.Repository) error { return nil }})4350 if err != nil { t.Fatal(err) }4451 var _ http.Handler = g4552 if err := g.Publish(ctx, tinygit.Event{}); err == nil { t.Fatal("invalid event accepted") }
M tinygit/gitrelay.go +7 -8
@@ -9,9 +9,7 @@ import (
99 "strings"1010 "sync"1111 12- "github.com/FelineStateMachine/tinyrelay/internal/event"13- "github.com/FelineStateMachine/tinyrelay/internal/policy"14- "github.com/FelineStateMachine/tinyrelay/internal/storage"12+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1513 )1614 1715 // Repository is the resolved authority and Git state for one hosted project.
@@ -48,7 +46,7 @@ type MaintainerSource interface {
4846 4947 // Config supplies the event store, Git root, authorization and transport hooks.5048 type Config struct {51- Store *Store49+ Store Store5250 Root string5351 Policy func() Policy5452 Authorize func(context.Context, Event, Repository) error
@@ -93,9 +91,9 @@ type Config struct {
9391 9492 // GitRelay coordinates signed repository events with native bare repositories.9593 type GitRelay struct {96- store *storage.Store94+ store Store9795 root string98- policy func() policy.Policy96+ policy func() Policy9997 authorize func(context.Context, event.Event, Repository) error10098 authorizeHTTP func(context.Context, *http.Request, Repository) error10199 maintainers MaintainerSource
@@ -121,6 +119,7 @@ func New(cfg Config) (*GitRelay, error) {
121119 if cfg.Store == nil {122120 return nil, errors.New("git relay: store is required")123121 }122+ store := cfg.Store124123 if cfg.Root == "" {125124 return nil, errors.New("git relay: root is required")126125 }
@@ -129,13 +128,13 @@ func New(cfg Config) (*GitRelay, error) {
129128 }130129 p := cfg.Policy131130 if p == nil {132- p = func() policy.Policy { return policy.Defaults("") }131+ p = func() Policy { return DefaultPolicy("") }133132 }134133 serviceURL := cfg.PublicURL135134 if serviceURL == "" {136135 serviceURL = cfg.ServiceURL137136 }138- g := &GitRelay{store: cfg.Store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, maintainers: cfg.Maintainers, serviceURL: strings.TrimRight(serviceURL, "/"), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})}137+ g := &GitRelay{store: store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, maintainers: cfg.Maintainers, serviceURL: strings.TrimRight(serviceURL, "/"), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})}139138 if err := g.recoverJournals(); err != nil {140139 return nil, err141140 }
M tinygit/gitrelay_test.go +17 -17
@@ -37,7 +37,7 @@ func TestPublishStagesRepositoryAndSignedStateHook(t *testing.T) {
3737 t.Fatal(err)3838 }3939 defer store.Close()40- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})40+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})4141 if err != nil {4242 t.Fatal(err)4343 }
@@ -81,7 +81,7 @@ func TestStateHEADSelectsNonMainBranch(t *testing.T) {
8181 t.Fatal(err)8282 }8383 defer store.Close()84- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})84+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})8585 if err != nil {8686 t.Fatal(err)8787 }
@@ -118,7 +118,7 @@ func TestStateProjectsExistingObjectsIntoNativeRefs(t *testing.T) {
118118 t.Fatal(err)119119 }120120 defer store.Close()121- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})121+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})122122 if err != nil {123123 t.Fatal(err)124124 }
@@ -188,7 +188,7 @@ func TestPendingPromotionRemovesRefsDroppedByState(t *testing.T) {
188188 t.Fatal(err)189189 }190190 defer store.Close()191- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})191+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})192192 if err != nil {193193 t.Fatal(err)194194 }
@@ -240,7 +240,7 @@ func TestStaleStateWorkerCannotRewindHEAD(t *testing.T) {
240240 t.Fatal(err)241241 }242242 defer store.Close()243- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})243+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})244244 if err != nil {245245 t.Fatal(err)246246 }
@@ -294,7 +294,7 @@ func TestObjectsPresentBatchesManyRefsAndDetectsMissing(t *testing.T) {
294294 t.Fatal(err)295295 }296296 defer store.Close()297- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})297+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})298298 if err != nil {299299 t.Fatal(err)300300 }
@@ -328,7 +328,7 @@ func TestAdmitSourceRejectsUnsafeEndpoints(t *testing.T) {
328328 t.Fatal(err)329329 }330330 defer store.Close()331- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})331+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})332332 if err != nil {333333 t.Fatal(err)334334 }
@@ -350,7 +350,7 @@ func TestAlternativePRPublicationUsesSeparateRepository(t *testing.T) {
350350 t.Fatal(err)351351 }352352 defer store.Close()353- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), EnableGRASP06: true})353+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), EnableGRASP06: true})354354 if err != nil {355355 t.Fatal(err)356356 }
@@ -429,7 +429,7 @@ func TestJournalRecoveryCompletesStateFile(t *testing.T) {
429429 t.Fatal(err)430430 }431431 defer store.Close()432- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})432+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})433433 if err != nil {434434 t.Fatal(err)435435 }
@@ -450,7 +450,7 @@ func TestJournalRecoveryCompletesStateFile(t *testing.T) {
450450 if err := g.writeJournal(r, jr); err != nil {451451 t.Fatal(err)452452 }453- if _, err := New(Config{Store: store, Root: filepath.Join(root, "git")}); err != nil {453+ if _, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")}); err != nil {454454 t.Fatal(err)455455 }456456 b, err := os.ReadFile(filepath.Join(root, "git", r.Owner, "demo.git", "tinyrelay.pending"))
@@ -468,7 +468,7 @@ func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {
468468 defer store.Close()469469 secret := strings.Repeat("0", 63) + "1"470470 owner, _ := event.PublicKey(secret)471- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})471+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})472472 if err != nil {473473 t.Fatal(err)474474 }
@@ -508,7 +508,7 @@ func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {
508508 if out, err := exec.Command("git", "--git-dir", repoPath, "update-ref", "refs/heads/unannounced", strings.TrimSpace(string(commit))).CombinedOutput(); err != nil {509509 t.Fatalf("stage unannounced ref: %v %s", err, out)510510 }511- restarted, err := New(Config{Store: store, Root: filepath.Join(root, "git")})511+ restarted, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})512512 if err != nil {513513 t.Fatal(err)514514 }
@@ -543,7 +543,7 @@ func TestGRASPServiceTickPersistsProgressAndRepairOrder(t *testing.T) {
543543 p := policy.Defaults("")544544 p.Features.Grasp = true545545 p.Features.Grasp02 = true546- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, GitSync: func(context.Context, Repository) error { called++; return nil }})546+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, GitSync: func(context.Context, Repository) error { called++; return nil }})547547 if err != nil {548548 t.Fatal(err)549549 }
@@ -577,7 +577,7 @@ func TestGRASPServiceTickContinuesAfterRepositoryFailure(t *testing.T) {
577577 p.Features.Grasp = true578578 p.Features.Grasp02 = true579579 var synced []string580- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, GitSync: func(_ context.Context, r Repository) error {580+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, GitSync: func(_ context.Context, r Repository) error {581581 synced = append(synced, r.Identifier)582582 if r.Identifier == "first" {583583 return errors.New("temporary source failure")
@@ -648,7 +648,7 @@ func TestValidateAndCommitAfterStoreAvoidDuplicateSave(t *testing.T) {
648648 t.Fatal(err)649649 }650650 defer store.Close()651- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})651+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})652652 if err != nil {653653 t.Fatal(err)654654 }
@@ -680,7 +680,7 @@ func TestSmartHTTPServesBareRepository(t *testing.T) {
680680 t.Fatal(err)681681 }682682 defer store.Close()683- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})683+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})684684 if err != nil {685685 t.Fatal(err)686686 }
@@ -752,7 +752,7 @@ func TestPendingStatePromotesAfterAuthorizedGitPush(t *testing.T) {
752752 t.Fatal(err)753753 }754754 defer store.Close()755- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})755+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})756756 if err != nil {757757 t.Fatal(err)758758 }
M tinygit/journal.go +2 -4
M tinygit/maintainers_test.go +1 -1
M tinygit/metadata_admission_test.go +11 -10
@@ -8,18 +8,19 @@ import (
88 "testing"99 1010 "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/storage"1112 )1213 1314 func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) {1415 ctx := context.Background()15- store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db"))16+ store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db"))1617 if err != nil {1718 t.Fatal(err)1819 }1920 defer store.Close()2021 calls := 02122 denied := errors.New("blocked: host does not accept this repository")22- g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {23+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {2324 calls++2425 return denied2526 }})
@@ -61,9 +62,9 @@ func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) {
6162 }6263 }6364 }64- var count int65- if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events").Scan(&count); err != nil || count != 0 {66- t.Fatalf("rejected metadata persisted: count=%d err=%v", count, err)65+ result, err := store.Query(ctx, event.Filter{}, storage.QueryOptions{})66+ if err != nil || len(result.Events) != 0 {67+ t.Fatalf("rejected metadata persisted: count=%d err=%v", len(result.Events), err)6768 }6869 }6970
@@ -81,14 +82,14 @@ func TestMetadataShapePrecedesRepositoryLookup(t *testing.T) {
8182 8283 func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) {8384 ctx := context.Background()84- store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db"))85+ store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db"))8586 if err != nil {8687 t.Fatal(err)8788 }8889 defer store.Close()8990 calls := 09091 denied := errors.New("host denial")91- g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {92+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {9293 calls++9394 return denied9495 }})
@@ -118,9 +119,9 @@ func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) {
118119 if calls != 0 {119120 t.Errorf("host authorizer called %d times for invalid shape", calls)120121 }121- var count int122- if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE id=?", e.ID).Scan(&count); err != nil || count != 0 {123- t.Fatalf("invalid shape persisted: count=%d err=%v", count, err)122+ result, err := store.Query(ctx, event.Filter{IDs: []string{e.ID}}, storage.QueryOptions{})123+ if err != nil || len(result.Events) != 0 {124+ t.Fatalf("invalid shape persisted: count=%d err=%v", len(result.Events), err)124125 }125126 })126127 }
M tinygit/pr_repair.go +3 -3
M tinygit/pr_repair_test.go +3 -3
@@ -108,7 +108,7 @@ func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {
108108 }109109 t.Cleanup(func() { _ = store.Close() })110110 p := policy.Defaults("")111- g, err := New(Config{Store: store, Root: filepath.Join(rootDir, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})111+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(rootDir, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})112112 if err != nil {113113 t.Fatal(err)114114 }
@@ -298,7 +298,7 @@ func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {
298298 t.Cleanup(func() { _ = store.Close() })299299 p := policy.Defaults("")300300 p.Reads = "members"301- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})301+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})302302 if err != nil {303303 t.Fatal(err)304304 }
@@ -321,7 +321,7 @@ func TestGRASPTickResumesAfterCanceledRepository(t *testing.T) {
321321 var cancel context.CancelFunc322322 g := &GitRelay{323323 root: t.TempDir(),324- policy: func() policy.Policy { return p },324+ policy: func() Policy { return fromInternalPolicy(p) },325325 repos: map[string]Repository{key(first.Owner, first.Identifier): first, key(second.Owner, second.Identifier): second},326326 gitSync: func(ctx context.Context, repo Repository) error {327327 calls = append(calls, repo.Identifier)
M tinygit/private_repair_acceptance_test.go +1 -1
M tinygit/private_transport.go +2 -2
M tinygit/public_test.go +2 -1
M tinygit/repair.go +1 -1
M tinygit/repository.go +6 -7
@@ -11,8 +11,7 @@ import (
1111 "strings"1212 "time"1313 14- "github.com/FelineStateMachine/tinyrelay/internal/event"15- "github.com/FelineStateMachine/tinyrelay/internal/storage"14+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1615 )1716 1817 func (g *GitRelay) repoPath(r Repository) string {
@@ -317,14 +316,14 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
317316 if ok {318317 return r, nil319318 }320- q, err := g.store.Query(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})319+ q, err := g.store.Query(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 1)321320 if err != nil {322321 return Repository{}, err323322 }324- if len(q.Events) == 0 {323+ if len(q) == 0 {325324 return Repository{}, os.ErrNotExist326325 }327- r, err = g.parseRepository(q.Events[0])326+ r, err = g.parseRepository(q[0])328327 if err != nil {329328 return Repository{}, err330329 }
@@ -333,9 +332,9 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
333332 // receive-pack request can stage its authorization hook synchronously.334333 // A maintainer, including an agent the host vouches for, may have signed335334 // it; parseRepository rejects any author who is not one.336- states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 8})335+ states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 8)337336 if stateErr == nil {338- for _, candidate := range states.Events {337+ for _, candidate := range states {339338 state, parseErr := g.parseRepository(candidate)340339 if parseErr != nil || state.Owner != owner {341340 continue
M tinygit/scheduler.go +3 -4
M tinygit/server.go +3 -4
A tinygit/store.go +39 -0
@@ -0,0 +1,39 @@
1+package tinygit2+3+import (4+ "context"5+6+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"7+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"8+)9+10+var _ Store = (*gitstore.Store)(nil)11+12+// Store is the persistence contract required by GitRelay. Implementations may13+// use any durable or in-memory representation of Nostr events. The caller14+// owns the store; New never closes it.15+type Store interface {16+ // Query returns matching events by descending timestamp, then ascending ID,17+ // excluding expired, hidden and pending events without applying reader ACLs.18+ // now is the Unix expiry cutoff. A positive limit caps the result; f.Limit19+ // can impose a smaller cap. With neither limit, all matches are returned.20+ Query(context.Context, nostr.Filter, int64, int) ([]Event, error)21+ // Save durably records e using now for storage timestamps. Duplicate events22+ // return ErrDuplicate.23+ Save(context.Context, Event, int64) error24+ // Latest returns the newest addressable event for kind, pubkey and d.25+ // Ties use the lowest event ID. This includes expired, hidden and pending26+ // rows so recovery can check durable state independently of read visibility.27+ Latest(context.Context, int, string, string) (Event, bool, error)28+ // Exists reports whether id is present with the requested kind.29+ Exists(context.Context, string, int) (bool, error)30+ // Close releases resources owned by the store.31+ Close() error32+}33+34+// ErrDuplicate indicates that an event was already persisted.35+var ErrDuplicate = gitstore.ErrDuplicate36+37+// OpenStore opens the standard SQLite event schema behind the public Store38+// contract. The caller owns the returned store and must close it.39+func OpenStore(ctx context.Context, path string) (Store, error) { return gitstore.Open(ctx, path) }
A tinygit/test_policy_test.go +13 -0