All docs

Routes

The application uses Next.js 15's App Router with three top-level route groups: (marketing) for the public landing and docs, (auth) for the sign-in and viewer access flows, and (app) for everything behind a session. Inside the app group, the workspace shell lives at /w/[workspace]/* and the global profile / docs pages live at the top level.

This document walks through every route in the app, what it renders, who can reach it, and what guards protect it. Use it as a map when you're hunting down where a particular UI lives.

Public routes

/ is the marketing landing page. It introduces the product, lists the features, and points the visitor at either the owner sign-in or the viewer access flow. No session required. Renders inside the marketing layout (header + footer + theme toggle).

/sign-in is the owner login page. A single form with email + password. The form submits to POST /api/auth/login, which validates the credentials, applies rate limit + lockout, issues an encrypted session cookie, and returns ok. On success the page redirects to /w which routes the owner into the most recently visited (or first) workspace.

/access is the viewer login. A two-step flow: step one collects the email and calls POST /api/auth/viewer/lookup to see if the email already has any valid ViewerJoin rows. If it does, the UI presents a "Choose workspace" picker and clicking a workspace re-issues a session via POST /api/auth/viewer/select without ever asking for the access code. If it doesn't, the UI prompts for the 16-character access code which goes to POST /api/auth/viewer for verification + session issuance.

/docs is the documentation index, listing every markdown file in this docs/ folder with a short blurb each. /docs/[slug] renders any individual doc with the markdown styling layer (react-markdown + remark-gfm).

Workspace routes (per workspace)

Every authenticated workspace page lives under /w/[workspace]/.... The layout (src/app/(app)/w/[workspace]/layout.tsx) checks the session, resolves the workspace by slug, gates viewer access to workspaces they hold valid joins for, fetches the owner profile to render in the sidebar, and renders the sidebar + topbar shell around the page.

RouteOwnerViewerWhat it shows
/w/[workspace]yesyesHome page: greeting, KPI cards, today's focus, Meetings widget, recent activity, work heatmap
/w/[workspace]/contentyesyesContent table / kanban / calendar with filters
/w/[workspace]/content/[id]yesyesSingle content record drawer with body + metrics
/w/[workspace]/tasksyesyesLocal tasks + Linear board in a tabbed layout
/w/[workspace]/workyesyesDaily work log editor + heatmap
/w/[workspace]/calendaryesyesMonth / week calendar of content + meetings
/w/[workspace]/documentsyesviewers see isShared=true onlyDocument list and detail (Notion / Google Docs / local)
/w/[workspace]/documents/[id]yesyes (if shared)Document detail editor or iframe
/w/[workspace]/sheetsyesviewers see isShared=true onlySpreadsheet list
/w/[workspace]/sheets/[id]yesyes (if shared)CSV / PDF preview, Google Sheets iframe, XLSX download fallback
/w/[workspace]/analyticsyesyesPer-platform charts + tasks-shipped trend
/w/[workspace]/notificationsyesnoUnified inbox
/w/[workspace]/integrationsyesnoPer-workspace integration cards
/w/[workspace]/settingsyesnoIdentity, access code, viewer roster, danger zone

Viewer access to owner-only routes redirects to the workspace home. Pages also double-gate: the layout filters the navigation so viewers don't even see the link in the sidebar, and the page itself redirects if a viewer types the URL directly.

Global app routes

/profile is the owner's profile editor. Display name, avatar upload, plus a list of every workspace the owner has created with a quick-jump link. Owner-only — viewers redirect to /.

/w is a routing-only index. Owners get redirected to their first workspace; viewers get redirected to a workspace they hold a join for. There is no UI at /w itself.

API surface

EndpointMethodWhat it does
/api/auth/loginPOSTOwner login (rate-limited, locked out on repeated failure)
/api/auth/logoutPOSTDestroy session cookie
/api/auth/viewerPOSTViewer login with email + access code
/api/auth/viewer/lookupPOSTReturning-viewer lookup: workspaces this email can re-enter
/api/auth/viewer/selectPOST / PUTPick a workspace, re-issue session
/api/uploadsPOSTImage upload (mime + magic-bytes + owner gate)
/api/spreadsheets/[id]GETServe a local Spreadsheet.data blob (auth + share gated)
/api/integrations/google/startPOSTBegin Google OAuth flow
/api/integrations/google/callbackGETOAuth callback (cookie-state validation)
/api/integrations/google/disconnectPOSTRevoke + clear workspace's Google connection
/api/integrations/notion/imageGETCORS / referrer proxy for Notion-hosted images
/api/linear/proxyGETProxy Linear-hosted file URLs through the workspace's API key
/api/copilotkitPOSTAI assistant runtime (CopilotKit + AG-UI, owner-gated). See AI Assistant

Every mutating REST endpoint calls requireSameOrigin(req) to reject cross-origin POSTs, then the route-specific session and role checks. Every authenticated route also goes through middleware first, which redirects to /sign-in if the session cookie is missing or malformed before the route handler ever runs.

Server actions

Most mutations go through Next's server-action mechanism rather than REST endpoints. Server actions are defined in actions.ts files colocated with the pages that call them, marked with the "use server" directive, and called directly from client components. They share Next's built-in CSRF protection (action ID + Next-Action header).

The pattern: every action calls requireOwner() first as its very first line (or requireWorkspaceAccess(workspaceId) for read-only actions a viewer should be able to make). After the guard, the action validates input with Zod, does the database work, and calls revalidatePath for any path whose cache it just invalidated. Returns a { ok: boolean; error?: string } shape that the client uses to show a toast.

Owner-only actions exist for every section: createTask / updateTaskStatus in tasks/actions.ts; createContent / updateContent / bulkSetStatus in content/actions.ts; syncContentSourcesAction / addHnByLinkAction / addRedditByLinkAction / addDevtoByLinkAction for the integrations; connectLinearAction / createLinearIssueAction / updateLinearIssueAction / archiveLinearIssueAction / postLinearCommentAction for Linear; createMeetingAction / updateMeetingAction / deleteMeetingAction for calendar; saveWorkspaceSettings / rotateAccessCode / addViewerJoinAction / removeViewerJoinsAction / deleteWorkspaceAction for settings; plus the dev.to-specific connectDevtoAction / disconnectDevtoAction. Each one is single-responsibility and small.

The two viewer-readable actions are fetchLinearIssueDetailAction and fetchLinearTeamMetaAction — they call requireWorkspaceAccess instead of requireOwner so the side panel populates sub-issues, comments, and labels for viewers too. Every other action stays locked behind requireOwner.

Middleware

src/middleware.ts runs on every request and does three things: it applies security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP, CORP, HSTS in production), checks for a session cookie on every non-public path (redirecting to /sign-in if missing or malformed), and lets public paths through (marketing, sign-in, viewer access, docs, the OAuth callback, static assets).

The session check in middleware is intentionally cheap — it only verifies the cookie's shape (v2: prefix or legacy body.mac), not the signature or the encryption. The full validation happens in the layout's getSession() call. Middleware is the pre-filter that stops anonymous requests from ever reaching the layout.

Static export

The /docs index, /docs/[slug], and / marketing pages are all force-static so they serve from the CDN edge. Everything inside /w/[workspace]/* is force-dynamic because the data is workspace-scoped and session-dependent — Next can't statically render those.