tinyrelaycommit

Commit aff3977dcd4a

browse files at this commit | history

1diff --git a/docs/architecture.md b/docs/architecture.md
2index 64f089b..688d601 100644
3--- a/docs/architecture.md
4+++ b/docs/architecture.md
5@@ -8,6 +8,12 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
6
7 [`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.
8
9+## Package layout
10+
11+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.
12+
13+`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.
14+
15 ## Responsibilities
16
17 | Packages | Responsibility |
18diff --git a/docs/module-contracts.md b/docs/module-contracts.md
19index fc95c02..cc1dd2b 100644
20--- a/docs/module-contracts.md
21+++ b/docs/module-contracts.md
22@@ -53,7 +53,7 @@ Use small, consumer-owned interfaces for access decisions, persistence and optio
23
24 `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.
25
26-`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.
27+`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.
28
29 ## Describe standards and project extensions separately
30
31diff --git a/internal/daemon/services.go b/internal/daemon/services.go
32index 9b1e2a8..853d889 100644
33--- a/internal/daemon/services.go
34+++ b/internal/daemon/services.go
35@@ -20,6 +20,7 @@ import (
36 "github.com/FelineStateMachine/tinyrelay/internal/community"
37 "github.com/FelineStateMachine/tinyrelay/internal/configport"
38 "github.com/FelineStateMachine/tinyrelay/internal/event"
39+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"
40 "github.com/FelineStateMachine/tinyrelay/internal/policy"
41 "github.com/FelineStateMachine/tinyrelay/internal/records"
42 "github.com/FelineStateMachine/tinyrelay/internal/relay"
43@@ -33,6 +34,10 @@ import (
44 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
45 )
46
47+func gitrelayPolicy(p policy.Policy) gitrelay.Policy {
48+ 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}}
49+}
50+
51 // initServices constructs feature services around the tenant store and policy
52 // reader. Custom-view storage is initialized before Git can promote events;
53 // the resulting Git service is bound before handlers and workers start.
54@@ -158,7 +163,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
55 return err
56 }
57 t.followups = &eventFollowups{store: t.store, callbacks: t.callbacks, views: t.customViews, telemetry: t.app.telemetry, push: t.planReplicationPushFollowup}
58- 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 {
59+ 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 {
60 return t.releaseGit(ctx, id)
61 }})
62 if err != nil {
63diff --git a/internal/gitstore/store.go b/internal/gitstore/store.go
64new file mode 100644
65index 0000000..380f2e6
66--- /dev/null
67+++ b/internal/gitstore/store.go
68@@ -0,0 +1,69 @@
69+package gitstore
70+
71+import (
72+ "context"
73+ "database/sql"
74+ "errors"
75+ "path/filepath"
76+
77+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
78+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
79+)
80+
81+// ErrDuplicate indicates that an event was already persisted.
82+var ErrDuplicate = errors.New("duplicate event")
83+
84+// Store adapts the shared SQLite event store to the Git engine contract.
85+type Store struct{ store *storage.Store }
86+
87+// Open creates a caller-owned SQLite store.
88+func Open(ctx context.Context, path string) (*Store, error) {
89+ path, err := filepath.Abs(path)
90+ if err != nil {
91+ return nil, err
92+ }
93+ store, err := storage.Open(ctx, path)
94+ if err != nil {
95+ return nil, err
96+ }
97+ return &Store{store: store}, nil
98+}
99+
100+// Wrap adapts an existing host store without taking lifecycle ownership.
101+func Wrap(store *storage.Store) *Store { return &Store{store: store} }
102+
103+func (s *Store) Query(ctx context.Context, f nostr.Filter, now int64, limit int) ([]nostr.Event, error) {
104+ result, err := s.store.Query(ctx, f, storage.QueryOptions{Now: now, Limit: limit, Access: storage.Access{All: true}})
105+ return result.Events, err
106+}
107+func (s *Store) Save(ctx context.Context, e nostr.Event, now int64) error {
108+ _, err := s.store.Save(ctx, e, storage.SaveOptions{Now: now})
109+ if errors.Is(err, storage.ErrDuplicate) {
110+ return ErrDuplicate
111+ }
112+ return err
113+}
114+func (s *Store) Latest(ctx context.Context, kind int, pubkey, identifier string) (nostr.Event, bool, error) {
115+ var raw string
116+ 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)
117+ if errors.Is(err, sql.ErrNoRows) {
118+ return nostr.Event{}, false, nil
119+ }
120+ if err != nil {
121+ return nostr.Event{}, false, err
122+ }
123+ event, err := nostr.Parse([]byte(raw))
124+ if err != nil {
125+ return nostr.Event{}, false, err
126+ }
127+ return event, true, nil
128+}
129+func (s *Store) Exists(ctx context.Context, id string, kind int) (bool, error) {
130+ var found int
131+ err := s.store.DB().QueryRowContext(ctx, "SELECT 1 FROM events WHERE id=? AND kind=?", id, kind).Scan(&found)
132+ if errors.Is(err, sql.ErrNoRows) {
133+ return false, nil
134+ }
135+ return err == nil, err
136+}
137+func (s *Store) Close() error { return s.store.Close() }
138diff --git a/internal/gitstore/store_test.go b/internal/gitstore/store_test.go
139new file mode 100644
140index 0000000..df82961
141--- /dev/null
142+++ b/internal/gitstore/store_test.go
143@@ -0,0 +1,50 @@
144+package gitstore
145+
146+import (
147+ "context"
148+ "errors"
149+ "path/filepath"
150+ "strings"
151+ "testing"
152+
153+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
154+)
155+
156+func TestStorePreservesDurableMetadataAndReadVisibility(t *testing.T) {
157+ ctx := context.Background()
158+ store, err := Open(ctx, filepath.Join(t.TempDir(), "events.db"))
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ t.Cleanup(func() { _ = store.Close() })
163+ e := nostr.Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d", "repo"}}}
164+ if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
165+ t.Fatal(err)
166+ }
167+ if err := store.Save(ctx, e, 1); err != nil {
168+ t.Fatal(err)
169+ }
170+ if err := store.Save(ctx, e, 1); !errors.Is(err, ErrDuplicate) {
171+ t.Fatalf("duplicate save: %v", err)
172+ }
173+ if found, err := store.Exists(ctx, e.ID, 30618); err != nil || !found {
174+ t.Fatalf("exists=%v, error=%v", found, err)
175+ }
176+ if _, err := store.store.DB().ExecContext(ctx, `INSERT INTO pending_events(id,reason) VALUES(?,'git')`, e.ID); err != nil {
177+ t.Fatal(err)
178+ }
179+ rows, err := store.Query(ctx, nostr.Filter{Kinds: []int{30618}}, 1, 0)
180+ if err != nil || len(rows) != 0 {
181+ t.Fatalf("pending query=%v, error=%v", rows, err)
182+ }
183+ latest, found, err := store.Latest(ctx, 30618, e.PubKey, "repo")
184+ if err != nil || !found || latest.ID != e.ID {
185+ t.Fatalf("latest=%v, found=%v, error=%v", latest, found, err)
186+ }
187+ if _, err := store.store.DB().ExecContext(ctx, `UPDATE events SET raw='{}' WHERE id=?`, e.ID); err != nil {
188+ t.Fatal(err)
189+ }
190+ if _, _, err := store.Latest(ctx, 30618, e.PubKey, "repo"); err == nil {
191+ t.Fatal("accepted corrupt stored metadata")
192+ }
193+}
194diff --git a/internal/peerurl/peerurl.go b/internal/peerurl/peerurl.go
195new file mode 100644
196index 0000000..3ae07bd
197--- /dev/null
198+++ b/internal/peerurl/peerurl.go
199@@ -0,0 +1,43 @@
200+// Package peerurl matches configured private peers across HTTP and WebSocket transports.
201+package peerurl
202+
203+import (
204+ "net/url"
205+ "strings"
206+)
207+
208+// Match returns the normalized configured base for a source URL.
209+// WebSocket schemes are mapped to their HTTP equivalents because private Git
210+// transport probes and signs HTTP repository roots.
211+func Match(source string, peers []string) string {
212+ u, err := url.Parse(source)
213+ if err != nil {
214+ return ""
215+ }
216+ for _, configured := range peers {
217+ peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))
218+ if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {
219+ continue
220+ }
221+ base := strings.TrimRight(peer.Path, "/")
222+ if base != "" && u.Path != base && !strings.HasPrefix(u.Path, base+"/") {
223+ continue
224+ }
225+ peer.Scheme = normalizePeerScheme(peer.Scheme)
226+ peer.Path = base
227+ peer.RawPath = ""
228+ return strings.TrimRight(peer.String(), "/")
229+ }
230+ return ""
231+}
232+
233+func normalizePeerScheme(scheme string) string {
234+ switch scheme {
235+ case "ws":
236+ return "http"
237+ case "wss":
238+ return "https"
239+ default:
240+ return scheme
241+ }
242+}
243diff --git a/internal/policy/privacy.go b/internal/policy/privacy.go
244index 3851745..42c7943 100644
245--- a/internal/policy/privacy.go
246+++ b/internal/policy/privacy.go
247@@ -2,10 +2,9 @@ package policy
248
249 import (
250 "context"
251- "net/url"
252- "strings"
253
254 "github.com/FelineStateMachine/tinyrelay/internal/event"
255+ "github.com/FelineStateMachine/tinyrelay/internal/peerurl"
256 )
257
258 // PrivateRepositoryLookup supplies the stored repository privacy marker for
259@@ -33,38 +32,7 @@ func PrivateRepository(ctx context.Context, lookup PrivateRepositoryLookup, e ev
260 return lookup.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))
261 }
262
263-// PrivatePeerBase returns the normalized configured base for a source URL.
264-// WebSocket schemes are mapped to their HTTP equivalents because private Git
265-// transport probes and signs HTTP repository roots.
266+// PrivatePeerBase returns the configured HTTP base for a private peer URL.
267 func PrivatePeerBase(source string, peers []string) string {
268- u, err := url.Parse(source)
269- if err != nil {
270- return ""
271- }
272- for _, configured := range peers {
273- peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))
274- if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {
275- continue
276- }
277- base := strings.TrimRight(peer.Path, "/")
278- if base != "" && u.Path != base && !strings.HasPrefix(u.Path, base+"/") {
279- continue
280- }
281- peer.Scheme = normalizePeerScheme(peer.Scheme)
282- peer.Path = base
283- peer.RawPath = ""
284- return strings.TrimRight(peer.String(), "/")
285- }
286- return ""
287-}
288-
289-func normalizePeerScheme(scheme string) string {
290- switch scheme {
291- case "ws":
292- return "http"
293- case "wss":
294- return "https"
295- default:
296- return scheme
297- }
298+ return peerurl.Match(source, peers)
299 }
300diff --git a/tinygit/README.md b/tinygit/README.md
301index 3fc88fe..9aae68e 100644
302--- a/tinygit/README.md
303+++ b/tinygit/README.md
304@@ -50,11 +50,12 @@ Import `github.com/FelineStateMachine/tinyrelay/tinygit`.
305
306 - `OpenServer` owns the standalone service's store and returns an HTTP handler. Stop serving requests before calling `Close`.
307 - `New` constructs the lower-level engine using `Config`. The caller owns its store, policy, access decisions, synchronization transports and lifecycle.
308-- `OpenStore`, `Event`, `Policy` and `Store` make the public API usable from another Go module.
309+- `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.
310+- `Policy` contains only the Git capabilities, owner, read rule and private peers used by the engine. Adapt richer host policy in the host assembly.
311 - `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.
312 - `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.
313
314-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.
315+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).
316
317 ## Verify
318
319diff --git a/tinygit/admission.go b/tinygit/admission.go
320index b7bbc34..40265b9 100644
321--- a/tinygit/admission.go
322+++ b/tinygit/admission.go
323@@ -2,7 +2,6 @@ package tinygit
324
325 import (
326 "context"
327- "database/sql"
328 "encoding/json"
329 "errors"
330 "fmt"
331@@ -13,8 +12,7 @@ import (
332 "strings"
333 "time"
334
335- "github.com/FelineStateMachine/tinyrelay/internal/event"
336- "github.com/FelineStateMachine/tinyrelay/internal/storage"
337+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
338 )
339
340 // ValidateEvent checks the wire shape, canonical ID and signature, not
341@@ -98,16 +96,11 @@ func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repo
342 if e.Kind == 30617 {
343 // Announcements and ref state are separate addressable events. An
344 // announcement update must retain the current signed refs and HEAD.
345- var raw string
346- 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)
347- if err != nil && !errors.Is(err, sql.ErrNoRows) {
348+ state, found, err := g.store.Latest(ctx, 30618, e.PubKey, event.Tag(e, "d"))
349+ if err != nil {
350 return err
351 }
352- if err == nil {
353- state, err := event.Parse([]byte(raw))
354- if err != nil {
355- return err
356- }
357+ if found {
358 stateRepo, err := g.parseRepository(state)
359 if err != nil {
360 return err
361@@ -154,15 +147,11 @@ func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, er
362 if e.Kind != 30617 && e.Kind != 30618 {
363 return true, nil
364 }
365- var id string
366- 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)
367- if errors.Is(err, sql.ErrNoRows) {
368- return false, nil
369- }
370- if err != nil {
371+ current, found, err := g.store.Latest(ctx, e.Kind, e.PubKey, event.Tag(e, "d"))
372+ if err != nil || !found {
373 return false, err
374 }
375- return id == e.ID, nil
376+ return current.ID == e.ID, nil
377 }
378
379 // IsPending lets the event admission/read layer hide a metadata event until
380@@ -268,11 +257,11 @@ func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error {
381 }
382
383 func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) {
384- var current int
385- if err := g.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM events WHERE id=? AND kind=30618`, repo.EventID).Scan(&current); err != nil {
386+ found, err := g.store.Exists(ctx, repo.EventID, 30618)
387+ if err != nil {
388 return false, err
389 }
390- if current == 0 {
391+ if !found {
392 return false, nil
393 }
394 if !g.stateObjectsPresent(ctx, repo) {
395@@ -307,7 +296,7 @@ func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
396 return err
397 }
398 if e.Kind != 30617 && e.Kind != 30618 {
399- if !((g.grasp06 || g.policy().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {
400+ if !((g.grasp06 || g.policyValue().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {
401 return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
402 }
403 g.commitMu.Lock()
404@@ -318,7 +307,7 @@ func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
405 if err != nil {
406 return err
407 }
408- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
409+ if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {
410 return err
411 }
412 return g.CommitAfterStore(ctx, e, repo)
413@@ -344,14 +333,14 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
414 ann := g.repos[key(e.PubKey, id)]
415 g.mu.RUnlock()
416 if ann.Owner == "" {
417- 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})
418+ q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 0)
419 if err != nil {
420 return Repository{}, err
421 }
422- if len(q.Events) == 0 {
423+ if len(q) == 0 {
424 return Repository{}, errors.New("blocked: repository announcement is missing")
425 }
426- for _, candidate := range q.Events {
427+ for _, candidate := range q {
428 ann, err = g.parseRepository(candidate)
429 if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {
430 break
431@@ -383,7 +372,7 @@ var ErrIncomplete = errors.New("synchronization incomplete")
432
433 // Capabilities returns only protocol surfaces that this instance can serve.
434 func (g *GitRelay) Capabilities() []string {
435- p := g.policy()
436+ p := g.policyValue()
437 c := []string{"GRASP-01"}
438 if p.Features.Grasp02 && g.eventSync != nil {
439 c = append(c, "GRASP-02")
440@@ -458,12 +447,12 @@ func (g *GitRelay) Reload(ctx context.Context) error {
441 if err := g.recoverJournals(); err != nil {
442 return err
443 }
444- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
445+ q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, time.Now().Unix(), 0)
446 if err != nil {
447 return err
448 }
449 repos := make(map[string]Repository)
450- for _, raw := range q.Events {
451+ for _, raw := range q {
452 r, parseErr := g.parseRepository(raw)
453 if parseErr == nil {
454 repos[key(r.Owner, r.Identifier)] = r
455@@ -500,25 +489,25 @@ func (g *GitRelay) Reload(ctx context.Context) error {
456 } else if !errors.Is(err, os.ErrNotExist) {
457 return err
458 }
459- 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})
460+ 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)
461 if stateErr != nil {
462 return stateErr
463 }
464- if len(states.Events) == 0 {
465+ if len(states) == 0 {
466 continue
467 }
468- sort.Slice(states.Events, func(i, j int) bool { return states.Events[i].CreatedAt > states.Events[j].CreatedAt })
469- state, parseErr := g.parseRepository(states.Events[0])
470+ sort.Slice(states, func(i, j int) bool { return states[i].CreatedAt > states[j].CreatedAt })
471+ state, parseErr := g.parseRepository(states[0])
472 if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) {
473 continue
474 }
475 repos[coordinate] = state
476 }
477- 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})
478+ prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, time.Now().Unix(), 0)
479 if prErr != nil {
480 return prErr
481 }
482- for _, raw := range prs.Events {
483+ for _, raw := range prs {
484 identifier := event.Tag(raw, "d")
485 if identifier == "" {
486 identifier = event.Tag(raw, "a")
487@@ -587,7 +576,7 @@ func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error {
488 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 {
489 return err
490 }
491- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
492+ if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {
493 return err
494 }
495 if err := g.ensureRepo(r); err != nil {
496diff --git a/tinygit/api.go b/tinygit/api.go
497index b33aded..79d6dac 100644
498--- a/tinygit/api.go
499+++ b/tinygit/api.go
500@@ -1,11 +1,6 @@
501 package tinygit
502
503 import (
504- "context"
505- "path/filepath"
506-
507- "github.com/FelineStateMachine/tinyrelay/internal/policy"
508- "github.com/FelineStateMachine/tinyrelay/internal/storage"
509 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
510 )
511
512@@ -13,24 +8,37 @@ import (
513 // sign events themselves; the server never needs their private keys.
514 type Event = nostr.Event
515
516-// Policy configures the embedded engine. It currently shares tinyrelay's
517-// policy model; DefaultPolicy provides the initial values.
518-type Policy = policy.Policy
519+// Policy contains the policy values used by the Git engine. Hosts may keep a
520+// richer policy internally and adapt these values at the integration boundary.
521+type Policy struct {
522+ Owner string
523+ Reads string
524+ PrivatePeers []string
525+ Features PolicyFeatures
526+}
527
528-// Store is the SQLite event store used by the engine. This alias preserves
529-// shared transactions with tinyrelay; it is not a pluggable storage interface.
530-type Store = storage.Store
531+// PolicyFeatures selects optional Git protocol capabilities.
532+type PolicyFeatures struct {
533+ Grasp bool
534+ Grasp02 bool
535+ Grasp03 bool
536+ Grasp05 bool
537+ Grasp06 bool
538+ Grasp08 bool
539+}
540
541-// OpenStore opens the shared event schema without starting any relay services.
542-// The caller owns the returned store and must close it after stopping requests.
543-func OpenStore(ctx context.Context, path string) (*Store, error) {
544- path, err := filepath.Abs(path)
545- if err != nil {
546- return nil, err
547- }
548- return storage.Open(ctx, path)
549+func (p Policy) PrivateServiceEnabled() bool {
550+ return p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members"
551 }
552
553-// DefaultPolicy returns the embedded engine's default host policy. Hosting
554-// admission and private HTTP authorization still require Config callbacks.
555-func DefaultPolicy(owner string) Policy { return policy.Defaults(owner) }
556+// DefaultPolicy returns the defaults for an embedded engine.
557+func DefaultPolicy(owner string) Policy {
558+ return Policy{Owner: owner, Reads: "open"}
559+}
560+
561+func (g *GitRelay) policyValue() Policy {
562+ if g.policy == nil {
563+ return DefaultPolicy("")
564+ }
565+ return g.policy()
566+}
567diff --git a/tinygit/archive.go b/tinygit/archive.go
568index 5e28366..a3aa1c0 100644
569--- a/tinygit/archive.go
570+++ b/tinygit/archive.go
571@@ -6,9 +6,7 @@ import (
572 "net/url"
573 "strings"
574
575- "github.com/FelineStateMachine/tinyrelay/internal/event"
576- "github.com/FelineStateMachine/tinyrelay/internal/policy"
577- "github.com/FelineStateMachine/tinyrelay/internal/storage"
578+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
579 )
580
581 // validateArchiveAnnouncement implements the GRASP-05 admission distinction:
582@@ -27,10 +25,7 @@ func (g *GitRelay) validateArchiveAnnouncement(r Repository) error {
583 if g.archiveRelated(context.Background(), r) {
584 return nil
585 }
586- p := policy.Policy{}
587- if g.policy != nil {
588- p = g.policy()
589- }
590+ p := g.policyValue()
591 if len(r.Clone) == 0 && len(r.Relays) == 0 && p.Features.Grasp02 && p.Features.Grasp05 {
592 return nil
593 }
594@@ -62,11 +57,11 @@ func (g *GitRelay) archiveRelated(ctx context.Context, r Repository) bool {
595 if g.store == nil {
596 return false
597 }
598- 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})
599+ q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {r.Identifier}}}, 0, 0)
600 if err != nil {
601 return false
602 }
603- for _, candidate := range q.Events {
604+ for _, candidate := range q {
605 parsed, parseErr := g.parseRepository(candidate)
606 if parseErr != nil || parsed.Identifier != r.Identifier {
607 continue
608diff --git a/tinygit/archive_test.go b/tinygit/archive_test.go
609index b52bb1a..e7acdca 100644
610--- a/tinygit/archive_test.go
611+++ b/tinygit/archive_test.go
612@@ -31,7 +31,7 @@ func TestArchiveAnnouncementRequiresOptInWhenServiceConfigured(t *testing.T) {
613 "enabled": {Features: policy.Features{Grasp02: true, Grasp05: true}},
614 } {
615 t.Run(name, func(t *testing.T) {
616- g, err := New(Config{Store: store, Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }})
617+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() Policy { return fromInternalPolicy(p) }})
618 if err != nil {
619 t.Fatal(err)
620 }
621@@ -58,7 +58,7 @@ func TestArchiveAdmissionRecognizesServiceAndMaintainerExceptions(t *testing.T)
622 _, _ = event.PublicKey(ownerSecret)
623 maintainer, _ := event.PublicKey(maintainerSecret)
624 p := policy.Policy{Features: policy.Features{Grasp02: true, Grasp05: true}}
625- 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 }})
626+ 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 }})
627 if err != nil {
628 t.Fatal(err)
629 }
630diff --git a/tinygit/capabilities_test.go b/tinygit/capabilities_test.go
631index b14c8eb..c28cbab 100644
632--- a/tinygit/capabilities_test.go
633+++ b/tinygit/capabilities_test.go
634@@ -10,7 +10,7 @@ import (
635
636 func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
637 p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}
638- g := &GitRelay{policy: func() policy.Policy { return p }}
639+ g := &GitRelay{policy: func() Policy { return fromInternalPolicy(p) }}
640 if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
641 t.Fatalf("without event transport: %v", got)
642 }
643@@ -26,9 +26,20 @@ func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
644
645 func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {
646 p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}
647- g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}
648+ g := &GitRelay{policy: func() Policy { return fromInternalPolicy(p) }, eventSync: func(context.Context, Repository) error { return nil }}
649 want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}
650 if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {
651 t.Fatalf("private profiles: %v", got)
652 }
653 }
654+
655+func TestPolicyPrivateServiceRequiresGRASP(t *testing.T) {
656+ p := Policy{Reads: "members", Features: PolicyFeatures{Grasp08: true}}
657+ if p.PrivateServiceEnabled() {
658+ t.Fatal("private service enabled without GRASP")
659+ }
660+ p.Features.Grasp = true
661+ if !p.PrivateServiceEnabled() {
662+ t.Fatal("private service not enabled with complete profile")
663+ }
664+}
665diff --git a/tinygit/doc.go b/tinygit/doc.go
666index 4bb129c..d4fb23b 100644
667--- a/tinygit/doc.go
668+++ b/tinygit/doc.go
669@@ -40,11 +40,9 @@
670 // hook used by the embedded engine. GET /healthz reports process readiness.
671 //
672 // New is the lower-level integration boundary. Supply an absolute Config.Root
673-// and an OpenStore result, or share the host's existing Store. Event, Policy
674-// and Store aliases make this API callable outside the tinyrelay module.
675-// The package still depends on tinyrelay's internal event, policy, auth and
676-// SQLite storage implementations and schema. It is a public package in the
677-// root Go module, not a separate dependency-free module. Neither constructor
678-// starts the daemon, relay protocol, UI or replication workers. Native Git
679-// and a POSIX shell are required for smart HTTP and receive hooks.
680+// and an OpenStore result, or provide a Store implementation for an embedded
681+// host. Policy, Event and Store are public contracts; the standard SQLite
682+// adapter and the host's richer policy remain behind those contracts. Neither
683+// constructor starts the daemon, relay protocol, UI or replication workers.
684+// Native Git and a POSIX shell are required for smart HTTP and receive hooks.
685 package tinygit
686diff --git a/tinygit/external_test.go b/tinygit/external_test.go
687index 2fd62c7..bfacd4b 100644
688--- a/tinygit/external_test.go
689+++ b/tinygit/external_test.go
690@@ -29,17 +29,24 @@ import (
691 "strings"
692 "testing"
693 "github.com/FelineStateMachine/tinyrelay/tinygit"
694+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
695 )
696+type fakeStore struct{}
697+func (fakeStore) Query(context.Context, nostr.Filter, int64, int) ([]tinygit.Event, error) { return nil, nil }
698+func (fakeStore) Save(context.Context, tinygit.Event, int64) error { return nil }
699+func (fakeStore) Latest(context.Context, int, string, string) (tinygit.Event, bool, error) { return tinygit.Event{}, false, nil }
700+func (fakeStore) Exists(context.Context, string, int) (bool, error) { return false, nil }
701+func (fakeStore) Close() error { return nil }
702 func TestUse(t *testing.T) {
703 ctx := context.Background()
704 dir := t.TempDir()
705- var store *tinygit.Store
706+ var store tinygit.Store
707 var err error
708 store, err = tinygit.OpenStore(ctx, filepath.Join(dir, "events.db"))
709 if err != nil { t.Fatal(err) }
710 defer store.Close()
711 p := tinygit.DefaultPolicy("")
712- 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 }})
713+ 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 }})
714 if err != nil { t.Fatal(err) }
715 var _ http.Handler = g
716 if err := g.Publish(ctx, tinygit.Event{}); err == nil { t.Fatal("invalid event accepted") }
717diff --git a/tinygit/gitrelay.go b/tinygit/gitrelay.go
718index f4d08af..cb35682 100644
719--- a/tinygit/gitrelay.go
720+++ b/tinygit/gitrelay.go
721@@ -9,9 +9,7 @@ import (
722 "strings"
723 "sync"
724
725- "github.com/FelineStateMachine/tinyrelay/internal/event"
726- "github.com/FelineStateMachine/tinyrelay/internal/policy"
727- "github.com/FelineStateMachine/tinyrelay/internal/storage"
728+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
729 )
730
731 // Repository is the resolved authority and Git state for one hosted project.
732@@ -48,7 +46,7 @@ type MaintainerSource interface {
733
734 // Config supplies the event store, Git root, authorization and transport hooks.
735 type Config struct {
736- Store *Store
737+ Store Store
738 Root string
739 Policy func() Policy
740 Authorize func(context.Context, Event, Repository) error
741@@ -93,9 +91,9 @@ type Config struct {
742
743 // GitRelay coordinates signed repository events with native bare repositories.
744 type GitRelay struct {
745- store *storage.Store
746+ store Store
747 root string
748- policy func() policy.Policy
749+ policy func() Policy
750 authorize func(context.Context, event.Event, Repository) error
751 authorizeHTTP func(context.Context, *http.Request, Repository) error
752 maintainers MaintainerSource
753@@ -121,6 +119,7 @@ func New(cfg Config) (*GitRelay, error) {
754 if cfg.Store == nil {
755 return nil, errors.New("git relay: store is required")
756 }
757+ store := cfg.Store
758 if cfg.Root == "" {
759 return nil, errors.New("git relay: root is required")
760 }
761@@ -129,13 +128,13 @@ func New(cfg Config) (*GitRelay, error) {
762 }
763 p := cfg.Policy
764 if p == nil {
765- p = func() policy.Policy { return policy.Defaults("") }
766+ p = func() Policy { return DefaultPolicy("") }
767 }
768 serviceURL := cfg.PublicURL
769 if serviceURL == "" {
770 serviceURL = cfg.ServiceURL
771 }
772- 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{})}
773+ 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{})}
774 if err := g.recoverJournals(); err != nil {
775 return nil, err
776 }
777diff --git a/tinygit/gitrelay_test.go b/tinygit/gitrelay_test.go
778index d15d3b6..b9bbf44 100644
779--- a/tinygit/gitrelay_test.go
780+++ b/tinygit/gitrelay_test.go
781@@ -37,7 +37,7 @@ func TestPublishStagesRepositoryAndSignedStateHook(t *testing.T) {
782 t.Fatal(err)
783 }
784 defer store.Close()
785- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
786+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
787 if err != nil {
788 t.Fatal(err)
789 }
790@@ -81,7 +81,7 @@ func TestStateHEADSelectsNonMainBranch(t *testing.T) {
791 t.Fatal(err)
792 }
793 defer store.Close()
794- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
795+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
796 if err != nil {
797 t.Fatal(err)
798 }
799@@ -118,7 +118,7 @@ func TestStateProjectsExistingObjectsIntoNativeRefs(t *testing.T) {
800 t.Fatal(err)
801 }
802 defer store.Close()
803- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
804+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
805 if err != nil {
806 t.Fatal(err)
807 }
808@@ -188,7 +188,7 @@ func TestPendingPromotionRemovesRefsDroppedByState(t *testing.T) {
809 t.Fatal(err)
810 }
811 defer store.Close()
812- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
813+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
814 if err != nil {
815 t.Fatal(err)
816 }
817@@ -240,7 +240,7 @@ func TestStaleStateWorkerCannotRewindHEAD(t *testing.T) {
818 t.Fatal(err)
819 }
820 defer store.Close()
821- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
822+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
823 if err != nil {
824 t.Fatal(err)
825 }
826@@ -294,7 +294,7 @@ func TestObjectsPresentBatchesManyRefsAndDetectsMissing(t *testing.T) {
827 t.Fatal(err)
828 }
829 defer store.Close()
830- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
831+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
832 if err != nil {
833 t.Fatal(err)
834 }
835@@ -328,7 +328,7 @@ func TestAdmitSourceRejectsUnsafeEndpoints(t *testing.T) {
836 t.Fatal(err)
837 }
838 defer store.Close()
839- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})
840+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})
841 if err != nil {
842 t.Fatal(err)
843 }
844@@ -350,7 +350,7 @@ func TestAlternativePRPublicationUsesSeparateRepository(t *testing.T) {
845 t.Fatal(err)
846 }
847 defer store.Close()
848- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), EnableGRASP06: true})
849+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), EnableGRASP06: true})
850 if err != nil {
851 t.Fatal(err)
852 }
853@@ -429,7 +429,7 @@ func TestJournalRecoveryCompletesStateFile(t *testing.T) {
854 t.Fatal(err)
855 }
856 defer store.Close()
857- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
858+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
859 if err != nil {
860 t.Fatal(err)
861 }
862@@ -450,7 +450,7 @@ func TestJournalRecoveryCompletesStateFile(t *testing.T) {
863 if err := g.writeJournal(r, jr); err != nil {
864 t.Fatal(err)
865 }
866- if _, err := New(Config{Store: store, Root: filepath.Join(root, "git")}); err != nil {
867+ if _, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")}); err != nil {
868 t.Fatal(err)
869 }
870 b, err := os.ReadFile(filepath.Join(root, "git", r.Owner, "demo.git", "tinyrelay.pending"))
871@@ -468,7 +468,7 @@ func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {
872 defer store.Close()
873 secret := strings.Repeat("0", 63) + "1"
874 owner, _ := event.PublicKey(secret)
875- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
876+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
877 if err != nil {
878 t.Fatal(err)
879 }
880@@ -508,7 +508,7 @@ func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {
881 if out, err := exec.Command("git", "--git-dir", repoPath, "update-ref", "refs/heads/unannounced", strings.TrimSpace(string(commit))).CombinedOutput(); err != nil {
882 t.Fatalf("stage unannounced ref: %v %s", err, out)
883 }
884- restarted, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
885+ restarted, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
886 if err != nil {
887 t.Fatal(err)
888 }
889@@ -543,7 +543,7 @@ func TestGRASPServiceTickPersistsProgressAndRepairOrder(t *testing.T) {
890 p := policy.Defaults("")
891 p.Features.Grasp = true
892 p.Features.Grasp02 = true
893- 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 }})
894+ 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 }})
895 if err != nil {
896 t.Fatal(err)
897 }
898@@ -577,7 +577,7 @@ func TestGRASPServiceTickContinuesAfterRepositoryFailure(t *testing.T) {
899 p.Features.Grasp = true
900 p.Features.Grasp02 = true
901 var synced []string
902- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, GitSync: func(_ context.Context, r Repository) error {
903+ 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 {
904 synced = append(synced, r.Identifier)
905 if r.Identifier == "first" {
906 return errors.New("temporary source failure")
907@@ -648,7 +648,7 @@ func TestValidateAndCommitAfterStoreAvoidDuplicateSave(t *testing.T) {
908 t.Fatal(err)
909 }
910 defer store.Close()
911- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
912+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
913 if err != nil {
914 t.Fatal(err)
915 }
916@@ -680,7 +680,7 @@ func TestSmartHTTPServesBareRepository(t *testing.T) {
917 t.Fatal(err)
918 }
919 defer store.Close()
920- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
921+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
922 if err != nil {
923 t.Fatal(err)
924 }
925@@ -752,7 +752,7 @@ func TestPendingStatePromotesAfterAuthorizedGitPush(t *testing.T) {
926 t.Fatal(err)
927 }
928 defer store.Close()
929- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
930+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})
931 if err != nil {
932 t.Fatal(err)
933 }
934diff --git a/tinygit/journal.go b/tinygit/journal.go
935index 33ebe61..52a3dda 100644
936--- a/tinygit/journal.go
937+++ b/tinygit/journal.go
938@@ -3,7 +3,6 @@ package tinygit
939 import (
940 "context"
941 "crypto/sha256"
942- "database/sql"
943 "encoding/hex"
944 "encoding/json"
945 "errors"
946@@ -96,9 +95,8 @@ func (g *GitRelay) recoverJournals() error {
947 }
948 keepJournal := false
949 if jr.Kind == 30618 {
950- var current string
951- 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(&current)
952- if errors.Is(err, sql.ErrNoRows) || current != jr.EventID {
953+ current, found, err := g.store.Latest(context.Background(), 30618, owner, id)
954+ if err != nil || !found || current.ID != jr.EventID {
955 if removeErr := os.Remove(filepath.Join(g.journalDir(), entry.Name())); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
956 return removeErr
957 }
958diff --git a/tinygit/maintainers_test.go b/tinygit/maintainers_test.go
959index 8ea541a..15c569d 100644
960--- a/tinygit/maintainers_test.go
961+++ b/tinygit/maintainers_test.go
962@@ -28,7 +28,7 @@ func TestMaintainerSourceDecidesStateAuthority(t *testing.T) {
963 defer store.Close()
964 vouched := map[string]bool{}
965 var asked []string
966- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {
967+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {
968 asked = append(asked, r.Identifier+":"+pubkey)
969 return vouched[pubkey]
970 })})
971diff --git a/tinygit/metadata_admission_test.go b/tinygit/metadata_admission_test.go
972index 8388fd8..4f2f7b7 100644
973--- a/tinygit/metadata_admission_test.go
974+++ b/tinygit/metadata_admission_test.go
975@@ -8,18 +8,19 @@ import (
976 "testing"
977
978 "github.com/FelineStateMachine/tinyrelay/internal/event"
979+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
980 )
981
982 func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) {
983 ctx := context.Background()
984- store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db"))
985+ store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db"))
986 if err != nil {
987 t.Fatal(err)
988 }
989 defer store.Close()
990 calls := 0
991 denied := errors.New("blocked: host does not accept this repository")
992- g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {
993+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {
994 calls++
995 return denied
996 }})
997@@ -61,9 +62,9 @@ func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) {
998 }
999 }
1000 }
1001- var count int
1002- if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events").Scan(&count); err != nil || count != 0 {
1003- t.Fatalf("rejected metadata persisted: count=%d err=%v", count, err)
1004+ result, err := store.Query(ctx, event.Filter{}, storage.QueryOptions{})
1005+ if err != nil || len(result.Events) != 0 {
1006+ t.Fatalf("rejected metadata persisted: count=%d err=%v", len(result.Events), err)
1007 }
1008 }
1009
1010@@ -81,14 +82,14 @@ func TestMetadataShapePrecedesRepositoryLookup(t *testing.T) {
1011
1012 func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) {
1013 ctx := context.Background()
1014- store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db"))
1015+ store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db"))
1016 if err != nil {
1017 t.Fatal(err)
1018 }
1019 defer store.Close()
1020 calls := 0
1021 denied := errors.New("host denial")
1022- g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {
1023+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error {
1024 calls++
1025 return denied
1026 }})
1027@@ -118,9 +119,9 @@ func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) {
1028 if calls != 0 {
1029 t.Errorf("host authorizer called %d times for invalid shape", calls)
1030 }
1031- var count int
1032- if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE id=?", e.ID).Scan(&count); err != nil || count != 0 {
1033- t.Fatalf("invalid shape persisted: count=%d err=%v", count, err)
1034+ result, err := store.Query(ctx, event.Filter{IDs: []string{e.ID}}, storage.QueryOptions{})
1035+ if err != nil || len(result.Events) != 0 {
1036+ t.Fatalf("invalid shape persisted: count=%d err=%v", len(result.Events), err)
1037 }
1038 })
1039 }
1040diff --git a/tinygit/pr_repair.go b/tinygit/pr_repair.go
1041index 677852d..0045728 100644
1042--- a/tinygit/pr_repair.go
1043+++ b/tinygit/pr_repair.go
1044@@ -11,7 +11,7 @@ import (
1045 "strings"
1046 "time"
1047
1048- "github.com/FelineStateMachine/tinyrelay/internal/event"
1049+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1050 )
1051
1052 const (
1053@@ -36,7 +36,7 @@ type PullRequestRepair struct {
1054 // Updates are accepted only from the PR author and must point to the root PR.
1055 // This prevents an unrelated event from supplying a ref to a hosted PR.
1056 func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Repository, set PullRequestRepair) error {
1057- root, err := pullRequestTarget(set.Root, event.KIND_GIT_PR)
1058+ root, err := pullRequestTarget(set.Root, 1618)
1059 if err != nil {
1060 return err
1061 }
1062@@ -263,7 +263,7 @@ func validatePullRequestUpdate(root, update event.Event) error {
1063 if err := event.Validate(update); err != nil {
1064 return err
1065 }
1066- if update.Kind != event.KIND_GIT_PR_UPDATE {
1067+ if update.Kind != 1619 {
1068 return fmt.Errorf("unsupported: event kind %d is not a pull request update", update.Kind)
1069 }
1070 parent := event.Tag(update, "E")
1071diff --git a/tinygit/pr_repair_test.go b/tinygit/pr_repair_test.go
1072index be42c10..86d8f86 100644
1073--- a/tinygit/pr_repair_test.go
1074+++ b/tinygit/pr_repair_test.go
1075@@ -108,7 +108,7 @@ func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {
1076 }
1077 t.Cleanup(func() { _ = store.Close() })
1078 p := policy.Defaults("")
1079- g, err := New(Config{Store: store, Root: filepath.Join(rootDir, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
1080+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(rootDir, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})
1081 if err != nil {
1082 t.Fatal(err)
1083 }
1084@@ -298,7 +298,7 @@ func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {
1085 t.Cleanup(func() { _ = store.Close() })
1086 p := policy.Defaults("")
1087 p.Reads = "members"
1088- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
1089+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})
1090 if err != nil {
1091 t.Fatal(err)
1092 }
1093@@ -321,7 +321,7 @@ func TestGRASPTickResumesAfterCanceledRepository(t *testing.T) {
1094 var cancel context.CancelFunc
1095 g := &GitRelay{
1096 root: t.TempDir(),
1097- policy: func() policy.Policy { return p },
1098+ policy: func() Policy { return fromInternalPolicy(p) },
1099 repos: map[string]Repository{key(first.Owner, first.Identifier): first, key(second.Owner, second.Identifier): second},
1100 gitSync: func(ctx context.Context, repo Repository) error {
1101 calls = append(calls, repo.Identifier)
1102diff --git a/tinygit/private_repair_acceptance_test.go b/tinygit/private_repair_acceptance_test.go
1103index 87cf9bd..a54cb54 100644
1104--- a/tinygit/private_repair_acceptance_test.go
1105+++ b/tinygit/private_repair_acceptance_test.go
1106@@ -57,7 +57,7 @@ func TestFetchMissingHTTPSRepairAndNoop(t *testing.T) {
1107 }
1108 t.Cleanup(func() { _ = store.Close() })
1109 p := policy.Defaults("")
1110- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
1111+ g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})
1112 if err != nil {
1113 t.Fatal(err)
1114 }
1115diff --git a/tinygit/private_transport.go b/tinygit/private_transport.go
1116index 6390f9d..4cc2b53 100644
1117--- a/tinygit/private_transport.go
1118+++ b/tinygit/private_transport.go
1119@@ -15,7 +15,7 @@ import (
1120 "os"
1121 "strings"
1122
1123- "github.com/FelineStateMachine/tinyrelay/internal/policy"
1124+ "github.com/FelineStateMachine/tinyrelay/internal/peerurl"
1125 "github.com/FelineStateMachine/tinyrelay/protocol/auth"
1126 )
1127
1128@@ -218,7 +218,7 @@ func (g *GitRelay) privatePeerBase(source string) string {
1129 g.mu.RLock()
1130 peers := append([]string(nil), g.privatePeers...)
1131 g.mu.RUnlock()
1132- return policy.PrivatePeerBase(source, peers)
1133+ return peerurl.Match(source, peers)
1134 }
1135
1136 func probePrivatePeer(ctx context.Context, source string, allowPrivate ...bool) error {
1137diff --git a/tinygit/public_test.go b/tinygit/public_test.go
1138index c741e7f..b645009 100644
1139--- a/tinygit/public_test.go
1140+++ b/tinygit/public_test.go
1141@@ -5,6 +5,7 @@ import (
1142 "path/filepath"
1143 "testing"
1144
1145+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1146 "github.com/FelineStateMachine/tinyrelay/tinygit"
1147 )
1148
1149@@ -15,7 +16,7 @@ func TestPublicStoreAcceptsRelativePath(t *testing.T) {
1150 t.Fatal(err)
1151 }
1152 defer store.Close()
1153- if err := store.DB().Ping(); err != nil {
1154+ if _, err := store.Query(context.Background(), nostr.Filter{}, 0, 0); err != nil {
1155 t.Fatal(err)
1156 }
1157 }
1158diff --git a/tinygit/repair.go b/tinygit/repair.go
1159index d5e2112..dee8d01 100644
1160--- a/tinygit/repair.go
1161+++ b/tinygit/repair.go
1162@@ -127,7 +127,7 @@ func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []str
1163 return errRepairByteLimit
1164 }
1165 // Private object IDs must never be requested from a public source.
1166- private := r.Private || (g.policy != nil && g.policy().Reads == "members")
1167+ private := r.Private || g.policyValue().Reads == "members"
1168 if private && !g.privatePeer(raw) {
1169 lastErr = errors.New("blocked: private Git source is not a configured peer")
1170 continue
1171diff --git a/tinygit/repository.go b/tinygit/repository.go
1172index 690a086..6f3e38d 100644
1173--- a/tinygit/repository.go
1174+++ b/tinygit/repository.go
1175@@ -11,8 +11,7 @@ import (
1176 "strings"
1177 "time"
1178
1179- "github.com/FelineStateMachine/tinyrelay/internal/event"
1180- "github.com/FelineStateMachine/tinyrelay/internal/storage"
1181+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1182 )
1183
1184 func (g *GitRelay) repoPath(r Repository) string {
1185@@ -317,14 +316,14 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
1186 if ok {
1187 return r, nil
1188 }
1189- 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})
1190+ 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)
1191 if err != nil {
1192 return Repository{}, err
1193 }
1194- if len(q.Events) == 0 {
1195+ if len(q) == 0 {
1196 return Repository{}, os.ErrNotExist
1197 }
1198- r, err = g.parseRepository(q.Events[0])
1199+ r, err = g.parseRepository(q[0])
1200 if err != nil {
1201 return Repository{}, err
1202 }
1203@@ -333,9 +332,9 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
1204 // receive-pack request can stage its authorization hook synchronously.
1205 // A maintainer, including an agent the host vouches for, may have signed
1206 // it; parseRepository rejects any author who is not one.
1207- 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})
1208+ states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 8)
1209 if stateErr == nil {
1210- for _, candidate := range states.Events {
1211+ for _, candidate := range states {
1212 state, parseErr := g.parseRepository(candidate)
1213 if parseErr != nil || state.Owner != owner {
1214 continue
1215diff --git a/tinygit/scheduler.go b/tinygit/scheduler.go
1216index d71d7af..fbdecf8 100644
1217--- a/tinygit/scheduler.go
1218+++ b/tinygit/scheduler.go
1219@@ -14,8 +14,7 @@ import (
1220 "strings"
1221 "time"
1222
1223- "github.com/FelineStateMachine/tinyrelay/internal/event"
1224- "github.com/FelineStateMachine/tinyrelay/internal/policy"
1225+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1226 )
1227
1228 // Service is the durable GRASP scheduler boundary. Tick is safe to call from
1229@@ -160,7 +159,7 @@ func (s *Service) Tick(ctx context.Context) error {
1230 }
1231 repoKey := key(r.Owner, r.Identifier)
1232 status := tickRepository{AttemptedAt: progress.At, Attempts: 1}
1233- profile := s.relay.policy()
1234+ profile := s.relay.policyValue()
1235 status.Fingerprint = repoFingerprint(r, profile)
1236 if previous, ok := previousProgress.Repos[repoKey]; ok {
1237 if previous.NextAt > progress.At && previous.Fingerprint == status.Fingerprint {
1238@@ -225,7 +224,7 @@ func (s *Service) Tick(ctx context.Context) error {
1239 return tickErr
1240 }
1241
1242-func repoFingerprint(r Repository, profile policy.Policy) string {
1243+func repoFingerprint(r Repository, profile Policy) string {
1244 refs := make([]string, 0, len(r.Refs))
1245 for ref, oid := range r.Refs {
1246 refs = append(refs, ref+"="+oid)
1247diff --git a/tinygit/server.go b/tinygit/server.go
1248index af891ce..005c2e0 100644
1249--- a/tinygit/server.go
1250+++ b/tinygit/server.go
1251@@ -13,8 +13,7 @@ import (
1252 "sync"
1253 "time"
1254
1255- "github.com/FelineStateMachine/tinyrelay/internal/event"
1256- "github.com/FelineStateMachine/tinyrelay/internal/storage"
1257+ event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1258 )
1259
1260 // ServerConfig configures the standalone public Git host. Use a dedicated
1261@@ -30,7 +29,7 @@ type ServerConfig struct {
1262 // repository metadata. Other paths are handled by Git smart HTTP.
1263 type Server struct {
1264 git *GitRelay
1265- store *Store
1266+ store Store
1267 publishMu sync.Mutex
1268 }
1269
1270@@ -86,7 +85,7 @@ func (s *Server) Publish(ctx context.Context, e Event) error {
1271 if err != nil {
1272 return err
1273 }
1274- if _, err := s.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil && !errors.Is(err, storage.ErrDuplicate) {
1275+ if err := s.store.Save(ctx, e, time.Now().Unix()); err != nil && !errors.Is(err, ErrDuplicate) {
1276 return err
1277 }
1278 return s.git.CommitAfterStore(ctx, e, repo)
1279diff --git a/tinygit/store.go b/tinygit/store.go
1280new file mode 100644
1281index 0000000..ee5ce61
1282--- /dev/null
1283+++ b/tinygit/store.go
1284@@ -0,0 +1,39 @@
1285+package tinygit
1286+
1287+import (
1288+ "context"
1289+
1290+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"
1291+ "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
1292+)
1293+
1294+var _ Store = (*gitstore.Store)(nil)
1295+
1296+// Store is the persistence contract required by GitRelay. Implementations may
1297+// use any durable or in-memory representation of Nostr events. The caller
1298+// owns the store; New never closes it.
1299+type Store interface {
1300+ // Query returns matching events by descending timestamp, then ascending ID,
1301+ // excluding expired, hidden and pending events without applying reader ACLs.
1302+ // now is the Unix expiry cutoff. A positive limit caps the result; f.Limit
1303+ // can impose a smaller cap. With neither limit, all matches are returned.
1304+ Query(context.Context, nostr.Filter, int64, int) ([]Event, error)
1305+ // Save durably records e using now for storage timestamps. Duplicate events
1306+ // return ErrDuplicate.
1307+ Save(context.Context, Event, int64) error
1308+ // Latest returns the newest addressable event for kind, pubkey and d.
1309+ // Ties use the lowest event ID. This includes expired, hidden and pending
1310+ // rows so recovery can check durable state independently of read visibility.
1311+ Latest(context.Context, int, string, string) (Event, bool, error)
1312+ // Exists reports whether id is present with the requested kind.
1313+ Exists(context.Context, string, int) (bool, error)
1314+ // Close releases resources owned by the store.
1315+ Close() error
1316+}
1317+
1318+// ErrDuplicate indicates that an event was already persisted.
1319+var ErrDuplicate = gitstore.ErrDuplicate
1320+
1321+// OpenStore opens the standard SQLite event schema behind the public Store
1322+// contract. The caller owns the returned store and must close it.
1323+func OpenStore(ctx context.Context, path string) (Store, error) { return gitstore.Open(ctx, path) }
1324diff --git a/tinygit/test_policy_test.go b/tinygit/test_policy_test.go
1325new file mode 100644
1326index 0000000..91be96b
1327--- /dev/null
1328+++ b/tinygit/test_policy_test.go
1329@@ -0,0 +1,13 @@
1330+package tinygit
1331+
1332+import (
1333+ "github.com/FelineStateMachine/tinyrelay/internal/gitstore"
1334+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1335+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
1336+)
1337+
1338+func tinyStore(store *storage.Store) Store { return gitstore.Wrap(store) }
1339+
1340+func fromInternalPolicy(p policy.Policy) Policy {
1341+ 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}}
1342+}

About

ownerc01503e8035f
visibilitypublic
headaff3977dcd4a

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs