This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle: remove collaborator PDS record ingest

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 29, 2026, 5:25 PM +0900) commit 1b2cefa5 parent b1443658 change-id wmxmntqm
+243 -1050
-87
spindle/casbin_copy.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "tangled.org/core/rbac" 10 - "tangled.org/core/spindle/db" 11 - ) 12 - 13 - func migrateLegacyRepoCasbin(ctx context.Context, d *db.DB, e *rbac.Enforcer, logger *slog.Logger, owner syntax.DID, name string, rkey syntax.RecordKey, repoDid syntax.DID) { 14 - candidates := legacyKeyCandidates(owner, name, rkey) 15 - if siblings, err := d.SiblingRkeysForRepoDid(owner, repoDid, rkey); err == nil { 16 - var fold func(rest []string, acc []string) []string 17 - fold = func(rest []string, acc []string) []string { 18 - if len(rest) == 0 { 19 - return acc 20 - } 21 - return fold(rest[1:], append(acc, owner.String()+"/"+rest[0])) 22 - } 23 - candidates = fold(siblings, candidates) 24 - } else { 25 - logger.Warn("legacy casbin rekey: sibling lookup failed", "err", err) 26 - } 27 - if len(candidates) == 0 { 28 - return 29 - } 30 - flag := "legacy-casbin-rekey:" + repoDid.String() + ":" + rkey.String() 31 - var exists bool 32 - if err := d.QueryRowContext(ctx, `select exists (select 1 from migrations where name = ?)`, flag).Scan(&exists); err != nil { 33 - logger.Warn("legacy casbin rekey: check migration flag", "err", err) 34 - return 35 - } 36 - if exists { 37 - return 38 - } 39 - 40 - if err := e.AddRepo(owner.String(), rbac.ThisServer, repoDid.String()); err != nil { 41 - logger.Warn("legacy casbin rekey: owner add new key failed", "err", err) 42 - return 43 - } 44 - 45 - collabs, err := d.ListCollaboratorsByRepoDid(repoDid) 46 - if err != nil { 47 - logger.Warn("legacy casbin rekey: list collaborators failed", "err", err) 48 - return 49 - } 50 - 51 - var addCollabs func(remaining []db.RepoCollaborator) error 52 - addCollabs = func(remaining []db.RepoCollaborator) error { 53 - if len(remaining) == 0 { 54 - return nil 55 - } 56 - c := remaining[0] 57 - if err := e.AddCollaborator(c.Subject.String(), rbac.ThisServer, repoDid.String()); err != nil { 58 - return fmt.Errorf("AddCollaborator %s -> %s: %w", c.Subject, repoDid, err) 59 - } 60 - return addCollabs(remaining[1:]) 61 - } 62 - if err := addCollabs(collabs); err != nil { 63 - logger.Warn("legacy casbin rekey: collaborator add failed", "err", err) 64 - return 65 - } 66 - 67 - var wipeCandidates func(remaining []string) error 68 - wipeCandidates = func(remaining []string) error { 69 - if len(remaining) == 0 { 70 - return nil 71 - } 72 - if err := e.WipeRepoPolicies(rbac.ThisServer, remaining[0]); err != nil { 73 - return fmt.Errorf("WipeRepoPolicies %s: %w", remaining[0], err) 74 - } 75 - return wipeCandidates(remaining[1:]) 76 - } 77 - if err := wipeCandidates(candidates); err != nil { 78 - logger.Warn("legacy casbin rekey: wipe failed", "err", err) 79 - return 80 - } 81 - 82 - if _, err := d.ExecContext(ctx, `insert or ignore into migrations (name) values (?)`, flag); err != nil { 83 - logger.Warn("legacy casbin rekey: mark flag failed", "err", err) 84 - return 85 - } 86 - logger.Info("legacy casbin rekeyed", "owner", owner, "name", name, "rkey", rkey, "repoDid", repoDid, "candidates", candidates, "collabs", len(collabs)) 87 - }
-115
spindle/db/collaborators.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type RepoCollaborator struct { 11 - OwnerDid syntax.DID 12 - Rkey syntax.RecordKey 13 - Subject syntax.DID 14 - RepoDid syntax.DID 15 - } 16 - 17 - func (d *DB) AddRepoCollaborator(c RepoCollaborator) error { 18 - _, err := d.Exec( 19 - `insert into repo_collaborators (owner_did, rkey, subject, repo_did) 20 - values (?, ?, ?, ?) 21 - on conflict(owner_did, rkey) do update set 22 - subject = excluded.subject, 23 - repo_did = excluded.repo_did`, 24 - c.OwnerDid.String(), c.Rkey.String(), c.Subject.String(), c.RepoDid.String(), 25 - ) 26 - return err 27 - } 28 - 29 - func (d *DB) AddKnotCollaborator(repoDid, subject syntax.DID) error { 30 - _, err := d.Exec( 31 - `insert into repo_collaborators (owner_did, rkey, subject, repo_did) 32 - values (?, ?, ?, ?) 33 - on conflict(owner_did, rkey) do nothing`, 34 - repoDid.String(), subject.String(), subject.String(), repoDid.String(), 35 - ) 36 - return err 37 - } 38 - 39 - func scanCollab(row interface{ Scan(...any) error }) (*RepoCollaborator, error) { 40 - var owner, rkey, subject, repoDid string 41 - if err := row.Scan(&owner, &rkey, &subject, &repoDid); err != nil { 42 - return nil, err 43 - } 44 - return &RepoCollaborator{ 45 - OwnerDid: syntax.DID(owner), 46 - Rkey: syntax.RecordKey(rkey), 47 - Subject: syntax.DID(subject), 48 - RepoDid: syntax.DID(repoDid), 49 - }, nil 50 - } 51 - 52 - func (d *DB) GetRepoCollaborator(ownerDid syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 53 - return scanCollab(d.QueryRow( 54 - `select owner_did, rkey, subject, repo_did from repo_collaborators where owner_did = ? and rkey = ?`, 55 - ownerDid.String(), rkey.String(), 56 - )) 57 - } 58 - 59 - func (d *DB) DeleteRepoCollaborator(ownerDid syntax.DID, rkey syntax.RecordKey) error { 60 - res, err := d.Exec(`delete from repo_collaborators where owner_did = ? and rkey = ?`, ownerDid.String(), rkey.String()) 61 - if err != nil { 62 - return err 63 - } 64 - n, err := res.RowsAffected() 65 - if err != nil { 66 - return err 67 - } 68 - if n == 0 { 69 - return sql.ErrNoRows 70 - } 71 - return nil 72 - } 73 - 74 - func (d *DB) DeleteRepoCollaboratorBySubjectRepo(subject, repoDid syntax.DID) error { 75 - _, err := d.Exec( 76 - `delete from repo_collaborators where repo_did = ? and subject = ?`, 77 - repoDid.String(), subject.String(), 78 - ) 79 - if err != nil { 80 - return fmt.Errorf("delete collaborator %s on %s: %w", subject, repoDid, err) 81 - } 82 - return nil 83 - } 84 - 85 - func (d *DB) DeleteRepoCollaboratorsByRepoDid(repoDid syntax.DID) error { 86 - _, err := d.Exec(`delete from repo_collaborators where repo_did = ?`, repoDid.String()) 87 - if err != nil { 88 - return fmt.Errorf("delete collaborators for %s: %w", repoDid, err) 89 - } 90 - return nil 91 - } 92 - 93 - func (d *DB) ListCollaboratorsByRepoDid(repoDid syntax.DID) ([]RepoCollaborator, error) { 94 - rows, err := d.Query( 95 - `select owner_did, rkey, subject, repo_did from repo_collaborators where repo_did = ?`, 96 - repoDid.String(), 97 - ) 98 - if err != nil { 99 - return nil, fmt.Errorf("list collaborators for %s: %w", repoDid, err) 100 - } 101 - defer rows.Close() 102 - 103 - var out []RepoCollaborator 104 - for rows.Next() { 105 - c, err := scanCollab(rows) 106 - if err != nil { 107 - return nil, err 108 - } 109 - out = append(out, *c) 110 - } 111 - if err := rows.Err(); err != nil { 112 - return nil, err 113 - } 114 - return out, nil 115 - }
-94
spindle/db/collaborators_test.go
··· 1 - package db 2 - 3 - import ( 4 - "testing" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 - ) 8 - 9 - func subjectsOf(t *testing.T, d *DB, repoDid syntax.DID) []syntax.DID { 10 - t.Helper() 11 - rows, err := d.ListCollaboratorsByRepoDid(repoDid) 12 - if err != nil { 13 - t.Fatalf("ListCollaboratorsByRepoDid: %v", err) 14 - } 15 - out := make([]syntax.DID, 0, len(rows)) 16 - for _, r := range rows { 17 - out = append(out, r.Subject) 18 - } 19 - return out 20 - } 21 - 22 - func TestAddKnotCollaborator_PersistsAndIsIdempotent(t *testing.T) { 23 - d := newTestDB(t) 24 - repo := syntax.DID("did:plc:repo") 25 - bob := syntax.DID("did:plc:bob") 26 - 27 - if err := d.AddKnotCollaborator(repo, bob); err != nil { 28 - t.Fatalf("add: %v", err) 29 - } 30 - if err := d.AddKnotCollaborator(repo, bob); err != nil { 31 - t.Fatalf("re-add: %v", err) 32 - } 33 - 34 - got := subjectsOf(t, d, repo) 35 - if len(got) != 1 || got[0] != bob { 36 - t.Fatalf("collaborators = %v, want exactly [bob]", got) 37 - } 38 - } 39 - 40 - func TestDeleteRepoCollaboratorBySubjectRepo(t *testing.T) { 41 - d := newTestDB(t) 42 - repo := syntax.DID("did:plc:repo") 43 - bob := syntax.DID("did:plc:bob") 44 - carol := syntax.DID("did:plc:carol") 45 - 46 - if err := d.AddKnotCollaborator(repo, bob); err != nil { 47 - t.Fatalf("add bob: %v", err) 48 - } 49 - if err := d.AddKnotCollaborator(repo, carol); err != nil { 50 - t.Fatalf("add carol: %v", err) 51 - } 52 - 53 - if err := d.DeleteRepoCollaboratorBySubjectRepo(bob, repo); err != nil { 54 - t.Fatalf("delete bob: %v", err) 55 - } 56 - got := subjectsOf(t, d, repo) 57 - if len(got) != 1 || got[0] != carol { 58 - t.Fatalf("after removing bob, collaborators = %v, want [carol]", got) 59 - } 60 - 61 - if err := d.DeleteRepoCollaboratorBySubjectRepo(bob, repo); err != nil { 62 - t.Fatalf("idempotent delete: %v", err) 63 - } 64 - } 65 - 66 - func TestKnotCollaborator_NoCollisionAcrossReposAndSubjects(t *testing.T) { 67 - d := newTestDB(t) 68 - repoA := syntax.DID("did:plc:repoA") 69 - repoB := syntax.DID("did:plc:repoB") 70 - bob := syntax.DID("did:plc:bob") 71 - carol := syntax.DID("did:plc:carol") 72 - 73 - for _, c := range []struct{ repo, subj syntax.DID }{ 74 - {repoA, bob}, {repoB, bob}, {repoA, carol}, 75 - } { 76 - if err := d.AddKnotCollaborator(c.repo, c.subj); err != nil { 77 - t.Fatalf("add %s/%s: %v", c.repo, c.subj, err) 78 - } 79 - } 80 - 81 - if got := subjectsOf(t, d, repoA); len(got) != 2 { 82 - t.Errorf("repoA collaborators = %v, want bob+carol", got) 83 - } 84 - if got := subjectsOf(t, d, repoB); len(got) != 1 || got[0] != bob { 85 - t.Errorf("repoB collaborators = %v, want [bob]", got) 86 - } 87 - 88 - if err := d.DeleteRepoCollaboratorBySubjectRepo(bob, repoA); err != nil { 89 - t.Fatalf("delete bob@repoA: %v", err) 90 - } 91 - if got := subjectsOf(t, d, repoB); len(got) != 1 || got[0] != bob { 92 - t.Errorf("repoB after removing bob@repoA = %v, want still [bob]", got) 93 - } 94 - }
+42
spindle/db/db.go
··· 287 287 return err 288 288 } 289 289 290 + // we use casbin instead 291 + if err := orm.RunMigration(conn, logger, "drop-legacy-acl-tables", func(tx *sql.Tx) error { 292 + _, err := tx.Exec(` 293 + drop table if exists repo_collaborators; 294 + drop table if exists known_dids; 295 + `) 296 + return err 297 + }); err != nil { 298 + return err 299 + } 300 + 301 + // repo_did is required 302 + if err := orm.RunMigration(conn, logger, "enforce-repo_did", func(tx *sql.Tx) error { 303 + _, err := tx.Exec(` 304 + create table repos_new ( 305 + repo_did text primary key, 306 + knot text not null, 307 + owner text not null, 308 + rkey text not null, 309 + created_at text, 310 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 311 + 312 + unique(owner, rkey) 313 + ); 314 + insert into repos_new (repo_did, knot, owner, rkey, created_at, addedAt) 315 + select repo_did, knot, owner, rkey, created_at, addedAt from repos 316 + where coalesce(repo_did, '') <> '' 317 + and rowid = ( 318 + select r2.rowid from repos r2 319 + where r2.repo_did = repos.repo_did 320 + order by r2.created_at is null, r2.created_at desc, r2.rowid desc 321 + limit 1 322 + ); 323 + 324 + drop table repos; 325 + alter table repos_new rename to repos; 326 + `) 327 + return err 328 + }); err != nil { 329 + return err 330 + } 331 + 290 332 return nil 291 333 } 292 334
+22 -5
spindle/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 7 8 ) ··· 122 123 123 124 func (d *DB) GetRepoByDid(repoDid syntax.DID) (*Repo, error) { 124 125 return scanRepo(d.QueryRow( 125 - `select knot, owner, rkey, coalesce(repo_did, '') from repos where repo_did = ?`, 126 + `select knot, owner, rkey, repo_did from repos where repo_did = ?`, 126 127 repoDid.String(), 127 128 )) 128 129 } 129 130 130 131 func (d *DB) GetRepoByOwnerRkey(owner syntax.DID, rkey syntax.RecordKey) (*Repo, error) { 131 132 return scanRepo(d.QueryRow( 132 - `select knot, owner, rkey, coalesce(repo_did, '') from repos where owner = ? and rkey = ?`, 133 + `select knot, owner, rkey, repo_did from repos where owner = ? and rkey = ?`, 133 134 owner.String(), rkey.String(), 134 135 )) 135 136 } 136 137 137 138 func (d *DB) AllRepos() ([]Repo, error) { 138 - rows, err := d.Query(`select knot, owner, rkey, coalesce(repo_did, '') from repos`) 139 + rows, err := d.Query(`select knot, owner, rkey, repo_did from repos`) 139 140 if err != nil { 140 141 return nil, err 141 142 } ··· 157 158 return repos, nil 158 159 } 159 160 160 - func (d *DB) DeleteRepoByOwnerRkey(owner syntax.DID, rkey syntax.RecordKey) error { 161 - _, err := d.Exec(`delete from repos where owner = ? and rkey = ?`, owner.String(), rkey.String()) 161 + func (d *DB) DeleteRepo(repoDid syntax.DID) error { 162 + _, err := d.Exec(`delete from repos where repo_did = ?`, repoDid) 162 163 return err 163 164 } 165 + 166 + // DeleteRepoByOwnerRkey deletes a repo by (owner,rkey) pair and returns deleted repos DID. 167 + func (d *DB) DeleteRepoByOwnerRkey(owner syntax.DID, rkey syntax.RecordKey) (syntax.DID, error) { 168 + var repoDid string 169 + err := d.QueryRow( 170 + `delete from repos where owner = ? and rkey = ? returning repo_did`, 171 + owner.String(), rkey.String(), 172 + ).Scan(&repoDid) 173 + if errors.Is(err, sql.ErrNoRows) { 174 + return "", nil 175 + } 176 + if err != nil { 177 + return "", err 178 + } 179 + return syntax.DID(repoDid), nil 180 + }
+1 -1
spindle/ingester.go
··· 20 20 21 21 var err error 22 22 switch e.Commit.Collection { 23 - case tangled.RepoNSID, tangled.RepoCollaboratorNSID: 23 + case tangled.RepoNSID: 24 24 if evt, ok := jetstreamToTapEvent(e); ok { 25 25 err = s.tap.processEvent(ctx, evt) 26 26 }
+13 -19
spindle/server.go
··· 29 29 kgit "tangled.org/core/knotserver/git" 30 30 "tangled.org/core/log" 31 31 "tangled.org/core/notifier" 32 - "tangled.org/core/rbac" 32 + "tangled.org/core/rbac/v2" 33 33 "tangled.org/core/repoident" 34 34 "tangled.org/core/repoverify" 35 35 "tangled.org/core/spindle/config" ··· 46 46 47 47 //go:embed motd 48 48 var defaultMotd []byte 49 - 50 - const ( 51 - rbacDomain = "thisserver" 52 - ) 53 49 54 50 type Spindle struct { 55 51 jc *jetstream.JetstreamClient ··· 79 75 if err != nil { 80 76 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 81 77 } 82 - e.E.EnableAutoSave(true) 78 + e.EnableAutoSave(true) 83 79 84 80 n := notifier.New() 85 81 ··· 114 110 115 111 collections := []string{ 116 112 tangled.RepoNSID, 117 - tangled.RepoCollaboratorNSID, 118 113 tangled.RepoPullNSID, 119 114 } 120 115 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) ··· 235 230 // Enforcer returns the RBAC enforcer instance. 236 231 func (s *Spindle) Enforcer() *rbac.Enforcer { 237 232 return s.e 233 + } 234 + 235 + func (s *Spindle) VerifyRepo(ctx context.Context, repo syntax.DID) (repoverify.Result, error) { 236 + return s.verify(ctx, repoident.RepoDid(repo)) 238 237 } 239 238 240 239 // SetMotdContent sets custom MOTD content, replacing the embedded default. ··· 382 381 func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error { 383 382 l := log.FromContext(ctx).With("handler", "processKnotStream") 384 383 l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 385 - if msg.Nsid == knotdb.RepoCollaboratorUpdateNSID { 384 + switch msg.Nsid { 385 + case knotdb.RepoCollaboratorUpdateNSID: 386 386 return s.ingestKnotCollaborator(ctx, l, src, msg) 387 - } 388 - if msg.Nsid == tangled.GitRefUpdateNSID { 387 + 388 + case tangled.GitRefUpdateNSID: 389 389 event := tangled.GitRefUpdate{} 390 390 if err := json.Unmarshal(msg.EventJson, &event); err != nil { 391 391 l.Error("error unmarshalling", "err", err) ··· 475 475 476 476 switch rec.Op { 477 477 case knotdb.AclOpAdd: 478 - if err := s.e.AddCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil { 478 + if err := s.e.AddRepoCollaborator(subject, repoDid); err != nil { 479 479 return fmt.Errorf("add collaborator policy: %w", err) 480 - } 481 - if err := s.db.AddKnotCollaborator(repoDid, subject); err != nil { 482 - return fmt.Errorf("track collaborator: %w", err) 483 480 } 484 481 l.Info("added knot-managed collaborator", "subject", subject, "repo", repoDid) 485 482 case knotdb.AclOpRemove: 486 - if err := s.e.RemoveCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil { 483 + if err := s.e.RemoveRepoCollaborator(subject, repoDid); err != nil { 487 484 return fmt.Errorf("remove collaborator policy: %w", err) 488 - } 489 - if err := s.db.DeleteRepoCollaboratorBySubjectRepo(subject, repoDid); err != nil { 490 - return fmt.Errorf("delete collaborator row: %w", err) 491 485 } 492 486 l.Info("removed knot-managed collaborator", "subject", subject, "repo", repoDid) 493 487 default: ··· 747 741 } 748 742 } 749 743 750 - func (s *Spindle) runJob(ctx context.Context, job *db.JobRow) { 744 + func (s *Spindle) runJob(_ context.Context, job *db.JobRow) { 751 745 pipelineId := models.PipelineId{ 752 746 Knot: job.PipelineIdKnot, 753 747 Rkey: job.PipelineIdRkey,
-23
spindle/startup_migrations.go
··· 15 15 const forceTapResyncFlag = "force-tap-repo-resync-v1" 16 16 17 17 func runStartupMigrations(ctx context.Context, d *db.DB, tapEmbed bool, tapDBPath string, logger *slog.Logger) error { 18 - if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 19 - return fmt.Errorf("cleanup orphan repos: %w", err) 20 - } 21 18 if !tapEmbed { 22 19 logger.Warn("tap not embedded: legacy repos won't auto-resync; trigger external tap resync to migrate secrets/casbin") 23 20 return nil 24 21 } 25 22 if err := nudgeTapForResync(ctx, d, tapDBPath, logger); err != nil { 26 23 return fmt.Errorf("nudge tap for resync: %w", err) 27 - } 28 - return nil 29 - } 30 - 31 - func cleanupOrphanRepos(ctx context.Context, d *db.DB, logger *slog.Logger) error { 32 - res, err := d.ExecContext(ctx, ` 33 - delete from repos 34 - where coalesce(repo_did, '') = '' 35 - and exists ( 36 - select 1 from repos r2 37 - where r2.owner = repos.owner 38 - and coalesce(r2.repo_did, '') <> '' 39 - ) 40 - `) 41 - if err != nil { 42 - return fmt.Errorf("delete orphan repos: %w", err) 43 - } 44 - n, _ := res.RowsAffected() 45 - if n > 0 { 46 - logger.Info("cleaned up orphan repos missing repo_did", "deleted", n) 47 24 } 48 25 return nil 49 26 }
+2 -466
spindle/startup_migrations_test.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "fmt" 7 6 "io" 8 7 "log/slog" 9 8 "path/filepath" ··· 12 11 13 12 "github.com/bluesky-social/indigo/atproto/syntax" 14 13 15 - "tangled.org/core/rbac" 14 + "tangled.org/core/rbac/v2" 16 15 "tangled.org/core/spindle/db" 17 16 "tangled.org/core/spindle/secrets" 18 17 ) ··· 88 87 if err != nil { 89 88 t.Fatalf("rbac.NewEnforcer: %v", err) 90 89 } 91 - e.E.EnableAutoSave(true) 90 + e.EnableAutoSave(true) 92 91 return d, e 93 92 } 94 93 ··· 112 111 }) 113 112 if err != nil { 114 113 t.Fatalf("AddSecret(%s/%s): %v", repo, key, err) 115 - } 116 - } 117 - 118 - func mustAddCollab(t *testing.T, d *db.DB, owner, rkey, subject, repoDid string) { 119 - t.Helper() 120 - if err := d.AddRepoCollaborator(db.RepoCollaborator{ 121 - OwnerDid: syntax.DID(owner), 122 - Rkey: syntax.RecordKey(rkey), 123 - Subject: syntax.DID(subject), 124 - RepoDid: syntax.DID(repoDid), 125 - }); err != nil { 126 - t.Fatalf("AddRepoCollaborator(%s): %v", rkey, err) 127 114 } 128 115 } 129 116 ··· 367 354 } 368 355 } 369 356 370 - func TestMigrateLegacyRepoCasbin_NameCandidate(t *testing.T) { 371 - ctx := context.Background() 372 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 373 - d, e := newTestSpindleDB(t) 374 - 375 - if err := e.AddSpindle(rbacDomain); err != nil { 376 - t.Fatalf("AddSpindle: %v", err) 377 - } 378 - 379 - owner := "did:plc:akshay" 380 - repoDid := "did:plc:boltless" 381 - displayName := "myrepo" 382 - rkey := "3kspindlerkey00a" 383 - collab := "did:plc:limpet" 384 - oldNameKey := owner + "/" + displayName 385 - oldRkeyKey := owner + "/" + rkey 386 - 387 - mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid) 388 - 389 - if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil { 390 - t.Fatalf("seed AddRepo at Name key: %v", err) 391 - } 392 - if err := e.AddCollaborator(collab, rbacDomain, oldNameKey); err != nil { 393 - t.Fatalf("seed AddCollaborator at Name key: %v", err) 394 - } 395 - 396 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 397 - 398 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got { 399 - t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err) 400 - } 401 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got { 402 - t.Errorf("collab should have settings at new repoDid key, allowed=%v err=%v", got, err) 403 - } 404 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got { 405 - t.Errorf("owner Name-keyed policy should be removed, allowed=%v err=%v", got, err) 406 - } 407 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldNameKey); err != nil || got { 408 - t.Errorf("collab Name-keyed policy should be removed, allowed=%v err=%v", got, err) 409 - } 410 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got { 411 - t.Errorf("owner rkey-keyed policy should be absent (never added), allowed=%v err=%v", got, err) 412 - } 413 - 414 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 415 - 416 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got { 417 - t.Errorf("collab settings still expected after idempotent re-run, allowed=%v err=%v", got, err) 418 - } 419 - 420 - var marked int 421 - if err := d.QueryRow( 422 - `select count(*) from migrations where name = ?`, 423 - "legacy-casbin-rekey:"+repoDid+":"+rkey, 424 - ).Scan(&marked); err != nil { 425 - t.Fatalf("query migrations: %v", err) 426 - } 427 - if marked != 1 { 428 - t.Errorf("expected per-repo flag recorded exactly once, got %d", marked) 429 - } 430 - } 431 - 432 - func TestMigrateLegacyRepoCasbin_RkeyCandidate(t *testing.T) { 433 - ctx := context.Background() 434 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 435 - d, e := newTestSpindleDB(t) 436 - 437 - if err := e.AddSpindle(rbacDomain); err != nil { 438 - t.Fatalf("AddSpindle: %v", err) 439 - } 440 - 441 - owner := "did:plc:akshay" 442 - repoDid := "did:plc:boltless" 443 - displayName := "myrepo" 444 - rkey := "3kspindlerkey00a" 445 - collab := "did:plc:limpet" 446 - oldRkeyKey := owner + "/" + rkey 447 - 448 - mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid) 449 - 450 - if err := e.AddRepo(owner, rbacDomain, oldRkeyKey); err != nil { 451 - t.Fatalf("seed AddRepo at rkey: %v", err) 452 - } 453 - if err := e.AddCollaborator(collab, rbacDomain, oldRkeyKey); err != nil { 454 - t.Fatalf("seed AddCollaborator at rkey: %v", err) 455 - } 456 - 457 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 458 - 459 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got { 460 - t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err) 461 - } 462 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got { 463 - t.Errorf("collab should have settings at new repoDid key, allowed=%v err=%v", got, err) 464 - } 465 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got { 466 - t.Errorf("owner rkey-keyed policy should be removed, allowed=%v err=%v", got, err) 467 - } 468 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldRkeyKey); err != nil || got { 469 - t.Errorf("collab rkey-keyed policy should be removed, allowed=%v err=%v", got, err) 470 - } 471 - } 472 - 473 - func TestMigrateLegacyRepoCasbin_BothCandidates(t *testing.T) { 474 - ctx := context.Background() 475 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 476 - d, e := newTestSpindleDB(t) 477 - 478 - if err := e.AddSpindle(rbacDomain); err != nil { 479 - t.Fatalf("AddSpindle: %v", err) 480 - } 481 - 482 - owner := "did:plc:akshay" 483 - repoDid := "did:plc:boltless" 484 - displayName := "myrepo" 485 - rkey := "3kspindlerkey00a" 486 - collab := "did:plc:limpet" 487 - oldNameKey := owner + "/" + displayName 488 - oldRkeyKey := owner + "/" + rkey 489 - 490 - mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid) 491 - 492 - if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil { 493 - t.Fatalf("seed AddRepo at Name key: %v", err) 494 - } 495 - if err := e.AddRepo(owner, rbacDomain, oldRkeyKey); err != nil { 496 - t.Fatalf("seed AddRepo at rkey: %v", err) 497 - } 498 - if err := e.AddCollaborator(collab, rbacDomain, oldNameKey); err != nil { 499 - t.Fatalf("seed AddCollaborator at Name key: %v", err) 500 - } 501 - if err := e.AddCollaborator(collab, rbacDomain, oldRkeyKey); err != nil { 502 - t.Fatalf("seed AddCollaborator at rkey: %v", err) 503 - } 504 - 505 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 506 - 507 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got { 508 - t.Errorf("owner Name-keyed policy should be removed, allowed=%v err=%v", got, err) 509 - } 510 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got { 511 - t.Errorf("owner rkey-keyed policy should be removed, allowed=%v err=%v", got, err) 512 - } 513 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldNameKey); err != nil || got { 514 - t.Errorf("collab Name-keyed policy should be removed, allowed=%v err=%v", got, err) 515 - } 516 - if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldRkeyKey); err != nil || got { 517 - t.Errorf("collab rkey-keyed policy should be removed, allowed=%v err=%v", got, err) 518 - } 519 - } 520 - 521 - func TestMigrateLegacyRepoCasbin_BothEmpty(t *testing.T) { 522 - ctx := context.Background() 523 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 524 - d, e := newTestSpindleDB(t) 525 - 526 - if err := e.AddSpindle(rbacDomain); err != nil { 527 - t.Fatalf("AddSpindle: %v", err) 528 - } 529 - 530 - owner := syntax.DID("did:plc:akshay") 531 - repoDid := syntax.DID("did:plc:boltless") 532 - 533 - migrateLegacyRepoCasbin(ctx, d, e, logger, owner, "", "", repoDid) 534 - 535 - var marked int 536 - if err := d.QueryRow( 537 - `select count(*) from migrations where name like ?`, 538 - "legacy-casbin-rekey:"+repoDid.String()+":%", 539 - ).Scan(&marked); err != nil { 540 - t.Fatalf("query migrations: %v", err) 541 - } 542 - if marked != 0 { 543 - t.Errorf("empty inputs should not record flag, got %d", marked) 544 - } 545 - } 546 - 547 357 func TestNudgeTapForResync(t *testing.T) { 548 358 ctx := context.Background() 549 359 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) ··· 680 490 t.Errorf("non-embed mode should skip tap nudge flag, got %d", marked) 681 491 } 682 492 } 683 - 684 - func TestCleanupOrphanRepos_DeletesWhenSiblingExists(t *testing.T) { 685 - ctx := context.Background() 686 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 687 - d, _ := newTestSpindleDB(t) 688 - 689 - owner := "did:plc:akshay" 690 - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values 691 - ('k', ?, 'legacy_name', null, null), 692 - ('k', ?, '3kspindlerkey00a', 'did:plc:boltless', '2024-01-01T00:00:00Z')`, 693 - owner, owner); err != nil { 694 - t.Fatalf("seed: %v", err) 695 - } 696 - 697 - if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 698 - t.Fatalf("cleanupOrphanRepos: %v", err) 699 - } 700 - 701 - var nullCount int 702 - if err := d.QueryRow(`select count(*) from repos where repo_did is null`).Scan(&nullCount); err != nil { 703 - t.Fatalf("null count: %v", err) 704 - } 705 - if nullCount != 0 { 706 - t.Errorf("orphan should be deleted when sibling exists, got %d remaining", nullCount) 707 - } 708 - 709 - var sibCount int 710 - if err := d.QueryRow(`select count(*) from repos where repo_did is not null`).Scan(&sibCount); err != nil { 711 - t.Fatalf("sibling count: %v", err) 712 - } 713 - if sibCount != 1 { 714 - t.Errorf("sibling row should be preserved, got %d", sibCount) 715 - } 716 - } 717 - 718 - func TestCleanupOrphanRepos_KeepsWhenAlone(t *testing.T) { 719 - ctx := context.Background() 720 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 721 - d, _ := newTestSpindleDB(t) 722 - 723 - owner := "did:plc:akshay" 724 - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values 725 - ('k', ?, 'legacy_name', null, null)`, owner); err != nil { 726 - t.Fatalf("seed: %v", err) 727 - } 728 - 729 - if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 730 - t.Fatalf("cleanupOrphanRepos: %v", err) 731 - } 732 - 733 - var remaining int 734 - if err := d.QueryRow(`select count(*) from repos where owner = ?`, owner).Scan(&remaining); err != nil { 735 - t.Fatalf("count: %v", err) 736 - } 737 - if remaining != 1 { 738 - t.Errorf("orphan with no sibling should be kept (preserves owner registration), got %d", remaining) 739 - } 740 - } 741 - 742 - func TestCleanupOrphanRepos_PerOwnerScope(t *testing.T) { 743 - ctx := context.Background() 744 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 745 - d, _ := newTestSpindleDB(t) 746 - 747 - ownerA := "did:plc:akshay" 748 - ownerB := "did:plc:limpet" 749 - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values 750 - ('k', ?, 'legacy_a', null, null), 751 - ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z'), 752 - ('k', ?, 'legacy_b', null, null)`, 753 - ownerA, ownerA, ownerB); err != nil { 754 - t.Fatalf("seed: %v", err) 755 - } 756 - 757 - if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 758 - t.Fatalf("cleanupOrphanRepos: %v", err) 759 - } 760 - 761 - var ownerARows, ownerBRows int 762 - if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerA).Scan(&ownerARows); err != nil { 763 - t.Fatalf("count A: %v", err) 764 - } 765 - if ownerARows != 1 { 766 - t.Errorf("ownerA: orphan should be deleted (sibling exists), expected 1 row, got %d", ownerARows) 767 - } 768 - if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerB).Scan(&ownerBRows); err != nil { 769 - t.Fatalf("count B: %v", err) 770 - } 771 - if ownerBRows != 1 { 772 - t.Errorf("ownerB: orphan should be kept (no sibling), expected 1 row, got %d", ownerBRows) 773 - } 774 - } 775 - 776 - func TestCleanupOrphanRepos_EmptyStringRepoDid(t *testing.T) { 777 - ctx := context.Background() 778 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 779 - d, _ := newTestSpindleDB(t) 780 - 781 - owner := "did:plc:akshay" 782 - if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values 783 - ('k', ?, 'legacy_empty', '', null), 784 - ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z')`, 785 - owner, owner); err != nil { 786 - t.Fatalf("seed: %v", err) 787 - } 788 - 789 - if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 790 - t.Fatalf("cleanupOrphanRepos: %v", err) 791 - } 792 - 793 - var emptyCount int 794 - if err := d.QueryRow(`select count(*) from repos where coalesce(repo_did, '') = ''`).Scan(&emptyCount); err != nil { 795 - t.Fatalf("empty count: %v", err) 796 - } 797 - if emptyCount != 0 { 798 - t.Errorf("empty-string repo_did orphan should be deleted when sibling exists, got %d remaining", emptyCount) 799 - } 800 - } 801 - 802 - func TestMigrateLegacyRepoCasbin_MultipleCollabsAllRekeyed(t *testing.T) { 803 - ctx := context.Background() 804 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 805 - d, e := newTestSpindleDB(t) 806 - 807 - if err := e.AddSpindle(rbacDomain); err != nil { 808 - t.Fatalf("AddSpindle: %v", err) 809 - } 810 - 811 - owner := "did:plc:akshay" 812 - repoDid := "did:plc:boltless" 813 - displayName := "myrepo" 814 - rkey := "3kspindlerkey00a" 815 - oldNameKey := owner + "/" + displayName 816 - collabs := []string{"did:plc:limpet", "did:plc:nautilus", "did:plc:whelk", "did:plc:cuttle"} 817 - 818 - var addCollabRows func(rest []string, idx int) 819 - addCollabRows = func(rest []string, idx int) { 820 - if len(rest) == 0 { 821 - return 822 - } 823 - mustAddCollab(t, d, owner, fmt.Sprintf("3kcollabrkey%04d", idx), rest[0], repoDid) 824 - addCollabRows(rest[1:], idx+1) 825 - } 826 - addCollabRows(collabs, 0) 827 - 828 - if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil { 829 - t.Fatalf("seed owner: %v", err) 830 - } 831 - var seedAll func(rest []string) error 832 - seedAll = func(rest []string) error { 833 - if len(rest) == 0 { 834 - return nil 835 - } 836 - if err := e.AddCollaborator(rest[0], rbacDomain, oldNameKey); err != nil { 837 - return err 838 - } 839 - return seedAll(rest[1:]) 840 - } 841 - if err := seedAll(collabs); err != nil { 842 - t.Fatalf("seed collab policies: %v", err) 843 - } 844 - 845 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 846 - 847 - var assertEach func(rest []string) 848 - assertEach = func(rest []string) { 849 - if len(rest) == 0 { 850 - return 851 - } 852 - c := rest[0] 853 - if got, err := e.IsSettingsAllowed(c, rbacDomain, repoDid); err != nil || !got { 854 - t.Errorf("collab %s should have settings at repoDid, allowed=%v err=%v", c, got, err) 855 - } 856 - if got, err := e.IsPushAllowed(c, rbacDomain, repoDid); err != nil || !got { 857 - t.Errorf("collab %s should have push at repoDid, allowed=%v err=%v", c, got, err) 858 - } 859 - if got, err := e.IsSettingsAllowed(c, rbacDomain, oldNameKey); err != nil || got { 860 - t.Errorf("collab %s old policy should be wiped, allowed=%v err=%v", c, got, err) 861 - } 862 - assertEach(rest[1:]) 863 - } 864 - assertEach(collabs) 865 - } 866 - 867 - func TestMigrateLegacyRepoCasbin_RenameSiblingsEachWiped(t *testing.T) { 868 - ctx := context.Background() 869 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 870 - d, e := newTestSpindleDB(t) 871 - 872 - if err := e.AddSpindle(rbacDomain); err != nil { 873 - t.Fatalf("AddSpindle: %v", err) 874 - } 875 - 876 - owner := syntax.DID("did:plc:akshay") 877 - repoDid := syntax.DID("did:plc:di4gol2smljyj6gjnjdu5qrg") 878 - siblings := []string{"pre-rename-life", "i-renamed-this", "post-rename-rename", "post-rename-renamed-again"} 879 - 880 - var seedAll func(rest []string) error 881 - seedAll = func(rest []string) error { 882 - if len(rest) == 0 { 883 - return nil 884 - } 885 - if err := e.AddRepo(owner.String(), rbacDomain, owner.String()+"/"+rest[0]); err != nil { 886 - return err 887 - } 888 - return seedAll(rest[1:]) 889 - } 890 - if err := seedAll(siblings); err != nil { 891 - t.Fatalf("seed siblings: %v", err) 892 - } 893 - 894 - var run func(rest []string) 895 - run = func(rest []string) { 896 - if len(rest) == 0 { 897 - return 898 - } 899 - migrateLegacyRepoCasbin(ctx, d, e, logger, owner, "", syntax.RecordKey(rest[0]), repoDid) 900 - run(rest[1:]) 901 - } 902 - run(siblings) 903 - 904 - var assertWiped func(rest []string) 905 - assertWiped = func(rest []string) { 906 - if len(rest) == 0 { 907 - return 908 - } 909 - key := owner.String() + "/" + rest[0] 910 - if got, err := e.IsSettingsAllowed(owner.String(), rbacDomain, key); err != nil || got { 911 - t.Errorf("rename sibling %s should be wiped, allowed=%v err=%v", rest[0], got, err) 912 - } 913 - assertWiped(rest[1:]) 914 - } 915 - assertWiped(siblings) 916 - 917 - if got, err := e.IsSettingsAllowed(owner.String(), rbacDomain, repoDid.String()); err != nil || !got { 918 - t.Errorf("owner should retain settings at repoDid, allowed=%v err=%v", got, err) 919 - } 920 - } 921 - 922 - func TestMigrateLegacyRepoCasbin_StrandedCollabWiped(t *testing.T) { 923 - ctx := context.Background() 924 - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 925 - d, e := newTestSpindleDB(t) 926 - 927 - if err := e.AddSpindle(rbacDomain); err != nil { 928 - t.Fatalf("AddSpindle: %v", err) 929 - } 930 - 931 - owner := "did:plc:akshay" 932 - repoDid := "did:plc:boltless" 933 - displayName := "myrepo" 934 - rkey := "3kspindlerkey00a" 935 - strandedCollab := "did:plc:nautilus" 936 - oldNameKey := owner + "/" + displayName 937 - 938 - if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil { 939 - t.Fatalf("seed AddRepo at Name key: %v", err) 940 - } 941 - if err := e.AddCollaborator(strandedCollab, rbacDomain, oldNameKey); err != nil { 942 - t.Fatalf("seed stranded collab at Name key: %v", err) 943 - } 944 - 945 - migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid)) 946 - 947 - if got, err := e.IsSettingsAllowed(strandedCollab, rbacDomain, oldNameKey); err != nil || got { 948 - t.Errorf("stranded collab should be wiped from old key, allowed=%v err=%v", got, err) 949 - } 950 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got { 951 - t.Errorf("owner old policy should be wiped, allowed=%v err=%v", got, err) 952 - } 953 - if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got { 954 - t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err) 955 - } 956 - }
+143 -228
spindle/tapclient.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "database/sql" 6 5 "encoding/json" 7 - "errors" 8 6 "fmt" 9 7 "log/slog" 10 8 "net/http" 11 9 "net/url" 12 - "sync" 10 + "slices" 11 + "strings" 13 12 "time" 14 13 15 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 18 17 avmodels "tangled.org/core/appview/models" 19 18 "tangled.org/core/eventconsumer" 20 19 "tangled.org/core/log" 21 - "tangled.org/core/rbac" 22 20 "tangled.org/core/spindle/db" 23 21 "tangled.org/core/spindle/git" 24 22 "tangled.org/core/spindle/models" ··· 28 26 ) 29 27 30 28 const ( 31 - maxPendingPerRepo = 64 32 - pendingCollabTTL = 10 * time.Minute 29 + collaboratorPageLimit = 100 30 + maxCollaboratorPages = 50 33 31 ) 34 32 35 - type pendingCollabEvent struct { 36 - evt *tapc.RecordEventData 37 - at time.Time 38 - } 39 - 40 33 type Tap struct { 41 - logger *slog.Logger 42 - spindle *Spindle 43 - tap tapc.Client 44 - pendingMu sync.Mutex 45 - pendingCollabs map[syntax.DID][]pendingCollabEvent 34 + logger *slog.Logger 35 + spindle *Spindle 36 + tap tapc.Client 46 37 } 47 38 48 39 func NewTapClient(s *Spindle) *Tap { 49 40 return &Tap{ 50 - logger: log.SubLogger(s.l, "tapclient"), 51 - spindle: s, 52 - tap: tapc.NewClient(s.cfg.Server.Tap.Url, s.cfg.Server.Tap.AdminPassword), 53 - pendingCollabs: make(map[syntax.DID][]pendingCollabEvent), 41 + logger: log.SubLogger(s.l, "tapclient"), 42 + spindle: s, 43 + tap: tapc.NewClient(s.cfg.Server.Tap.Url, s.cfg.Server.Tap.AdminPassword), 54 44 } 55 45 } 56 46 ··· 66 56 EventHandler: t.processEvent, 67 57 ConnectHandler: t.onConnect, 68 58 }) 69 - go t.purgePendingCollabsLoop(t.spindle.rootCtx) 70 59 } 71 60 72 61 func (t *Tap) onConnect(ctx context.Context) { ··· 80 69 switch evt.Record.Collection.String() { 81 70 case tangled.RepoNSID: 82 71 return t.processRepo(ctx, evt.Record) 83 - case tangled.RepoCollaboratorNSID: 84 - return t.processCollaborator(ctx, evt.Record) 85 72 } 86 73 return nil 87 74 } 88 75 76 + // processRepo ingests repo declaration record: `sh.tangled.repo`. It skips alias records. 89 77 func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error { 90 78 l := t.logger.With("collection", tangled.RepoNSID, "did", evt.Did, "rkey", evt.Rkey) 91 79 ··· 100 88 return nil 101 89 } 102 90 103 - hostname := t.spindle.cfg.Server.Hostname 104 - prior, priorErr := t.spindle.db.GetRepoByOwnerRkey(ownerDid, rkey) 105 - knownRepo := priorErr == nil 106 - 107 - if record.Spindle == nil || *record.Spindle != hostname { 108 - if knownRepo { 109 - l.Info("tearing down repo reassigned from this spindle", "newSpindle", record.Spindle) 110 - return t.teardownRepo(l, prior, ownerDid, rkey) 111 - } 112 - return nil 113 - } 114 - 115 91 if record.RepoDid == nil || *record.RepoDid == "" { 116 92 l.Warn("skipping repo record without repoDid") 117 93 return nil ··· 131 107 return nil 132 108 } 133 109 134 - // check if this repo DID is already owned by someone else 135 - existingRepo, err := t.spindle.db.GetRepoByDid(repoDid) 136 - if err == nil { 137 - if existingRepo.Owner != ownerDid { 138 - l.Warn("rejecting repo record: repoDid already registered by another owner", "repoDid", repoDid, "existingOwner", existingRepo.Owner, "newOwner", ownerDid) 110 + // ignore repos not pointing this spindle. 111 + hostname := t.spindle.cfg.Server.Hostname 112 + if record.Spindle == nil || *record.Spindle != hostname { 113 + // teardown existing repo 114 + prior, err := t.spindle.db.GetRepoByDid(repoDid) 115 + if err != nil { 139 116 return nil 140 117 } 141 - } else if !errors.Is(err, sql.ErrNoRows) { 142 - return fmt.Errorf("lookup existing repo by DID: %w", err) 118 + if prior.Owner == ownerDid && prior.Rkey == rkey { 119 + l.Info("tearing down repo reassigned from this spindle", "newSpindle", record.Spindle) 120 + return t.teardownRepo(l, prior.RepoDid) 121 + } 122 + l.Warn("ignoring reassignment from non-registering record", "owner", prior.Owner, "rkey", prior.Rkey) 123 + return nil 143 124 } 144 125 145 - if err := t.spindle.e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String()); err != nil { 126 + // verify repo declaration 127 + // NOTE: we are ignoring repo declaration records that *might* become correct pointer in 128 + // future with repository rename or ownership transfer. On repo rename, new pointer record 129 + // should always be recreated even when it already exists. 130 + verified, err := t.verifyRepoDeclaration(ctx, l, repoDid, ownerDid, rkey.String(), record.Knot) 131 + if err != nil { 132 + l.Warn("failed to verify repo declaration", "err", err) 133 + return nil 134 + } 135 + if !verified { 136 + // ignore alias records 137 + return nil 138 + } 139 + 140 + if err := t.spindle.e.SetRepoOwner(ownerDid, repoDid); err != nil { 146 141 l.Error("failed to add repo policy", "err", err) 147 142 return fmt.Errorf("add repo policy: %w", err) 148 143 } 149 144 150 - src := eventconsumer.NewKnotSource(record.Knot) 151 - t.spindle.ks.AddSource(t.spindle.rootCtx, src) 152 - 153 145 repo := db.Repo{ 154 146 Knot: record.Knot, 155 147 Owner: ownerDid, ··· 163 155 return fmt.Errorf("add repo: %w", err) 164 156 } 165 157 158 + t.reconcileCollaborators(ctx, l, record.Knot, repoDid, ownerDid) 159 + t.spindle.ks.AddSource(t.spindle.rootCtx, eventconsumer.NewKnotSource(record.Knot)) 160 + 166 161 // setup sparse sync 167 162 repoCloneUri := t.spindle.newRepoCloneUrl(repo.Knot, repo.RepoDid) 168 163 repoPath := t.spindle.newRepoPath(repo.RepoDid) ··· 175 170 legacyName = *record.Name 176 171 } 177 172 migrateLegacyRepoSecrets(ctx, t.spindle.db, t.spindle.vault, l, ownerDid, legacyName, rkey, repoDid) 178 - migrateLegacyRepoCasbin(ctx, t.spindle.db, t.spindle.e, l, ownerDid, legacyName, rkey, repoDid) 179 173 180 174 if removed, err := t.spindle.db.CollapseRepoSiblings(ownerDid, repoDid); err != nil { 181 175 l.Warn("collapse rename siblings failed", "err", err) ··· 190 184 } 191 185 t.spindle.jc.AddDid(ownerDid.String()) 192 186 193 - t.drainPendingCollabs(ctx, repoDid) 194 - 195 187 case tapc.RecordDeleteAction: 196 - repo, err := t.spindle.db.GetRepoByOwnerRkey(ownerDid, rkey) 188 + repoDid, err := t.spindle.db.DeleteRepoByOwnerRkey(ownerDid, rkey) 197 189 if err != nil { 198 - l.Info("skipping delete for unknown repo") 190 + return fmt.Errorf("deleting repo record: %w", err) 191 + } 192 + 193 + if repoDid == "" { 194 + // no record is deleted. record was not pointing this spindle or was an alias record 199 195 return nil 200 196 } 201 - return t.teardownRepo(l, repo, ownerDid, rkey) 197 + return t.teardownRepo(l, repoDid) 202 198 } 203 199 return nil 204 200 } 205 201 206 - func (t *Tap) teardownRepo(l *slog.Logger, repo *db.Repo, ownerDid syntax.DID, rkey syntax.RecordKey) error { 207 - if repo.RepoDid != "" { 208 - collabs, err := t.spindle.db.ListCollaboratorsByRepoDid(repo.RepoDid) 209 - if err != nil { 210 - l.Error("failed to list collaborators for cleanup", "err", err) 211 - return fmt.Errorf("list collaborators: %w", err) 212 - } 213 - for _, c := range collabs { 214 - if err := t.spindle.e.RemoveCollaborator(c.Subject.String(), rbac.ThisServer, repo.RepoDid.String()); err != nil { 215 - l.Error("failed to remove collaborator policy", "subject", c.Subject, "err", err) 216 - return fmt.Errorf("remove collaborator policy: %w", err) 217 - } 218 - } 219 - if err := t.spindle.db.DeleteRepoCollaboratorsByRepoDid(repo.RepoDid); err != nil { 220 - l.Error("failed to clear collaborator rows", "err", err) 221 - return err 222 - } 223 - if err := t.spindle.e.RemoveRepo(ownerDid.String(), rbac.ThisServer, repo.RepoDid.String()); err != nil { 224 - l.Error("failed to remove repo policy", "err", err) 225 - return fmt.Errorf("remove repo policy: %w", err) 226 - } 202 + func (t *Tap) verifyRepoDeclaration(ctx context.Context, l *slog.Logger, repo, owner syntax.DID, rkey, knot string) (bool, error) { 203 + result, err := t.spindle.VerifyRepo(ctx, repo) 204 + if err != nil { 205 + return false, err 206 + } 207 + l = l.With( 208 + "repoDid", result.RepoDid, 209 + "repoOwner", result.OwnerDid, 210 + "repoKnot", result.KnotURL.Host, 211 + "claimedOwner", owner, 212 + "claimedRkey", rkey, 213 + "claimedKnot", knot, 214 + ) 215 + if syntax.DID(result.OwnerDid) != owner { 216 + l.Warn("rejecting repo event: owner mismatch") 217 + return false, nil 218 + } 219 + if result.Rkey != rkey { 220 + l.Warn("rejecting repo event: rkey mismatch") 221 + return false, nil 227 222 } 228 - if err := t.spindle.db.DeleteRepoByOwnerRkey(ownerDid, rkey); err != nil { 229 - l.Error("failed to delete repo row", "err", err) 230 - return fmt.Errorf("delete repo row: %w", err) 223 + if !strings.EqualFold(knot, result.KnotURL.Host) { 224 + l.Warn("rejecting repo event: record knot does not match DID-doc endpoint") 225 + return false, nil 231 226 } 232 - // TODO: clear sparse-synced git repo 233 - return nil 227 + return true, nil 234 228 } 235 229 236 - func (t *Tap) processCollaborator(ctx context.Context, evt *tapc.RecordEventData) error { 237 - l := t.logger.With("collection", tangled.RepoCollaboratorNSID, "did", evt.Did, "rkey", evt.Rkey) 230 + func (t *Tap) reconcileCollaborators(ctx context.Context, l *slog.Logger, knot string, repo, owner syntax.DID) { 231 + wanted, err := t.fetchKnotCollaborators(ctx, knot, repo) 232 + if err != nil { 233 + l.Warn("collaborator reconcile: failed to fetch roster from knot", "knot", knot, "err", err) 234 + return 235 + } 238 236 239 - switch evt.Action { 240 - case tapc.RecordCreateAction, tapc.RecordUpdateAction: 241 - record := tangled.RepoCollaborator{} 242 - if err := json.Unmarshal(evt.Record, &record); err != nil { 243 - l.Warn("skipping invalid collaborator record", "err", err) 244 - return nil 245 - } 237 + have, err := t.spindle.e.GetRepoCollaborators(repo) 238 + if err != nil { 239 + l.Warn("collaborator reconcile: failed to read current grants", "err", err) 240 + return 241 + } 246 242 247 - actor := evt.Did 248 - rkey := evt.Rkey 243 + // the owner is a collaborator by role inheritance, never by an explicit grant 244 + delete(wanted, owner) 249 245 250 - subjectDid, err := syntax.ParseDID(record.Subject) 251 - if err != nil { 252 - l.Info("skipping collaborator with malformed subject DID", "subject", record.Subject, "err", err) 253 - return nil 246 + for did := range wanted { 247 + if slices.Contains(have, did) { 248 + continue 254 249 } 255 - if _, err := t.spindle.res.ResolveIdent(ctx, subjectDid.String()); err != nil { 256 - l.Info("skipping unresolvable collaborator subject", "subject", subjectDid, "err", err) 257 - return nil 250 + if err := t.spindle.grantCollaborator(did, repo); err != nil { 251 + l.Error("collaborator reconcile: failed to add", "subject", did, "err", err) 252 + return 258 253 } 259 - 260 - repoRefDid, err := syntax.ParseDID(record.Repo) 261 - if err != nil { 262 - l.Info("skipping collaborator with non-DID repo ref", "repo", record.Repo, "err", err) 263 - return nil 254 + l.Info("collaborator reconcile: added", "subject", did) 255 + } 256 + for _, did := range have { 257 + if did == owner { 258 + continue 264 259 } 265 - repo, lookupErr := t.spindle.db.GetRepoByDid(repoRefDid) 266 - if errors.Is(lookupErr, sql.ErrNoRows) { 267 - t.bufferCollab(repoRefDid, evt) 268 - l.Info("buffering collaborator until repo arrives", "repo", repoRefDid) 269 - return nil 260 + if _, ok := wanted[did]; ok { 261 + continue 270 262 } 271 - if lookupErr != nil { 272 - return fmt.Errorf("lookup repo %s: %w", repoRefDid, lookupErr) 263 + if err := t.spindle.e.RemoveRepoCollaborator(did, repo); err != nil { 264 + l.Error("collaborator reconcile: failed to remove", "subject", did, "err", err) 265 + return 273 266 } 274 - repoDid := repo.RepoDid 275 - ownerDid := repo.Owner 267 + l.Info("collaborator reconcile: removed", "subject", did) 268 + } 269 + } 276 270 277 - if actor != ownerDid { 278 - l.Info("rejecting collaborator with non-owner actor", "actor", actor, "owner", ownerDid) 279 - return nil 280 - } 271 + func (t *Tap) fetchKnotCollaborators(ctx context.Context, knot string, repo syntax.DID) (map[syntax.DID]struct{}, error) { 272 + scheme := "https" 273 + if t.spindle.cfg.Server.Dev { 274 + scheme = "http" 275 + } 276 + xc := &indigoxrpc.Client{ 277 + Host: fmt.Sprintf("%s://%s", scheme, knot), 278 + Client: &http.Client{Timeout: 30 * time.Second}, 279 + } 281 280 282 - ok, err := t.spindle.e.IsCollaboratorInviteAllowed(ownerDid.String(), rbac.ThisServer, repoDid.String()) 281 + subjects := make(map[syntax.DID]struct{}) 282 + cursor := "" 283 + for range maxCollaboratorPages { 284 + out, err := tangled.RepoListCollaborators(ctx, xc, cursor, collaboratorPageLimit, "", repo.String()) 283 285 if err != nil { 284 - l.Error("invite permission check failed", "err", err) 285 - return fmt.Errorf("invite check: %w", err) 286 - } 287 - if !ok { 288 - l.Info("rejecting collaborator invite", "owner", ownerDid, "repo", repoDid) 289 - return nil 286 + return nil, err 290 287 } 291 - 292 - prior, priorErr := t.spindle.db.GetRepoCollaborator(actor, rkey) 293 - staleSubject := priorErr == nil && (prior.Subject != subjectDid || prior.RepoDid != repoDid) 294 - 295 - if err := t.spindle.e.AddCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String()); err != nil { 296 - l.Error("failed to add collaborator policy", "err", err) 297 - return fmt.Errorf("add collaborator policy: %w", err) 298 - } 299 - if staleSubject { 300 - if err := t.spindle.e.RemoveCollaborator(prior.Subject.String(), rbac.ThisServer, prior.RepoDid.String()); err != nil { 301 - l.Error("failed to remove stale collaborator policy", "err", err) 302 - return fmt.Errorf("remove stale collaborator: %w", err) 288 + for _, item := range out.Items { 289 + did, err := syntax.ParseDID(item.Subject) 290 + if err != nil { 291 + continue 303 292 } 293 + subjects[did] = struct{}{} 304 294 } 305 - if err := t.spindle.db.AddRepoCollaborator(db.RepoCollaborator{ 306 - OwnerDid: actor, 307 - Rkey: rkey, 308 - Subject: subjectDid, 309 - RepoDid: repoDid, 310 - }); err != nil { 311 - l.Error("failed to persist collaborator row", "err", err) 312 - return fmt.Errorf("track collaborator: %w", err) 295 + if out.Cursor == nil || *out.Cursor == "" { 296 + return subjects, nil 313 297 } 298 + cursor = *out.Cursor 299 + } 300 + return nil, fmt.Errorf("collaborator roster exceeded %d pages", maxCollaboratorPages) 301 + } 314 302 315 - case tapc.RecordDeleteAction: 316 - actor := evt.Did 317 - rkey := evt.Rkey 318 - 319 - tracked, err := t.spindle.db.GetRepoCollaborator(actor, rkey) 320 - if err != nil { 321 - l.Info("skipping delete for unknown collaborator record") 322 - return nil 323 - } 324 - if err := t.spindle.e.RemoveCollaborator(tracked.Subject.String(), rbac.ThisServer, tracked.RepoDid.String()); err != nil { 325 - l.Error("failed to remove collaborator policy", "err", err) 326 - return fmt.Errorf("remove collaborator policy: %w", err) 327 - } 328 - if err := t.spindle.db.DeleteRepoCollaborator(actor, rkey); err != nil { 329 - l.Error("failed to delete collaborator row", "err", err) 330 - return fmt.Errorf("delete collaborator row: %w", err) 331 - } 303 + func (t *Tap) teardownRepo(l *slog.Logger, repo syntax.DID) error { 304 + if repo == "" { 305 + return nil 306 + } 307 + if err := t.spindle.db.DeleteRepo(repo); err != nil { 308 + l.Error("failed to remove repo", "err", err) 309 + return fmt.Errorf("remove repo: %w", err) 310 + } 311 + if err := t.spindle.e.DeleteRepo(repo); err != nil { 312 + l.Error("failed to remove repo policy", "err", err) 313 + return fmt.Errorf("remove repo policy: %w", err) 332 314 } 315 + // TODO: clear sparse-synced git repo 333 316 return nil 334 317 } 335 318 ··· 375 358 return nil 376 359 } 377 360 378 - // check if pull record author has push access to target repo 379 - allowed, err := s.e.IsPushAllowed(evt.Did.String(), rbac.ThisServer, repo.RepoDid.String()) 361 + // check if pull record author can trigger CI in target repo 362 + allowed, err := s.e.IsRepoCiTriggerAllowed(evt.Did, repo.RepoDid) 380 363 if err != nil { 381 364 return fmt.Errorf("checking push access for pull record author: %w", err) 382 365 } ··· 475 458 // no-op 476 459 } 477 460 return nil 478 - } 479 - 480 - func (t *Tap) bufferCollab(repoDid syntax.DID, evt *tapc.RecordEventData) { 481 - t.pendingMu.Lock() 482 - defer t.pendingMu.Unlock() 483 - list := t.pendingCollabs[repoDid] 484 - list = append(list, pendingCollabEvent{evt: evt, at: time.Now()}) 485 - if len(list) > maxPendingPerRepo { 486 - list = list[len(list)-maxPendingPerRepo:] 487 - } 488 - t.pendingCollabs[repoDid] = list 489 - } 490 - 491 - func (t *Tap) drainPendingCollabs(ctx context.Context, repoDid syntax.DID) { 492 - t.pendingMu.Lock() 493 - list := t.pendingCollabs[repoDid] 494 - delete(t.pendingCollabs, repoDid) 495 - t.pendingMu.Unlock() 496 - if len(list) == 0 { 497 - return 498 - } 499 - cutoff := time.Now().Add(-pendingCollabTTL) 500 - for _, p := range list { 501 - if p.at.Before(cutoff) { 502 - continue 503 - } 504 - if err := t.processCollaborator(ctx, p.evt); err != nil { 505 - t.logger.Warn("replaying buffered collaborator failed", "repo", repoDid, "rkey", p.evt.Rkey, "err", err) 506 - } 507 - } 508 - } 509 - 510 - func (t *Tap) purgePendingCollabsLoop(ctx context.Context) { 511 - ticker := time.NewTicker(pendingCollabTTL / 2) 512 - defer ticker.Stop() 513 - for { 514 - select { 515 - case <-ctx.Done(): 516 - return 517 - case <-ticker.C: 518 - t.purgeStalePendingCollabs() 519 - } 520 - } 521 - } 522 - 523 - func (t *Tap) purgeStalePendingCollabs() { 524 - cutoff := time.Now().Add(-pendingCollabTTL) 525 - t.pendingMu.Lock() 526 - defer t.pendingMu.Unlock() 527 - expired := 0 528 - for did, list := range t.pendingCollabs { 529 - kept := list[:0] 530 - for _, p := range list { 531 - if !p.at.Before(cutoff) { 532 - kept = append(kept, p) 533 - } else { 534 - expired++ 535 - } 536 - } 537 - if len(kept) == 0 { 538 - delete(t.pendingCollabs, did) 539 - } else { 540 - t.pendingCollabs[did] = kept 541 - } 542 - } 543 - if expired > 0 { 544 - t.logger.Warn("expired buffered collaborator events without matching repo arrival", "count", expired, "ttl", pendingCollabTTL) 545 - } 546 461 } 547 462 548 463 func (s *Spindle) fetchLatestSubmission(ctx context.Context, did, rkey string, record *tangled.RepoPull) (*avmodels.PullSubmission, error) {
+6 -3
spindle/xrpc/add_secret.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 69 68 fail(xrpcerr.GenericError(fmt.Errorf("repo record %s has no repoDid", repoAt))) 70 69 return 71 70 } 72 - repoDid := *repoRec.RepoDid 71 + repoDid, err := syntax.ParseDID(*repoRec.RepoDid) 72 + if err != nil { 73 + fail(xrpcerr.GenericError(fmt.Errorf("repo record %q has invalid repoDid: %q", repoAt, *repoRec.RepoDid))) 74 + return 75 + } 73 76 74 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { 77 + if ok, err := x.Enforcer.IsRepoSecretsAllowed(actorDid, repoDid); !ok || err != nil { 75 78 l.Error("insufficient permissions", "did", actorDid.String()) 76 79 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 77 80 return
+1 -2
spindle/xrpc/ci_pipeline_trigger_pipeline.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 xrpcerr "tangled.org/core/xrpc/errors" 15 14 ) 16 15 ··· 162 161 return "", xerr, false 163 162 } 164 163 165 - isPushAllowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid.String()) 164 + isPushAllowed, err := x.Enforcer.IsRepoCiTriggerAllowed(actorDid, repoDid) 166 165 if err != nil || !isPushAllowed { 167 166 return "", xrpcerr.AccessControlError(actorDid.String()), false 168 167 }
+6 -3
spindle/xrpc/list_secrets.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 64 63 fail(xrpcerr.GenericError(fmt.Errorf("repo record %s has no repoDid", repoAt))) 65 64 return 66 65 } 67 - repoDid := *repoRec.RepoDid 66 + repoDid, err := syntax.ParseDID(*repoRec.RepoDid) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(fmt.Errorf("repo record %q has invalid repoDid: %q", repoAt, *repoRec.RepoDid))) 69 + return 70 + } 68 71 69 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { 72 + if ok, err := x.Enforcer.IsRepoSecretsAllowed(actorDid, repoDid); !ok || err != nil { 70 73 l.Error("insufficient permissions", "did", actorDid.String()) 71 74 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 72 75 return
+6 -3
spindle/xrpc/remove_secret.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 "tangled.org/core/api/tangled" 12 - "tangled.org/core/rbac" 13 12 "tangled.org/core/spindle/secrets" 14 13 xrpcerr "tangled.org/core/xrpc/errors" 15 14 ) ··· 63 62 fail(xrpcerr.GenericError(fmt.Errorf("repo record %s has no repoDid", repoAt))) 64 63 return 65 64 } 66 - repoDid := *repoRec.RepoDid 65 + repoDid, err := syntax.ParseDID(*repoRec.RepoDid) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(fmt.Errorf("repo record %q has invalid repoDid: %q", repoAt, *repoRec.RepoDid))) 68 + return 69 + } 67 70 68 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { 71 + if ok, err := x.Enforcer.IsRepoSecretsAllowed(actorDid, repoDid); !ok || err != nil { 69 72 l.Error("insufficient permissions", "did", actorDid.String()) 70 73 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 74 return
+1 -1
spindle/xrpc/xrpc.go
··· 14 14 "tangled.org/core/api/tangled" 15 15 "tangled.org/core/idresolver" 16 16 "tangled.org/core/notifier" 17 - "tangled.org/core/rbac" 17 + "tangled.org/core/rbac/v2" 18 18 "tangled.org/core/spindle/config" 19 19 "tangled.org/core/spindle/db" 20 20 "tangled.org/core/spindle/models"