Commit Graph
15 Commits
Author SHA1 Message Date
yusufipek 6561e0817e feat(storage): point a full trial account at the upgrade rather than at nothing
Being told "storage limit exceeded" when the limit is the free trial's three
gigabytes is a dead end. The account is not full because it stores a lot; it is
capped because it has not subscribed, and deleting files buys back very little.

The refusal now says which ceiling it is and carries its own error code, so the
toast can offer a link to the billing settings on the trial ceiling and stay
quiet on the paid one, where subscribing changes nothing.

The code had to survive the trip to the toast, which meant the upload helpers
throwing something that carries it rather than a bare Error. Two places were
dropping the server's message on the floor entirely: adding a version reported
"Failed to initialize upload" whatever the server said, and every asset upload
in the pane rewrote its own failure text.
2026-08-18 11:42:30 +03:00
yusufipek 63288761ed fix(storage): count a finished Bunny upload the moment it lands
Two reasons the number on the storage page could read as nothing.

The per-user Bunny figure was computed inside a two minute cache. The declared
size lands on the row in the same transaction that deletes the reservation, so
for up to two minutes an upload that had just succeeded counted as nothing:
usage fell back towards zero and the next upload was measured against a total
that ignored the one before it. The call to Bunny stays cached, because it is
the slow half and its answer is the same for everybody. The join against our own
rows is now read fresh, per user, on every check.

A failed call to Bunny returned an empty map before it had looked at a single
row, so an account with gigabytes of declared uploads read as empty whenever
Bunny was unreachable. Bunny's figure being gone is not a reason to forget the
sizes we wrote down ourselves.

The rule for which of the two numbers to charge is unchanged, and the comment
above it now says why rather than guessing. What Bunny reports mid-encode is
partial: storageSize counts what has been written so far and climbs as each
rendition lands. A six minute cut uploaded at 2.5 GB read as 475 MB halfway
through and settled above 3 GB once it finished, because Bunny keeps the
original alongside every rendition. Taking the larger of the declared size and
Bunny's is right at every point on that curve; taking Bunny's whenever it is
non-zero would hand most of the quota back in the middle of an encode.

The settings card also claimed a 200 GB limit while showing a 3 GB one, and told
a trial account to delete files or contact support.
2026-08-18 11:42:30 +03:00
yusufipek e78911e850 fix(comments): say the account is full instead of blaming the comment
The attachment goes up before the comment does, so a full account fails on
the image and never reaches the comment at all. Both that failure and a
rejected comment came out as "Failed to add comment", which tells the
uploader to try again, and trying again is the one thing that cannot work
when there is no room left.

Both now read out what the server said. A network fault, which has no message
anybody wants to see, still falls back to the old line.
2026-08-18 11:07:25 +03:00
yusufipek 00f1d430b8 fix(uploads): stop a storage hold from being dropped by whoever can name it
A reservation id was never a secret and could not have been one. An upload
token is base64url(payload) followed by its signature, so a client can read
every claim out of its own token, and the two R2 init routes hand their
reservation ids to the client outright. The asset route takes a reservation
id from the request body and deleted it on the strength of that id and the
billed user alone, and every hold an account owns is billed to the same user.

So a caller could start a Bunny upload, read the id out of the token they
were just given, quote it while attaching a one byte image or even a bare
YouTube link, and have the quota handed back while the upload carried on.
Repeat and a trial worth three gigabytes uploads as much as it likes for as
long as Bunny takes to report a figure of its own. Signing the id rather than
handing it over bought nothing, because signing is not hiding.

A hold now records what it was opened for and is only ever consumed by that
flow, so naming one is no longer enough to drop it.

Guests hold against the workspace owner's quota rather than their own and had
no way to give it back: the release was gated on being signed in. Declaring a
size and walking away cost the guest nothing and cost the owner their whole
remaining allowance for two hours. The guest grant now carries the reservation
and the declared size, bound to the Bunny video as well as to ours, so
cancelling gives the quota back and costs them the upload it stood for. What a
guest can hold without cancelling lapses in half an hour rather than two hours.

The in-transaction fallback check counted the account's Bunny storage as zero
on a Bunny upload, because the figure was only prefetched for R2 providers and
that branch was unreachable for Bunny until this PR made it reachable. On an
account whose storage is all Bunny that was a check that could not fail. It is
prefetched for every provider that can reach the fallback now.
2026-08-18 11:07:18 +03:00
yusufipek 4ff801738c fix(uploads): count a Bunny upload from the moment it is admitted
A Bunny init asked the quota whether it could store zero bytes, which is a
question with only one answer. Nothing an upload was about to consume was
visible to the next request, so every init inside the same window read the
same total and every one of them passed, and an upload that could never
fit was only refused after it had been sent.

The client now declares the size up front. It is checked against the
account's remaining room before Bunny is asked for anything, and held as
a reservation the next init has to see. The declaration is a claim rather
than proof, so it is signed into the upload token: the same token already
binds the video id, which is what makes the reservation safe to release
on a caller's say-so, since releasing it costs them the video it belongs
to.

The declared size is then written onto the version or asset row and the
reservation is dropped in the same transaction, because Bunny reports no
size at all for a video until it has finished encoding it. On a half hour
of footage that is most of an hour during which the upload did not appear
on the uploader's own storage page and did not count against the next
upload. Per-video accounting now takes the larger of what Bunny reports
and what was declared, so the estimate stands in until the real figure
arrives and Bunny's wins once it does.

Two smaller things came out of the same reading. The asset route's
in-transaction fallback compared against the plan limit, so a caller
quoting a reservation that no longer existed was measured against 200 GiB
even on a trial worth three. And the guest branch reserves without being
able to release early, because a guest grant is bound to our video id and
the caller's network context rather than to the Bunny video, which would
let the reservation be dropped while the upload it stands for carried on.
2026-08-18 10:35:08 +03:00
yusufipek bfe3cb28b4 feat(admin): let a token read the growth scoreboard without a session
The weekly digest reads /api/admin/growth from a script, which has no browser
and therefore no NextAuth session. The alternative was copying a session cookie
out of a browser by hand: those are JWTs with a 30-day lifetime, so a scheduled
job built on one stops working a month later and reports nothing rather than
reporting a failure.

The token path is off unless OPENFRAME_ADMIN_API_TOKEN is set, so an instance
that never sets it keeps session-only admin access. A value under 32 characters
is treated as no token at all: behind this header sit every paying account's
name, email and usage, and a short token is a guessable path to all of it.
Comparison runs over SHA-256 digests so it stays constant time without leaking
the token's length.
2026-08-18 10:22:42 +03:00
yusufipek 313cd552e6 fix(billing): stop an unpaid subscription from unlocking the paid limits
isPaidTier read a future stripeCurrentPeriodEnd as proof of payment, and
Stripe stamps a current period on an incomplete subscription all the
same. A checkout whose first charge failed therefore carried a period
end a month out with nothing paid behind it, and every ceiling the
cardless trial puts on an unpaid account (200 GB of storage, unlimited
projects, unlimited workspaces) came off with it.

Written as a deny list of the two statuses that mean no charge has ever
gone through, so a real customer whose renewal failed keeps the full
plan for the period they already paid for.

hasBillingAccess reads the same column the same way and is deliberately
left alone: being wrong there locks a paying customer out, and the SQL
in buildBillingAccessWhereInput has to move with it.
2026-08-18 09:48:10 +03:00
yusufipek 1ec5c53802 fix(auth): start the cardless trial for social signups too
The trial is granted in the credentials signup route and on the
verification link, and an account created by Google or GitHub goes
through neither: the Prisma adapter writes it directly. Since checkout
no longer offers a trial either, such an account reached a locked
product and an immediate bill, which is the opposite of what the signup
page promised it.

The address is already proven at this point, because the signIn callback
turns away an OAuth profile that reports its email as unverified, and
the grant is idempotent, so nothing here can hand out a second trial.
2026-08-18 09:48:00 +03:00
yusufipek 4c0b18e5e4 feat(marketing): say no card where the trial is actually claimed
Three places still carried the old promise. The register form, which is where
the CTA lands and where the trial is granted, said nothing about it at all, so
the landing page's claim had to be taken on faith across a page load. The
comparison profile summary still described the trial without mentioning the
card. And the CtaLink comment quoted a button label that no longer exists.

The hero and closing CTAs now read 'Open dashboard' for a signed-in visitor.
That button already pointed at /dashboard for them, and offering a free trial
to someone who is mid-subscription reads as a bug. The pricing card keeps the
short label because it sits directly under 'No credit card' and the card
narrows to about 230px at the md breakpoint, where the long label would wrap
out of a fixed-height button.
2026-08-18 09:23:21 +03:00
yusufipek 364e60146d test(e2e): expect a fresh account to reach onboarding, not billing
The registration spec asserted that a brand new account lands on /settings,
which was true only because registering left trialEndsAt empty. It is now
filled at signup, so the account has billing access and the dashboard lets it
through to the onboarding wizard. That redirect is the done condition of the
cardless trial, so the spec should hold it.
2026-08-18 09:13:42 +03:00
yusufipek 03f670cf0e Merge remote-tracking branch 'origin/master' into feat/cardless-trial 2026-08-18 09:12:32 +03:00
yusufipek 63f331d220 docs: add instructions for using the published Docker image Closes #10 2026-04-25 21:29:31 +03:00
yusufipek 0c54498d34 chore: update Dockerfile to use full image path and add Docker publish workflow 2026-04-25 21:04:37 +03:00
yusufipek 7058ea9f87 style: fix prettier issues after syncing with master 2026-04-25 20:24:17 +03:00
yusufipek ef7402bc23 Merge origin/master into pr-9-review 2026-04-25 20:21:40 +03:00