docker pull atcr.io/byjp.me/deepsky:latest
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 shutdownidentity_cmd.go— theidentityminting commandinternal/bsky— client and types for the Bluesky AppView and PDS records (read-only exceptwrite.go, used only byidentity)internal/doc— parser for site.standard.document content into renderable blocksinternal/render— gemtext rendering of profiles, posts, publications and documentsinternal/server— Gemini route handlers and the image/blob proxies (a-h/gemini)internal/identity— certificate fingerprinting and badge.blue record signing/verificationinternal/identity/index— verified fingerprint→DID index (bbolt) fed by Jetstream + lightrailinternal/tlsx— load-or-generate the server keypair and client certificates
Acknowledgements
Built on a-h/gemini.