- TypeScript 47%
- Kotlin 41%
- HTML 11%
- CSS 1%
- Shell <1%
- 2 more languages <1%
Pneumo #
Pneumo is a self-hosted, end-to-end encrypted replacement for the core of Pushbullet. Send notes, links and files between your own browser and Android phone and get instant notifications. Each deployment has one user and runs as one small server plus any S3-compatible bucket; the server never sees your passphrase, your keys or any plaintext.
Status: in development. The design is in docs/SPEC.md.
Source, pull requests and CI live on Tangled: tangled.org/demi.dev/pneumo.
Development #
Install the pinned tools with mise, then install the workspace dependencies:
mise install
mise exec -- pnpm install --frozen-lockfile
The TypeScript workspace contains @pneumo/protocol, @pneumo/crypto, @pneumo/server and
@pneumo/web. The shared packages expose TypeScript source to workspace consumers. The server's
tsup build bundles @pneumo/* packages into its ESM entrypoint; npm dependencies stay external so
plain Node can run the output without a TypeScript loader.
mise run check:protocol # Biome, TypeScript and Vitest for protocol and crypto
mise run check:server # Biome, TypeScript and Vitest for the server
mise run check:web # Biome, TypeScript, Vitest and a Vite production build
mise run android:setup # Configure the local SDK path on a fresh checkout
mise run check # All implemented lanes, including Android
mise run fmt # Format TS packages and root config; exclude Android, brand and generated files
mise exec -- pnpm test runs all four Vitest projects. Start the minimal web app with
mise exec -- pnpm --filter @pneumo/web dev.
Copy .env.example to .env.local, start the development bucket with mise run dev:infra,
then run mise exec -- pnpm --filter @pneumo/server dev for the server watcher.
For a production-style local run:
mise exec -- pnpm --filter @pneumo/web build
mise exec -- pnpm --filter @pneumo/server build
mise exec -- node --env-file=.env.local apps/server/dist/index.js
The server serves the built web app and GET /api/health. WEB_DIST_DIR defaults to
apps/web/dist, resolved relative to the server rather than the current directory. Before
listening, the server loads meta/owner.json, the device registry, the VAPID keypair,
and all push records from the bucket (up to 32 concurrent reads). Health reports the
package version and whether the owner record exists.
First-run setup uses PNEUMO_SETUP_TOKEN when configured. Otherwise startup logs one
warning containing the setup URL and its token fragment, only while uninitialized.
POST /api/setup conditionally creates the owner record and registers the first device;
subsequent attempts return 409. The server receives derived authentication material,
never the passphrase, and stores a salted scrypt verifier.
Login creates a device with a bearer token; only its SHA-256 hash is persisted.
Authentication failures are limited to five per IP and thirty globally per fifteen minutes.
TRUST_PROXY controls whether the first X-Forwarded-For address identifies the client.
Authenticated clients can list, rename and revoke devices, store or clear their own Web Push
subscription, and issue single-use ten-minute Android pairing codes. Revocation invalidates
the token immediately and signals socket closure for the later WebSocket hub.
GET /api/me returns the key check, resolved upload mode, and the 65-byte base64url VAPID
public key. Device DTOs never expose token hashes or subscriptions.
When meta/vapid.json is missing, startup generates a P-256 VAPID keypair and persists
{ publicKey, privateKey, subject }. The subject is VAPID_SUBJECT, defaulting to PUBLIC_URL.
Later boots reuse the stored keypair, but sending uses the current VAPID_SUBJECT configuration,
not the persisted subject. It must use https: or mailto:; an HTTP development origin or
invalid subject disables delivery with a warning, without preventing boot or push creation.
For local development, set a mailto: subject explicitly. The private key is never returned
by the API and is redacted from structured logs.
Web Push sends to subscribed web devices other than the source, respecting the push target. It runs asynchronously with four workers, a 1,000-delivery waiting queue and a ten-second deadline per send. Overflow drops the oldest waiting delivery and logs a warning. Messages use TTL 86,400 seconds and high urgency: the full push fits up to 3,000 UTF-8 JSON bytes, otherwise only its id is sent for the service worker to fetch. A push-service 404 or 410 clears the subscription in the bucket; other failures retain it. Endpoint URLs and keys are never included in sender logs.
Server bucket access uses the Storage contract in apps/server/src/storage/types.ts.
S3Storage is used at runtime; tests use MemoryStorage or an isolated MinIO instance.
S3_PREFIX is a namespace: a nonempty value gets a trailing / if needed, and callers
always use unprefixed keys. Listing is UTF-8 lexicographic; maxKeys controls page size,
not the total number of results. Missing reads throw StorageError with code NotFound;
missing heads return null. Conditional JSON creates use { ifNoneMatch: "*" }.
Upload stream errors abort the outstanding bucket request and surface as sanitized storage
errors rather than leaving a request waiting for missing bytes.
With UPLOAD_MODE=auto, startup applies and reads bucket CORS. Both calls must succeed
to select direct uploads; otherwise one warning records the operation and safe error code,
and uploads use proxy mode. A briefly missing bucket is retried for about ten seconds so
minio-init can finish; other failures fall back immediately. Explicit direct/proxy
skip detection. CSP includes the bucket origin only in the resolved direct mode.
Authenticated clients reserve encrypted uploads with POST /api/files, upload using the
returned PUT descriptor, and attach the issued file id in POST /api/pushes. Direct PUT
URLs expire after one hour; download URLs expire after ten minutes. Proxy transfers stream
through authenticated /api/files/:id/content routes and reject any body whose byte count
differs from its reservation. A slow proxy upload does not block other pushes or uploads.
A concurrent PUT for the same file returns 409; attaching an actively uploading file
returns 400. Upload reservations are memory-only: after a server restart, an unattached
upload must be reserved and uploaded again.
Push creation is idempotent: an identical body for an existing id returns its stored record;
a different body returns 409. History is newest first by (createdAt, id), with UUID
before cursors and a limit of 1–100 (default 50). An unknown valid cursor returns an
empty page. Deleting a push also deletes its encrypted file. The in-memory index has a
soft operating limit of about 20,000 pushes; compaction is not implemented. Every thirty
seconds, reconciliation merges unknown records from the first 100 bucket keys and emits
push events. Both the WebSocket hub and Web Push sender consume these events. Web Push
deduplicates push ids for ten minutes, including events merged by reconciliation.
GET /api/ws upgrades on the same HTTP server. Send a protocol hello within ten seconds;
the deadline stops when a schema-valid hello arrives, so slow authentication does not
sign the client out. Authentication uses the same token-hash registry as HTTP bearer requests.
ready is followed by at most 200 newer pushes, oldest first, then replay_done.
An unknown cursor or more than 200 newer pushes produces resync instead;
since: null skips replay.
Push, deletion and device events reach every authenticated socket, regardless of targeting.
Device revocation broadcasts device_removed before closing that device's sockets with 4401.
The hub sends application-level ping frames every WS_PING_SECONDS (default 60).
Clients must send JSON pong frames; two missed response windows terminate the socket.
Other client frame types, repeated hello, binary frames and invalid JSON close with 4400.
Incoming messages over 64 KiB close with 1009 before JSON parsing, including fragmented messages.
Sockets buffering more than 1 MiB are terminated so clients can reconnect and replay.
Device DTO presence reflects authenticated socket counts immediately; presence transitions
broadcast device_updated after a two-second debounce. Presence is not stored in the bucket.
An hourly sweep deletes unreferenced file objects older than 24 hours. With
RETENTION_DAYS > 0, a daily sweep deletes older pushes and their files; zero keeps push
history indefinitely. Reconciliation and sweeps use an injected scheduler in tests and
unreferenced timers at runtime; shutdown stops the timers.
The built administration CLI uses the same environment configuration as the server:
mise exec -- node --env-file=.env.local apps/server/dist/cli.js info
# Stop the server before resetting its bucket namespace.
mise exec -- node --env-file=.env.local apps/server/dist/cli.js reset --yes
info reports initialization, device/push counts, and the resolved upload mode without
creating owner or VAPID records. reset reports the deleted object count and removes
everything within S3_PREFIX, including metadata; it refuses without --yes.
Storage, authentication, push/file routes, index reconciliation, sweeps, CLI and real-socket
WebSocket tests use memory storage and fake clocks/timers in check:server (no Docker).
Run MinIO persistence, direct/proxy upload-download byte equality, administration CLI, and
bundled HTTP/WebSocket lifecycle integration tests with:
mise run e2e:server
The integration runner starts and stops its own uniquely named MinIO container and creates
the bucket. Set PNEUMO_E2E_MINIO=native to use installed minio/mc binaries instead.
The pinned MinIO build provides global CORS via MINIO_API_CORS_ALLOW_ORIGIN but reports
bucket PutBucketCors as unsupported, so auto detection selects proxy mode. Forced direct
mode still permits signed PUT/GET when bucket CORS is configured externally.
check:server also checks bundled startup's safe config failures. The MinIO integration
suite launches the bundle with plain Node, an ephemeral port, and a temporary web fixture.
It exercises setup, authentication, pairing, live delivery, replay after restart, HTML
serving, and SIGTERM shutdown. The WebSocket hub registers in the shutdown hook list in
src/index.ts and closes all sockets with 1012, with a ten-second drain deadline.
Storage sockets remain open during the drain; process exit releases them afterward.
The injected Clock.now() returns Unix epoch milliseconds, matching Date.now(). Tests use
epoch-millisecond fake clocks so timestamps and expiry calculations share the production unit.
Request log durations are differences between clock readings, clamped to zero on backward jumps.
License: MIT.