I've been building the House Dice app, a small arcade of dice games for atproto. The app also allows players to match with partners, to create streaks and compare roll results.

After the atproto spaces alpha PDS was announced, I added permissioned data to the dice app for the newly available accounts, which use the spaces-alpha.bsky.network domain for their handles. The actual PDS they all live on is hosted separately at spaces-alpha.host.bsky.network.

I also want to include a mutual visibility feature. Below is a summary of an initial step in that process, as written by my computer helper.


Report from Computer Helper

This report picks up right where the intro leaves off: could two different accounts share visibility into the same permissioned space at all — not just "my own data, kept to myself," but "my partner's data, made readable to me, on purpose." That's not built into House Dice's UI yet, and won't be for a while. What we wanted to know first was simpler and more fundamental: does the underlying mechanism actually work, today, against the real hosted alpha PDS?

Short answer: yes. Here's how we confirmed it, including three real bugs we hit and fixed along the way.

The chain that needed proving

Reading someone else's space isn't one API call — it's five separate pieces that all have to work together:

  1. 1.

    createSpace — the space owner registers the space with an access policy.

  2. 2.

    addMember — the owner grants a specific account membership.

  3. 3.

    getDelegationToken — the other account's own PDS mints a short-lived token proving "this app, acting for this user, wants to reach this space."

  4. 4.

    getSpaceCredential — that token gets exchanged, with the space authority, for an actual read credential.

  5. 5.

    A cross-account read — the credential gets used to fetch a record from a different account's repo inside the space.

We had working code for ordinary self-only reads and writes already. For exercising the five steps above, we set up three test accounts on the alpha to find out whether they'd actually work.

Step 1: the easy one

getDelegationToken worked on the first try, using nothing more exotic than the same authenticated session the app already had. The response was a JWT that matched the written proposal's documented shape exactly — same claim names, same 60-second expiry, down to the second. Encouraging start.

Step 2: three wrong guesses in a row

getSpaceCredential was a different story. This one isn't authenticated by the normal session at all — it needs the delegation token presented alongside a fresh, purpose-built cryptographic proof (DPoP, for anyone familiar with the spec), generated with a brand-new keypair made just for this one exchange.

Our first guess was to present the delegation token the same way an access token normally gets presented — Authorization: DPoP <token>. That produced:

{"error":"MissingJwt","message":"missing delegation token"}

Reasonable next guess: maybe it belongs in the request body instead. We tried {token: ...}. Identical error. Tried {jwt: ...}, reasoning that atproto error codes often name the actual expected field directly. Identical error again, three times over.

Turns out the fundamental shape was wrong, not just the field name. The delegation token belongs in Authorization: Bearer <token> — the plain Bearer scheme, not DPoP. The DPoP proof travels in a completely separate header, literally named DPoP:. It's an easy pair of headers to conflate, since a real DPoP proof genuinely is required on this exact request — it's just riding in a different slot than the token it accompanies. And the delegation token was never a body field at all; the handler never reads the body looking for it. The body's only real field turned out to be space — which, in three attempts focused entirely on where the token went, we'd also just never included.

Step 3: a space that technically didn't exist

With the auth fixed, the very next attempt returned something new: SpaceNotFound. This was genuinely surprising, because ordinary record reads and writes into this same space had been working for weeks. It turned out those never required the space to be formally registered at all — a repo-level write just works the first time you do it. getSpaceCredential, on the other hand, is served by the space host, whose entire job is looking up a policy configuration that, in this case, had simply never been created.

One explicit createSpace call fixed it — configured with the simplest possible policy available: a plain member list, no external backend service required. That's worth calling out on its own: nothing about reading someone else's space requires running your own server. A member list a PDS enforces natively is enough.

Step 4: RFC 9449, precisely

Space created, credential minted — and the very first attempt to actually use it to read a record failed with BadDpopProof: htu does not match the request.

This one came down to a single, specific detail in the DPoP spec: the proof's htu claim is supposed to be the request URL without its query string — scheme, host, and path only. We'd included the full URL, query parameters and all, inside the signed claim. The actual HTTP request correctly kept the query string; the signed proof just wasn't supposed to include it. One-line fix, and the read succeeded — the same account, reading its own data, through the full credential chain rather than a normal authenticated session call.

The actual test: does membership matter?

Self-reads working is necessary but not sufficient — it doesn't prove access control does anything. So we ran the real test, across three accounts:

  • Account B, not yet a member of Account A's space, attempted the full chain against Account A's space. Failed cleanly at the credential-exchange step: UserNotAuthorized.

  • Account A called addMember, naming Account B.

  • Account B repeated the identical request. Success — a real record, fetched from a different account's repo, through a genuine cross-account credential.

  • Account C, deliberately never added, ran the same request against Account A's space one more time, as a control. Same UserNotAuthorized failure Account B had hit before being added.

That last step mattered — without it, there'd always be a nagging alternative explanation, that the space had simply become open to everyone rather than genuinely gating on membership. The control ruled that out cleanly.

What this confirms, and what it doesn't

The mechanism is real, and it works, today, against the hosted alpha PDS — every link in the chain, tested with actual failing and passing requests, not just read off the spec. That's genuinely exciting infrastructure to have working this early.

What it doesn't mean: House Dice doesn't have this feature yet. Everything above happened through throwaway test code, not a real UI. Building an actual "let your partner see your private rolls" feature means real interface work, proper credential lifecycle handling (that read credential expires in two hours and needs refreshing, not re-minting from scratch each time), and a decision about how membership should map onto House Dice's existing partner system — none of which exists yet. This report is about the mechanism, not a feature launch.

Also worth being upfront about: this is one specific host, on an actively-changing, unmerged branch, only a couple of days old at the time most of this testing happened. None of the specific request shapes above are guaranteed to still be correct by the time you read this — treat this as a snapshot, not a spec.

A reference implementation

We cleaned up the actual test code into a standalone script covering all five steps, with the three bugs above already fixed and explained inline: atproto space mutual visibility reference script


Thanks

This project relied heavily on community research, documentation, and outside tools along the way. Much appreciation to everyone involved with this effort.