A Gemini Protocol capsule for viewing Bluesky, Standard Site & similar records from the Atmosphere
Go 93%
Go Template 3%
Nix 2%
Shell 2%

README.md

DeepSky #

A Gemini server for Bluesky microblog posts. It reads from the public Bluesky AppView (https://public.api.bsky.app), so no account or authentication is needed.

Generate a client certificate #

To be recognised as your Bluesky account while browsing (see Identity below), generate a P-256 client certificate and publish the binding record:

deepsky identity \
  -handle you.bsky.social \
  -generate-cert \
  -cert lagrange.crt \
  -key  lagrange.key \
  -app-password <your-app-password>

This writes lagrange.crt / lagrange.key — import that pair as an identity in your Gemini client (e.g. Lagrange). Drop -app-password to print the record JSON and publish it yourself. The certificate must use ECDSA P-256, which -generate-cert guarantees.

Routes #

Path Shows
gemini://deepsky.byjp.me/p/{handle-or-did} Profile details and links to the lists below
gemini://deepsky.byjp.me/p/{handle-or-did}/posts The account's posts (replies and reposts excluded)
gemini://deepsky.byjp.me/p/{handle-or-did}/replies The account's replies
gemini://deepsky.byjp.me/p/{handle-or-did}/reposts Posts the account has reposted
gemini://deepsky.byjp.me/p/{handle-or-did}/likes Posts the account has liked
gemini://deepsky.byjp.me/p/{handle-or-did}/publications The account's standard.site publications, each previewing its newest documents
gemini://deepsky.byjp.me/p/{handle-or-did}/publications/{pub-id}/ A single publication and its documents (paginated)
gemini://deepsky.byjp.me/p/{handle-or-did}/p/{tid}/ A single post
gemini://deepsky.byjp.me/p/{handle-or-did}/documents/{doc-id}/ A single site.standard.document
gemini://deepsky.byjp.me/p/{handle-or-did}/avatar The account's avatar image (proxied)
gemini://deepsky.byjp.me/me The atproto account your client certificate is linked to (if any)

{handle-or-did} is a Bluesky handle (e.g. bsky.app) or DID (e.g. did:plc:z72i7hdynmk6r22z27h6tvur). {tid} is a post's record key — the last segment of its at:// URI.

The list pages are paginated ~10 at a time; a => …?cursor=… More → link at the foot of a page fetches the next one.

Posts render as gemtext: the account's own text as plain paragraphs (quote blocks are reserved for the text of quoted posts), external link cards and quoted posts as => links, and each image as an => img-N.webp 🖼️ <alt text> link. Those image links are proxied through this server from the Bluesky CDN (the smaller, size-limited variant — full colour WebP) so Gemini clients that render inline images can show them without reaching out to the CDN directly.

Publications & documents #

standard.site is a set of atproto lexicons for publishing. /publications lists an account's site.standard.publication records, previewing each publication's newest site.standard.document records (with a count of any others), followed by an "Other documents" section for documents not tied to a known publication, then any publications with no documents. Each publication has a "View more" link to its own page — /publications/{pub-id}/ — which lists that publication's documents, paginated newest-first.

Document bodies are stored in platform-specific shapes. The renderer handles leaflet.pub (pub.leaflet.content), pckt.blog (blog.pckt.content), offprint.app (app.offprint.content), and older raw-markdown documents (textContent), via a single walker keyed on each block's $type — so an unfamiliar platform still degrades to readable text. Headings, lists, code, block quotes and links are rendered as gemtext; images embedded as PDS blobs are proxied through this server (like post images) and linked as img-N.<ext>.

These records live in the account's repository, not the AppView, so they are read from the account's PDS (resolved from its DID document). Some PDS implementations emit user content with invalid JSON escapes; the client repairs these before decoding.

Likes #

The public AppView does not expose likes for arbitrary accounts (app.bsky.feed.getActorLikes requires authentication), so /likes is read straight from the account's repository: the DID document is resolved to find the PDS, its app.bsky.feed.like records are listed, and the referenced posts are hydrated via the AppView.

Running #

go build -o deepsky .
./deepsky

Gemini requires TLS. On first run, if the certificate and key files don't exist, a self-signed keypair (trust-on-first-use, as Gemini expects) is generated and written to server.crt / server.key and reused thereafter. Provide real certificates (e.g. Let's Encrypt) by placing them at those paths.

Press Ctrl-C to stop: the server stops accepting connections, gives in-flight requests a moment to finish, and exits cleanly. A second Ctrl-C forces an immediate quit.

serve flags #

Flag Default Description
-addr :1965 Address to listen on
-domain deepsky.byjp.me Server name (TLS SNI) to serve
-cert server.crt TLS certificate file (generated if absent)
-key server.key TLS private key file (generated if absent)
-appview https://public.api.bsky.app Bluesky AppView base URL
-index identity.db Identity index database (empty to disable)
-jetstream wss://jetstream2.us-east.bsky.network/subscribe Jetstream endpoint for live identity records
-lightrail https://lightrail.microcosm.blue lightrail host for the identity backfill (empty to skip)

deepsky (no subcommand) and deepsky serve are equivalent. The handler is also served under localhost, so for local development you can point a Gemini client at gemini://localhost/p/bsky.app.

Identity: who is browsing? #

Gemini authenticates clients with TLS client certificates — a stable but anonymous key. DeepSky can recognise the Bluesky account behind a certificate via a custom lexicon, me.byjp.deepsky.identity. A record at at://<did>/me.byjp.deepsky.identity/<fingerprint> is a badge.blue-style signed statement that the holder of the certificate's private key is that account.

The record is signed by the certificate's own private key and bound to the repository DID, so it cannot be copied into another account's repo. The server only ever verifies; you create the record once with the identity command:

# Generate a P-256 client cert and publish the binding via an app password:
deepsky identity -handle you.bsky.social -generate-cert -app-password <pw>

# …or print the record JSON to publish yourself (no credentials needed):
deepsky identity -handle you.bsky.social -cert your.crt -key your.key

The server learns about records from a one-time lightrail backfill plus a live Jetstream tail, both verifying signatures before indexing. Once linked, visit gemini://deepsky.byjp.me/me with your certificate to see yourself recognised. Certificates must use ECDSA P-256 (Bluesky's signing curve); browsing without a certificate stays fully anonymous.

Layout #

  • main.go — subcommand dispatch (serve / identity)
  • serve.go — TLS setup, identity index wiring, server startup, graceful shutdown
  • identity_cmd.go — the identity minting command
  • internal/bsky — client and types for the Bluesky AppView and PDS records (read-only except write.go, used only by identity)
  • internal/doc — parser for site.standard.document content into renderable blocks
  • internal/render — gemtext rendering of profiles, posts, publications and documents
  • internal/server — Gemini route handlers and the image/blob proxies (a-h/gemini)
  • internal/identity — certificate fingerprinting and badge.blue record signing/verification
  • internal/identity/index — verified fingerprint→DID index (bbolt) fed by Jetstream + lightrail
  • internal/tlsx — load-or-generate the server keypair and client certificates

Acknowledgements #

Built on a-h/gemini.