Skip to content

Why it is like this

The reasoning behind this project lives in its commit messages, which is a good place for it and an undiscoverable one. This is the index: the decisions that would look arbitrary or wrong to somebody arriving now, and what they cost.

Each has the commit to read if you want the full argument.

Product

The phone edits, the server does not. From the PRD. It makes the server small enough to cost a few dollars a month, and it makes the app a real piece of engineering rather than an upload form. The cost is the whole native module, and it is most of the difficulty in this repository.

Silences are not trimmed. The VAD is implemented, tested, and switched off. Cutting somebody's pauses reads as the app editing over them instead of for them — an invasive edit. One constant decides it: TRIM_SILENCES in src/edit/trimming.ts. 3932356

Publishing sits above editing. The review screen exists because publishing something you have not seen is frightening, not because anybody wants a timeline. So the default path is one tap and the editing controls are below it. 7d5bb77

Gallery import contradicts the PRD, on purpose. The spec ruled out the camera roll to force footage shot in the moment. It was added later at the product owner's request, and items from the library are tagged in the manifest so the decision stays visible and reversible.

A re-roll is not a strike. The group choosing somebody else is a decision, not a failure. Missing a deadline is the failure, and that is what strikes count.

A flip is one take, not two. From explicit feedback: everything from pressing record to pressing stop is a single take however many times the camera turns around. f250c24

Architecture

The video never passes through the API. Presigned upload, CDN playback. A premiere is the one moment every member of a circle arrives at once, which is precisely when the API cannot afford to be moving 50 MB per viewer.

The EDL is duplicated in TypeScript and Python. Two implementations of the same thing, with mirrored tests, so the phone and the server produce the same vlog. Sharing it would mean a runtime bridge between the two, which is a larger and more fragile dependency than the duplication.

Exactly one server machine. The turn scheduler and the rate limiter both count in memory, so a second machine draws turns twice and halves every limit, both silently. Making this scale out is real work — the schedule into the database with a lock, the limiter into something shared — not a config change.

Render results merge, they do not overwrite. A finished render used to save the whole manifest, including the clip list it started with, undoing anything edited while it ran. saveRenderResult writes only render outputs. 045efb9

Renders are superseded, not cancelled. MediaCodec has no cancel. The queue runs one at a time and discards results whose generation number has moved on. ca7bb82

Accounts and tokens

Revocation is a version number on the user, not a denylist. The token carries the account's session epoch; bumping the number on the row invalidates every token ever issued to it. No table to store, nothing to expire, and it cannot drift out of sync because it is read from the same row the request already loads. The cost is that it is all-or-nothing — you cannot sign out one device — which is the right trade for a closed group app where the reason to press it is a phone you no longer have.

Access tokens last an hour; refresh tokens sixty days. A month-long access token was fine while the only user could be asked to reinstall. It is not once other people have accounts: an access token cannot be checked against anything until it expires, so its lifetime is the exposure.

The refresh token is not rotated on use. Rotation with reuse detection is the stronger design and it needs the tokens stored server-side, which is a table, a cleanup job, and a new way to lock somebody out of their account by losing a race on a flaky network. With revocation already working account-wide, the cost outweighs what it buys here. Written down so it is a choice.

A password reset is a code you type, not a link you tap. A link needs somewhere to land — a website, or deep links registered against a domain — and this project has neither. The code is safe because of three things together, no one of which is enough: it expires in fifteen minutes, it works once, and guessing it is rate limited to ten attempts an hour.

Email goes over SMTP rather than a provider's API. Every transactional email service wants a verified domain before it will send to anybody but you, and there is no domain. SMTP works with whatever account exists today and with a proper provider later, unchanged.

Changing or resetting a password ends every session. Otherwise the change accomplishes nothing against the person you changed it because of.

Infrastructure

Fly.io, one machine, Amsterdam. Fly has no region in Spain, and of those near it only ams/fra/lhr offer managed Postgres. The database has to sit with the app; the video does not, and comes from Cloudflare's edge. c50bbb7

Neon instead of Fly's Managed Postgres. The smallest Fly plan is $38 a month against a free Neon project, and at this size the difference buys reliability the project does not need yet. It cost one change: DATABASE_URL.

The scheduler ticks every 15 minutes, not every minute. Neon's free tier suspends the compute when idle and that suspension is the saving. A query a minute keeps it awake permanently and exceeds the monthly allowance. Turns come round every 3 to 7 days, so a draw landing a quarter of an hour late is beneath anyone's notice. f75df5d

R2 rather than S3. Egress. The shape of this product is one upload read by every member of a circle, every week, forever, and R2 charges nothing for that.

Backups live on a laptop, not in the cloud. Everything else here is rented and a backup is the one thing that should not be: its value comes from being independent of what it copies. A copy that production's own credentials can reach is no protection against those credentials being misused. The gap that leaves — a laptop switched off for a fortnight — is named in deployment.md. 2d81ce8

A systemd timer, not cron. cron silently skips runs that fall while the machine is off, which on a laptop is most nights. That is how people end up believing in backups they do not have.

Engineering

Coverage is measured on pure modules only. A global percentage over a codebase that is mostly screens and native bridges measures how much React got rendered in a test, which is worth nothing. jest.config.js lists the modules where the number means something, and says so. 5c706c3

CI compiles the Kotlin. Three consecutive EAS builds failed on compile errors at nine minutes each before this existed. 3ae1040

The npm audit job reports everything and fails only on critical. Twenty-odd advisories, all transitive through Expo's build tooling, none fixable. A job that is permanently red is a job nobody reads. 8ce47fc

Error reporting scrubs this app's own vocabulary. Sentry strips passwords and auth headers by itself; it cannot know that a push token identifies a device or that an invite code is a working key to somebody's circle. Keys are removed and the values around them kept, because an event with everything stripped explains nothing. c5bbb25

A flaky test was replaced with measured bounds. test_everyone_comes_up_over_a_long_rotation asserted a spread of 2 across 60 rounds, which failed roughly one run in six. Measured over 4000 simulations the extremes were 7 and 15, so the bounds are now 5..19 — loose enough never to flake, tight enough to catch a draw that has genuinely stopped rotating. The reasoning is written into the test.

Rejected

Raspberry Pi behind a Cloudflare tunnel. Documented and then dropped for something more professional. It left one mark worth knowing: the tunnel meant the origin was unreachable directly, which is why the rate limiter used to trust a forwarded header. On a host with a public address that assumption is worth nothing. a6a827a

ffmpeg-kit-react-native. Deprecated, binaries withdrawn from Maven Central. This is why the native engine is written directly against MediaCodec.

A second bucket in the same Cloudflare account as a backup. It feels like a backup and only survives the mildest failure.