Commit Graph
19 Commits
Author SHA1 Message Date
yusufipk 39e81042bb feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.

An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.

Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.

Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.

/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
2026-08-05 19:40:36 +03:00
yusufipk 33c845636c fix(analytics): sign the acquisition cookies and bound what they can write
Both cookies were read straight into database columns after nothing more than a
format check. httpOnly keeps JavaScript out of them and does nothing about curl,
so the anonymous id was a string the caller picked: enough to write a first-touch
row for a visitor who never existed, to file it under a channel of their
choosing, and to claim that id's events at signup, since the backfill matches on
the id alone.

They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than
node:crypto because the proxy runs on the edge and the pages that read the
cookies back run in Node. The first-touch body moved to base64url on the way:
cookie values are percent-encoded and decoded by several layers that do not agree
on how many times, and a payload carrying its own percent escapes comes back
subtly different and takes the signature with it.

Signing stops a caller choosing an id, not collecting one, since dropping the
cookie and asking for the landing page again mints another. So the bot and
prefetch filters moved to where the rows are written rather than only where the
cookies are issued, which also fixes a returning visitor's prefetch of /register
recording a signup start, and a per-client hourly ceiling now sits in front of
the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every
caller resolves to 127.0.0.1 and the bucket would empty on real traffic long
before it emptied on a flood.

Four smaller things around it:

- /api/events checked the flag and the origin after paying for a rate-limit
  write, so a host who never turned analytics on was still writing a row per
  anonymous POST. Both checks are free and now come first, and the limiter
  answers 204 rather than 429: a beacon has nobody to tell, and a flooder should
  not be handed the reset time.
- /api/onboarding/source was keyed by IP on an authenticated route. Without
  TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and
  with it a shared office address locks out everyone after one colleague
  answered. Keyed by account, like /api/onboarding/complete beside it.
- The cookies took their Secure flag from request.nextUrl.protocol, which behind
  a TLS-terminating reverse proxy is the container-internal http address. It
  comes off the configured public origin now.
- sanitizeLandingPath took anything that started with a slash, including from the
  cookie, so a hand-written one could put newlines and markup into a column an
  admin table may render one day.

Also: the paid-account query had no LIMIT and returned every active account's
name and email, the growth route answered 403 where it meant 401, and the schema
claimed no free text is stored when self_reported_note holds 200 characters of it.
2026-08-01 20:29:28 +03:00
yusufipk 7ca5abd041 feat(analytics): record where paying customers actually came from
Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
2026-08-01 20:00:27 +03:00
yusufipk b51e690062 fix: close the findings the test suite surfaced
The suite that landed in #43/#44 was written against existing behaviour, so a
number of tests pinned bugs rather than asserting correct behaviour. This fixes
the production code and moves each of those tests onto the fixed behaviour in
the same change.

Security:

- project-download: derive the archive entry extension from the last path
  segment and restrict it to a short alphanumeric run, so an extensionless
  allowlisted url can no longer contribute a path separator; validate the r2
  branch against the strict proxy-path pattern instead of a `startsWith`, which
  let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim.
- rate-limit: hash a key or action wider than its column instead of skipping the
  query. Both the guard and the failing INSERT used to answer "allowed", so the
  limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is
  unset in production.
- video uploads: the file name decides the content type; a client-declared video
  mime no longer makes `payload.exe` acceptable.
- email templates: escape in the helpers rather than relying on every caller,
  with an explicit `rawEmailHtml()` opt-out for the one call site that builds
  markup. `escapeHtml` now covers the single quote.
- CSP: allow loopback object storage outside production only.
- route-access: reach the billing redirect only for the workspace owner. Keying
  it off the owner's billing status alone made the redirect target an oracle for
  whose subscription had lapsed, and sent members to a page they cannot act on.
- search: carry the same billing condition every other read path carries.
- logger: check `err.name` as well as `err.constructor.name`, so a re-thrown,
  deserialised or minified Prisma error is still redacted.
- upload tokens: resolve the signing secret outside the try, so a server booted
  without one fails loudly instead of reporting every grant as a forgery.
- invitations: never downgrade an existing membership, and report a scoped
  invitation that points at nothing as not_found rather than accepted.
- auth: resolve the workspace role for every signed-in caller, so
  checkProjectAccess and computeProjectAccess stop disagreeing about the owner
  who also owns the workspace. The `intent` option is gone with it.
- r2-media-proxy: validate the object key inside the proxy so the guard travels
  with the function; delete the unused, unanchored `mediaUrlToR2Key`.
- r2: sign the content type into presigned PUT grants.

Correctness:

- frame rate snapping picks the nearest standard, not the first within
  tolerance, so 24, 30 and 60 fps are reachable at all.
- a version upload registers its Bunny cleanup as soon as bunny-init answers, so
  a failed tus upload no longer leaves a billed video behind.
- deleting videos clears storage before the rows, so a refused DELETE leaves a
  retryable row rather than an orphaned object.
- an expired upload session can be cancelled, which is what releases its quota.
- `voice/` joins the delete allowlist, so a voice note can be removed by the
  module that wrote it.
- a failed CORS write propagates instead of being mistaken for an empty config
  and replacing the bucket's rules.
- filtering projects by workspace no longer hides projects the unfiltered call
  returns.
- upload retries skip aborts and permanent 4xx; progress no longer divides by
  zero.
- reply edits no longer clear the comment's tag; optimistic resolve rolls back
  to the state it replaced; the delete snapshot is captured once.
- assorted UI fixes: duplicate React keys, double-click guards reading stale
  closures, the tag list fetched twice per load, a failed member list rendering
  as an empty one, a stale "Initializing upload..." beside a failure, and a
  registration banner pointing at an email that never arrives.

Consistency and access:

- the two download routes answer 404 for an id belonging to another tenant, as
  the comment export route already did. A caller who does belong still gets 403.
- accessible names for the share-link password field, the guest name gates, the
  version dialog inputs and the comment-tag controls.

Repository health:

- the runner image installs production dependencies only.
- a setup file for the unit project restores stubbed env centrally.
- native tsconfig path resolution replaces vite-tsconfig-paths.
- `uploadBytesWithProgress` exists once.
- admin stats bill Bunny storage to the workspace owner like every other
  quota, gate on the configured flag, wire up the single-flight guard and count
  the statuses that belonged to no bucket.
- `r2Client.destroy()` releases the presign client too.
- `prepare` tolerates a production install, where husky is absent.
2026-07-26 18:53:54 +07:00
yusufipk f64c04b271 feat(admin): add search and status filters to the users table
The users table could only be sorted, so finding a single account or
reviewing everyone in a given billing state meant paging through the
whole list.

Add three filters that compose with each other and with sorting:
- q: case-insensitive name/email search, submitted as a plain GET form
- status: one button per BillingSubscriptionStatus (active, canceled, ...)
- access: real in-app access, including collaborators on a paying
  owner's workspace or project

Resolving that collaborator access per user meant two queries per row.
Replace it with getCollaboratorAccessUserIds, which resolves every user
in two queries total and now backs both the column and the new filter.
2026-07-25 15:15:01 +07:00
yusufipk b5fd73dcf2 feat(admin): show subscription status and real access in user list
Add a Subscription column to the admin user listing showing each user's
billing status (Active, Trialing, Past due, Canceled, etc.) as a badge,
plus an effective-access indicator. Access reflects real in-app access,
not just the user's own subscription: collaborators on a paying owner's
workspace/project are shown as having access 'via team' (mirrors
hasAppNavigationAccess in lib/route-access.ts). Canceled-but-not-yet-
expired and trialing users are surfaced with their access-until date.
Column is DB-sortable and gated behind isStripeBillingEnabled().
2026-07-25 13:24:55 +07:00
Enes Köksal 3cfea40fbd refactor: eslint and prettier conflict will be resolved and formatted 2026-04-23 17:05:43 +03:00
Yusuf İpek 288c5d2624 feat(stripe-billing): add Stripe stats retrieval and display in admin dashboard 2026-04-10 21:35:22 +03:00
Yusuf İpek b1b1715578 Refactor registration and dashboard features to support invite codes and Bunny uploads
- Moved registration logic to a new client component for better separation of concerns.
- Integrated invite code requirement based on feature flags in the registration process.
- Enhanced dashboard functionality to conditionally enable Bunny uploads based on feature flags.
- Updated various components and API routes to check for Bunny uploads and Stripe billing feature flags.
- Added new feature flag utilities for managing feature toggles in the application.
2026-04-08 18:46:12 +03:00
Yusuf İpek 6f22b0bf8b feat(billing): integrate Stripe for subscription management and billing access
- Added billing-related fields to the User model in the database.
- Implemented functions for managing billing access, including trial periods and subscription statuses.
- Created new billing utility functions for Stripe integration.
- Updated onboarding page to include billing overview and workspace creation eligibility.
- Enhanced route access checks to require billing access for certain actions.
- Implemented cleanup scripts for expired billing workspaces and associated media.
- Updated header component to conditionally show app navigation based on billing access.
- Added new migrations for billing-related database changes.
2026-04-08 17:50:40 +03:00
Yusuf İpek 6e14d4d19f feat(admin): add manual R2 stats refresh endpoint/button and share in-memory storage snapshot cache 2026-02-24 17:35:42 +03:00
Yusuf İpek 6a6b48f6a9 optimize admin users table sorting and pagination by pushing supported sorts to Prisma 2026-02-24 17:23:44 +03:00
Yusuf İpek afa1529873 feat(feedback): add user feedback/review system with admin management and hardened image upload validation 2026-02-24 16:05:11 +03:00
Yusuf İpek 609b33bbcb feat(admin): track and display per-user download egress for Bunny version downloads 2026-02-22 20:19:55 +03:00
Yusuf İpek bbae612a70 feat(admin): add sortable paginated users table with invited-members metric and async searchParams fix 2026-02-22 20:04:41 +03:00
Yusuf İpek 72d14f4a5d feat(comments): add secure CSV/PDF export for version comments with auth, rate limits, and CSV injection hardening 2026-02-22 13:56:02 +03:00
Yusuf İpek e30b4a5b19 feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats 2026-02-22 13:03:45 +03:00
Yusuf İpek e32196c430 fix: address security vulnerabilities and add image attachments
- Fix type confusion vulnerability in comment content updates
- Validate pagination offsets to prevent negative values
- Validate timestamp is a valid number before parsing
- Exclude guestEmail from comment API responses for privacy
- Fix TypeScript error in audio upload route
- Add image attachment support for comments with upload API
- Update admin dashboard to track image attachments
- Rename cleanup functions to handle both voice and image media
2026-02-21 16:40:58 +03:00
Yusuf İpek 83eeffe5c0 feat: Implement admin dashboard with user management, statistics, and role-based access control. 2026-02-20 15:17:32 +03:00