- Julia 95.3%
- CSS 4.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| inspiration | ||
| lexicons | ||
| public | ||
| src | ||
| test | ||
| app.jl | ||
| LICENSE | ||
| Manifest.toml | ||
| Project.toml | ||
| README.md | ||
pdsls.jl
A Genie.jl web app that implements the
AT Protocol OAuth 2.0 login flow (following the
Go OAuth CLI tutorial and the
atproto OAuth spec) on top of
ATProto.jl, then lists a Bluesky account's
app.beaconbits.beacon records as a DataFrame with CSV export.
Features
- Full atproto OAuth flow for a public loopback client (the
http://localhostdevelopment client, no client secret needed):- handle/DID → DID document → PDS discovery
/.well-known/oauth-protected-resource+/.well-known/oauth-authorization-serverdiscovery- PKCE (S256) + per-session DPoP key (ES256 / P-256)
- Pushed Authorization Request (PAR) with the mandatory
use_dpop_nonceretry - token exchange, token refresh (single-use refresh tokens), and best-effort revocation
- DPoP-signed XRPC requests with the
athclaim and the PDSuse_dpop_nonce/invalid_tokenretry paths
- Web UI (Genie):
GET /— landing page with a handle/DID inputGET /auth/start— resolve + PAR, redirect to the Bluesky consent screenGET /auth/callback— exchange the code, store the session (cookie + in-memory)GET /beacons— the account'sapp.beaconbits.beaconrecords as a DataFrameGET /beacons/export— the records as a CSV downloadGET /lexicon— theapp.beaconbits.beaconlexicon schema as a DataFrameGET /lexicon/export— the schema as a CSV downloadGET /lexicon/graph— a live, interactive lexicon dependency graph (Graphs.jl + GraphMakie, rendered in the browser with WGLMakie/WebGL)POST /lexicon/graph/add— fetch a lexicon by NSID (goat lex pull)POST /lexicon/graph/remove— delete a loaded lexicon by NSIDGET /post— compose a textapp.bsky.feed.postPOST /post— publish it viacom.atproto.repo.createRecordGET /logout— revoke tokens, clear the session
Everything crypto-side is pure Julia: DPoP ES256 signing uses RFC 6979 deterministic
nonces on top of ATProto.jl's P-256 primitives (P256_CURVE, multiply, …).
Project layout
lexicons/ app.beaconbits.beacon + referenced lexicons (goat lex pull)
src/Pdsls.jl package module, run()/start() entry points
src/oauth.jl Pdsls.OAuth — the atproto OAuth flow (PKCE, DPoP, PAR, tokens)
src/xrpc.jl Pdsls.Xrpc — DPoP-authenticated XRPC client
src/beacons.jl Pdsls.Beacons — listRecords → DataFrame, lexicon → DataFrame
src/posts.jl Pdsls.Posts — publish app.bsky.feed.post records
src/lexgraph.jl Pdsls.LexGraph — lexicon dependency graph (Graphs/GraphMakie/WGLMakie)
+ add/remove lexicons by NSID
src/routes.jl Pdsls.App — Genie routes + UI
public/pdsls.css standalone stylesheet (Xresources palette as CSS variables)
inspiration/lexicon_graph.md — the same graph as an interactive BonitoBook notebook
app.jl entry script (julia --project=. app.jl)
test/runtests.jl unit tests + a mock AS/PDS integration test
Setup
Requires Julia ≥ 1.11 (tested with 1.12).
ATProto.jl is not on the General registry, so it is pulled from git. This project
pins it via [sources] to the local checkout at ../ATProto.jl (relative to the
project). If you cloned pdsls.jl somewhere else, either:
julia --project=. -e 'using Pkg; Pkg.develop(url="https://codeberg.org/ntm/ATProto.jl")'
or edit [sources] in Project.toml to point at your checkout, then:
julia --project=. -e 'using Pkg; Pkg.instantiate()'
The lexicons under lexicons/ were fetched with
goat lex pull and are read by
ATProto.jl's Lexicon parser at runtime. The graph page's "Add / update" form
calls the same goat lex pull under the hood (it must be on your PATH); to
refresh them from the shell:
goat lex pull app.beaconbits.beacon community.lexicon.location.geo \
community.lexicon.location.address com.atproto.repo.strongRef
The graph also needs the Makie stack, which is not loaded unless you visit
/lexicon/graph (it is JIT-compiled on first use):
julia --project=. -e 'import Pkg; Pkg.add(["Graphs", "GraphMakie", "NetworkLayout", "WGLMakie", "Makie", "Bonito"])'
The BonitoBook notebook
inspiration/lexicon_graph.md is a standalone BonitoBook.jl
notebook version of the same lexicon dependency graph — it shares this project's
environment and renders interactively (GraphMakie + WGLMakie) with widgets to
toggle loaded_only and highlight a lexicon's references/dependents. BonitoBook
is not on the General registry, so it is installed from git (already a project dep):
using BonitoBook
BonitoBook.book("inspiration/lexicon_graph.md")
Running
julia --project=. app.jl # serves http://127.0.0.1:8000 (blocking)
or from a REPL:
using Pdsls
Pdsls.run() # blocking
Pdsls.start(; port=8000, async=true) # non-blocking (scripting/tests)
Open http://127.0.0.1:8000, enter a handle (e.g. alice.bsky.social), approve the
consent screen, and you land on the beacons page with the records DataFrame and the
CSV export buttons.
/lexicon/graph renders the dependency graph of everything in lexicons/ live in
your browser (WGLMakie WebGL): loaded lexicons are circles, referenced-but-not-loaded
lexicons are faded diamonds, and edges point from a lexicon to the ones it references.
Use the form to add a lexicon by NSID (goat lex pull) or the per-NSID buttons to
remove one — the graph re-renders on the next page load. The first visit per server
start is slow while the Makie stack JIT-compiles; the app pre-renders it in the
background at startup so it is usually already warm.
Notes:
- This is a public loopback (localhost) development client — exactly like the Go
tutorial. The
client_idishttp://localhost?redirect_uri=…&scope=atproto%20transition:generic. - Sessions are in-memory (keyed by the account DID in a cookie): every server restart requires re-authorizing. There is no database.
- Two scopes are requested: the base
atprotoscope (identity + read access) andtransition:generic(the atproto "transitional" write scope), which is what publishing posts needs. The token exchange rejects the session if the consent screen did not grant every requested scope, and the post form refuses to open without the write scope. The consent screen will show the app as an untrusted development client. - The final code exchange cannot be automated in tests: the mock-server integration
test (
full flow against mock AS/PDS) exercises PAR + token exchange + refresh + revocation + DPoP-authenticated XRPC against a local HTTP server instead, and the pre-redirect pipeline (identity resolution, discovery, PAR) is verified against the real bsky.social in the smoke test below.
Tests
julia --project=. -e 'using Pkg; Pkg.test()'
Covers base64url, URL/form encoding, PKCE, ES256 signing (RFC 6979 determinism,
low-S, signature verification), DPoP proof structure/claims, callback validation
(including the required-scope check), the full OAuth exchange against a mock
AS/PDS (including all nonce/refresh retry paths), the lexicon parser,
record→DataFrame flattening, publishing text posts via createRecord, and the
lexicon dependency graph (construction, NSID validation, add/remove, and the
WGLMakie HTML fragment).
Smoke test against the real network
using Pdsls, Pdsls.OAuth
id = OAuth.resolve_account_identity("bsky.app") # → did, PDS
as = OAuth.discover_auth_server(id.pds) # → bsky.social metadata
info = OAuth.start_auth_flow(OAuth.ClientConfig("http://127.0.0.1:8000/auth/callback"), "bsky.app")
OAuth.authorization_url(OAuth.ClientConfig("http://127.0.0.1:8000/auth/callback"), info)
If this returns a https://bsky.social/oauth/authorize?… URL, the DPoP/PAR pipeline is
working against the live server.
Notes on ATProto.jl
Two bugs were found and fixed upstream while building this project (commit
d1225e1): ecdsa_verify was broken on Julia 1.12 by the new try-block
scoping rules, and ATProto.resolve_handle resolved to the Client namespace
method instead of the Identity resolver method due to an import collision.
With the fixed upstream, Pdsls.OAuth uses ATProto.resolve_handle(::IdResolver, …) directly and delegates signature verification to ATProto.ecdsa_verify.
The Firehose.from_bytes / Core.from_bytes export collision still produces an
"ignoring conflicting import" warning on load (Core's CAR parser wins); it does
not affect this project.