Monorepo for Tangled tangled.org
7

Configure Feed

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

appview/oauth: scope auth thru knot capability

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 8, 2026, 4:18 PM +0300) commit a04c535b parent bc598317 change-id wkwsmxvm
+208 -27
+106 -27
appview/oauth/handler.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 + "io" 9 10 "log/slog" 10 11 "net/http" 11 - "slices" 12 12 "strings" 13 13 "time" 14 14 15 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 17 18 lexutil "github.com/bluesky-social/indigo/lex/util" 18 19 xrpc "github.com/bluesky-social/indigo/xrpc" 19 20 "github.com/go-chi/chi/v5" 20 21 "github.com/posthog/posthog-go" 21 22 "tangled.org/core/api/tangled" 22 23 "tangled.org/core/appview/db" 24 + "tangled.org/core/appview/knotcompat" 23 25 "tangled.org/core/appview/models" 24 26 "tangled.org/core/consts" 25 27 "tangled.org/core/idresolver" ··· 27 29 "tangled.org/core/tid" 28 30 ) 29 31 32 + const knotAdminTimeout = 30 * time.Second 33 + 30 34 func (o *OAuth) Router() http.Handler { 31 35 r := chi.NewRouter() 32 36 ··· 91 95 92 96 o.Logger.Debug("session saved successfully") 93 97 94 - go o.addToDefaultKnot(sessData.AccountDID.String()) 98 + go o.addToDefaultKnot(sessData.AccountDID) 95 99 go o.addToDefaultSpindle(sessData.AccountDID.String()) 96 100 go o.ensureTangledProfile(sessData) 97 101 go o.autoClaimTnglShDomain(sessData.AccountDID.String()) ··· 181 185 l.Debug("successfully added to default spindle", "did", did) 182 186 } 183 187 184 - func (o *OAuth) addToDefaultKnot(did string) { 188 + type onboardAction int 189 + 190 + const ( 191 + onboardViaAdminAPI onboardAction = iota 192 + onboardViaRecord 193 + onboardBlockedMissingSecret 194 + onboardBlockedSecretSet 195 + ) 196 + 197 + type defaultKnotState struct { 198 + native bool 199 + adminSecretSet bool 200 + } 201 + 202 + func onboardActionFor(s defaultKnotState) onboardAction { 203 + switch { 204 + case s.native && s.adminSecretSet: 205 + return onboardViaAdminAPI 206 + case s.native: 207 + return onboardBlockedMissingSecret 208 + case s.adminSecretSet: 209 + return onboardBlockedSecretSet 210 + default: 211 + return onboardViaRecord 212 + } 213 + } 214 + 215 + func (o *OAuth) addToDefaultKnot(did syntax.DID) { 185 216 l := o.Logger.With("subject", did) 186 217 187 - // use the tangled.sh app password to get an accessJwt 188 - // and create an sh.tangled.spindle.member record with that 218 + ctx := context.Background() 189 219 190 - allKnots, err := o.Enforcer.GetKnotsForUser(did) 191 - if err != nil { 192 - l.Error("failed to get knot members for did", "err", err) 220 + if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) { 221 + l.Warn("already a member of the default knot") 193 222 return 194 223 } 195 224 196 - if slices.Contains(allKnots, consts.DefaultKnot) { 197 - l.Warn("already a member of the default knot") 198 - return 225 + native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL) 226 + 227 + switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) { 228 + case onboardViaAdminAPI: 229 + if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil { 230 + l.Error("failed to add to default knot via admin api", "err", err) 231 + return 232 + } 233 + o.Acl.InvalidateMembers(o.Config.Knot.Default) 234 + l.Debug("successfully added to default knot via admin api") 235 + 236 + case onboardBlockedMissingSecret: 237 + l.Error("cannot add to default knot: knot admin secret not configured") 238 + 239 + case onboardBlockedSecretSet: 240 + l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured") 241 + 242 + case onboardViaRecord: 243 + l.Debug("adding to default knot") 244 + session, err := o.getAppPasswordSession() 245 + if err != nil { 246 + l.Error("failed to create session", "err", err) 247 + return 248 + } 249 + 250 + record := tangled.KnotMember{ 251 + LexiconTypeID: tangled.KnotMemberNSID, 252 + Subject: did.String(), 253 + Domain: o.Config.Knot.Default, 254 + CreatedAt: time.Now().Format(time.RFC3339), 255 + } 256 + 257 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 258 + l.Error("failed to add to default knot", "err", err) 259 + return 260 + } 261 + 262 + if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil { 263 + l.Error("failed to set up enforcer rules", "err", err) 264 + return 265 + } 266 + 267 + l.Debug("successfully added to default knot") 199 268 } 269 + } 200 270 201 - l.Debug("adding to default knot") 202 - session, err := o.getAppPasswordSession() 271 + func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error { 272 + ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout) 273 + defer cancel() 274 + 275 + scheme := "https://" 276 + if o.Config.Core.Dev { 277 + scheme = "http://" 278 + } 279 + endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost) 280 + 281 + body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()}) 203 282 if err != nil { 204 - l.Error("failed to create session", "err", err) 205 - return 283 + return err 206 284 } 207 285 208 - record := tangled.KnotMember{ 209 - LexiconTypeID: tangled.KnotMemberNSID, 210 - Subject: did, 211 - Domain: consts.DefaultKnot, 212 - CreatedAt: time.Now().Format(time.RFC3339), 286 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) 287 + if err != nil { 288 + return err 213 289 } 290 + req.Header.Set("Content-Type", "application/json") 291 + req.SetBasicAuth("admin", o.Config.Knot.AdminSecret) 214 292 215 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 216 - l.Error("failed to add to default knot", "err", err) 217 - return 293 + resp, err := http.DefaultClient.Do(req) 294 + if err != nil { 295 + return err 218 296 } 297 + defer resp.Body.Close() 219 298 220 - if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 221 - l.Error("failed to set up enforcer rules", "err", err) 222 - return 299 + if resp.StatusCode != http.StatusOK { 300 + msg, _ := io.ReadAll(resp.Body) 301 + return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg)) 223 302 } 224 303 225 - l.Debug("successfully added to default knot") 304 + return nil 226 305 } 227 306 228 307 func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) {
+102
appview/oauth/handler_test.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "log/slog" 7 + "net/http" 8 + "net/http/httptest" 9 + "strings" 10 + "testing" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + type fakeAcl struct { 19 + member bool 20 + gotHost string 21 + gotDid string 22 + } 23 + 24 + func (f *fakeAcl) InvalidateMembers(host string) {} 25 + 26 + func (f *fakeAcl) IsKnotMember(ctx context.Context, host, userDid string) bool { 27 + f.gotHost = host 28 + f.gotDid = userDid 29 + return f.member 30 + } 31 + 32 + func TestAddToDefaultKnot_ShortCircuitsWhenAlreadyMember(t *testing.T) { 33 + acl := &fakeAcl{member: true} 34 + o := &OAuth{ 35 + Acl: acl, 36 + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 37 + Config: &config.Config{ 38 + Core: config.CoreConfig{Dev: true}, 39 + Knot: config.KnotConfig{Default: consts.DefaultKnot}, 40 + }, 41 + } 42 + 43 + o.addToDefaultKnot(syntax.DID("did:plc:akshay")) 44 + 45 + if acl.gotDid != "did:plc:akshay" { 46 + t.Fatalf("IsKnotMember did = %q, want did:plc:akshay", acl.gotDid) 47 + } 48 + if acl.gotHost != consts.DefaultKnot { 49 + t.Fatalf("IsKnotMember host = %q, want %q", acl.gotHost, consts.DefaultKnot) 50 + } 51 + } 52 + 53 + func TestAddMemberViaKnotAdmin_HonorsDeadline(t *testing.T) { 54 + release := make(chan struct{}) 55 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 + <-release 57 + })) 58 + defer srv.Close() 59 + defer close(release) 60 + 61 + o := &OAuth{Config: &config.Config{ 62 + Core: config.CoreConfig{Dev: true}, 63 + Knot: config.KnotConfig{AdminSecret: "hunter2"}, 64 + }} 65 + 66 + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 67 + defer cancel() 68 + 69 + done := make(chan error, 1) 70 + go func() { 71 + done <- o.addMemberViaKnotAdmin(ctx, strings.TrimPrefix(srv.URL, "http://"), syntax.DID("did:plc:whelk")) 72 + }() 73 + 74 + select { 75 + case err := <-done: 76 + if err == nil { 77 + t.Fatal("a hung knot must surface an error, got nil") 78 + } 79 + case <-time.After(5 * time.Second): 80 + t.Fatal("addMemberViaKnotAdmin blocked past its deadline; the request has no timeout") 81 + } 82 + } 83 + 84 + func TestOnboardActionFor(t *testing.T) { 85 + cases := []struct { 86 + name string 87 + state defaultKnotState 88 + want onboardAction 89 + }{ 90 + {"native default knot with admin secret uses the admin api", defaultKnotState{native: true, adminSecretSet: true}, onboardViaAdminAPI}, 91 + {"native default knot without admin secret is blocked", defaultKnotState{native: true, adminSecretSet: false}, onboardBlockedMissingSecret}, 92 + {"legacy default knot with admin secret skips the legacy record", defaultKnotState{native: false, adminSecretSet: true}, onboardBlockedSecretSet}, 93 + {"legacy default knot without admin secret writes the legacy record", defaultKnotState{native: false, adminSecretSet: false}, onboardViaRecord}, 94 + } 95 + for _, c := range cases { 96 + t.Run(c.name, func(t *testing.T) { 97 + if got := onboardActionFor(c.state); got != c.want { 98 + t.Fatalf("onboardActionFor(%+v) = %d, want %d", c.state, got, c.want) 99 + } 100 + }) 101 + } 102 + }