Genie dashboard for PDS interactions and data visualization
  • Julia 95.3%
  • CSS 4.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-26 16:33:29 -05:00
inspiration Initialize project 2026-08-26 16:33:29 -05:00
lexicons Initialize project 2026-08-26 16:33:29 -05:00
public Initialize project 2026-08-26 16:33:29 -05:00
src Initialize project 2026-08-26 16:33:29 -05:00
test Initialize project 2026-08-26 16:33:29 -05:00
app.jl Initialize project 2026-08-26 16:33:29 -05:00
LICENSE Initial commit 2026-08-26 15:50:34 -05:00
Manifest.toml Initialize project 2026-08-26 16:33:29 -05:00
Project.toml Initialize project 2026-08-26 16:33:29 -05:00
README.md Initialize project 2026-08-26 16:33:29 -05:00

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://localhost development client, no client secret needed):
    • handle/DID → DID document → PDS discovery
    • /.well-known/oauth-protected-resource + /.well-known/oauth-authorization-server discovery
    • PKCE (S256) + per-session DPoP key (ES256 / P-256)
    • Pushed Authorization Request (PAR) with the mandatory use_dpop_nonce retry
    • token exchange, token refresh (single-use refresh tokens), and best-effort revocation
    • DPoP-signed XRPC requests with the ath claim and the PDS use_dpop_nonce / invalid_token retry paths
  • Web UI (Genie):
    • GET / — landing page with a handle/DID input
    • GET /auth/start — resolve + PAR, redirect to the Bluesky consent screen
    • GET /auth/callback — exchange the code, store the session (cookie + in-memory)
    • GET /beacons — the account's app.beaconbits.beacon records as a DataFrame
    • GET /beacons/export — the records as a CSV download
    • GET /lexicon — the app.beaconbits.beacon lexicon schema as a DataFrame
    • GET /lexicon/export — the schema as a CSV download
    • GET /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 NSID
    • GET /post — compose a text app.bsky.feed.post
    • POST /post — publish it via com.atproto.repo.createRecord
    • GET /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_id is http://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 atproto scope (identity + read access) and transition: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.