clone

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

feat/standalone-relay
tinyrelay / tinygit / admission.go
admission.go 19.0K
1package tinygit23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "os"9 "os/exec"10 "path/filepath"11 "sort"12 "strings"13 "time"1415 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"16)1718// ValidateEvent checks the wire shape, canonical ID and signature, not19// repository metadata shape or host admission. Use ParseMetadata for the pure20// metadata check, or Validate for the complete admission boundary.21func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) }2223// Validate checks the signature and metadata shape, resolves repository24// 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{}, err30 }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{}, err37 }38 if e.Kind == 30617 {39 if err := g.validateArchiveAnnouncement(r); err != nil {40 return Repository{}, err41 }42 }43 if g.authorize != nil {44 if err := g.authorize(ctx, e, r); err != nil {45 return Repository{}, err46 }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 is55 // accepted, but remains invisible until receive-pack supplies every tip.56 }57 return r, nil58}5960// CommitAfterStore applies the Git-side transition after the caller has61// 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}6566// CommitAfterStoreNoNotify stages the Git transition without invoking the67// host fanout callback. Use this while already inside the relay publish68// 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}7273func (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 lock79 // 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 err84}8586func (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 err90 }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 nil95 }96 if e.Kind == 30617 {97 // Announcements and ref state are separate addressable events. An98 // 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 err102 }103 if found {104 stateRepo, err := g.parseRepository(state)105 if err != nil {106 return err107 }108 repo.Refs, repo.Head, repo.EventID = stateRepo.Refs, stateRepo.Head, state.ID109 }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 err114 }115 g.mu.Lock()116 g.repos[key(repo.Owner, repo.Identifier)] = repo117 g.mu.Unlock()118 if err := g.ensureRepo(repo); err != nil {119 return err120 }121 pending := false122 if e.Kind == 30618 {123 var err error124 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 = true132 }133 if err != nil {134 return err135 }136 }137 if pending {138 return nil139 }140 if err := g.commitJournal(repo, jr); err != nil {141 return err142 }143 return nil144}145146func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, error) {147 if e.Kind != 30617 && e.Kind != 30618 {148 return true, nil149 }150 current, found, err := g.store.Latest(ctx, e.Kind, e.PubKey, event.Tag(e, "d"))151 if err != nil || !found {152 return false, err153 }154 return current.ID == e.ID, nil155}156157// IsPending lets the event admission/read layer hide a metadata event until158// 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 ok164}165166// ExpirePending removes the file and in-memory authorities for state events167// whose retained deadline has elapsed. The SQL event sweep calls this before168// deleting the corresponding event so a stale journal cannot promote it after169// a restart.170func (g *GitRelay) ExpirePending(ctx context.Context, eventIDs []string) error {171 if g == nil || len(eventIDs) == 0 {172 return nil173 }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 err181 }182 for _, entry := range entries {183 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {184 continue185 }186 path := filepath.Join(g.journalDir(), entry.Name())187 body, err := os.ReadFile(path)188 if err != nil {189 return err190 }191 var jr journalRecord192 if err := json.Unmarshal(body, &jr); err != nil {193 return err194 }195 if _, ok := want[jr.EventID]; !ok {196 continue197 }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 err205 }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}214215// PruneRef removes an expired native Git ref before its retained metadata is216// 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 *Repository223 for _, candidate := range g.repos {224 if key(candidate.Owner, candidate.Identifier) == repository || candidate.Identifier == repository {225 copy := candidate226 found = &copy227 break228 }229 }230 g.mu.RUnlock()231 if found == nil {232 return nil233 }234 if err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(*found), "update-ref", "-d", ref).Run(); err != nil {235 return err236 }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)] = current241 }242 g.mu.Unlock()243 return nil244}245246// PromotePending verifies and exposes a previously accepted state event. It247// is useful for local receive-pack integrations; ServeHTTP invokes it after a248// 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 err257}258259func (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, err263 }264 if !found {265 return false, nil266 }267 if !g.stateObjectsPresent(ctx, repo) {268 return false, nil269 }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, nil275 }276 if err := g.writeState(repo); err != nil {277 return false, err278 }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, err287 }288 return true, nil289}290291// Publish validates and admits a signed repository announcement or state292// before saving it and staging the Git transition. Enabled GRASP-06 events293// 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 err297 }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 err309 }310 if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {311 return err312 }313 return g.CommitAfterStore(ctx, e, repo)314}315316// parseRepository resolves parsed metadata against the host's announcement317// 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{}, err322 }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, nil328 }329 // State is replaceable: bind it to the current announcement owner and330 // carry the announcement's identity into the hook/state file.331 id := m.Identifier332 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{}, err339 }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 break347 }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.Private358 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.ID362 return state, nil363}364365// validateRefs preserves the internal caller boundary for synchronization.366func (g *GitRelay) validateRefs(refs map[string]string) error { return validateRefs(refs) }367368// ErrIncomplete reports a synchronization pass that saved progress and has369// more history to fetch. The scheduler continues it soon without counting a370// failure.371var ErrIncomplete = errors.New("synchronization incomplete")372373// 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 c390}391392// SupportedGRASPs is the NIP-11-facing capability list. It deliberately393// mirrors Capabilities so callers cannot advertise preview protocols by hand.394func (g *GitRelay) SupportedGRASPs() []string { return g.Capabilities() }395396// DeletePRRef removes a temporary GRASP-06 ref before its Nostr metadata is397// 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 err410 }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}417418func (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 nil422 }423 if err != nil {424 return err425 }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}438439// Reload rebuilds repository and pending indexes after a restore has inserted440// 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 = nil446 g.repoConfigMu.Unlock()447 if err := g.recoverJournals(); err != nil {448 return err449 }450 q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, time.Now().Unix(), 0)451 if err != nil {452 return err453 }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)] = r459 }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 err466 }467 refs, refsErr := g.publishedNativeRefs(announcement)468 if refsErr != nil {469 return refsErr470 }471 if len(refs) > 0 {472 announcement.Refs = refs473 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] = announcement477 }478 }479 // If no native refs exist (for example an older store written before native480 // 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 continue484 } else if !errors.Is(err, os.ErrNotExist) {485 return err486 }487 if _, err := os.Stat(g.pendingStatePath(announcement)); err == nil {488 continue489 } else if !errors.Is(err, os.ErrNotExist) {490 return err491 }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 stateErr495 }496 if len(states) == 0 {497 continue498 }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 continue503 }504 repos[coordinate] = state505 }506 prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, time.Now().Unix(), 0)507 if prErr != nil {508 return prErr509 }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 continue518 }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 continue522 } else if err != nil {523 return err524 }525 if refs, refsErr := g.publishedNativeRefs(pr); refsErr == nil && len(refs) > 0 {526 pr.Refs = refs527 }528 repos[key(pr.Owner, pr.Identifier)] = pr529 }530 entries, err := os.ReadDir(g.journalDir())531 if err != nil && !errors.Is(err, os.ErrNotExist) {532 return err533 }534 refreshedPending := make(map[string]struct{})535 for _, entry := range entries {536 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {537 continue538 }539 b, readErr := os.ReadFile(filepath.Join(g.journalDir(), entry.Name()))540 if readErr != nil {541 return readErr542 }543 var jr journalRecord544 if json.Unmarshal(b, &jr) == nil && jr.EventID != "" {545 refreshedPending[jr.EventID] = struct{}{}546 }547 }548 g.mu.Lock()549 g.repos = repos550 g.pending = refreshedPending551 g.mu.Unlock()552 return nil553}554555func (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] = commit570 }571 if g.authorize != nil {572 if err := g.authorize(ctx, e, r); err != nil {573 return err574 }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 err578 }579 if err := g.store.Save(ctx, e, time.Now().Unix()); err != nil {580 return err581 }582 if err := g.ensureRepo(r); err != nil {583 return err584 }585 g.mu.Lock()586 g.repos[key(r.Owner, r.Identifier)] = r587 g.mu.Unlock()588 pending := !g.stateObjectsPresent(ctx, r)589 if !pending {590 if err := g.writeState(r); err != nil {591 return err592 }593 } else {594 if err := g.writePendingState(r); err != nil {595 return err596 }597 g.mu.Lock()598 g.pending[e.ID] = struct{}{}599 g.mu.Unlock()600 }601 if pending {602 return nil603 }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}