Commit Graph
12 Commits
Author SHA1 Message Date
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 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