Skip to content

AppView development

The AppView lives in colibri-social/api. It’s a Node 24 and TypeScript pnpm workspace, built on @atproto/xrpc-server and Drizzle. See the architecture page for how the pieces fit together, and the specification for what the endpoints have to do.

  1. Clone it and install:

    Terminal window
    git clone https://github.com/colibri-social/api.git
    cd appview
    pnpm install
  2. Create your configuration and fill it in:

    Terminal window
    cp .env.example .env

    The AppView refuses to boot without six variables. .env.example carries the openssl lines that generate the two secrets.

    Variable What it is
    APPVIEW_DID The did:web this AppView answers as. It must be a hostname, not an IP address: see below.
    PUBLIC_URL Where this AppView is reachable from the public internet. A community’s PDS calls back here on every credential mint.
    SIGNING_KEY A secp256k1 private key, published in the DID document.
    CREDENTIAL_ENCRYPTION_KEY Encrypts community passwords at rest.
    PDS_URL The PDS this AppView talks to. It must run the spaces alpha, ghcr.io/bluesky-social/atproto:pds-spaces-alpha. See Running a PDS.
    COMMUNITY_HANDLE_DOMAIN The domain communities get handles under, such as my-community.communities.example.com.

    Everything else in .env.example has a default. DATABASE_URL defaults to a local libSQL file, so no database service is required to start.

  3. Run it:

    Terminal window
    PORT=3000 pnpm dev

    This runs the TypeScript directly under tsx, watching for changes. There is no build step in the dev loop, and no separate compiler to keep running alongside it.

Editing a workspace package restarts the AppView too, not just editing apps/appview. That works because every package declares a colibri-source export condition pointing at its own src, and the dev script asks for it:

packages/community/package.json
"exports": {
".": {
"types": "./dist/index.d.ts",
"colibri-source": "./src/index.ts",
"default": "./dist/index.js"
}
}

The condition is not called development, which several bundlers set on their own. Three of these packages are published, and a consumer whose bundler picked development would resolve to a src directory that is not in the published tarball.

Two quick checks that it came up correctly:

Terminal window
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/.well-known/did.json

The first reports {"status":"ok","did":"..."}. The second is the DID document derived from APPVIEW_DID and SIGNING_KEY.

The AppView answers /xrpc with CORS headers for the origins named in CORS_ORIGINS, a comma-separated list. * allows any origin. Every request carries its own service-auth token in an Authorization header and nothing is authenticated by a cookie, so a wildcard grants no ambient access.

With CORS_ORIGINS unset, development allows http://localhost:4321 and http://127.0.0.1:4321, which is where the client’s dev server runs, and production allows nothing. A deployment whose client lives on another origin has to name it:

.env
CORS_ORIGINS=https://app.example.com,https://staging.example.com

The headers go on error responses too. Without them a 401 reaches the browser as an opaque CORS failure instead of the error the AppView sent.

Requests may carry authorization, atproto-proxy, atproto-accept-labelers, content-type and range. Responses expose atproto-repo-rev, accept-ranges and content-range. A preflight is answered with a 204 and cached for a day.

The WebSocket endpoints are unaffected: a browser does not apply CORS to a WebSocket handshake, and the AppView does not check Origin on one.

libSQL is the default, and needs nothing else running: DATABASE_URL defaults to file:./data/colibri.db. A postgres:// URL switches the driver to Postgres instead. Both dialects share one hand-written schema, generated from the libSQL version at build time, and a test asserts the two agree on column names, nullability, primary keys and indexes.

Reading and writing anything beyond the AppView’s own tables needs a real spaces-alpha PDS at PDS_URL, because this AppView pulls member repos directly rather than reading a firehose. Running a PDS covers running one locally in Docker, including why the PLC directory next to it is built from source.

  • Directorypackages
    • lexicons every Colibri schema, the space type declarations, and the types generated from them
    • space client for com.atproto.space and com.atproto.simplespace
    • space-sync keeps a local copy of a space current
    • db Drizzle schema and migrations for both libSQL and Postgres
    • projections turns synced records into the typed read models the API serves
    • identity service auth, DID documents, handle resolution
    • community provisioning, credential custody, roles and permissions, moderation
    • notifications notification indexing, Web Push and FCM
    • blobs CID-verified blob proxy for permissioned blobs
    • embeds SSRF-guarded link previews and the GIF picker
    • voice mediasoup voice SFU
  • Directoryapps
    • Directoryappview the server
      • Directorysrc
        • config.ts environment parsing and validation
        • context.ts wires the packages above into one AppContext
        • Directoryroutes/ one module per lexicon group
        • Directoryviews/ read models assembled for the API responses
        • Directoryws/ the events and voice WebSocket surfaces
        • jetstream.ts consumes identity and account events
    • migrate one-shot migration of repo-backed communities onto spaces

A new endpoint is usually a handler in apps/appview/src/routes/, registered in apps/appview/src/server.ts, plus an errors entry in its lexicon and a matching entry in the AppView specification.

No image is published, so compose builds one from the Dockerfile in the repository. The first build takes a while and later ones are cached.

Terminal window
cp .env.example .env # required, see Running it above
docker compose up --build

That runs the AppView alone on libSQL against the appview-data volume, pointed at whatever PDS_URL names. Three overlays compose on top of it:

Overlay What it adds
docker-compose.postgres.yml Postgres, and rewrites DATABASE_URL to use it
docker-compose.pds.yml a local spaces-alpha PDS and a private PLC directory, for developing without a PDS of your own
docker-compose.integration.yml the same PDS and PLC, ephemeral, for pnpm test:integration
Terminal window
docker compose -f docker-compose.yml -f docker-compose.pds.yml up

The spaces-alpha PDS is published for amd64 only, so its platform is pinned and it runs under emulation on an arm64 host. The PLC directory is built from docker/plc rather than pulled, because no current one is published anywhere: every PLC image on any registry dates from 2023 and predates the operation format the PDS emits. See Running a PDS.

Terminal window
pnpm build # generate lexicon types and the pg schema, then compile
pnpm test # unit tests
pnpm test:integration # against a real spaces-alpha PDS, not run in normal CI
pnpm typecheck # sources and tests
pnpm lint # Biome

Testing covers what each of those runs.

The code carries no comments. Anything that needs explaining is named to explain itself, written down in the README of the package it belongs to, or written up in these docs when it is protocol behaviour rather than an implementation detail.

The same flow as the client repository. Nothing is released by pushing a tag by hand.

Add a changeset to your pull request describing what changed:

Terminal window
pnpm changeset

Three packages are published from this repository: @colibri-social/lexicons, @colibri-social/space and @colibri-social/space-sync. A change to the lexicons is a protocol change, so treat it as a minor bump at least.

Once your pull request lands, a workflow opens or updates a pull request titled [ci] release holding the accumulated version bumps and changelog entries. Merging that one publishes the packages to npm and tags the repository. The release pull request is the review step, so nothing ships until someone merges it.

The AppView itself is not published. There is no container image on any registry, and the version in apps/appview/package.json exists so describeServer has something to report. Running it means building from the repository, which is what docker compose up --build does.

Private packages are versioned but not tagged, set by privatePackages in .changeset/config.json. Without it, changesets treats a private package as ignored and refuses any changeset naming both it and a published one, which is what a change touching the server and the lexicons together looks like.