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>
@@ -8,6 +8,12 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
88 99 [`cmd/tiny`](../cmd/tiny/doc.go) owns command dispatch, listeners and process shutdown. [`daemon`](../internal/daemon/doc.go) describes tenant assembly and event flow. Package overviews and interface, constructor and transaction comments describe the contracts at their declarations. These comments are also available through `go doc` and editor symbol help.1010 11+## Package layout12+13+Executable entry points live in `cmd/tiny`, `cmd/tinygit` and `cmd/tinyclient`. Host services and storage implementations live under `internal/`, where Go prevents outside projects from importing them.14+15+`tinygit`, `tinyclient` and `protocol/` are public so other Go projects can embed them. Separate executables alone do not require public packages. These packages expose the contracts an embedder needs; host policy, database access and tenant assembly remain internal. All packages belong to the root Go module.16+1117 ## Responsibilities1218 1319 | Packages | Responsibility |@@ -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 @@ -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 {@@ -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() }@@ -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+}@@ -0,0 +1,43 @@
1+// Package peerurl matches configured private peers across HTTP and WebSocket transports.2+package peerurl3+4+import (5+ "net/url"6+ "strings"7+)8+9+// Match returns the normalized configured base for a source URL.10+// WebSocket schemes are mapped to their HTTP equivalents because private Git11+// transport probes and signs HTTP repository roots.12+func Match(source string, peers []string) string {13+ u, err := url.Parse(source)14+ if err != nil {15+ return ""16+ }17+ for _, configured := range peers {18+ peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))19+ if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {20+ continue21+ }22+ base := strings.TrimRight(peer.Path, "/")23+ if base != "" && u.Path != base && !strings.HasPrefix(u.Path, base+"/") {24+ continue25+ }26+ peer.Scheme = normalizePeerScheme(peer.Scheme)27+ peer.Path = base28+ peer.RawPath = ""29+ return strings.TrimRight(peer.String(), "/")30+ }31+ return ""32+}33+34+func normalizePeerScheme(scheme string) string {35+ switch scheme {36+ case "ws":37+ return "http"38+ case "wss":39+ return "https"40+ default:41+ return scheme42+ }43+}@@ -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 }@@ -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 @@ -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(¤t); 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 {@@ -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+}@@ -6,9 +6,7 @@ import (
66 "net/url"77 "strings"88 9- "github.com/FelineStateMachine/tinyrelay/internal/event"10- "github.com/FelineStateMachine/tinyrelay/internal/policy"11- "github.com/FelineStateMachine/tinyrelay/internal/storage"9+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1210 )1311 1412 // validateArchiveAnnouncement implements the GRASP-05 admission distinction:@@ -27,10 +25,7 @@ func (g *GitRelay) validateArchiveAnnouncement(r Repository) error {
2725 if g.archiveRelated(context.Background(), r) {2826 return nil2927 }30- p := policy.Policy{}31- if g.policy != nil {32- p = g.policy()33- }28+ p := g.policyValue()3429 if len(r.Clone) == 0 && len(r.Relays) == 0 && p.Features.Grasp02 && p.Features.Grasp05 {3530 return nil3631 }@@ -62,11 +57,11 @@ func (g *GitRelay) archiveRelated(ctx context.Context, r Repository) bool {
6257 if g.store == nil {6358 return false6459 }65- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {r.Identifier}}}, storage.QueryOptions{Now: 0, Access: storage.Access{All: true}, Limit: 0})60+ q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {r.Identifier}}}, 0, 0)6661 if err != nil {6762 return false6863 }69- for _, candidate := range q.Events {64+ for _, candidate := range q {7065 parsed, parseErr := g.parseRepository(candidate)7166 if parseErr != nil || parsed.Identifier != r.Identifier {7267 continue@@ -31,7 +31,7 @@ func TestArchiveAnnouncementRequiresOptInWhenServiceConfigured(t *testing.T) {
3131 "enabled": {Features: policy.Features{Grasp02: true, Grasp05: true}},3232 } {3333 t.Run(name, func(t *testing.T) {34- g, err := New(Config{Store: store, Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }})34+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() Policy { return fromInternalPolicy(p) }})3535 if err != nil {3636 t.Fatal(err)3737 }@@ -58,7 +58,7 @@ func TestArchiveAdmissionRecognizesServiceAndMaintainerExceptions(t *testing.T)
5858 _, _ = event.PublicKey(ownerSecret)5959 maintainer, _ := event.PublicKey(maintainerSecret)6060 p := policy.Policy{Features: policy.Features{Grasp02: true, Grasp05: true}}61- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }, Authorize: func(context.Context, event.Event, Repository) error { return nil }})61+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), PublicURL: "https://relay.example", Policy: func() Policy { return fromInternalPolicy(p) }, Authorize: func(context.Context, Event, Repository) error { return nil }})6262 if err != nil {6363 t.Fatal(err)6464 }@@ -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+}@@ -40,11 +40,9 @@
4040 // hook used by the embedded engine. GET /healthz reports process readiness.4141 //4242 // New is the lower-level integration boundary. Supply an absolute Config.Root43-// and an OpenStore result, or share the host's existing Store. Event, Policy44-// and Store aliases make this API callable outside the tinyrelay module.45-// The package still depends on tinyrelay's internal event, policy, auth and46-// SQLite storage implementations and schema. It is a public package in the47-// root Go module, not a separate dependency-free module. Neither constructor48-// starts the daemon, relay protocol, UI or replication workers. Native Git49-// and a POSIX shell are required for smart HTTP and receive hooks.43+// and an OpenStore result, or provide a Store implementation for an embedded44+// host. Policy, Event and Store are public contracts; the standard SQLite45+// adapter and the host's richer policy remain behind those contracts. Neither46+// constructor starts the daemon, relay protocol, UI or replication workers.47+// Native Git and a POSIX shell are required for smart HTTP and receive hooks.5048 package tinygit@@ -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") }@@ -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 }@@ -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 }@@ -3,7 +3,6 @@ package tinygit
33 import (44 "context"55 "crypto/sha256"6- "database/sql"76 "encoding/hex"87 "encoding/json"98 "errors"@@ -96,9 +95,8 @@ func (g *GitRelay) recoverJournals() error {
9695 }9796 keepJournal := false9897 if jr.Kind == 30618 {99- var current string100- err := g.store.DB().QueryRowContext(context.Background(), `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=30618 ORDER BY events.created_at DESC,events.id ASC LIMIT 1`, id, owner).Scan(¤t)101- if errors.Is(err, sql.ErrNoRows) || current != jr.EventID {98+ current, found, err := g.store.Latest(context.Background(), 30618, owner, id)99+ if err != nil || !found || current.ID != jr.EventID {102100 if removeErr := os.Remove(filepath.Join(g.journalDir(), entry.Name())); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {103101 return removeErr104102 }@@ -28,7 +28,7 @@ func TestMaintainerSourceDecidesStateAuthority(t *testing.T) {
2828 defer store.Close()2929 vouched := map[string]bool{}3030 var asked []string31- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {31+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {3232 asked = append(asked, r.Identifier+":"+pubkey)3333 return vouched[pubkey]3434 })})@@ -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 }@@ -11,7 +11,7 @@ import (
1111 "strings"1212 "time"1313 14- "github.com/FelineStateMachine/tinyrelay/internal/event"14+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1515 )1616 1717 const (@@ -36,7 +36,7 @@ type PullRequestRepair struct {
3636 // Updates are accepted only from the PR author and must point to the root PR.3737 // This prevents an unrelated event from supplying a ref to a hosted PR.3838 func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Repository, set PullRequestRepair) error {39- root, err := pullRequestTarget(set.Root, event.KIND_GIT_PR)39+ root, err := pullRequestTarget(set.Root, 1618)4040 if err != nil {4141 return err4242 }@@ -263,7 +263,7 @@ func validatePullRequestUpdate(root, update event.Event) error {
263263 if err := event.Validate(update); err != nil {264264 return err265265 }266- if update.Kind != event.KIND_GIT_PR_UPDATE {266+ if update.Kind != 1619 {267267 return fmt.Errorf("unsupported: event kind %d is not a pull request update", update.Kind)268268 }269269 parent := event.Tag(update, "E")@@ -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)@@ -57,7 +57,7 @@ func TestFetchMissingHTTPSRepairAndNoop(t *testing.T) {
5757 }5858 t.Cleanup(func() { _ = store.Close() })5959 p := policy.Defaults("")60- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})60+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})6161 if err != nil {6262 t.Fatal(err)6363 }@@ -15,7 +15,7 @@ import (
1515 "os"1616 "strings"1717 18- "github.com/FelineStateMachine/tinyrelay/internal/policy"18+ "github.com/FelineStateMachine/tinyrelay/internal/peerurl"1919 "github.com/FelineStateMachine/tinyrelay/protocol/auth"2020 )2121 @@ -218,7 +218,7 @@ func (g *GitRelay) privatePeerBase(source string) string {
218218 g.mu.RLock()219219 peers := append([]string(nil), g.privatePeers...)220220 g.mu.RUnlock()221- return policy.PrivatePeerBase(source, peers)221+ return peerurl.Match(source, peers)222222 }223223 224224 func probePrivatePeer(ctx context.Context, source string, allowPrivate ...bool) error {@@ -5,6 +5,7 @@ import (
55 "path/filepath"66 "testing"77 8+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"89 "github.com/FelineStateMachine/tinyrelay/tinygit"910 )1011 @@ -15,7 +16,7 @@ func TestPublicStoreAcceptsRelativePath(t *testing.T) {
1516 t.Fatal(err)1617 }1718 defer store.Close()18- if err := store.DB().Ping(); err != nil {19+ if _, err := store.Query(context.Background(), nostr.Filter{}, 0, 0); err != nil {1920 t.Fatal(err)2021 }2122 }@@ -127,7 +127,7 @@ func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []str
127127 return errRepairByteLimit128128 }129129 // Private object IDs must never be requested from a public source.130- private := r.Private || (g.policy != nil && g.policy().Reads == "members")130+ private := r.Private || g.policyValue().Reads == "members"131131 if private && !g.privatePeer(raw) {132132 lastErr = errors.New("blocked: private Git source is not a configured peer")133133 continue@@ -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@@ -14,8 +14,7 @@ import (
1414 "strings"1515 "time"1616 17- "github.com/FelineStateMachine/tinyrelay/internal/event"18- "github.com/FelineStateMachine/tinyrelay/internal/policy"17+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1918 )2019 2120 // Service is the durable GRASP scheduler boundary. Tick is safe to call from@@ -160,7 +159,7 @@ func (s *Service) Tick(ctx context.Context) error {
160159 }161160 repoKey := key(r.Owner, r.Identifier)162161 status := tickRepository{AttemptedAt: progress.At, Attempts: 1}163- profile := s.relay.policy()162+ profile := s.relay.policyValue()164163 status.Fingerprint = repoFingerprint(r, profile)165164 if previous, ok := previousProgress.Repos[repoKey]; ok {166165 if previous.NextAt > progress.At && previous.Fingerprint == status.Fingerprint {@@ -225,7 +224,7 @@ func (s *Service) Tick(ctx context.Context) error {
225224 return tickErr226225 }227226 228-func repoFingerprint(r Repository, profile policy.Policy) string {227+func repoFingerprint(r Repository, profile Policy) string {229228 refs := make([]string, 0, len(r.Refs))230229 for ref, oid := range r.Refs {231230 refs = append(refs, ref+"="+oid)@@ -13,8 +13,7 @@ import (
1313 "sync"1414 "time"1515 16- "github.com/FelineStateMachine/tinyrelay/internal/event"17- "github.com/FelineStateMachine/tinyrelay/internal/storage"16+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"1817 )1918 2019 // ServerConfig configures the standalone public Git host. Use a dedicated@@ -30,7 +29,7 @@ type ServerConfig struct {
3029 // repository metadata. Other paths are handled by Git smart HTTP.3130 type Server struct {3231 git *GitRelay33- store *Store32+ store Store3433 publishMu sync.Mutex3534 }3635 @@ -86,7 +85,7 @@ func (s *Server) Publish(ctx context.Context, e Event) error {
8685 if err != nil {8786 return err8887 }89- if _, err := s.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil && !errors.Is(err, storage.ErrDuplicate) {88+ if err := s.store.Save(ctx, e, time.Now().Unix()); err != nil && !errors.Is(err, ErrDuplicate) {9089 return err9190 }9291 return s.git.CommitAfterStore(ctx, e, repo)@@ -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) }@@ -0,0 +1,13 @@
1+package tinygit2+3+import (4+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"5+ "github.com/FelineStateMachine/tinyrelay/internal/policy"6+ "github.com/FelineStateMachine/tinyrelay/internal/storage"7+)8+9+func tinyStore(store *storage.Store) Store { return gitstore.Wrap(store) }10+11+func fromInternalPolicy(p policy.Policy) Policy {12+ return Policy{Owner: p.Owner, Reads: p.Reads, PrivatePeers: append([]string(nil), p.PrivatePeers...), Features: 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}}13+}