Skip to content

Commit aecb638

Browse files
bluesky plugin (#211): AT-Protocol XRPC shim, Phase-1 slice
describeServer -> createSession (pod creds -> accessJwt) -> getSession -> createRecord (->pod at:// uri) -> listRecords/getAuthorFeed round-trips the post. Token bridge: createSession calls the host's /idp/credentials over loopback, returns the pod bearer verbatim as accessJwt. did = synthetic did:web:<host>, handle = pod name, rkey = sortable TID, records stored under the caller's bearer so real WAC governs writes. SECOND confirmation of the multi-prefix seam, sharpened: bluesky needs only ONE fixed root (/xrpc) and STILL can't self-exempt it — so the seam is 'a plugin declares the paths it owns independently of its mount prefix' (api.reservePath / paths:[]), not 'give plugins more prefixes'. Operator passes appPaths:['/xrpc']. Confirms the loopback token bridge generalizes across protocols (mastodon + bluesky). 11/11.
1 parent 78f2b66 commit aecb638

3 files changed

Lines changed: 828 additions & 0 deletions

File tree

bluesky/README.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# bluesky — an AT-Protocol (Bluesky) XRPC shim over your own pod (#211)
2+
3+
Point an AT-Protocol client (or `@atproto/api`) at a JavaScript Solid Server,
4+
log in with your pod credentials, post an `app.bsky.feed.post`, and read your
5+
posts back. This is **Phase 1 of
6+
[#211](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/211)**
7+
— "a personal AT-Protocol client over your own pod." No relay, no federation,
8+
no other people's feeds: your **author feed is the posts you wrote**, stored
9+
as AT records in your own pod.
10+
11+
It is the same shim shape as [`mastodon/`](../mastodon/): fixed absolute API
12+
roots, a loopback token bridge to `/idp/credentials`, records stored in the
13+
pod under the caller's own bearer.
14+
15+
```
16+
plugins: [{
17+
module: 'bluesky/plugin.js',
18+
config: {
19+
baseUrl: 'http://localhost:3000', // public origin (service DID + loopback)
20+
loopbackUrl: 'http://127.0.0.1:3000', // optional: where the shim reaches the host
21+
},
22+
}]
23+
```
24+
25+
**and** — the load-bearing caveat — the operator must WAC-exempt the fixed
26+
`/xrpc` root, because a plugin cannot do it itself (see [Findings](#findings)):
27+
28+
```
29+
createServer({
30+
appPaths: ['/xrpc'], // REQUIRED: or WAC 401s every client call
31+
plugins: [ … ],
32+
})
33+
```
34+
35+
## The vertical slice
36+
37+
AT-Protocol clients speak XRPC over HTTP at fixed `/xrpc/<nsid>` endpoints.
38+
The minimum a client does to log in, post, and read:
39+
40+
```bash
41+
# 1. server metadata (public)
42+
curl -s localhost:3000/xrpc/com.atproto.server.describeServer
43+
44+
# 2. log in (identifier + password → pod bearer as accessJwt)
45+
JWT=$(curl -s localhost:3000/xrpc/com.atproto.server.createSession \
46+
-H 'content-type: application/json' \
47+
-d '{"identifier":"alice","password":"…"}' | jq -r .accessJwt)
48+
49+
# 3. who am I
50+
curl -s localhost:3000/xrpc/com.atproto.server.getSession -H "Authorization: Bearer $JWT"
51+
52+
# 4. post
53+
curl -s localhost:3000/xrpc/com.atproto.repo.createRecord -H "Authorization: Bearer $JWT" \
54+
-H 'content-type: application/json' \
55+
-d '{"repo":"<did>","collection":"app.bsky.feed.post","record":{"text":"hello bluesky","createdAt":"2026-07-11T00:00:00Z"}}'
56+
57+
# 5. read it back
58+
curl -s "localhost:3000/xrpc/com.atproto.repo.listRecords?repo=<did>&collection=app.bsky.feed.post" \
59+
-H "Authorization: Bearer $JWT"
60+
curl -s "localhost:3000/xrpc/app.bsky.feed.getAuthorFeed?actor=<did>" -H "Authorization: Bearer $JWT"
61+
```
62+
63+
## XRPC endpoint coverage
64+
65+
| Endpoint | Status | Notes |
66+
|---|---|---|
67+
| `GET com.atproto.server.describeServer` | ✅ done | public metadata + service DID |
68+
| `POST com.atproto.server.createSession` | ✅ done | `{identifier,password}``{accessJwt,refreshJwt,handle,did}` via `/idp/credentials` |
69+
| `GET com.atproto.server.getSession` | ✅ done | `{did,handle}` from `getAgent()` |
70+
| `POST com.atproto.repo.createRecord` | ✅ done | `app.bsky.feed.post``<pod>/public/bsky/<rkey>.json`, returns `at://…` + cid |
71+
| `GET com.atproto.repo.listRecords` | ✅ done | your posts as `{records:[{uri,cid,value}]}` |
72+
| `GET app.bsky.feed.getAuthorFeed` | ✅ done | your posts as `{feed:[{post:{…}}]}`, newest first |
73+
| `OPTIONS /xrpc/*` | ✅ done | wide-open CORS preflight |
74+
| `com.atproto.server.refreshSession` / `deleteSession` | ⛔ not yet | no refresh cycle — `refreshJwt` re-presents the pod bearer |
75+
| `com.atproto.repo.getRecord` / `deleteRecord` / `putRecord` | ⛔ not yet | read-one / mutations beyond the create slice |
76+
| `app.bsky.feed.getTimeline` / `getPostThread`, actor/graph, notifications | ⛔ not yet | need federation / a social graph (Phase 2+) |
77+
| blobs / images / embeds, facets, langs | ⛔ not yet | text-only records for now |
78+
| `com.atproto.identity.resolveHandle`, real DID docs, signed commits/MST | ⛔ not yet | synthetic DIDs, unsigned records — not a real PDS repo (Phase 4) |
79+
80+
Every response carries wide-open CORS (`access-control-allow-origin: *`) and
81+
`OPTIONS /xrpc/*` is answered locally, so browser clients can talk to the
82+
shim cross-origin.
83+
84+
## The createSession → pod-credentials bridge
85+
86+
AT-Protocol's `accessJwt` is just a bearer the client resends. A Solid pod's
87+
access token is *also* just a bearer. So the bridge is direct — the shim
88+
mints no token of its own, keeps no session store:
89+
90+
| AT-Protocol step | What the shim does |
91+
|---|---|
92+
| `POST createSession` `{identifier,password}` | loopback `POST /idp/credentials` with the username+password → return the pod bearer verbatim as `accessJwt` (and `refreshJwt`) |
93+
| every later `Authorization: Bearer <accessJwt>` | forwarded verbatim: `getAgent()` resolves it to the WebID; record writes are loopback LDP PUTs under that same bearer, so **real WAC**, not this shim, decides what lands |
94+
| `refreshJwt` | there is no refresh cycle — it re-presents the same pod bearer; `refreshSession` is not yet implemented |
95+
96+
## Object mapping (AT ↔ pod)
97+
98+
- `POST createRecord` writes the AT record
99+
`{ $type:'app.bsky.feed.post', text, createdAt }` to
100+
`<pod>/public/bsky/<rkey>.json`.
101+
- **`rkey`** is a **TID** (timestamp id, 13-char sortable base32) minted at
102+
write time and used as the filename, so `listRecords`/`getAuthorFeed`
103+
round-trip in chronological order (newest first) purely by sorting keys.
104+
- **`uri`** is `at://<did>/app.bsky.feed.post/<rkey>`.
105+
- **`cid`** is a `sha256` content hash of the record, base32-encoded into a
106+
`bafyrei…`-shaped string. It is **not** a real dag-cbor CIDv1 — clients
107+
treat cids as opaque, and the shim stores no signed MST — but it is stable
108+
across reads of the same record, which is all a client checks.
109+
- **`did`** derivation (`didFromWebid`): a synthetic **`did:web`** from the
110+
WebID's origin + first path segment —
111+
`http://host:port/alice/…``did:web:host%3Aport:alice` (the `:` in the
112+
authority is `%3A`-encoded per did:web). Single-user pods derive
113+
`did:web:host%3Aport`. The shim can decode its own did:web back to a pod
114+
path (`podFromDid`) for anonymous public reads. It is **not** a real,
115+
globally-resolvable DID (no did:plc, no `/.well-known/atproto-did`, no DID
116+
document) — resolution is the shim's convention, not a network fact.
117+
- **`handle`** is the pod name (first WebID path segment, `/alice/…`
118+
`alice`). Real AT handles are domain names that resolve to a DID; a bare
119+
pod name is close enough for a client to display but would not resolve on
120+
the real network (Phase 3 identity bridge territory).
121+
122+
## Findings
123+
124+
### 1. SECOND independent confirmation of the reserved-path / multi-prefix seam
125+
126+
**Headline.** This is the same wall [`mastodon/`](../mastodon/) hit, reached
127+
from a completely different external protocol — which is exactly what makes it
128+
a *confirmation* rather than a repeat. An AT-Protocol client hits **fixed
129+
absolute paths** under one root, `/xrpc/<nsid>`, that no client will let you
130+
relocate under a plugin `prefix`. Two things follow, identically to mastodon:
131+
132+
- **Routing works.** Like mastodon's `/api` + `/oauth` and nip05's
133+
`/.well-known/nostr.json`, the loader does not confine a plugin's routes to
134+
its prefix (`api.fastify` is the real scoped instance), and each
135+
`/xrpc/<nsid>` static route outranks core's LDP `GET /*` wildcard on
136+
Fastify's specificity ordering. Registration is fine.
137+
- **Authorization does not.** `/xrpc` is an ordinary pod path as far as WAC is
138+
concerned. The WAC hook skips only paths in `appPaths` (`server.js`), and
139+
the loader pushes a plugin's **single `prefix`** there (`plugins.js` — `if
140+
(prefix) ctx.appPaths.push(prefix)`). This shim's surface lives at a **fixed
141+
protocol root that is not its prefix**, and a plugin has no
142+
`api.appPaths.add()` / `api.reservePath()` to push it. So the plugin
143+
**cannot self-exempt its own surface**, and every unexempted `/xrpc` call is
144+
401'd by WAC before the handler runs.
145+
146+
The honest consequence, verbatim from mastodon: this shim is only usable if
147+
the **operator** widens `appPaths` by hand (`appPaths: ['/xrpc']`). Note the
148+
sharpening over mastodon: mastodon needed *two* roots and blamed the
149+
"one-prefix" model; bluesky needs only *one* root and **still can't reach it**,
150+
because the exempt path is tied to the plugin's *own* prefix, not to the paths
151+
it actually serves. The seam is not "a plugin should get more than one
152+
prefix" — it is "**a plugin must be able to declare the paths it owns**,
153+
independently of its mount prefix" (`paths: ['/xrpc']` on the entry, or an
154+
`api.reservePath()` surface, with collision detection). This is the seam **all
155+
API-shim plugins** (mastodon, bluesky, and any future ActivityPub / gateway)
156+
structurally require; it now has **two independent consumers** and belongs
157+
above mastodon-alone in NOTES.md's ranking of the reserved-path seam.
158+
159+
### 2. The loopback → `/idp/credentials` token bridge generalizes cleanly
160+
161+
The mastodon finding held with zero changes: two OAuth-shaped worlds (AT's
162+
`accessJwt`, Solid's bearer) are both "a value resent in `Authorization`," so
163+
`createSession` is a thin translation over the host's own programmatic
164+
credentials endpoint and the shim never decides identity — `getAgent` does.
165+
That the *same* bridge served a *different* protocol's login is evidence the
166+
pattern is protocol-agnostic, not a mastodon accident.
167+
168+
### 3. LDP ↔ AT-record mapping is awkward, and DID derivation is the sharpest gap
169+
170+
- **No native repo, no signatures, synthetic identity.** A real AT PDS holds a
171+
signed Merkle Search Tree (an authenticated repo) and a globally-resolvable
172+
DID with a DID document. A pod holds LDP resources under WAC. The shim
173+
bridges the *data* (records in, feeds out) but not the *cryptography*: `cid`
174+
is a plain content hash, not a dag-cbor CIDv1 over a signed commit, and
175+
`did` is a synthetic `did:web` the shim invents and resolves by convention.
176+
Good enough that a client round-trips its own posts; **not** something the
177+
real Bluesky network would federate or verify. Faithful federation (Phase 4)
178+
needs real repo/commit machinery that has no LDP analogue — the deepest gap
179+
#211 will hit.
180+
- **IDs are synthesized, not native.** AT sorts by TID rkey; LDP names
181+
resources by URL. The shim mints a sortable TID at write time and uses it as
182+
the filename, so ordering round-trips — but a record written to
183+
`public/bsky/` by any other tool (not matching `<rkey>.json`) is simply
184+
skipped by the feed. Same shape as mastodon's snowflake-id finding.
185+
- **N+1 reads for a feed.** LDP lists a container's members but not their
186+
bodies, so a feed is one `GET` for the container plus one per record — the
187+
same `api.events` write-index seam `sparql/` documents.
188+
- **Write auth is honest, though.** Because the record write is a loopback LDP
189+
`PUT` carrying the caller's own bearer, real WAC governs it: a client can
190+
only post to a pod it actually controls, and a 401/403 from WAC surfaces as
191+
an XRPC `Forbidden` — the shim adds no authority of its own.
192+
193+
## Tests
194+
195+
`node --test --test-concurrency=1 bluesky/test.js`**11 tests, all green**.
196+
Drives the full slice end-to-end: `describeServer` (public 200) → bad-baseUrl
197+
boot rejection → `createSession` (pod creds → accessJwt, bad password → 401) →
198+
`getSession` (did/handle; anon → 401) → `createRecord` "hello bluesky" (at://
199+
uri, and the JSON record verified in the pod) → anon write refused →
200+
`listRecords` / `getAuthorFeed` contain the post → second post sorts first.

0 commit comments

Comments
 (0)