byjp.me deepsky

A Gemini window onto Bluesky.

0 0 pulls 7 Updated 5h
docker pull atcr.io/byjp.me/deepsky:latest
Image Size Layers
4.7 MB 6
Pushed 5h
Vulnerabilities
Loading...
Pulls
0 total
No pulls yet

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.space/p/{handle-or-did} Profile details and links to the lists below
gemini://deepsky.space/p/{handle-or-did}/posts The account’s posts (replies and reposts excluded)
gemini://deepsky.space/p/{handle-or-did}/replies The account’s replies
gemini://deepsky.space/p/{handle-or-did}/reposts Posts the account has reposted
gemini://deepsky.space/p/{handle-or-did}/likes Posts the account has liked
gemini://deepsky.space/p/{handle-or-did}/publications The account’s standard.site publications, each previewing its newest documents
gemini://deepsky.space/p/{handle-or-did}/publications/{pub-id}/ A single publication and its documents (paginated)
gemini://deepsky.space/p/{handle-or-did}/p/{tid}/ A single post
gemini://deepsky.space/p/{handle-or-did}/documents/{doc-id}/ A single site.standard.document
gemini://deepsky.space/p/{handle-or-did}/avatar The account’s avatar image (proxied)
gemini://deepsky.space/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.space 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, space.deepsky.identity. A record at
at://<did>/space.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.space/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.