A cross platform chat app with end to end encrypted direct messages.
One Expo codebase for iOS, Android and web, an Express backend on PostgreSQL, and identity from Oxy.
|
Direct messages are encrypted on the device before they leave it. The server stores ciphertext and public keys, never private key material. Private keys live in the iOS Keychain and the Android Keystore through
|
Messages are written locally first and the server is secondary. Mutations made offline are queued and replayed on reconnect, so the app keeps working without a network. |
Note
Every conversation is an MLS group. The app encrypts, decrypts and stores messages on
the device through the Allo platform SDK (packages/core, consumed by the app via
packages/react); the server carries ciphertext and public keys. Each installation is
a device with its own signing key, approved by one that is already signed in.
docs/platform/ describes the design and its threat model, including
what the web build cannot protect: its keys live in IndexedDB, readable by any script on
the origin.
The repo is a Bun workspace monorepo. Everything lives under packages/.
| Package | What it is |
|---|---|
@allo/frontend |
The Expo app for iOS, Android and web. Expo Router, NativeWind, Zustand, TanStack Query |
@allo/backend |
Express API and Socket.IO server — PostgreSQL via drizzle, for every domain |
@allo/shared-types |
TypeScript types shared by both |
Identity and sessions come from the Oxy platform rather than from a login system in this
repo: @oxy.so/services on the frontend,
@oxy.so/core/server for the backend's auth, CORS and rate limit middleware, and
@oxy.so/bloom for shared UI.
There is no controllers/, middleware/ or sockets/ directory in the backend. Routes
hold their own handlers and the Socket.IO wiring sits directly in server.ts.
You need Node 20.19+, Bun and a PostgreSQL instance. The root engines field pins Node
>=20.19.0 and Bun >=1.0.0.
bun install # postinstall builds @allo/shared-types for you
bun run dev # frontend and backend togetherOr one at a time:
bun run dev:frontend # Expo on Metro port 8140, then press w, i or a
bun run dev:backend # bun --watch with hot reloadiOS needs macOS and Xcode. Android needs Android Studio. The per platform scripts have no root aliases, so reach them through the workspace filter:
bun run --filter @allo/frontend web # or ios, or androidBefore opening a pull request, run what CI runs:
bun run typecheck && bun run testEvery script, by package
Root
| Script | What it does |
|---|---|
bun run dev |
Start every package in dev mode |
bun run dev:frontend / dev:backend |
Start one of them |
bun run build |
Build every package |
bun run build:shared-types / build:frontend / build:backend |
Build one of them |
bun run typecheck |
tsc --noEmit over backend then frontend |
bun run test |
Tests across all packages |
bun run start:frontend / start:backend |
Production start |
bun run clean |
Remove build artifacts and node_modules |
@allo/frontend: start, dev, android, ios, web, build, build-web,
test (Jest), lint (expo lint ., the minimal Expo-only ESLint config), clean, plus clear-cache and reset-project.
@allo/backend: dev, start, build, test (bun test, which needs a real Postgres
server — each *.realdb.test.ts suite creates its own throwaway, fully-migrated
database on it), clean, plus db:generate and db:migrate.
@allo/shared-types: build, dev (watch), typecheck, test, clean.
Postgres migrations ARE applied by the deploy, in two phases. packages/backend/drizzle/
holds them and bun run db:migrate -- --target-database=<name> --phase=<pre|post|all> is the
only migrator. deploy-aws.yml sets RUN_MIGRATIONS: "true" and runs --phase=pre before
the rollout and --phase=post after it, each as a one-shot ECS task — so merging a schema
change ships it, and the -- oxy:deploy-phase= marker on each .sql decides which side of
the rollout it lands on.
Note
Formatting and linting are Biome (biome.jsonc): bun run lint runs biome check .
plus the frontend's minimal Expo ESLint (expo lint .), bun run lint:fix applies safe
fixes and bun run format formats. CI runs the same in its lint job.
The chat transport was replaced, not extended. The decision and the design are in
docs/adr/0001-clean-break-platform.md and
docs/platform/. The frontend reaches it through @allo/react and one
seam, packages/frontend/lib/allo/, which constructs the client with the platform's
storage (SQLite on a phone, IndexedDB on the web), secret store and Oxy session; see
packages/frontend/README.md.
Describing the system as it runs today:
| Doc | Subject |
|---|---|
| Overview | What Allo is and how the pieces fit together |
| Architecture | Packages, data flow, and the realtime transport |
| Encryption | Device keys, static ECDH derivation, and what is not implemented |
| API reference | The REST and Socket.IO surface |
Per package detail lives in packages/backend/README.md and
packages/frontend/README.md.
Issues and pull requests are welcome. Fork, branch, make the change, run
bun run typecheck && bun run test, then open a pull request.
MIT. Copyright (c) 2024-present OxyHQ.
For the supervised messaging UI, the existing local harness also accepts
?harnessSupervised=1 on the initial URL. It uses the real Allo client and MLS
engine with the in-memory transport and minimal people fixtures. In that browser,
window.dispatchEvent(new CustomEvent('allo:harness-policy', { detail: 'blocked' }))
withholds the conversation; unavailable simulates authority failure and allowed
restores it. These controls exist only behind ALLO_HARNESS=1; they are visual
fixtures, not authorization proofs. The backend's supervised messaging tests run
against PostgreSQL and the real Oxy SDK transport to a bounded authority fixture.