···11+// Package repodid mints per-repo did:plc identities. The knot is set as the
22+// DID's atproto_pds service endpoint, acting as a pseudo-PDS that answers
33+// describeRepo for the repo DID.
44+package repodid
55+66+import (
77+ "context"
88+ "fmt"
99+1010+ tangledrepodid "tangled.org/core/knotserver/repodid"
1111+)
1212+1313+// Mint prepares a did:plc genesis op with the knot as service endpoint and
1414+// submits it to the PLC directory. Returns the DID and its rotation key.
1515+func Mint(ctx context.Context, plcURL, knotServiceURL string) (string, []byte, error) {
1616+ prepared, err := tangledrepodid.PrepareRepoDID(plcURL, knotServiceURL)
1717+ if err != nil {
1818+ return "", nil, fmt.Errorf("preparing repo DID: %w", err)
1919+ }
2020+ if err := prepared.Submit(ctx); err != nil {
2121+ return "", nil, fmt.Errorf("submitting repo DID: %w", err)
2222+ }
2323+ return prepared.RepoDid, prepared.SigningKeyRaw, nil
2424+}
+48
internal/repodid/repodid_test.go
···11+package repodid
22+33+import (
44+ "context"
55+ "net/http"
66+ "net/http/httptest"
77+ "strings"
88+ "testing"
99+1010+ tangledrepodid "tangled.org/core/knotserver/repodid"
1111+)
1212+1313+// TestPrepareRepoDID checks that PrepareRepoDID produces a valid did:plc DID
1414+// and non-empty signing key without hitting a real PLC directory. Mint itself
1515+// requires a live Submit call, so we test at the prepare level and separately
1616+// verify that Mint propagates a submit error.
1717+func TestPrepareRepoDID(t *testing.T) {
1818+ // Use a stub PLC server that accepts the genesis op.
1919+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2020+ // go-didplc posts to /<did> — any non-error response is success.
2121+ w.WriteHeader(http.StatusOK)
2222+ }))
2323+ defer stub.Close()
2424+2525+ prepared, err := tangledrepodid.PrepareRepoDID(stub.URL, "https://knot.example.com")
2626+ if err != nil {
2727+ t.Fatalf("PrepareRepoDID: %v", err)
2828+ }
2929+ if !strings.HasPrefix(prepared.RepoDid, "did:plc:") {
3030+ t.Errorf("RepoDid = %q, want did:plc: prefix", prepared.RepoDid)
3131+ }
3232+ if len(prepared.SigningKeyRaw) == 0 {
3333+ t.Error("SigningKeyRaw is empty")
3434+ }
3535+}
3636+3737+func TestMintPropagatesSubmitError(t *testing.T) {
3838+ // Server that rejects submissions.
3939+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4040+ http.Error(w, "bad request", http.StatusBadRequest)
4141+ }))
4242+ defer stub.Close()
4343+4444+ _, _, err := Mint(context.Background(), stub.URL, "https://knot.example.com")
4545+ if err == nil {
4646+ t.Error("Mint should return error when PLC directory rejects submission")
4747+ }
4848+}
+113
internal/resolve/resolve.go
···11+// Package resolve maps knot-protocol repo identifiers (ownerDid/name,
22+// ownerDid/rkey, or bare did:plc repo DIDs) to Forgejo repos on disk.
33+package resolve
44+55+import (
66+ "context"
77+ "fmt"
88+ "os"
99+ "path/filepath"
1010+ "strings"
1111+1212+ securejoin "github.com/cyphar/filepath-securejoin"
1313+ "github.com/isabelroses/snot/internal/config"
1414+ "github.com/isabelroses/snot/internal/forgejo"
1515+ "github.com/isabelroses/snot/internal/state"
1616+)
1717+1818+type Resolver struct {
1919+ Cfg *config.Config
2020+ Store forgejo.Store
2121+ Rkeys *state.RkeyMap
2222+ Dids *state.RepoDids
2323+ // MintDid mints a new repo DID (injected; tests use a fake).
2424+ MintDid func(ctx context.Context) (did string, key []byte, err error)
2525+}
2626+2727+// UserFor returns the Forgejo user whose repos the given DID exposes.
2828+func (rs *Resolver) UserFor(did string) (string, bool) {
2929+ user, ok := rs.Cfg.UserMap[did]
3030+ return user, ok
3131+}
3232+3333+// DidFor returns the DID mapped to a Forgejo user (the inverse of UserFor).
3434+func (rs *Resolver) DidFor(user string) (string, bool) {
3535+ for did, u := range rs.Cfg.UserMap {
3636+ if strings.EqualFold(u, user) {
3737+ return did, true
3838+ }
3939+ }
4040+ return "", false
4141+}
4242+4343+// Repo resolves an owner DID plus a repo name or record rkey.
4444+func (rs *Resolver) Repo(ctx context.Context, ownerDid, nameOrRkey string) (*forgejo.Repo, string, error) {
4545+ user, ok := rs.UserFor(ownerDid)
4646+ if !ok {
4747+ return nil, "", forgejo.ErrNotFound
4848+ }
4949+ name := nameOrRkey
5050+ if mapped, ok := rs.Rkeys.RepoByRkey(nameOrRkey); ok {
5151+ name = mapped
5252+ }
5353+ return rs.lookup(ctx, user, name)
5454+}
5555+5656+// RepoFromParam accepts both forms the protocol uses: "ownerDid/name" and a
5757+// bare repo DID (did:plc).
5858+func (rs *Resolver) RepoFromParam(ctx context.Context, param string) (*forgejo.Repo, string, error) {
5959+ if !strings.HasPrefix(param, "did:") {
6060+ return nil, "", forgejo.ErrNotFound
6161+ }
6262+ if i := strings.Index(param, "/"); i >= 0 {
6363+ return rs.Repo(ctx, param[:i], param[i+1:])
6464+ }
6565+ // Bare repo DID — look up in persisted mapping.
6666+ if rs.Dids == nil {
6767+ return nil, "", forgejo.ErrNotFound
6868+ }
6969+ info, ok := rs.Dids.Get(param)
7070+ if !ok {
7171+ return nil, "", forgejo.ErrNotFound
7272+ }
7373+ // Defense: require the owner DID is still mapped.
7474+ if _, mapped := rs.DidFor(info.User); !mapped {
7575+ return nil, "", forgejo.ErrNotFound
7676+ }
7777+ return rs.lookup(ctx, info.User, info.Repo)
7878+}
7979+8080+// EnsureRepoDid returns the repo's did:plc, minting and persisting one on
8181+// first use.
8282+func (rs *Resolver) EnsureRepoDid(ctx context.Context, repo *forgejo.Repo) (string, error) {
8383+ if rs.Dids != nil {
8484+ if did, ok := rs.Dids.ByRepo(repo.OwnerName, repo.Name); ok {
8585+ return did, nil
8686+ }
8787+ }
8888+ did, key, err := rs.MintDid(ctx)
8989+ if err != nil {
9090+ return "", fmt.Errorf("minting repo DID: %w", err)
9191+ }
9292+ if rs.Dids != nil {
9393+ if err := rs.Dids.Put(did, state.RepoDidInfo{User: repo.OwnerName, Repo: repo.Name, Key: key}); err != nil {
9494+ return "", err
9595+ }
9696+ }
9797+ return did, nil
9898+}
9999+100100+func (rs *Resolver) lookup(ctx context.Context, user, name string) (*forgejo.Repo, string, error) {
101101+ repo, err := rs.Store.ResolveRepo(ctx, user, name)
102102+ if err != nil {
103103+ return nil, "", err
104104+ }
105105+ path, err := securejoin.SecureJoin(rs.Cfg.RepoRoot, filepath.Join(repo.OwnerName, repo.Name+".git"))
106106+ if err != nil {
107107+ return nil, "", forgejo.ErrNotFound
108108+ }
109109+ if _, err := os.Stat(path); err != nil {
110110+ return nil, "", forgejo.ErrNotFound
111111+ }
112112+ return repo, path, nil
113113+}