Bluesky is a relatively new Social network. I joined about a year ago, but it hasn’t seen much action until very recently. Now a lot of people in the tech bubble I’m interested in have moved there and it’s an extremely nice experience without all the hate, bots and abysmal content on Twitter, but with a lot more interaction. Do you still remember the days when Twitter was nice? That is what Bluesky is now. And probably most pleasingly from a technical / development perspective, it has an open API. I used this for a little tool to enable verification of user accounts and took the opportunity to use Fermyon Spin again.

The TL;DR

If you are a Microsoft MVP, Microsoft Regional Director or GitHub Star, you can use the tool to add your Bluesky handle and your ID to one of the aforementioned sites. The tool will check if your Bluesky profile exists, if the ID on the program exists and if there is a link on the program site to the Bluesky profile. If yes, it accepts the user as verified and adds the profile to a Bluesky list and a Bluesky starter pack based on the verification source. Microsoft RDs and GitHub Stars are not further separated, so they only have one of each, e.g. the list of Verified Microsoft RDs or the GitHub Stars starter pack. Microsoft MVPs are separated by category (e.g. “Azure” or “Business Applications”) and technology area (e.g. “Application PaaS” or “Business Central”) and the tool places MVPs in the appropriate lists and starter packs, e.g. the list of Azure MVPs or the starter pack of Business Central MVPs. It also adds Bluesky labels to the users. To see them, other users need to subscribe to the Bluesky labeler. The result looks like this

screenshot of a Bluesky account showing labels for Microsoft MVP and GitHub Star

The lists, starter packs and labeler are all hosted on this Bluesky account. If you want to see the code, it lives at github.com/tfenster/verified-bluesky.

I am in contact with people to potentially add Docker Captains, AWS Heroes and Google Developer Experts. I am also toying with the idea of adding conference speakers. If you have any other ideas for possible verification sources, please let me know. The requirement is that it’s a publicly visible website or web service, I don’t want any kind of manual interaction, confirmation emails or anything like that.

The details: The technology base Fermyon Spin and Fermyon Cloud

This little project gave me a great excuse to work with Fermyon Spin again. I started with the verification backend, which is a perfect fit for Spin: It has no state other than a persisted key and value, it should start very quickly and run securely. I went with the Go-based template although I am really not a Go expert. So between GitHub Copilot and me, a collection of code lines came into existence which an experienced Go developer would probably find offensive. Therefore, I am not showing any code parts here, but you can find it at github.com/tfenster/verified-bluesky as mentioned above. Apart from my Go struggles, the development experience was great. I coded in a VS Code devcontainer and used the Spin CLI, which made it very easy to get started and work with it.

Besides the Go components for verification and one for some admin tasks, I have two more: One builds on the static file server for Spin applications and serves only two HTML files (one for the self-verification and one for the overview of existing lists and starter packs) and a CSS. To make this work, all I need are a few lines in the spin.toml manifest. It defines the component frontend as listening on the /... route in lines 1-3 and declares that the Wasm file for it should be fetched from GitHub (line 6). The files to serve are to be read from the static subfolder (line 7):

1
2
3
4
5
6
7
[[trigger.http]]
route = "/..."
component = "frontend"

[component.frontend]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.3.0/spin_static_fs.wasm", digest = "sha256:ef88708817e107bf49985c7cefe4dd1f199bf26f6727819183d5c996baa3d148" }
files = [{ source = "static", destination = "/" }]

The other one is the Spin key/value store explorer, which is for administration users only. It also only needs a name and a route (lines 1-3), a file (line 6) and the configuration which key/value store to access:

1
2
3
4
5
6
7
8
[[trigger.http]]
component = "kv-explorer"
route = "/internal/kv-explorer/..."

[component.kv-explorer]
source = { url = "https://github.com/fermyon/spin-kv-explorer/releases/download/v0.10.0/spin-kv-explorer.wasm", digest = "sha256:65bc286f8315746d1beecd2430e178f539fa487ebf6520099daae09a35dbce1d" }
allowed_outbound_hosts = ["redis://*:*", "mysql://*:*", "postgres://*:*"]
key_value_stores = ["default"]

These two ‘external’ components perfectly illustrate one of the strengths of WebAssembly and Spin applications: I can pull other components into my application, cleanly isolated, without having to worry about programming languages or execution environments. It just works, in a secure and performant way.

Local testing is also very smooth with a running application just a spin up (to run already built code) or spin build --up (to build and then run) away. And deployment is just as easy using spin cloud deploy to bring your application to the Fermyon Cloud to make it publicly available. But of course, I also wanted to set up Continuous Integration and Continuous Delivery. Thanks to the recently announced Spin GitHub plugin, all I had to do was spin gh create-action to get a working GitHub action for CI that installs the prerequisites and runs the build:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: "Continuous Integration"
on:
  push:
    branches:
      - "main"
env:
  GO_VERSION: "1.23.2"
  TINYGO_VERSION: "v0.34.0"
  SPIN_VERSION: ""
jobs:
  spin:
    runs-on: "ubuntu-latest"
    name: Build Spin App
    steps:
      - uses: actions/checkout@v4
      - name: Install Go
        uses: actions/setup-go@v5
        with:
          go-version: "$"
      - name: Install TinyGo
        uses: rajatjindal/setup-actions/tinygo@v0.0.1
        with:
          version: "$"
      - name: Install Spin
        uses: fermyon/actions/spin/setup@v1
        with:
          plugins: 
      - name: Build verified-bluesky
        run: spin build
        working-directory: .

The CD action looks very similar. Worth nothing is the trigger in line 4/5 to make sure it only runs when I set a tag like v0.7.0 and lines 28-31 to not only run a build, but also a deployment to the Fermyon Cloud:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
name: "Continuous Deployment"
on:
  push:
    tags:
    - 'v*' 
env:
  GO_VERSION: "1.23.2"
  TINYGO_VERSION: "v0.34.0"
  SPIN_VERSION: ""
jobs:
  spin:
    runs-on: "ubuntu-latest"
    name: Build Spin App
    steps:
      - uses: actions/checkout@v4
      - name: Install Go
        uses: actions/setup-go@v5
        with:
          go-version: "$"
      - name: Install TinyGo
        uses: rajatjindal/setup-actions/tinygo@v0.0.1
        with:
          version: "$"
      - name: Install Spin
        uses: fermyon/actions/spin/setup@v1
        with:
          plugins: 
      - name: Build and deploy verified-bluesky
        uses: fermyon/actions/spin/deploy@v1
        with:
          fermyon_token: $

With this set up, my development workflow typically consists of some work on a feature branch, a merge to main and a tag, and the rest works automatically. More details can be found in the official docs.

Overall, a really nice dev and deployment experience and I can only encourage everyone to give it a try if you have a suitable use case.

The details: Interacting with the Bluesky API

The Bluesky REST API is somewhat well documented, but in my opinion lacks some examples. Therefore, here are some of the most important interactions with the API that tool implements:

Assuming that you have a variable username and a variable password with obvious purposes, a login works like this

1
2
3
4
5
6
7
POST https://bsky.social/xrpc/com.atproto.server.createSession
Content-Type: application/json

{
  "identifier": "",
  "password": ""
}

The response gives you among others your unique ID, a JWT access token, a refresh token and a service endpoint that you can use for further API calls

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
    "did": "did:plc:px34esz3zqocesnhjoyllu7q",
    "didDoc": {
        ...
        "service": [
            {
                "id": "#atproto_pds",
                "type": "AtprotoPersonalDataServer",
                "serviceEndpoint": "https://panthercap.us-east.host.bsky.network"
            },
            ...
        ]
    },
    ...
    "accessJwt": "...",
    "refreshJwt": "..."
}

Now let’s read a profile. Assuming you have a variable actorToRead containing a Bluesky handle like tobiasfenster.io and a variable baseurl containing the service endpoint mentioned above, you can it read it as follows:

1
2
GET /xrpc/app.bsky.actor.getProfile?actor=
Authorization: Bearer 

This returns detailed information about the user.

Creating a list is a simple call as well. You create a record of type app.bsky.graph.list (line 13) with a purpose app.bsky.graph.defs#curatelist (line 12) and give it a name (line 9) and a description (line 10). The repo (line 7) is the Bluesky profile under which the list will be created.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /xrpc/com.atproto.repo.createRecord
Authorization: Bearer 
Content-Type: application/json

{
    "collection": "app.bsky.graph.list",
    "repo": "",
    "record": {
        "name": "test title from REST (50 max)",
        "description": "Test description via REST",
        "createdAt": "2024-11-11T18:28:20.213Z",
        "purpose": "app.bsky.graph.defs#curatelist",
        "$type": "app.bsky.graph.list"
    }
}

Creating a starter pack is a bit more complicated, because you first need to create a list similar to the one above,, but with purpose app.bsky.graph.defs#referencelist (line 12)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /xrpc/com.atproto.repo.createRecord
Authorization: Bearer 
Content-Type: application/json

{
    "collection": "app.bsky.graph.list",
    "repo": "",
    "record": {
        "name": "test title from REST (50 max)",
        "description": "Test description via REST",
        "createdAt": "2024-11-11T18:28:20.213Z",
        "purpose": "app.bsky.graph.defs#referencelist",
        "$type": "app.bsky.graph.list"
    }
}

To make this available as a starter pack, we need to create another record, this time of type app.bsky.graph.starterpack (line 14), pointing to the list we just created (line 11)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /xrpc/com.atproto.repo.createRecord
Authorization: Bearer 
Content-Type: application/json

{
    "collection": "app.bsky.graph.starterpack",
    "repo": "did:plc:e6dbkqufnaoml54hrimf4arc",
    "record": {
        "name": "test title from REST (50 max)",
        "description": "Test description via REST",
        "list": "at://did:plc:e6dbkqufnaoml54hrimf4arc/app.bsky.graph.list/3lb6ezvokkl26",
        "feeds": [],
        "createdAt": "2024-11-11T18:28:20.670Z",
        "$type": "app.bsky.graph.starterpack"
    }
}

Adding users to a list works through the applyWrites endpoint. Assuming you have the unique ID of the user to be added in a variable didToAdd, the list in a variable list and the owner of the list in a variable did, you do this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /xrpc/com.atproto.repo.applyWrites
Authorization: Bearer 
Content-Type: application/json

{
    "repo": "",
    "writes": [
        {
            "$type": "com.atproto.repo.applyWrites#create",
            "collection": "app.bsky.graph.listitem",
            "value": {
                "$type": "app.bsky.graph.listitem",
                "subject": "",
                "list": "",
                "createdAt": "2024-11-11T16:04:13.156Z"
            }
        }
    ]
}

I hope this gives you some idea of how to interact with the Bluesky API. As I said, the documentation is not bad, but lacks actual examples. I overcame this by simply doing what I wanted to automate via the Bluesky website and watching the traffic through my browser’s development tools. That way it is relatively straightforward what to do.

The details: The labeler

The last part of the story is the labeler to make the labels visible on Bluesky profiles and posts. Fortunately Bluesky has made Ozone available for everyone to use. So all I had to do was follow the hosting instructions to set it up.

At the end of this post I would like to ask you again to get in touch if you have any ideas of other verification backends I could use. I would prefer something or someone else involved, like community programs or speakers at events. Something like a LinkedIn profile would be a bit pointless because you could just open a LinkedIn profile and use that to “verify” your Bluesky profile. But anything else I would be happy to look into.