HappyView v2.16
HappyView becomes the authority for its own spaces, credentials get signed requests, and space sync goes both ways.
Bluesky shipped a new release of the spaces alpha, so... time to catch up again.
This release is almost entirely about atproto spaces. Your HappyView instance is now properly the authority for the spaces it creates, space credentials are bound to a signing key, and sync works in both directions between HappyView and the PDSes that host space repos. There's also a pile of Postgres fixes because apparently the Postgres tests weren't running in CI. (I promise that they do do now. More on that later.)
RESPECT MAH AUTHORITAH
Every space has an authority, which is the DID whose document tells everybody else where the space is hosted and which key signs for it. Until now, a space created on HappyView used the space creator's DID as its authority. That worked fine while HappyView was the only thing that cared, but it doesn't work when other software is involved.
Other hosts look up the authority's DID document to find the space's signing key and its host. With a user's DID as the authority, they'd find the user's PDS, and their DID doc would not cover this. Thanks to that, credentials HappyView minted couldn't be verified anywhere, and updated PDSes sent their write notifications to the creator's PDS instead of to HappyView. Whoops.
New now spaces use your instance's DID as the authority, so a space URI looks like at://<instance DID>/space/<type>/<skey>. HappyView publishes an #atproto_space_host service entry in its DID document and signs credentials with the instance's #atproto_space key, so other hosts can find and verify everything they need.
Important points connected to this change:
- The creator still runs the space. Membership, invites,
updateSpaceanddeleteSpaceare all still controlled by whoever created the space, andisOwnerinlistSpacesstill reflects them. - Space keys are unique per type across the whole instance. If two people try to create a
com.example.forumspace with the sameskey, the second one gets a409 Conflict. - Your existing spaces keep working. Spaces created before v2.16 keep the creator's DID as their authority for now.
did:plcand linked-account operators have an extra step. HappyView adds the#atproto_space_hostentry for you the first time a space is created.did:webinstances serve it immediately, but if your instance usesdid:plcor a linked account, you'll need to sync your service entries to the PLC directory so the entry and the#atproto_spacekey show up in your DID document. For linked accounts, that sync goes through the account's PDS, which emails the account holder a confirmation code.- No service identity, no change. If your instance doesn't have a service identity (or doesn't expose it), there's no instance DID for other hosts to resolve, so new spaces keep using the creator's DID as their authority, same as before.
- Apps need to update their OAuth scopes.
space:scopes have to name HappyView as the authority, either withauthority=<your HappyView DID>orauthority=*. The defaultauthority=selfdoesn't cover spaces where HappyView is the authority.
Signed space credentials
Space credentials got a big overhaul to match the spec.
- HTTP Message Signatures replace DPoP. To get a credential, your app sends the delegation token as a bearer token, signed with a fresh P-256 key using an RFC 9421
atproto-spacesignature. No OAuth session needed. - Credentials are bound to that key. Every request that uses a credential has to carry a signature from the same key, plus an
Atproto-Space-Audienceheader naming who the request is for. That means a host that receives a credential can't turn around and replay it against some other host. - Credentials last 10 minutes instead of 2 hours. HappyView also refuses any credential that lasts longer than an hour, doesn't have a
jti, or claims to have been issued in the future. - Delegation tokens are single-use. Exchanging the same one twice fails with
InvalidDelegationToken. - Losing read access revokes your credentials. Removing a member or setting their
readtofalserevokes their credentials immediately, and HappyView callscom.atproto.space.notifyCredentialRevokedon any PDS hosting the space's repos so they know about it too. - Errors have names.
BadSpaceSignature,BadSpaceAudience,CredentialRevoked, andInvalidCredentialreplace the old generic failures.
Also, a fun one: HappyView was producing high-S ECDSA signatures about half the time. @atproto/crypto only accepts low-S signatures, so any peer verifying with it would reject roughly 50% of HappyView's credentials, service auth tokens, and commits, at random. Space credentials, service auth, and commits are all normalized to low-S now.
Sync goes both ways
With spaces living across HappyView and PDSes, everybody needs to know when something changes. I've filled in all of those gaps:
- Syncers register by service identifier.
registerNotifytakes{space, service}, whereserviceis a DID with an optional fragment (likedid:web:forum.example.com#forum). HappyView resolves it to an endpoint, so you don't have to pass one. - HappyView sends
notifyWrite. Registered services get acom.atproto.space.notifyWritecall for every commit, authenticated with service auth from HappyView. - HappyView accepts
notifyWrite. When a PDS hosts a member's space repo, it can tell HappyView about new writes. The call has to be signed by the writing account, and the writer has to pass the space's write policy. - Spaces have a revision. Every accepted repo update advances a space-wide revision, so you can tell exactly what you've already seen.
listReposreturns the writer set. It lists every repo in the space in revision order. Pass the last space revision you processed as thecursorand you'll only get repos that changed after it.- A fallback sweep catches lost notifications. Every 5 minutes, HappyView re-syncs repos hosted on their authors' PDSes, in case a notification got dropped somewhere along the way.
Breaking changes
I've tried to keep everything I could backwards compatible (see below), but a few changes couldn't be avoided:
- Credential clients must sign their requests and use the
Atproto-Spacescheme. Bearer space credentials are refused. - Apps must request
space:scopes that name the HappyView instance as the authority. - Syncers must accept
notifyWritecalls instead of webhook payloads, or keep a legacy registration. - The old
getSpaceCredentialflows are gone. That means the{grant}body, the OAuth caller flow, anddev.happyview.space.getSpaceCredential. Credentials minted that way were never accepted by anything else anyway.
Backwards compatibility
HappyView is used by a ton of apps I'm not familiar with and until I have more telemetry enabled, I no way of knowing who's still using older APIs. With that inm mind, every other change in this release keeps the old shape working for now:
createSpaceandupdateSpacestill accept a singlepolicyormintPolicy, applied to both reads and writes whenreadPolicyandwritePolicyare missing.addMemberstill works alongsideputMember.appAccessstill accepts the old{"type": "open"}and{"type": "allowList", ...}shapes.registerNotifystill accepts webhook registrations, andnotifyWritestill accepts the old payload.typestill works whereverspaceTypereplaced it.getSpaceCredentialresponses still includedelegationTokenbeside its new name,token.
Full details are in the spaces changelog.
Other stuff
Minor polish
listRecordscan include record values. PassincludeValuesto a space'slistRecordsand you'll get each record'svalueback with it, the same waycom.atproto.repo.listRecordsdoes.listSpacescan filter byspaceType. AndcreateSpacetakesspaceTypeinstead oftypeto match the lexicon.- Lua scripts can call
space:put_member.space:put_member{did, read, write}sets a member's flags, andspace:members()entries now includereadandwritebooleans. records_publicis deprecated. This setting was never actually enforced, so setting it didn't change who could read a space. If you want a space anyone can read, use apublicreadPolicy. HappyView still accepts and storesrecords_publicfor the time being.
Bug fixes
- Write-only invites stay write-only.
Invites stored access as a single word, and
writewas read back as read and write. The create response looked right, but redeeming the invite granted read access that was supposed to be withheld. Invites now store independentreadandwriteflags, same as members. - Expired labels expire on time. Labelers can send an expiry in any timezone offset, and HappyView compared them as text against a UTC cutoff. An expired label could stay live for up to fourteen hours. Expiries are now normalized to UTC on the way in.
- Write notifications reach syncers for writes from every author, and syncers are notified when a space is deleted.
- Managing apps are reached through the service entry their identifier names (e.g.
did:web:forum.example.com#forum) instead of always assuming#atproto_pds. - Service auth tokens bound to a different method are rejected. HappyView wasn't checking
lxmon inbound tokens. - The record/label scripts page has the right name.
Postgres fixes
Several things just didn't work on Postgres. They worked fine on SQLite which is what the majority of operators use, so they were accidentally hidden hidden. 🙃
- Expired labels are collected. The cleanup query compared a text column against a timestamp, which Postgres refuses to do, so the sweep failed every time.
- Plugins can read their key-value store. Same problem, same fix.
- Looking up a record by external ID works.
- Dead-lettered scripts can be saved and read. The
payloadcolumn wasJSONB, which the database driver HappyView uses can't decode. It'sTEXTnow, like every other JSON column.
The Postgres test suite runs in CI now too, so this kind of thing should get caught sooner in the future.
Security fixes
- RUSTSEC-2026-0316 / GHSA-jqpg-j7w6-42pr Wasmtime's dynamic record lifting could allocate memory beyond the hostcall fuel limit, so a WebAssembly guest could get the host to allocate more than its budget allowed. Low severity, fixed in Wasmtime 36.0.16.
Contributors
Thanks to the folks who sent code this cycle:
- Tyler (@tylersayshi) for adding
includeValuestolistRecords, and documenting it. - noz.am for fixing the record/label scripts page name.
Go play
Full changelog is on GitHub. If you have questions, feature requests, or just need a little help, join the Cartridge Discord Server and hop into the #happyview channel.