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.