edgeBox is an atproto AppView. Canonical user data lives in each user's PDS as signed records; our server reads via the AppView cache with live-fetch fallback, writes on behalf of the user using their OAuth-ish session, and hydrates Bluesky posts through Bluesky's public AppView for counts and inline quotes.
The migration to AT Protocol shipped across several phases; test-harness migration and final legacy cleanup are in progress.
lexicons/com/edgebox/ # canonical schemas we authored
profile.json # edgeBox-specific profile extensions (links, accent)
box/layout.json # grid layout spec record
actor/manifest.json # installed actors (viewer-owned)
actor/publication.json # actor published by an author
matter/{post,track,image,game}.json
graph/follow.json # edgeBox-specific follow (complements app.bsky.graph.follow)
agent/permissionSet.json # external-agent grant
server/atproto/
client.js # AtpAgent + handle→DID→PDS resolver + XRPC helpers
bsky.js # Bluesky AppView hydration (getPosts, listReposts)
jetstream.js # firehose consumer (disabled on prod; needs Node 22+ WebSocket)
indexer.js # wires jetstream → appview cache
lexicons.js # loads + validates lexicons/ on disk
writes.js # putRecordForSession / deleteRecordForSession
server/appview/
cache.js # DID-keyed record + profile-view cache (firehose-populated)
boxes.js # compiled Box binary cache (data/appview/boxes/{did}/box.wasm)
compile_queue.js # debounced per-DID compile queue
feed.js # builds feed from cache (when populated)
live.js # live XRPC fallback for feed + profile (no firehose needed)
normalize.js # translates atproto records → edgeBox Post schema
layout.js, matter.js, marketplace.js, permissions.js
server/oauth.js # handle+app-password login against user's PDS (Phase 13G → DPoP)
server/auth.js # file-backed DID-keyed sessions
Data paths:
data/appview/sessions/{uuid}.json— active sessions (DID, handle, accessJwt, refreshJwt, pdsUrl)data/appview/index/records/{collection}/{did-encoded}/{rkey}.json— cached recordsdata/appview/index/profiles/{did-encoded}.json— merged profile view (Bluesky + edgeBox profile)data/appview/index/handles.json— handle → DID mappingdata/appview/boxes/{did-encoded}/box.wasm— compiled Box binary (derived artifact, AppView-owned)
All new fields are #[serde(default)] — old Boxes ignore them, new Boxes use them. Never remove or change field types.
Profile gains bannerUrl: Option<String> — URL pointing at author's PDS sync.getBlob for the Bluesky banner.
Post gains:
| Field | Type | Source |
|---|---|---|
facets |
Vec<Facet> |
app.bsky.feed.post.facets (byte spans for link/mention/tag) |
embeds |
Vec<Embed> |
app.bsky.embed.{images,external,record,recordWithMedia} |
author |
Option<AuthorRef> |
profile view of the post's author (handle, displayName, avatarUrl) |
uri, cid |
Option<String> |
for interaction writes (like/repost/reply target) |
likeCount, repostCount, replyCount |
u32 |
Bluesky AppView hydration |
viewerLikeUri, viewerRepostUri |
Option<String> |
existing viewer interaction record URIs (for unlike/unrepost) |
repostedBy |
Option<AuthorRef> |
set when this feed item is a repost — the reposter |
Embed gains for quote types: quotedText, quotedAuthor — populated by hydration.
See boxes/sdk/box_sdk.rs for the canonical Rust shapes.
boxes/sdk/rich.rs provides reusable helpers that every View can drop in:
render_post_content(content, facets)— interleaves plain text +<a>spans for facets. XSS-safe.render_embed(embed)— renders image figures, external link cards, quote blocks (hydrated or link-only).render_interaction_bar(post)— three-button bar (reply/repost/like) with counts and viewer state.render_repost_banner(post)— "X reposted" header whenreposted_byis set._rawvariants of the interaction helpers take primitives — used by FeedBox (which has its own post struct).
The emitted HTML uses .lb-* classes; CSS lives in server/index.js::buildPage so it cascades into any Wasm-rendered page.
| Method Path | Purpose |
|---|---|
GET /oauth/client-metadata.json |
OAuth client metadata (Phase 13G→DPoP stub) |
POST /auth/login |
handle + app-password → session |
POST /auth/logout |
destroy session |
GET /@handle/manifest |
public capability manifest |
All require session; each wraps server/atproto/writes.js::putRecordForSession with the relevant collection.
| Method Path | Writes |
|---|---|
POST /@handle/edit |
com.edgebox.box.layout (self) → triggers compile |
POST /api/matter |
com.edgebox.matter.{post,track,image,game} (by type) |
POST /api/subscribe / /api/unsubscribe |
com.edgebox.graph.follow create/delete |
POST /api/interact/like |
app.bsky.feed.like |
POST /api/interact/unlike |
delete by rkey |
POST /api/interact/repost |
app.bsky.feed.repost |
POST /api/interact/unrepost |
delete by rkey |
POST /api/interact/reply |
app.bsky.feed.post with reply field |
POST /@handle/agent/invoke |
agent skill invocation (X-Agent-Did header + permissionSet grant, or session) |
The AppView cache is populated by Jetstream. Since Jetstream needs Node 22's native WebSocket (not available on prod's Node 20), production uses the live-fetch path (server/appview/live.js):
liveProfilePostsForDid— for/@handleliveFeedForViewer— for/feed
Both:
- List raw records (matter +
app.bsky.feed.post+app.bsky.feed.repost) - Batch all Bluesky post URIs + repost subjects
- Single hydration call to
api.bsky.app/xrpc/app.bsky.feed.getPosts - Merge hydrated counts + inline quotes back into the normalized posts
- Emit reposts as separate feed rows with a
repostedByref
Enable Jetstream caching later with JETSTREAM=1 on Node 22.
Tier 1 (unit) runs on node --test:
npm run test:unit
Helpers:
tests/helpers/tempdir.js—withTempDataDir(fn)— isolates DATA_DIR per testtests/helpers/fetch_stub.js—withFetchStub(routes, fn)— intercepts globalThis.fetch
Coverage: lexicons, cache, client (handle/DID/XRPC), jetstream dispatch, indexer→cache, auth sessions, oauth, PDS writes, appview modules, normalizer, Bluesky hydration.
146 unit tests run in ~500ms.
- Add
FRAGMENTS.<type>(node)inscripts/generate_box.js— pure codegen returning Rust - Extend
VALID_TYPESwith the new type - Update
boxes/generated/box_v1/src/lib.rsif this View should appear by default - Add picker entry + property panel in
boxes/dslbox/src/lib.rs(under its category) - Rebuild:
bash scripts/build_box.sh boxes/dslbox data/boxes/_dslbox.wasm; rebuild default if changed
Matter-type Views additionally need the relevant live fetcher in server/appview/live.js to populate the input JSON.
- First deploy after a pull needs
npm install(dependencies exist now) - Restart the systemd service after deploying.
- Existing sessions reset on restart (file-backed); users re-log-in once
- Jetstream stays off unless
JETSTREAM=1is set and Node is 22+ INVITE_CODEenv var is a no-op now (atproto PDS is the account source)