Things are still pretty rough, FYI.
Cuanto.bio is a tool for counting organisms as part of a biolgical survey. Researchers can create protocols that define what organisms surveyors should look for and what information they should collect about them, and surveyors complete surveys that follow those protocols. Think of it like eBird but for everything!
- Allow researchers to author protocols for surveys
- Allow volunteers to complete surveys by following the protocols
- Allow everyone to see the aggregates results of the surveys following a protocol and export the data as a DarwinCore Data Package (DwC-DP).
Cuanto.bio is built on the AT Protocol, which means user data lives in places users control and can be re-used by a variety of applications.
The app is a SvelteKit application with a PostgreSQL database (w/ Docker Compose config if desired) used for server-side session and sync state. Signed-in functionality like completing surveys works offline as a Progressive Web App with local data stored in IndexedDB.
src/routes/
βββ (root) Public landing page and auth flows
βββ auth/ Sign-in / sign-out
βββ oauth/ AT Protocol OAuth callback
βββ protocols/ Public, server-rendered protocol browse and detail pages
βββ surveys/ Public, server-rendered survey browse and detail pages
βββ api/ JSON API endpoints consumed by the /app client
β βββ me Current user session info
β βββ sync Bulk data sync payload (protocols, surveys)
β βββ protocols/ Protocol detail and follow-state endpoints
β βββ surveys/ Survey CRUD endpoints
β βββ tap/webhook AT Protocol firehose webhook
βββ app/ Offline-capable authenticated app (see below)
βββ protocols/ Followed-protocol list and detail
βββ surveys/ Survey list, detail, new survey, and pending queue
βββ (layout) IDB-first auth + background sync on mount
Everything under /app is designed to work without a network connection:
-
Service worker (
src/service-worker.ts) caches the SvelteKit app shell at install time and serves it for every/app/*navigation, online or offline. Public/protocolspages use a stale-while-revalidate strategy so they load instantly from cache while a fresh response arrives in the background. -
IndexedDB (
src/lib/offline/db.ts) is the client-side store. It holds the signed-in user record, followed protocols, cached surveys, and a pending-surveys queue for work done while offline. -
/applayout (src/routes/app/+layout.ts) runs entirely client-side (ssr = false). On load it calls/api/meto verify the session; if that succeeds it saves the user to IDB and firessyncOfflineDatain the background. If the network is unreachable it falls back to the IDB user record so the app remains usable. -
Sync (
src/lib/offline/sync.ts) calls/api/syncto fetch the user's followed protocols and recent surveys in one request, then writes them to IDB. -
API endpoints under
/api/are thin JSON wrappers around the server-side database and AT Protocol PDS calls. The/apppages call these endpoints on navigation and fall back to IDB when the fetch fails, making every page readable offline. -
Pending surveys created offline are stored in the
pending-surveysIDB store and uploaded via/api/surveysonce the device is back online.
I'm currently using Railway to host cuanto.bio in case you need a reference setup.
Create three Railway services in a project:
- PostGIS β deploy the
postgis/postgis:16-3.4Docker image - tap β deploy
ghcr.io/bluesky-social/indigo/tap:latestas a Docker image service - app β an empty repo and connect it with the CLI (
railway link) or deploy from Github
App service:
| Variable | Description |
|---|---|
DATABASE_URL |
Injected automatically if you use the Railway Postgres plugin |
PUBLIC_URL |
The public URL of the app, e.g. https://cuanto.bio |
PUBLIC_OAUTH_CLIENT_ID |
Same as PUBLIC_URL (ATProto uses the app URL as the OAuth client ID) |
PRIVATE_OAUTH_KEY |
JWK for signing OAuth tokens β generate with pnpm gen-key |
TAP_ADMIN_PASSWORD |
Shared secret for authenticating TAP webhook requests |
TAP_URL |
Internal Railway URL of the TAP service |
TAP service:
| Variable | Description |
|---|---|
TAP_WEBHOOK_URL |
Internal Railway URL of the app's webhook endpoint, e.g. https://<app-internal>/api/tap/webhook |
TAP_ADMIN_PASSWORD |
Must match the value set on the app service |
TAP_SIGNAL_COLLECTION |
bio.cuanto.surveyProtocol |
TAP_COLLECTION_FILTERS |
bio.cuanto.surveyProtocol,bio.cuanto.protocolTarget,bio.cuanto.surveyTarget,bio.cuanto.survey,bio.cuanto.surveyProtocol.follow,bio.lexicons.temp.v0-1.occurrence,bio.lexicons.temp.v0-1.identification,bio.lexicons.temp.v0-1.remark |
Run migrations via the Railway CLI before or after deploying:
pnpm railway:migrate:up
pnpm railway:migrate:downThese run scripts/migrate.ts inside the app container over railway ssh, so they
use the private-network DATABASE_URL and need no public database access.
The database has no public TCP proxy, so connect over an SSH tunnel instead:
# psql shell
railway connect PostGIS --ssh
# local tunnel for GUI clients (TablePlus, DBeaver, pgAdmin), Ctrl+C to close
railway connect PostGIS --tunnel-only -P 5433To backfill records created before the webhook was live, unset TAP_NO_REPLAY on the TAP service and redeploy:
railway variable delete --service <tap-service-name> TAP_NO_REPLAY
railway redeploy --service <tap-service-name>TAP will replay all known records through the webhook on startup. Set TAP_NO_REPLAY=true again afterward to prevent re-replaying on future restarts.
To re-ingest a single repo's surveys, occurrences, identifications, and remarks straight from its PDS, e.g. records TAP missed or skipped because the app didn't handle them yet:
pnpm railway:backfill-surveys did:plc:abc123 # just that repo
pnpm railway:backfill-surveys # every DID in the users tableLike the migrations, this runs scripts/backfill-surveys.ts inside the app container over
railway ssh. Existing rows are updated in place, so re-running it is safe. Don't put
-- before the DID: pnpm passes it through to the script, which will treat it as the DID.
The iOS and Android apps are thin Capacitor wrappers
that load the live site over the network (server.url in capacitor.config.ts)
instead of bundling it, so shipping a web deploy reaches app users with no app
store round trip. See
docs/2026-07-20-capacitor-ios-overview.md
for the architecture and rationale.
Prerequisites:
- iOS: macOS with Xcode and CocoaPods (
brew install cocoapods) - Android: Android Studio, with the Android SDK and a JDK
The Capacitor CLI ships as a dev dependency. The pnpm cap:* scripts below wrap
it and sync the native projects from PUBLIC_URL first; reach for npx cap ...
directly only for anything they don't cover.
The wrapper loads whatever PUBLIC_URL points at (the same public origin the
app is served from), so no Capacitor-specific config is needed. In production
that is https://cuanto.bio; for device testing set PUBLIC_URL in .env to a
public HTTPS URL with real SSL, such as a
Tailscale funnel to a local
pnpm build && pnpm preview. A native build fails fast if PUBLIC_URL is a
loopback, IP-literal, or plain-http address (like the dev default
http://127.0.0.1:5173), since none of those can load in the WKWebView.
On iOS the host must also appear in WKAppBoundDomains in
ios/App/App/Info.plist, or App-Bound mode blocks the very page it loads (that
mode is what lets the site's service worker run). Info.plist reads that value
as $(WK_APP_BOUND_DOMAIN), an Xcode build setting substituted at build time
from the gitignored ios/Env.xcconfig (included by ios/debug.xcconfig) β the
pnpm cap:* scripts below regenerate that file from PUBLIC_URL for you, so
Info.plist itself never needs to change. Run one of them at least once after
cloning or PUBLIC_URL will substitute empty and the WKWebView will show a
blank screen.
After changing PUBLIC_URL you must re-sync the native projects before
building. The pnpm cap:run:* and pnpm cap:open:* scripts do this
automatically; to sync both platforms without building, run pnpm cap:sync.
pnpm gen-icons regenerates the iOS and Android launcher icons (alongside the
PWA icons and favicon) from static/favicon.svg. They are written straight into
the native projects, so just rebuild in Xcode / Android Studio to pick them up.
These scripts sync the native projects from PUBLIC_URL first (and, for iOS,
update WKAppBoundDomains to match), so you never have to remember to re-sync
after editing .env:
# Open the native project in Xcode / Android Studio, then build and run there
pnpm cap:open:ios
pnpm cap:open:android
# Or build and launch on a connected device or emulator from the command line
pnpm cap:run:ios
pnpm cap:run:androidpnpm cap:ios:domains runs the iOS WKAppBoundDomains sync on its own, and
pnpm cap:sync syncs both platforms without building.
Prerequisites: Node.js 20+, pnpm, Docker
This should get you running at 127.0.0.1:5173, with the caveat that not all PWA functionality will work. For that you'll probably need to use pnpm build && pnpm preview and a reverse proxy of some kind to get a public URL with SSL.
cp .env.example .env
docker compose up -d
pnpm install
pnpm migrate:up
pnpm devpnpm test:db:setup # once
pnpm testpnpm test:unitIntegration tests run against a dedicated cuanto_test database. Before running them for the first time, or after adding new migrations:
pnpm test:db:setupThen:
pnpm test:integrationpnpm test:db:setup is idempotent β safe to re-run if something goes wrong.
| Command | Description |
|---|---|
pnpm dev |
Start development server |
pnpm build |
Production build |
pnpm migrate:up |
Apply pending migrations to the dev database |
pnpm migrate:down |
Roll back the latest migration |
pnpm test:db:setup |
Create and migrate the integration test database (run once) |
pnpm test:unit |
Run unit tests |
pnpm test:integration |
Run Playwright integration tests |
pnpm test |
Run all tests |
pnpm check |
Type-check and lint |
pnpm format |
Auto-fix formatting |
pnpm psql |
Open a psql shell against the dev database |
pnpm gen-icons |
Regenerate PWA, favicon, and native iOS/Android app icons from static/favicon.svg (requires ImageMagick) |
pnpm cap:run:ios / pnpm cap:run:android |
Sync from PUBLIC_URL, then build and launch the native app on a device or emulator |
pnpm cap:open:ios / pnpm cap:open:android |
Sync from PUBLIC_URL, then open the native project in Xcode / Android Studio |
pnpm cap:sync |
Sync both native projects from PUBLIC_URL (incl. iOS WKAppBoundDomains) without building |
pnpm cap:ios:domains |
Update iOS WKAppBoundDomains in Info.plist to match PUBLIC_URL |
static/favicon.svg is the authoritative icon that pnpm gen-icons uses to generate the other icon assets: the PWA icons, the favicon, and the native iOS/Android app icons.