tinyrelayfile
tinyrelay/tinygit/admission.go/

tinygit/admission.go raw
1package tinygit
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14
15 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
16)
17
18// ValidateEvent checks the wire shape, canonical ID and signature, not
19// repository metadata shape or host admission. Use ParseMetadata for the pure
20// metadata check, or Validate for the complete admission boundary.
21func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) }
22
23// Validate checks the signature and metadata shape, resolves repository
24// authority, then applies host admission. Shape failures precede host callbacks.
25// The caller must associate the returned authority with its event transaction.
26// Validate has no store side effects and does not require Git objects locally.
27func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, error) {
28 if err := event.Validate(e); err != nil {
29 return Repository{}, err
30 }
31 if e.Kind != 30617 && e.Kind != 30618 {
32 return Repository{}, fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
33 }
34 r, err := g.parseRepository(e)
35 if err != nil {
36 return Repository{}, err
37 }
38 if e.Kind == 30617 {
39 if err := g.validateArchiveAnnouncement(r); err != nil {
40 return Repository{}, err
41 }
42 }
43 if g.authorize != nil {
44 if err := g.authorize(ctx, e, r); err != nil {
45 return Repository{}, err
46 }
47 } else if !g.IsMaintainer(ctx, r, e.PubKey) {
48 return Repository{}, errors.New("blocked: repository metadata must be signed by owner")
49 }
50 if e.Kind == 30618 {
51 if r.EventID == "" {
52 return Repository{}, errors.New("blocked: repository announcement is missing")
53 }
54 // Missing objects are a durable pending transition. The signed state is
55 // accepted, but remains invisible until receive-pack supplies every tip.
56 }
57 return r, nil
58}
59
60// CommitAfterStore applies the Git-side transition after the caller has
61// durably saved the event. It intentionally does not call Store.Save.
62func (g *GitRelay) CommitAfterStore(ctx context.Context, e event.Event, repo Repository) error {
63 return g.commitAfterStore(ctx, e, repo, true)
64}
65
66// CommitAfterStoreNoNotify stages the Git transition without invoking the
67// host fanout callback. Use this while already inside the relay publish
68// fence; the durable work intent will release visibility afterward.
69func (g *GitRelay) CommitAfterStoreNoNotify(ctx context.Context, e event.Event, repo Repository) error {
70 return g.commitAfterStore(ctx, e, repo, false)
71}
72
73func (g *GitRelay) commitAfterStore(ctx context.Context, e event.Event, repo Repository, notify bool) error {
74 g.commitMu.Lock()
75 err := g.stageAfterStore(ctx, e, repo)
76 pending := g.IsPending(e.ID)
77 g.commitMu.Unlock()
78 // The host callback enters its publish fence. Never hold the Git lock
79 // here: synchronous publication already enters these locks in reverse.
80 if err == nil && !pending && notify && g.onPromote != nil {
81 return g.onPromote(ctx, e.ID, repo)
82 }
83 return err
84}
85
86func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repository) error {
87 current, err := g.currentMetadata(ctx, e)
88 if err != nil {
89 return err
90 }
91 if !current {
92 // A queued intent may outlive a newer addressable announcement/state.
93 // Never let that stale intent replace the currently authorized hook.
94 return nil
95 }
96 if e.Kind == 30617 {
97 // Announcements and ref state are separate addressable events. An
98 // announcement update must retain the current signed refs and HEAD.
99 state, found, err := g.store.Latest(ctx, 30618, e.PubKey, event.Tag(e, "d"))
100 if err != nil {
101 return err
102 }
103 if found {
104 stateRepo, err := g.parseRepository(state)
105 if err != nil {
106 return err
107 }
108 repo.Refs, repo.Head, repo.EventID = stateRepo.Refs, stateRepo.Head, state.ID
109 }
110 }
111 jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: e.ID, Kind: e.Kind, Refs: repo.Refs, Head: repo.Head}
112 if err := g.writeJournal(repo, jr); err != nil {
113 return err
114 }
115 g.mu.Lock()
116 g.repos[key(repo.Owner, repo.Identifier)] = repo
117 g.mu.Unlock()
118 if err := g.ensureRepo(repo); err != nil {
119 return err
120 }
121 pending := false
122 if e.Kind == 30618 {
123 var err error
124 if g.stateObjectsPresent(ctx, repo) {
125 err = g.writeState(repo)
126 } else {
127 err = g.writePendingState(repo)
128 g.mu.Lock()
129 g.pending[e.ID] = struct{}{}
130 g.mu.Unlock()
131 pending = true
132 }
133 if err != nil {
134 return err
135 }
136 }
137 if pending {
138 return nil
139 }
140 if err := g.commitJournal(repo, jr); err != nil {
141 return err
142 }
143 return nil
144}
145
146func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, error) {
147 if e.Kind != 30617 && e.Kind != 30618 {
148 return true, nil
149 }
150 current, found, err := g.store.Latest(ctx, e.Kind, e.PubKey, event.Tag(e, "d"))
151 if err != nil || !found {
152 return false, err
153 }
154 return current.ID == e.ID, nil
155}
156
157// IsPending lets the event admission/read layer hide a metadata event until
158// its signed Git tips are verified locally.
159func (g *GitRelay) IsPending(eventID string) bool {
160 g.mu.RLock()
161 _, ok := g.pending[eventID]
162 g.mu.RUnlock()
163 return ok
164}
165
166// ExpirePending removes the file and in-memory authorities for state events
167// whose retained deadline has elapsed. The SQL event sweep calls this before
168// deleting the corresponding event so a stale journal cannot promote it after
169// a restart.
170func (g *GitRelay) ExpirePending(ctx context.Context, eventIDs []string) error {
171 if g == nil || len(eventIDs) == 0 {
172 return nil
173 }
174 want := make(map[string]struct{}, len(eventIDs))
175 for _, id := range eventIDs {
176 want[id] = struct{}{}
177 }
178 entries, err := os.ReadDir(g.journalDir())
179 if err != nil && !errors.Is(err, os.ErrNotExist) {
180 return err
181 }
182 for _, entry := range entries {
183 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
184 continue
185 }
186 path := filepath.Join(g.journalDir(), entry.Name())
187 body, err := os.ReadFile(path)
188 if err != nil {
189 return err
190 }
191 var jr journalRecord
192 if err := json.Unmarshal(body, &jr); err != nil {
193 return err
194 }
195 if _, ok := want[jr.EventID]; !ok {
196 continue
197 }
198 owner, identifier, ok := strings.Cut(jr.Repository, "\x00")
199 if ok {
200 _ = os.Remove(g.pendingStatePath(Repository{Owner: owner, Identifier: identifier}))
201 _ = os.Remove(g.pendingHeadPath(Repository{Owner: owner, Identifier: identifier}))
202 }
203 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
204 return err
205 }
206 }
207 g.mu.Lock()
208 for id := range want {
209 delete(g.pending, id)
210 }
211 g.mu.Unlock()
212 return ctx.Err()
213}
214
215// PruneRef removes an expired native Git ref before its retained metadata is
216// deleted. repository may be the storage key or its identifier.
217func (g *GitRelay) PruneRef(ctx context.Context, repository, ref string) error {
218 if g == nil || !validRef(ref) {
219 return errors.New("git relay: invalid expired ref")
220 }
221 g.mu.RLock()
222 var found *Repository
223 for _, candidate := range g.repos {
224 if key(candidate.Owner, candidate.Identifier) == repository || candidate.Identifier == repository {
225 copy := candidate
226 found = &copy
227 break
228 }
229 }
230 g.mu.RUnlock()
231 if found == nil {
232 return nil
233 }
234 if err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(*found), "update-ref", "-d", ref).Run(); err != nil {
235 return err
236 }
237 g.mu.Lock()
238 if current, ok := g.repos[key(found.Owner, found.Identifier)]; ok {
239 delete(current.Refs, ref)
240 g.repos[key(found.Owner, found.Identifier)] = current
241 }
242 g.mu.Unlock()
243 return nil
244}
245
246// PromotePending verifies and exposes a previously accepted state event. It
247// is useful for local receive-pack integrations; ServeHTTP invokes it after a
248// smart push as well.
249func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error {
250 g.commitMu.Lock()
251 promoted, err := g.promotePending(ctx, repo)
252 g.commitMu.Unlock()
253 if err == nil && promoted && g.onPromote != nil {
254 return g.onPromote(ctx, repo.EventID, repo)
255 }
256 return err
257}
258
259func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) {
260 found, err := g.store.Exists(ctx, repo.EventID, 30618)
261 if err != nil {
262 return false, err
263 }
264 if !found {
265 return false, nil
266 }
267 if !g.stateObjectsPresent(ctx, repo) {
268 return false, nil
269 }
270 g.mu.RLock()
271 _, tracked := g.pending[repo.EventID]
272 g.mu.RUnlock()
273 if _, err := os.Stat(g.pendingStatePath(repo)); err != nil && !tracked {
274 return false, nil
275 }
276 if err := g.writeState(repo); err != nil {
277 return false, err
278 }
279 _ = os.Remove(g.pendingStatePath(repo))
280 _ = os.Remove(g.pendingHeadPath(repo))
281 g.mu.Lock()
282 delete(g.pending, repo.EventID)
283 g.mu.Unlock()
284 jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: repo.EventID, Kind: 30618, Refs: repo.Refs, Head: repo.Head}
285 if err := g.commitJournal(repo, jr); err != nil {
286 return false, err
287 }
288 return true, nil
289}
290
291// Publish validates and admits a signed repository announcement or state
292// before saving it and staging the Git transition. Enabled GRASP-06 events
293// use their separate admission path.
294func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
295 if err := event.Validate(e); err != nil {
296 return err
297 }
298 if e.Kind != 30617 && e.Kind != 30618 {
299 if !((g.grasp06 || g.policyValue().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {
300 return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
301 }
302 g.commitMu.Lock()
303 defer g.commitMu.Unlock()
304 return g.publishPR(ctx, e)
305 }
306 repo, err := g.Validate(ctx, e)
307 if err != nil {
308 return err
309 }
310 if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {
311 return err
312 }
313 return g.CommitAfterStore(ctx, e, repo)
314}
315
316// parseRepository resolves parsed metadata against the host's announcement
317// and maintainer authority. All shape checks finish before consulting the host.
318func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
319 m, err := ParseMetadata(e)
320 if err != nil {
321 return Repository{}, err
322 }
323 state := Repository{Owner: m.Author, Identifier: m.Identifier, EventID: m.EventID,
324 Private: m.Private, Clone: m.Clone, Relays: m.Relays, Maintainers: m.Maintainers,
325 Refs: m.Refs, Head: m.Head}
326 if m.Kind == 30617 {
327 return state, nil
328 }
329 // State is replaceable: bind it to the current announcement owner and
330 // carry the announcement's identity into the hook/state file.
331 id := m.Identifier
332 g.mu.RLock()
333 ann := g.repos[key(e.PubKey, id)]
334 g.mu.RUnlock()
335 if ann.Owner == "" {
336 q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 0)
337 if err != nil {
338 return Repository{}, err
339 }
340 if len(q) == 0 {
341 return Repository{}, errors.New("blocked: repository announcement is missing")
342 }
343 for _, candidate := range q {
344 ann, err = g.parseRepository(candidate)
345 if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {
346 break
347 }
348 ann = Repository{}
349 }
350 if ann.Owner == "" {
351 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
352 }
353 }
354 if !g.IsMaintainer(context.Background(), ann, e.PubKey) {
355 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
356 }
357 state.Owner, state.Private = ann.Owner, ann.Private
358 state.Clone = append([]string(nil), ann.Clone...)
359 state.Relays = append([]string(nil), ann.Relays...)
360 state.Maintainers = append([]string(nil), ann.Maintainers...)
361 state.EventID = e.ID
362 return state, nil
363}
364
365// validateRefs preserves the internal caller boundary for synchronization.
366func (g *GitRelay) validateRefs(refs map[string]string) error { return validateRefs(refs) }
367
368// ErrIncomplete reports a synchronization pass that saved progress and has
369// more history to fetch. The scheduler continues it soon without counting a
370// failure.
371var ErrIncomplete = errors.New("synchronization incomplete")
372
373// Capabilities returns only protocol surfaces that this instance can serve.
374func (g *GitRelay) Capabilities() []string {
375 p := g.policyValue()
376 c := []string{"GRASP-01"}
377 if p.Features.Grasp02 && g.eventSync != nil {
378 c = append(c, "GRASP-02")
379 if p.Features.Grasp03 {
380 c = append(c, "GRASP-03")
381 }
382 }
383 if g.grasp06 || p.Features.Grasp06 {
384 c = append(c, "GRASP-06")
385 }
386 if p.PrivateServiceEnabled() {
387 c = append(c, "GRASP-08")
388 }
389 return c
390}
391
392// SupportedGRASPs is the NIP-11-facing capability list. It deliberately
393// mirrors Capabilities so callers cannot advertise preview protocols by hand.
394func (g *GitRelay) SupportedGRASPs() []string { return g.Capabilities() }
395
396// DeletePRRef removes a temporary GRASP-06 ref before its Nostr metadata is
397// deleted. Identity accepts the source coordinates used by delivery jobs:
398// pr:<owner>:<identifier> or 30617:<owner>:<identifier>.
399func (g *GitRelay) DeletePRRef(ctx context.Context, identity, ref string) error {
400 parts := strings.SplitN(identity, ":", 3)
401 if len(parts) != 3 || (parts[0] != "pr" && parts[0] != "30617") || !isPubKey(parts[1]) || parts[2] == "" || strings.ContainsAny(parts[2], `/\\`) {
402 return errors.New("invalid: PR repository identity")
403 }
404 if !strings.HasPrefix(ref, "refs/nostr/") || len(strings.TrimPrefix(ref, "refs/nostr/")) != 64 {
405 return errors.New("invalid: PR ref")
406 }
407 r := Repository{Owner: parts[1], Identifier: parts[2], Alternative: true, Refs: map[string]string{}}
408 if err := g.ensureRepo(r); err != nil {
409 return err
410 }
411 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "update-ref", "-d", ref)
412 if out, err := cmd.CombinedOutput(); err != nil {
413 return fmt.Errorf("delete PR ref: %w: %s", err, strings.TrimSpace(string(out)))
414 }
415 return g.removeRefFromPending(r, ref)
416}
417
418func (g *GitRelay) removeRefFromPending(r Repository, ref string) error {
419 b, err := os.ReadFile(g.pendingStatePath(r))
420 if errors.Is(err, os.ErrNotExist) {
421 return nil
422 }
423 if err != nil {
424 return err
425 }
426 var kept map[string]string = map[string]string{}
427 for _, line := range strings.Split(string(b), "\n") {
428 fields := strings.Fields(line)
429 if len(fields) == 2 && fields[0] != ref {
430 kept[fields[0]] = fields[1]
431 }
432 }
433 if len(kept) == 0 {
434 return os.Remove(g.pendingStatePath(r))
435 }
436 return g.writeRefsFile(g.pendingStatePath(r), kept)
437}
438
439// Reload rebuilds repository and pending indexes after a restore has inserted
440// events and native Git repositories on disk.
441func (g *GitRelay) Reload(ctx context.Context) error {
442 g.commitMu.Lock()
443 defer g.commitMu.Unlock()
444 g.repoConfigMu.Lock()
445 g.configured = nil
446 g.repoConfigMu.Unlock()
447 if err := g.recoverJournals(); err != nil {
448 return err
449 }
450 q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, time.Now().Unix(), 0)
451 if err != nil {
452 return err
453 }
454 repos := make(map[string]Repository)
455 for _, raw := range q {
456 r, parseErr := g.parseRepository(raw)
457 if parseErr == nil {
458 repos[key(r.Owner, r.Identifier)] = r
459 }
460 }
461 // Recover only native refs that match the published signed authority.
462 // Incoming receive-pack refs may still be awaiting promotion.
463 for coordinate, announcement := range repos {
464 if err := g.ensureRepo(announcement); err != nil {
465 return err
466 }
467 refs, refsErr := g.publishedNativeRefs(announcement)
468 if refsErr != nil {
469 return refsErr
470 }
471 if len(refs) > 0 {
472 announcement.Refs = refs
473 if head, headErr := exec.Command("git", "--git-dir", g.repoPath(announcement), "symbolic-ref", "-q", "HEAD").Output(); headErr == nil {
474 announcement.Head = "ref: " + strings.TrimSpace(string(head))
475 }
476 repos[coordinate] = announcement
477 }
478 }
479 // If no native refs exist (for example an older store written before native
480 // projection), recover the newest complete signed state as a fallback.
481 for coordinate, announcement := range repos {
482 if _, err := os.Stat(g.statePath(announcement)); err == nil {
483 continue
484 } else if !errors.Is(err, os.ErrNotExist) {
485 return err
486 }
487 if _, err := os.Stat(g.pendingStatePath(announcement)); err == nil {
488 continue
489 } else if !errors.Is(err, os.ErrNotExist) {
490 return err
491 }
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)
493 if stateErr != nil {
494 return stateErr
495 }
496 if len(states) == 0 {
497 continue
498 }
499 sort.Slice(states, func(i, j int) bool { return states[i].CreatedAt > states[j].CreatedAt })
500 state, parseErr := g.parseRepository(states[0])
501 if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) {
502 continue
503 }
504 repos[coordinate] = state
505 }
506 prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, time.Now().Unix(), 0)
507 if prErr != nil {
508 return prErr
509 }
510 for _, raw := range prs {
511 identifier := event.Tag(raw, "d")
512 if identifier == "" {
513 identifier = event.Tag(raw, "a")
514 }
515 commit := event.Tag(raw, "c")
516 if !validIdentifier(identifier) || !isObjectID(commit) {
517 continue
518 }
519 pr := Repository{Owner: raw.PubKey, Identifier: identifier, EventID: raw.ID, Alternative: true, Refs: map[string]string{}}
520 if _, err := os.Stat(g.repoPath(pr)); errors.Is(err, os.ErrNotExist) {
521 continue
522 } else if err != nil {
523 return err
524 }
525 if refs, refsErr := g.publishedNativeRefs(pr); refsErr == nil && len(refs) > 0 {
526 pr.Refs = refs
527 }
528 repos[key(pr.Owner, pr.Identifier)] = pr
529 }
530 entries, err := os.ReadDir(g.journalDir())
531 if err != nil && !errors.Is(err, os.ErrNotExist) {
532 return err
533 }
534 refreshedPending := make(map[string]struct{})
535 for _, entry := range entries {
536 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
537 continue
538 }
539 b, readErr := os.ReadFile(filepath.Join(g.journalDir(), entry.Name()))
540 if readErr != nil {
541 return readErr
542 }
543 var jr journalRecord
544 if json.Unmarshal(b, &jr) == nil && jr.EventID != "" {
545 refreshedPending[jr.EventID] = struct{}{}
546 }
547 }
548 g.mu.Lock()
549 g.repos = repos
550 g.pending = refreshedPending
551 g.mu.Unlock()
552 return nil
553}
554
555func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error {
556 id := event.Tag(e, "d")
557 if id == "" {
558 id = event.Tag(e, "a")
559 }
560 if !validIdentifier(id) {
561 return errors.New("invalid: PR repository identifier")
562 }
563 r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Alternative: true, Refs: map[string]string{}}
564 commit := event.Tag(e, "c")
565 if commit != "" && !isObjectID(commit) {
566 return errors.New("invalid: PR commit is not a SHA-1")
567 }
568 if commit != "" {
569 r.Refs["refs/nostr/"+e.ID] = commit
570 }
571 if g.authorize != nil {
572 if err := g.authorize(ctx, e, r); err != nil {
573 return err
574 }
575 }
576 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 {
577 return err
578 }
579 if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {
580 return err
581 }
582 if err := g.ensureRepo(r); err != nil {
583 return err
584 }
585 g.mu.Lock()
586 g.repos[key(r.Owner, r.Identifier)] = r
587 g.mu.Unlock()
588 pending := !g.stateObjectsPresent(ctx, r)
589 if !pending {
590 if err := g.writeState(r); err != nil {
591 return err
592 }
593 } else {
594 if err := g.writePendingState(r); err != nil {
595 return err
596 }
597 g.mu.Lock()
598 g.pending[e.ID] = struct{}{}
599 g.mu.Unlock()
600 }
601 if pending {
602 return nil
603 }
604 return g.commitJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head})
605}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs