Apps

apps/web

The primary web application on TanStack Start.

apps/web is the product. It is a TanStack Start application deployed as the onyx-web Worker, and it is where you add routes, pages, and features. It imports every private package and serves the public API.

Running it

vp dev apps/web        # http://localhost:3000
pnpm test:e2e          # Playwright suite, from apps/web
pnpm cf-typegen        # regenerate Worker types after editing wrangler.jsonc
pnpm run deploy        # build and deploy to Cloudflare

Set PORT to run several checkouts side by side; keep it in sync with BETTER_AUTH_URL in .dev.vars.

Route structure

File routes live in src/routes:

RoutePurpose
_marketing/*The public site at /: landing, blog, changelog, and contact.
app/*The signed-in product at /app behind the sidebar layout: chat, notes, tasks, members, settings.
sign/*Sign in, sign up, forgot and reset password.
orgs/new, invite/$idOrganization creation and invitations.
api/auth/$, api/files/$id, api/v1/$Better Auth, file uploads and downloads, and the public API.
deviceDevice-flow approval for CLI login.
rss.xml, sitemap.xml, robots.txt, llms.txtServer routes generated from the marketing content.

The root route resolves the session for every route, including marketing: a Better Auth cookie-cache read, not a database query. /app additionally fetches the user's organizations via ensureOrganizations and the caller's role in the active one (memberRole on the route context), layered on top of the session root already resolved; no route touches the database just to render a marketing page. If the session points at an organization the user can no longer use (left, removed, or deleted), the layout switches it to the first remaining organization before anything org-scoped runs.

Server functions guard themselves with the middlewares in src/session.ts: authenticatedMiddleware (signed in), organizationMiddleware (verifies the member row on every call, because the session cookie cache can outlive a removal, and puts organizationId and role on context), organizationAdminMiddleware (owners and admins; webhooks live here), and adminMiddleware (platform staff). The realtime, notification, and agent socket handlers in src/server.ts re-check membership the same way before routing to a Durable Object.

Add a product page by creating a file under src/routes/app/; the layout provides the sidebar, presence, and notifications around it.

Security

src/security.ts centralizes the worker's security posture. Every response gets nosniff and HSTS; HTML additionally gets a report-only Content Security Policy (staged for enforcement), X-Frame-Options: DENY, a strict referrer policy, and a minimal permissions policy. Two wrangler.jsonc rate-limit bindings back the throttles: AUTH_RATE_LIMIT covers the credential endpoints per IP and path, and API_RATE_LIMIT covers /api/v1 per API key on top of the per-plan quotas. The auth forms render a Cloudflare Turnstile challenge whenever TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set; Better Auth's captcha plugin verifies the token server-side. The widget itself comes from @marsidev/react-turnstile, and useCaptcha in src/auth/turnstile.ts holds its token and the submit button.

Files

Every uploaded file, avatars included, goes through one primitive in src/files. It is generic over a small registry of upload kinds, so a new upload flow is an entry in one table rather than another storage path.

ModulePurpose
kinds.tsThe registry: who owns each kind, what it accepts, and how large it can be.
create-upload.tsThe server function that reserves an asset and returns where to send it.
upload.tsThe browser half: reserve, then PUT the bytes.
assets.tsServer-side lookup, completion, and deletion.
access.tsWho may read an asset.
transform.tsImage resizing and re-encoding through the Images binding.
urls.tsBuilding asset URLs, on either side of the wire.

An upload is two steps. createUpload validates the file against its kind and the organization's storage quota, writes an asset row with status pending, and returns /api/files/{id}. The browser then PUTs the bytes to that URL, where the route streams them into R2 and flips the row to uploaded. The file never becomes a server-function payload, so nothing buffers it in memory, and the reservation is what authorizes the raw stream: the caller must own a pending row whose declared size matches the request.

Presigned URLs would remove the second hop from the worker entirely, but the R2 binding cannot sign them (there is no key in the runtime), so they would mean account-level credentials and a real bucket in local development. The streaming PUT behaves identically everywhere instead. A fork that wants presigned uploads only has to change what createUpload returns.

Kinds decide ownership. note-image is scoped to an organization, so its assets are tenant data readable by that organization's members and nobody else, platform staff included. avatar is scoped to the person, and keeps the rule the avatars-only route had before: readable by the owner, by platform staff, and by anyone sharing an organization with them, so a profile photo appears exactly where its owner does.

GET /api/files/{id} accepts w, h, fit, and format, which run through the IMAGES binding. Unrecognised values are dropped rather than rejected, and a transform the binding refuses falls back to the stored bytes, so a hand-edited URL still serves the image. Non-image types are served as attachments so a kind that one day accepts SVG or HTML cannot execute same-origin.

Nothing in D1 reaches into R2, so two sweeps keep the bucket honest: an hourly cron retires uploads whose bytes never arrived, and deleting an organization or an account queues a purge-assets job for everything it owned. Organization storage counts against the plan's storage quota; see @repo/billing.

The command menu

Cmd+K (or Ctrl+K) anywhere in /app opens one surface that both runs common actions and searches the organization. It lives in src/search/:

FileRole
command-menu.tsxThe menu: pages, entries, and what each one does.
functions.tsThe org-scoped search server function and its query.
entries.tsThe Entry and Page types every row reduces to.
shortcuts.tsThe Cmd+K binding and the G-then-key jump shortcuts.
use-debounced.tsTrails the input so a burst of typing is one request.

Destinations come from src/navigation.ts, the one list the sidebar renders too, so the two can never offer different pages and a member never sees a shortcut to a page they cannot open. Each destination's to is typed as LinkProps['to'], so renaming a route stops the build rather than shipping a dead entry.

Rows are all the same shape: an Entry with a label, an icon, and the work it does. Destinations, actions, and search hits differ only in where they come from. A nested page is a value in Page plus a branch that returns its groups; Escape or Backspace on an empty input returns to the root.

Search hits come from the FTS5 primitive in @repo/db/search. The index spans the whole table, so the server function hydrates the keys it returns with a Drizzle select scoped to the active organization: files by organizationId, people through member. That predicate is the tenant boundary, and it is covered by e2e/command-menu.spec.ts, which asserts a user in another organization never appears.

Static entries are matched in the browser with Base UI's locale-aware filter while server hits pass through untouched, because they arrive already matched. That is why the list turns the built-in filter off entirely.

The menu is plain .tsx rather than .tsrx: it is built from Base UI's render-prop children, which the directive syntax cannot express.

The notes editor

/app/notes is a collaborative rich text editor built on Tiptap, bound to the shared Y.Doc from @repo/realtime. Everything lives in src/notes/:

FileRole
extensions.tsThe single extension list. Add or remove editor features here.
editor-toolbar.tsrxThe fixed toolbar above the document.
bubble-toolbar.tsrxThe selection bubble, including the link field.
slash-command.tsThe / menu and its items.
mention.tsThe @ menu, backed by the organization roster.
suggestion-list.tsrxThe popup all three suggestion menus render.
highlight.tsThe lowlight instance and the code block language list.

Two integrations reach outside the editor:

  • Mentions insert a node carrying the member's user id and call notifyMention, which sends a mentioned-in-notes notification through @repo/notifications. The recipient selector is scoped to the caller's organization, so a forged id cannot notify a non-member.
  • Images pasted, dropped, or picked go through the generic file primitive in src/files as the note-image kind, which stores them against the active organization and serves them back to its members. See Files.

Undo and redo call the Collaboration extension's Yjs-aware history; StarterKit's own undoRedo is disabled, because a plain undo stack would revert other people's edits.

Editor packages are listed in optimizeDeps.include in vite.config.ts. Vite's dependency scanner cannot parse .tsrx, so without that list they are discovered one page at a time and the reloads serve mixed chunks. Add new editor dependencies there.

Marketing content

Blog posts and changelog releases are markdown files under content/, defined as content-collections in content-collections.ts and compiled to HTML strings (with Shiki highlighting) at build time, so the Worker never evaluates code at runtime.

  • Add a post: content/posts/<slug>.md with title, summary, and date frontmatter (plus optional tags, featured, author). Its OG share image is generated on the next install or build; add a route entry to og/config.ts if it's new.
  • Add a changelog entry: content/releases/<date>-<slug>.md.
  • The pitch (features, FAQ, stack, pricing copy) and the components live in @repo/ui/marketing and @repo/brand; this app wires routes to them, with collection access in src/lib/content.ts.
  • OG share images are generated at build time by og/generate.ts into public/og/ (gitignored), keyed by route path in og/config.ts with the design in og/template.tsx. Rendering runs in Node (satori + resvg), so the Worker serves them as static assets and ships no rendering code.

Conventions

  • #/ is the path alias for src/.
  • Components with co-located styles use the .tsrx extension and are imported explicitly with it.
  • End-to-end tests live in e2e/ and run in CI, along with bundle size budgets defined in package.json under size-limit.

src/policystack.ts is the single PolicyStack config: it renders the privacy policy at /privacy and the cookie policy at /cookies (localized boilerplate via the site locale), and derives the consent runtime. The banner in src/legal/consent-banner.tsrx runs on the headless useConsent() hooks with the app's own Paraglide copy, and OpenPanel analytics only initialize once the analytics category is granted (src/components/analytics.tsx).

The policyStack() Vite plugin scans src for declared cookies and vendors into src/policystack.gen.ts (committed; drift between declared and actual usage fails the build) and validates the config on every build. Accepted gaps are listed in suppress in vite.config.ts so the decision shows up in review. The demo config names JXD Ltd with a placeholder address: forks replace the company block, review every purpose and retention, and have a lawyer read the rendered output before launch.

On this page