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.
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.
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.
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.
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.
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.
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.
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.
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.