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 CloudflareSet 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:
| Route | Purpose |
|---|---|
_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/$id | Organization creation and invitations. |
api/auth/$, api/files/$id, api/v1/$ | Better Auth, file uploads and downloads, and the public API. |
device | Device-flow approval for CLI login. |
rss.xml, sitemap.xml, robots.txt, llms.txt | Server 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.
| Module | Purpose |
|---|---|
kinds.ts | The registry: who owns each kind, what it accepts, and how large it can be. |
create-upload.ts | The server function that reserves an asset and returns where to send it. |
upload.ts | The browser half: reserve, then PUT the bytes. |
assets.ts | Server-side lookup, completion, and deletion. |
access.ts | Who may read an asset. |
transform.ts | Image resizing and re-encoding through the Images binding. |
urls.ts | Building 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/:
| File | Role |
|---|---|
command-menu.tsx | The menu: pages, entries, and what each one does. |
functions.ts | The org-scoped search server function and its query. |
entries.ts | The Entry and Page types every row reduces to. |
shortcuts.ts | The Cmd+K binding and the G-then-key jump shortcuts. |
use-debounced.ts | Trails 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/:
| File | Role |
|---|---|
extensions.ts | The single extension list. Add or remove editor features here. |
editor-toolbar.tsrx | The fixed toolbar above the document. |
bubble-toolbar.tsrx | The selection bubble, including the link field. |
slash-command.ts | The / menu and its items. |
mention.ts | The @ menu, backed by the organization roster. |
suggestion-list.tsrx | The popup all three suggestion menus render. |
highlight.ts | The 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 amentioned-in-notesnotification 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/filesas thenote-imagekind, 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>.mdwithtitle,summary, anddatefrontmatter (plus optionaltags,featured,author). Its OG share image is generated on the next install or build; add a route entry toog/config.tsif 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/marketingand@repo/brand; this app wires routes to them, with collection access insrc/lib/content.ts. - OG share images are generated at build time by
og/generate.tsintopublic/og/(gitignored), keyed by route path inog/config.tswith the design inog/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 forsrc/.- Components with co-located styles use the
.tsrxextension and are imported explicitly with it. - End-to-end tests live in
e2e/and run in CI, along with bundle size budgets defined inpackage.jsonundersize-limit.
Privacy and consent
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.