# RsOpsHub > RsOpsHub is a self-hosted operations hub with a content tracker for Reddit, Hacker News, and dev.to, an analytics dashboard, Linear-synced tasks, a working hours tracker, a daily work log, a calendar with Google Meet, and Google Docs and Sheets. RsOpsHub is a self-hosted, single-owner operations hub built with Next.js, PostgreSQL, and Prisma. It combines a content tracker for Reddit, Hacker News, and dev.to, an analytics dashboard with PostHog referral traffic and Cloudflare edge analytics, task management with Linear sync, a manual working hours tracker, a daily work log, a calendar with Google Calendar meetings and Google Meet, Google Docs and Sheets, Notion pages, and an AI assistant. One owner edits; viewers join read-only with a 16-character access code. Built by Rohan Sharma. ## Product - [Home](https://rsopshub.rohansrma.me/): What RsOpsHub is, its features, and FAQ - [Every post, comment, and reply, tracked on one content page](https://rsopshub.rohansrma.me/solutions/content-tracker): Track Reddit posts, Hacker News submissions, dev.to articles, and blog posts in one content page with table, kanban, calendar, analytics, and report views. Synced metrics and shareable read-only access. - [Analytics that show what actually worked, and what drove traffic](https://rsopshub.rohansrma.me/solutions/analytics-dashboard): A content analytics dashboard with KPI cards and period-over-period change, engagement over time, platform breakdown, posting rhythm heatmap, top performers, tasks shipped, referral traffic, and a detailed PostHog and Cloudflare breakdown down to individual URLs. - [Log your working hours by hand and see the month add up](https://rsopshub.rohansrma.me/solutions/working-hours-tracker): A manual working hours tracker with a daily hours grid, live monthly totals, month-by-month navigation, spreadsheet import, and a read-only view for clients and teammates. Turn it on per workspace. - [A work log that fills itself from what you shipped](https://rsopshub.rohansrma.me/solutions/work-log): A daily work log that auto-fills from closed tasks, Linear issues, and published content, writes a short AI summary of your day, and rolls up into weekly, monthly, and quarterly reports with an activity heatmap. - [Linear issues and your own tasks, in one list](https://rsopshub.rohansrma.me/solutions/linear-tasks): Sync Linear teams, issues, sub-issues, comments, and notifications, and keep a personal task list with priorities and due dates next to them. Board and list views, assignee changes, and an AI assistant. - [One calendar for your posts, deadlines, and meetings](https://rsopshub.rohansrma.me/solutions/calendar-and-meetings): A personal calendar that shows scheduled and published content next to Google Calendar meetings, with month and week views, one-click Google Meet creation, and meeting invites sent from inside the app. - [All your documents and spreadsheets in one searchable place](https://rsopshub.rohansrma.me/solutions/documents-and-sheets): Manage local notes, mirrored Notion pages, Google Docs, Google Sheets, and CSV or XLSX uploads from one workspace, with per-document sharing, read-only viewer access, and encrypted integration credentials. ## Documentation index - [Docs index](https://rsopshub.rohansrma.me/docs): Every guide in one list - [Overview](https://rsopshub.rohansrma.me/docs/overview) - [Setup](https://rsopshub.rohansrma.me/docs/setup) - [Deploy](https://rsopshub.rohansrma.me/docs/deploy) - [Auth](https://rsopshub.rohansrma.me/docs/auth) - [Data model](https://rsopshub.rohansrma.me/docs/data-model) - [Integrations](https://rsopshub.rohansrma.me/docs/integrations) - [Cron](https://rsopshub.rohansrma.me/docs/cron) - [Routes](https://rsopshub.rohansrma.me/docs/routes) - [AI Assistant](https://rsopshub.rohansrma.me/docs/assistant) - [Sitemap](https://rsopshub.rohansrma.me/sitemap.xml) ## Frequently asked questions ### What is RsOpsHub? RsOpsHub is a self-hosted operations hub for one owner. It brings a content tracker for Reddit, Hacker News, and dev.to, an analytics dashboard, Linear-synced tasks, a manual working hours tracker, a daily work log, a calendar with Google Meet, and Google Docs and Sheets into one workspace. ### What can I track with RsOpsHub? Your posts and comments on Reddit, Hacker News, and dev.to with their metrics, the tasks and Linear issues you close, the hours you work each day, the meetings on your Google Calendar, and a daily log of what you shipped. ### What analytics does RsOpsHub have? KPI cards with period-over-period change, engagement over time, referral traffic by source, a platform breakdown, a posting rhythm heatmap, top performers, a status funnel, and a tasks-shipped chart, plus a detailed PostHog and Cloudflare breakdown with URL drill-down. ### Does RsOpsHub integrate with Linear and Google? Yes. Linear syncs issues, sub-issues, comments, and notifications and lets you create and assign issues. Google powers the calendar with Google Meet, and Google Docs and Sheets open inside the app. ### Is RsOpsHub free and self-hosted? RsOpsHub is self-hosted — you deploy it yourself and your data lives in your own Postgres database. There's no per-seat SaaS pricing and no third party has access to your workspace. ## Use cases ### Every post, comment, and reply, tracked on one content page. URL: https://rsopshub.rohansrma.me/solutions/content-tracker The Content page is the center of RsOpsHub. Reddit posts, comments and replies, Hacker News submissions and comments, dev.to articles, blog posts, and social posts all become structured records with a status, platform, metrics, and notes. You can see them as a table, a kanban board, a calendar, an analytics view, or a written report. - Five views of the same records: Switch between Table, Kanban, Calendar, Analytics, and Report without losing your filters. Each view reads the same content records. - Reddit, Hacker News, and dev.to sync: Add your handles once. Hacker News syncs every few hours, dev.to refreshes daily with page views and reactions, and Reddit refreshes on demand. - Add any item by link: Paste a Reddit, Hacker News, or dev.to URL and that single post or comment is added with its score and comment count. - A real publishing pipeline: Move records through Backlog, Idea, Draft, Scheduled, Published, and Archived, change status in bulk, and filter by type, platform, status, or posted date. - Detail drawer for every record: Open any row to edit the title, body, tags, status, and manual metrics, and capture wins, challenges, learnings, and follow-ups as first-class fields. - Written content reports: The Report view summarises a period with a breakdown by platform and by week, plus what readers liked, what they criticised, and the requests and questions in the comments. - Referral traffic next to engagement: The content analytics view overlays referral traffic from Reddit, Hacker News, and dev.to on top of upvotes and comments when traffic tracking is connected. ### Analytics that show what actually worked, and what drove traffic. URL: https://rsopshub.rohansrma.me/solutions/analytics-dashboard The Analytics page turns every content record and every closed task into charts you can filter and compare. Pick a date range, switch between daily and weekly buckets, narrow by platform, status, or type, and every card and chart updates together. Referral traffic is tracked alongside engagement so you can see which post actually sent visitors, and a detailed breakdown pulls live PostHog and Cloudflare data down to a single URL. - KPI cards that compare periods: Records, impressions, upvotes, comments, shares, and traffic are shown for the selected range and compared with the previous period of the same length. - Engagement over time: An area chart of records, impressions, upvotes, and comments by day or week. Click the legend to switch series on and off. - Referral traffic by source: See visits arriving from Reddit, Hacker News, and dev.to as their own series, filtered by the same platform selection as the rest of the page. - Detailed breakdown, down to one URL: Open a live PostHog and Cloudflare dashboard from the traffic chart. Filter by path, source, country, or device, see top pages, referrers, campaigns, and audiences, and click any URL to drill into it. The Cloudflare tab adds requests, cache hit rate, bandwidth, and threats. - Platform breakdown and engagement by platform: A donut that measures either reach or engagement per platform, and a comparison of upvotes, comments, and shares for each one. - Posting rhythm heatmap: A weekday by time-of-day heatmap in your own timezone shows when you publish, so you can test whether timing matters. - Top performers and status funnel: Rank your best records by impressions within the current filter, and see how many records sit in each status from idea to published. - Tasks shipped trend: A zoomable chart that merges local tasks and Linear issues completed, so output sits next to results. - Shareable, read-only: Viewers can open the same dashboard through an access code without being able to change any data. ### Log your working hours by hand and see the month add up. URL: https://rsopshub.rohansrma.me/solutions/working-hours-tracker Not every hour needs a stopwatch. The Hours page is a simple calendar-style log where you type how long you worked each day, and the month total updates as you type. It is built for freelancers, contractors, and founders who report hours at the end of the month and want them in the same workspace as the work itself. - Type hours straight into the month: Every day of the month has an input. Enter the hours and save, with no timer to start or forget to stop. - Live monthly total: The month total recalculates from your draft as you type, so you know where you stand before you save. - Move between months: Step back and forward through previous months to review or correct what you logged. - Import a month from a spreadsheet: Add a whole month of hours at once from a spreadsheet instead of typing day by day. - Off until you need it: Hour tracking is a per-workspace switch in settings. When it is on, an Hours page appears in the sidebar. - Read-only for clients: Viewers can see the logged hours and monthly totals through their access code, and cannot edit them. ### A work log that fills itself from what you shipped. URL: https://rsopshub.rohansrma.me/solutions/work-log The Work page answers one question: what did I ship? Each day it counts the tasks you closed, the Linear issues you completed, and the content you drafted and published, then lets an AI write a short summary you can edit. Over time it becomes a year-long heatmap and a set of weekly, monthly, and quarterly reports you can use for standups, reviews, and client updates. - Auto-filled daily metrics: Tasks done, Linear issues completed, content drafted, and content published are counted from your real activity, with a button to rebuild them on demand. - AI-written summary, yours to edit: A two-sentence recap of the day is generated automatically. If you write your own, it is never overwritten. - Twelve-month activity heatmap: A GitHub-style heatmap shows how intense each day was, built from tasks, Linear closures, and published content. - Weekly, monthly, and quarterly reports: Switch between the last 7, 30, and 90 days to see totals for tasks, Linear, drafted, and published, with an AI recap for the longer ranges. - Missed-day backfill: A prompt lists recent days without a log so you can fill the gaps in a click. - Timezone aware: Days are cut using your own timezone, so late-night work lands on the right date. ### Linear issues and your own tasks, in one list. URL: https://rsopshub.rohansrma.me/solutions/linear-tasks RsOpsHub connects to Linear with your API key and shows your issues in the app, then adds something Linear does not: a personal task list for everything that is not an engineering ticket. Both live on the Tasks page, so today's focus is one list instead of two tabs in two tools. - Linear board and list: Browse synced Linear issues as a board or a list, filter by status, and open any issue in a side panel. - Create and edit issues in place: Create, edit, comment on, assign, and archive Linear issues, and save issue templates, without leaving the app. - Sub-issues and threaded comments: Navigate sub-issues and read or write comments with @-mentions in the issue panel. - Linear notifications in one inbox: Assignments, status changes, and mentions arrive in the notifications inbox and open the issue inside the app. - Personal tasks with priority and due dates: Create your own tasks with a priority, a due date, and a status. Active tasks are sorted by what is due soonest, with Completed and Cancelled kept in their own sections. - Tasks feed the rest of the workspace: Closed tasks and Linear issues count toward the work log, the activity heatmap, and the tasks-shipped chart. - Create tasks by asking: The AI assistant can add a task, change its status, or create and assign a Linear issue from a sentence. ### One calendar for your posts, deadlines, and meetings. URL: https://rsopshub.rohansrma.me/solutions/calendar-and-meetings The Calendar page puts what you are publishing and who you are meeting on the same grid. Scheduled and published content comes from the Content page, meetings come from your Google Calendar, and you can create a new Google Meet without opening Google Calendar at all. - Posts and meetings together: Each day shows content chips and meeting chips, with overflow links, so a launch day and a client call are visible in the same cell. - Month and week views: Toggle between month and week to plan the next few days or the whole quarter. - Google Calendar sync: Connect Google once and the next two weeks of events from your primary calendar appear in the calendar and on the home page. - Create Google Meet calls: Pick a time, add invitees and a description, and the event is created with a Meet link, optionally emailing the attendees. - Edit and cancel from the app: Owners can reschedule or cancel meetings. Viewers can see them and join, but cannot change anything. - A heatmap of what shipped: The home page pairs the calendar with a contribution heatmap of closed tasks, Linear issues, and published content. ### All your documents and spreadsheets in one searchable place. URL: https://rsopshub.rohansrma.me/solutions/documents-and-sheets Documents are one part of RsOpsHub, kept together on the Documents and Sheets pages. Local notes, Notion pages, Google Docs, Google Sheets, and uploaded spreadsheets open inside the app, and you decide which ones viewers can see. Everything is stored in your own Postgres database, with third-party tokens encrypted. - Local rich-text notes: Write documents directly in the app with a rich-text editor and keep them next to your tasks and content. - Notion pages, rendered inline: Connect a Notion parent page and its pages are fetched and shown inside the Documents section, so nobody has to switch tabs. - Google Docs inline: Google Docs open in a preview inside the app, and shared-with-me documents can be claimed into your workspace. - Google Sheets and file uploads: Connect Google Sheets, or upload CSV and XLSX files, which are checked by type and file signature before they are stored. - Share one document at a time: Viewers see only the documents and sheets you mark as shared. A toggle on each item controls access. - Access you can revoke instantly: Rotate the 16-character viewer access code and every existing viewer session ends at once. - Your data, your database: Documents, metadata, and credentials live in your own Postgres database, and integration tokens are encrypted with AES-256-GCM. - Create documents by asking: The AI assistant can create a local note, or a real Notion page or Google Doc when that integration is connected. ## Documentation # Overview ![rsopshub banner](/banner.png) RsOpsHub is the operating system for content and work. It is a self-hosted, single-owner workspace that pulls everything an indie operator, technical founder, or content-heavy team relies on into one place: drafted and published posts across Reddit / Hacker News / dev.to, blog drafts and analytics, live site traffic from PostHog and Cloudflare, project work in Linear, documents from Notion and Google Docs, spreadsheets from Drive, daily work logs, and a unified calendar that includes Google Meet meetings created right from the app. There is no public sign-up, no third-party SaaS sitting between you and your data, and no per-seat pricing. You install it once, point your integrations at it, and from then on your daily ops live inside one editorial-looking app you fully control. ## The problem it solves Most of the people I built this for already have a working stack — Notion for docs, Google Drive for spreadsheets and slides, Linear for engineering tickets, Reddit and Hacker News for community work, dev.to or a blog for writing, a Google calendar for meetings, and a notebook (literal or in their head) for daily todos. Each one is fine in isolation. Combined, they cost you twenty minutes a day just switching tabs, copying URLs, refreshing dashboards, and reconstructing yesterday's work for today's standup. RsOpsHub fixes that by mirroring the data you care about into one Postgres database, rendering it inside a single fast UI that knows your shape, and writing back to the source provider when it makes sense (creating a Linear issue, scheduling a Meet, archiving a sheet) so the original tools stay the system of record. ## Who it's for The single-owner shape is deliberate. There is exactly one owner per deployment, defined by `OWNER_EMAIL` + `OWNER_PASSWORD` in your environment. You then invite as many viewers as you want with a 16-character access code per workspace. Owners have full read / write; viewers are read-only by design. The model fits four shapes especially well: - **Solo operator** running their own startup, content brand, or side project who wants every signal in one place. - **Founder + team** where the founder owns ops and the team needs visibility into roadmaps, content cadence, and shipping rhythm without write access. - **Agency lead** who needs to share progress with clients in a clean read-only view per client (one workspace per client). - **Internal tooling** for a small team that doesn't want to pay enterprise tier prices on five different SaaS dashboards. If you need multi-owner editing with role-based permissions, RsOpsHub is not the right tool — it intentionally trades the complexity of a multi-tenant identity system for the simplicity of one trusted owner. ## The mental model The application is organised around the workspace. A workspace is a self-contained world with its own access code, viewer roster, integration credentials, and data: every content record, task, work log, document, spreadsheet, calendar event, Linear issue, and notification belongs to exactly one workspace. Nothing is shared across workspaces — not even integrations — so you can run an "Acme client" workspace next to a "Personal" workspace and a "Side project" workspace and they never bleed into each other. The owner navigates between workspaces from the sidebar's workspace switcher; viewers see only the workspaces they have an active join for. Within a workspace, the navigation maps to the way most operators actually think about their day: **Home** as the quick-glance dashboard, **Content** as the publishing log, **Tasks** as the local + Linear backlog, **Work** as the daily journal, **Calendar** as the unified time view, **Documents** and **Sheets** as the file mirrors, **Analytics** as the trend view, and **Notifications** as the inbox for everything that wants your attention. Each section is one page; you never have to drill three levels deep. ## What's in a workspace | Section | Purpose | | ---------------- | ---------------------------------------------------------------- | | **Home** | Today's focus + activity heatmap | | **Content** | Reddit / HN / dev.to posts and comments + blog records | | **Tasks** | Local tasks alongside synced Linear issues | | **Work** | Daily work log with AI-generated summaries | | **Calendar** | Month / week view of scheduled + published content | | **Documents** | Local notes + mirrored Notion pages + Google Docs | | **Sheets** | Local files + Google Sheets | | **Analytics** | Engagement charts, referral traffic, PostHog + Cloudflare breakdown | | **Notifications**| In-app inbox for Linear, Google share offers, internal events | | **Settings** | Identity, access code, viewer joins, integration IDs | | **Integrations** | Per-workspace API keys + sync controls | ## Feature tour ### Home Personalised greeting, four KPI cards (Published, Scheduled, In draft, Impressions), today's focus list combining local tasks and Linear issues you own, a Meetings widget that lists upcoming Google Meet events with one-click Join links, a recent activity feed enriched with the actual content / task title (not just "Created a task"), and a year-long heatmap of work intensity computed from your task completions, Linear closures, and content publishes. The Meetings widget is also the entry point for creating a new Google Meet straight from the app — pick a time, invitees, optional description, and the event lands on your primary calendar with a Meet conference attached. ### Content A single editorial dashboard for every piece of content you have ever shipped. The Content page consolidates Reddit posts and comments (via your handle), Hacker News submissions and replies (via your handle), dev.to articles (full markdown body, page views, reactions, comments, organisation publication — pulled with your API key when available), and any other blog posts you add manually. You can switch between a sortable table view, a kanban board grouped by status (Idea / Draft / Scheduled / Published / Archived), and a calendar view that overlays content with meetings. Every row is editable: titles, status, body, tags, manual metrics. The integration sync is non-destructive — your manually-entered share counts on a public dev.to article are never overwritten by a refresh, because the API doesn't surface that field anyway. ### Tasks A two-tab layout. The **Local tasks** tab is a fast keyboard-driven list of tasks you've typed in yourself, with priority, due date, and status. The **Linear** tab is a synced view of your Linear issues with full board / list toggle, status filters, sub-issue navigation, comment threading with @-mention, and a side panel that mirrors Linear's own editor. Owners can create, edit, comment, archive, and template-save without leaving the app; viewers see the same data read-only so they can follow along. ### Work tracker A daily journal that combines a free-form summary, structured metrics (tasks shipped, content published, Linear closed, hours logged), and the heatmap. Each day's entry is generated automatically at 23:55 local time using OpenAI (if `OPENAI_API_KEY` is set) summarising the activity, but you can always type your own version — manual summaries are never overwritten by the cron. The auto-fill button reconstructs the day's metrics from the actual data on demand. ### Calendar Month or week view (toggle on the calendar page; the content page keeps month-only). Each day cell shows up to three content chips and two meeting chips, with overflow links and click-to-expand. Meetings come from Google Calendar (via OAuth); content comes from your publishing schedule. Owners can create new Google Meet calls; viewers can join existing ones. ### Documents Local rich-text notes, mirrored Notion pages, and Google Docs files side-by-side. Notion content is fetched on-demand and rendered inline (no need to switch tabs). Google Docs open inside an iframe using the `/preview` URL so viewers don't get stuck on Google's sign-in wall. Owners can pre-grant viewer access per document with a toggle. ### Sheets Local CSV / XLSX uploads (validated by mime + magic-bytes), Google Sheets via OAuth, plus any sheets shared with the connected Google account that surface as claim-able offers. Per-sheet share toggle works the same way as documents. ### Analytics Time-series charts per platform (impressions, upvotes, comments) plus a tasks-shipped line that merges local task completions with Linear issue closures. The date filter controls every chart at once. With PostHog connected, a Referral traffic chart shows which platform actually sent visitors. The **Detailed breakdown** button on the traffic chart opens a live dashboard over PostHog and Cloudflare: pageviews and visitors over time, top pages, full URLs, referrers, UTM campaigns, countries, devices, browsers, OS, and custom events, plus a Cloudflare tab with requests, cache hit rate, bandwidth, threats, status codes, and TLS / protocol mix. Filter by path, source, country, or device, and click any URL to drill into exactly that page. See [Integrations](06-integrations.md#detailed-breakdown) for the details. ### AI assistant A conversational layer over the whole app, launched from a floating button in the bottom-right (or ⌘/Ctrl + J). Tell it what you want — "add a task to prepare the Q3 investor update by next Friday", "show me analytics for the last three days", "turn on hour tracking" — and it executes the action, renders the result inline (task cards, charts, confirmation prompts), and only asks a follow-up when something genuinely can't be inferred. It's built on CopilotKit + AG-UI with a fully custom UI that matches the product, and it's owner-only since every action it can run is owner-gated. See [AI Assistant](09-assistant.md) for the architecture and how to extend it. ### Notifications A unified inbox for inbox-worthy signals across sources: Linear (assigned to you, status changed, mentioned), Google Docs / Sheets shared-with-me offers, internal viewer joins, system messages. Each row deep-links into the in-app view so a Linear notification opens the issue's side panel inside Tasks, not Linear's web app. Owners can mark read / unread / read-all. ### Profile Owners set a display name and avatar that viewers see at the top of `/access` and inside the sidebar of every workspace. Workspace list is presented with logos / emoji marks, click-to-open. Viewers always know whose hub they're inside. ### Settings Per-workspace identity (name, logo, slug, color, description), access code rotation, viewer roster with add / remove, integration IDs (Notion parent page, Google folder), and a Danger zone that demands the owner password before deleting a workspace. ### Integrations Per-workspace credentials for the providers that need it (Linear API key, dev.to API key, PostHog API key, Cloudflare API token, Google OAuth, dev.to / HN / Reddit handles). Global env-driven credentials for what's truly shared (Notion token, Google OAuth client, OpenAI key). ## How it compares Nothing else does exactly this, but plenty of tools overlap with a slice of it. They fall into four groups, and each one is excellent at its own slice: - **Generic workspaces** (Notion, Coda, ClickUp) store docs, tables, and tasks well. Content distribution, traffic, and daily ops are something you build yourself out of databases and paid add-ons. - **Daily planners** (Sunsama, Akiflow, Motion) pull tasks and calendars into one timeline. They know nothing about posts, metrics, or traffic, and they're built for one person, not for sharing with viewers. - **Social schedulers** (Buffer, Typefully, Hypefury, Publer) write and publish posts to X, LinkedIn, and friends. They stop at their own networks, so Reddit, Hacker News, and dev.to aren't covered, and the numbers never meet your task list or work log. - **Web analytics** (PostHog, Plausible, Cloudflare's own dashboard) tell you exactly who visited which URL. They have no idea which post sent the visitor or what you shipped that week. RsOpsHub sits where those four overlap. It doesn't replace them. Linear, Notion, Google, PostHog, and Cloudflare stay the system of record, and RsOpsHub is the one screen that reads them all together and writes back where it makes sense. In the tables below, **yes** means the workflow works end to end out of the box, **partial** means it's possible with a paid tier, an add-on, or a DIY setup, and **no** means it isn't there. The question is whether the workflow exists, not whether some half version of the feature ships. ### At a glance One table for the whole picture. The four tables after it break each row down into individual capabilities. | Area | RsOpsHub | Notion | ClickUp | Sunsama / Akiflow | Buffer / Typefully | PostHog / Plausible | |---|---|---|---|---|---|---| | Track Reddit, Hacker News, and dev.to posts with metrics | yes | no | no | no | no | no | | Write and auto-publish posts to social networks | no | no | no | no | yes | no | | Tasks together with Linear issues | yes | partial | partial | yes | no | no | | Daily work log and hours tracking | yes | partial | partial | partial | no | no | | Calendar with in-app Google Meet | yes | partial | partial | partial | partial | no | | Docs and sheets (Notion, Google Docs, Google Sheets) | yes | partial | partial | no | no | no | | Referral traffic tied to the post that sent it | yes | no | no | no | partial | yes | | URL-level analytics and Cloudflare edge data | yes | no | no | no | no | partial | | Funnels, session replay, experiments | no | no | no | no | no | yes | | AI assistant that acts on your data | yes | yes | yes | no | partial | partial | | Read-only viewers with no per-seat cost | yes | partial | partial | no | no | partial | | Separate integration accounts per client workspace | yes | partial | partial | no | partial | partial | | Self-hosted, data in your own Postgres | yes | no | no | no | no | yes | | Pricing shape | your own infra | per seat | per seat | per user | per channel / user | usage or pageviews | | Best at | running content, work, and traffic from one screen | team wikis and docs | team project management | daily planning | scheduling social posts | product analytics | Reading down the RsOpsHub column, the only "no" rows are things it deliberately leaves to other tools: it doesn't publish posts for you and it doesn't do product experimentation. Every other column has far more "no" and "partial" rows, because each of those tools is built around one slice. ### Content and distribution | Capability | RsOpsHub | Notion | ClickUp | Sunsama / Akiflow | Buffer / Typefully | PostHog / Plausible | |---|---|---|---|---|---|---| | Reddit and Hacker News posts + comments mirrored with metrics | yes | no | no | no | no | no | | dev.to articles mirrored with views, reactions, and comments | yes | no | no | no | no | no | | Idea → draft → scheduled → published pipeline (table, kanban, calendar) | yes | partial | partial | no | yes | no | | Add any post by pasting its link | yes | no | no | no | no | no | | Writing and auto-publishing posts to social networks | no | no | no | no | yes | no | ### Work and planning | Capability | RsOpsHub | Notion | ClickUp | Sunsama / Akiflow | Buffer / Typefully | PostHog / Plausible | |---|---|---|---|---|---|---| | Local tasks and Linear issues in one place | yes | partial | partial | yes | no | no | | Full Linear editing (sub-issues, comments, templates) without leaving the app | yes | no | no | partial | no | no | | Daily work log written by AI from what actually happened | yes | partial | partial | partial | no | no | | Manual hours tracking | yes | partial | yes | yes | no | no | | One calendar for content + meetings, Google Meet created in-app | yes | partial | partial | partial | partial | no | | Notion pages, Google Docs, and Google Sheets side by side | yes | partial | partial | no | no | no | ### Analytics | Capability | RsOpsHub | Notion | ClickUp | Sunsama / Akiflow | Buffer / Typefully | PostHog / Plausible | |---|---|---|---|---|---|---| | Engagement charts across Reddit, HN, and dev.to | yes | no | no | no | partial | no | | Referral traffic attributed to the platform that sent it | yes | no | no | no | partial | yes | | URL-level drill-down (sources, countries, devices per page) | yes | no | no | no | no | yes | | Cloudflare edge data (requests, cache, threats) next to product analytics | yes | no | no | no | no | no | | Content metrics, site traffic, and shipped work on one timeline | yes | no | no | no | no | no | | Funnels, session replay, feature flags, experiments | no | no | no | no | no | yes | ### AI, sharing, and ownership | Capability | RsOpsHub | Notion | ClickUp | Sunsama / Akiflow | Buffer / Typefully | PostHog / Plausible | |---|---|---|---|---|---|---| | AI assistant that acts on your data, not just writes text | yes | yes | yes | no | partial | partial | | Read-only viewers with no per-seat cost | yes | partial | partial | no | no | partial | | Different integration accounts per client workspace, one login | yes | partial | partial | no | partial | partial | | Self-hostable, data in your own Postgres | yes | no | no | no | no | yes | | Pricing shape | your own infra | per seat | per seat | per user | per channel / user | usage or pageviews | ### When something else is the better pick - You need many people editing the same docs and wikis: use **Notion**. - You need sprints, Gantt charts, and permissions for a whole team of editors: use **ClickUp** or **Linear** directly. - You want a guided time-blocking ritual every morning: use **Sunsama** or **Akiflow**. - You want to write threads and have them published on a schedule: use **Buffer** or **Typefully**. RsOpsHub tracks what you published; it doesn't post for you. - You need funnels, session replay, or A/B tests: use **PostHog** itself. RsOpsHub reads PostHog and Cloudflare for the questions an operator asks every day, and links out to them for the deep product analytics. ### Why the gap exists None of this is laziness on the competitors' part; it's structural. A SaaS product has to serve every customer from one credential pool and one schema, so it can't let you plug a different Linear key, PostHog project, or Cloudflare zone into each client workspace, and it can't justify building first-class support for niche sources like Hacker News. It also has to charge per seat, so a read-only viewer either costs the same as an editor or doesn't exist. RsOpsHub skips both constraints because it's single-owner and self-hosted. The same architecture that limits it to one operator is what lets it ship the features the SaaS products can't. ## The trade-offs Honesty section. RsOpsHub is intentionally single-owner: there is no multi-tenant identity layer, no SSO, no row-level permissions inside a workspace beyond "owner / viewer". If you outgrow that, you outgrow the tool. It is also self-hosted: you bring your own Postgres, your own host, your own OAuth client. There is no cloud version, no marketplace, no plug-and-play install. And it's a personal-scale app — the in-memory rate limiter, the in-memory token cache, and the in-memory cron all assume a single server instance per deployment, which is the right shape for almost all individual operators and small teams but the wrong shape for genuinely high-traffic workloads. ## How to get access RsOpsHub is not available as a public download or a hosted SaaS. To get a deployment running for yourself or your team, reach out to the author **Rohan Sharma** at [rohansrma.me](https://rohansrma.me) — include a sentence on what you'd use it for and the size of your team, and you'll get the codebase, a setup walkthrough, and ongoing support. If those trade-offs are fine and you've got access, keep reading. The [Setup guide](02-setup.md) gets you from a clone to a running local dev server in under ten minutes. # Setup This guide takes you from a fresh clone to a working local development server. It covers the runtime requirements, the full env variable list, the Postgres install on Windows / macOS / Linux, the Prisma schema push and seed, and the production build commands. Read it once in order; the next time you onboard a machine you can skim straight to the commands. ## Requirements You need three things on the machine: Node 20 or newer, Bun for installs and the dev server, and Postgres 14 or newer for storage. Bun replaces `npm install` and `npm run dev` because it's substantially faster at both and the rest of the app's tooling (Prisma, Next, scripts) works transparently with it. If you prefer `npm` or `pnpm` they work fine too — just substitute the package manager in every command below. Node ships with `corepack` for managing package managers, but the simplest install on macOS / Linux is via [nvm](https://github.com/nvm-sh/nvm) and on Windows via [nvm-windows](https://github.com/coreybutler/nvm-windows). Bun comes from [bun.sh](https://bun.sh) with a one-liner installer. Postgres has its own section below. ## Clone and install ```bash # Use the repository URL you received from Rohan Sharma # (https://rohansrma.me) when you got access. git clone rsopshub cd rsopshub bun install ``` The install pulls Prisma's binaries, the Next.js framework, and every UI library the app needs. Expect a couple of hundred megabytes in `node_modules`; that's normal for a Next 15 app. ## Postgres on Windows If you're on Windows and don't already have Postgres running, the most reliable path is the official EDB installer. Download Postgres 16 (or 14+ if you have a reason) from [postgresql.org/download/windows/](https://www.postgresql.org/download/windows/). Run the installer with these choices: install location default, data directory default, set a password for the `postgres` superuser (write it down — you'll put it in `.env.local`), port `5432` (the default), locale `Default locale`. After install, open **pgAdmin** (it ships with the installer) and connect to the local server. Right-click **Databases → Create → Database** and create one called `rsopshub`. That's the entire UI setup; everything else happens via Prisma. If you'd rather create the database from the command line, open a terminal after install and run: ```bash "C:\Program Files\PostgreSQL\16\bin\psql.exe" -U postgres -c "CREATE DATABASE rsopshub;" ``` Your `DATABASE_URL` in `.env.local` will then be `postgresql://postgres:@localhost:5432/rsopshub`. If your password contains special characters, URL-encode them — `@` becomes `%40`, `#` becomes `%23`, and so on. ## Postgres on macOS The fastest path is Homebrew: ```bash brew install postgresql@16 brew services start postgresql@16 createdb rsopshub ``` Brew sets up your user account as a superuser-equivalent role so you don't need a password locally. Your `DATABASE_URL` becomes `postgresql://@localhost:5432/rsopshub`. If you want a password-based setup for parity with production, run `psql postgres` and then `ALTER USER WITH PASSWORD '';` and use the same form as the Windows example. ## Postgres on Linux On Debian / Ubuntu: ```bash sudo apt update sudo apt install postgresql postgresql-contrib sudo systemctl enable --now postgresql sudo -u postgres psql -c "CREATE USER rsopshub WITH PASSWORD 'changeme';" sudo -u postgres psql -c "CREATE DATABASE rsopshub OWNER rsopshub;" ``` Your URL is then `postgresql://rsopshub:changeme@localhost:5432/rsopshub`. On Arch: `pacman -S postgresql` followed by the standard `initdb`, `systemctl enable --now postgresql`, then the same `psql -c` commands as Debian. On Fedora: `dnf install postgresql-server`, then `postgresql-setup --initdb`, then `systemctl enable --now postgresql`. ## Environment variables Copy the example file: ```bash cp .env.example .env.local # macOS / Linux Copy-Item .env.example .env.local # PowerShell on Windows ``` Open `.env.local` and fill in the variables. There are six required values, and a handful of optional ones that unlock specific integrations. The application refuses to boot in production when any required value is missing; in development it falls back to placeholder strings and logs a warning so you can iterate quickly. ### Required variables | Variable | Purpose | Generate / source | |---|---|---| | `DATABASE_URL` | Postgres connection string | Your Postgres host + the database you created | | `OWNER_EMAIL` | The only email allowed to sign in as owner | Pick any email | | `OWNER_PASSWORD` | Owner password (≥ 12 chars) | Pick a strong passphrase | | `SESSION_SECRET` | HMAC key for signing session cookies (≥ 32 chars) | `openssl rand -base64 48` | | `TOKEN_ENC_KEY` | AES-256-GCM key for encrypted secrets at rest (≥ 32 chars) | `openssl rand -base64 48` | | `APP_URL` | Canonical URL of the running app | `http://localhost:3000` for dev, real domain in prod | Detail on each below. `DATABASE_URL` is the Postgres connection string. It must be reachable from the machine running the dev server; for a local Postgres install on the same machine the host is `localhost`. Special characters in the password must be percent-encoded. `OWNER_EMAIL` is the only email address that can sign in as the owner. The app compares the typed email in constant time against this value, so you can use any email format you want — it never has to match an external service. Lowercase it for consistency. `OWNER_PASSWORD` is the owner's password. It must be at least 12 characters; longer is better. Treat it like the root password to the whole deployment because that's what it is. The app hashes it with scrypt before comparing, so it never touches the database in plaintext. `SESSION_SECRET` is the HMAC key used to sign session cookies. Generate a fresh value with `openssl rand -base64 48` and paste at least 32 characters of it. Rotating this value invalidates every existing session on every device. `TOKEN_ENC_KEY` is the AES-256-GCM key used to encrypt third-party API tokens (Linear, dev.to, Google refresh tokens) at rest in your database, and the same key encrypts the session cookie payload. Generate it with `openssl rand -base64 48` and use at least 32 characters. Rotating it makes every previously-encrypted token unreadable — you'll have to re-connect every integration — so write it down somewhere safe. `APP_URL` is the canonical URL of the running app. Use `http://localhost:3000` for local development; in production this is your real domain. Google OAuth uses this value to construct the redirect URI, so the value you set here must match the redirect URI you register in the Google Cloud console exactly. ### Optional variables | Variable | Unlocks | |---|---| | `NOTION_TOKEN` | Notion document mirroring inside `/documents` | | `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` | Google Docs / Sheets / Drive / Calendar / Meet | | `OPENAI_API_KEY` | AI-generated daily work-log summaries (falls back to template) | `NOTION_TOKEN` is a Notion integration token. Create one at [notion.so/profile/integrations](https://www.notion.so/profile/integrations); pick "Internal integration", give it read access to the pages you want mirrored, then copy the secret. Set it and Documents will gain a Notion provider. `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` together unlock the Google integrations (Docs, Sheets, Drive, Calendar / Meet). Create an OAuth 2.0 client of type "Web application" in [Google Cloud Console](https://console.cloud.google.com), authorise the redirect URI `/api/integrations/google/callback`, enable the **Drive API**, **Docs API**, **Sheets API**, and **Calendar API** in the same project, and paste the client ID and secret here. `OPENAI_API_KEY` is used for the AI-generated daily work-log summaries. Without it the worklog falls back to a deterministic template summary; setting it switches to `gpt-4o-mini` for richer prose. There is **no** `DEV_TO_API_KEY` or `LINEAR_API_KEY` in the env. Both of those integrations are per-workspace and the credential lives encrypted in the database. The workspace's integrations page lets each owner connect their own — every integration (Notion, Google, Hacker News, Reddit, dev.to, Linear, PostHog, Cloudflare) is managed from that single page. ## Apply the schema and seed Prisma reads `prisma/schema.prisma` and creates the matching tables in Postgres: ```bash bunx prisma db push ``` This is the right command for first-time setup and for iterating in development. In production you should use proper migrations (`bunx prisma migrate dev` to create one, `bunx prisma migrate deploy` to apply on the production database) so you have a versioned migration history; `db push` skips that step. Once the schema is in place, seed it with a default workspace: ```bash bunx prisma db seed ``` The seed script creates one workspace called "Personal" with a generated access code so the owner can sign in immediately and start populating data. If you want a different starting state, edit `prisma/seed.ts` before running the seed. ## Run the dev server ```bash bun run dev ``` The first request triggers a Next.js compile (about ten seconds the first time, sub-second after that) and starts the in-process cron schedules. Open [http://localhost:3000](http://localhost:3000), click **Owner sign in**, and use the email + password you put in `.env.local`. You're in. ## Production build When you're ready to deploy: ```bash bun run build bun run start ``` `build` produces an optimised Next standalone build in `.next/`. `start` runs it on port 3000 by default — set `PORT` to override. In production, set `NODE_ENV=production`, set every required env variable for real (not the dev placeholders), and run behind a reverse proxy (nginx, Caddy, Vercel, Fly, Railway — see the [Deploy](03-deploy.md) doc) that terminates TLS and forwards to the Next server. ## Troubleshooting If Prisma complains it cannot connect, double-check the `DATABASE_URL` host, port, user, and password. The most common Windows mistake is forgetting to URL-encode `@` in the password. If the dev server boots but the integrations don't work, look at the warning the env parser printed on boot; one of your required values is probably wrong. The app logs which variable failed validation and why. If you uploaded an `OWNER_PASSWORD` that was less than 12 characters and the app is refusing to boot in production, change it in env, restart, and the next sign-in will work. Already-issued sessions die when `OWNER_PASSWORD` changes because the session fingerprint stops matching. If Google Docs / Sheets embeds show **"Allow Google Docs access to your necessary cookies"** or **"Can't access your Google Account"**, Chrome's third-party cookie block is hiding the `docs.google.com` session from the iframe. Open **Chrome → ⋮ → Settings → Privacy and security → Third-party cookies** and add `[*.]google.com` to *Sites allowed to use third-party cookies*. During local development also add `[*.]localhost` (or whatever host you run the dev server on) so the parent page's cookies aren't blocked either. See [docs/06-integrations.md → Google Docs and Sheets](06-integrations.md#google-docs-and-sheets) for the full rationale. # Deploy RsOpsHub is a normal Next.js 15 standalone app with a Postgres dependency. You can deploy it anywhere that can run a Node 20 server and reach Postgres. This guide walks through the four most popular hosting paths, plus how to migrate your local Postgres database to a managed cloud database when you're ready for production. Pick whichever platform fits your taste — the application doesn't lock you into any of them. ## Platforms at a glance If you're just trying to pick a host, the table below summarises the trade-offs. The detailed sections below each platform cover the actual commands. | Platform | Free tier | In-process cron works | Postgres included | Best for | |---|---|---|---|---| | Vercel | yes | needs Vercel Cron Jobs | no (pair with Neon / Supabase) | Hobby deployments + ones already on Vercel | | Fly.io | hobby plan ($) | yes | optional Fly Postgres | Full-control deployments that want a real VM | | Railway | trial credit | yes | one-click Postgres plugin | Fastest "click deploy" with bundled Postgres | | Render | free web service | yes | managed Postgres add-on | Heroku-style developer experience | | Self-host (VM) | depends on provider | yes | install Postgres locally or remote | Anyone who already runs a server | | Docker | depends on host | yes (with the right runtime) | use a sidecar postgres service | Air-gapped or compliance-heavy environments | ## Build outputs Before deploying anywhere, you need to understand what the build produces. `bun run build` compiles the app into `.next/`. By default Next produces a standard server build that requires the whole `node_modules` directory at runtime; that works for VM-style hosts (Fly, Railway, your own server). For platforms that prefer a smaller image (Docker, some serverless hosts) you can switch `next.config.ts` to `output: "standalone"` and Next will produce a self-contained `.next/standalone/` folder with only the modules it actually needs. Both options are fine; pick whichever matches your host. The in-process cron schedule (HN sync, dev.to refresh, daily worklog) needs the server process to stay alive across HTTP requests. That rules out short-lived serverless runtimes like the AWS Lambda free tier or Cloudflare Workers' free plan if you want the cron to fire. Vercel, Fly, Railway, Render, and any classic VM keep the process alive long enough. ## Vercel Vercel is the closest-to-zero-config option. Connect your private repository, set the environment variables in the project's settings, and Vercel handles the build, the CDN, and TLS automatically. The catch is that Vercel's serverless functions short-circuit `node-cron`, so the daily worklog and dev.to refresh won't fire unless you replace them with Vercel Cron Jobs. To do that, add a `vercel.json` like the example below, expose a tiny `/api/cron/` route for each job, and protect each route with a shared secret check. ```json { "crons": [ { "path": "/api/cron/hn-sync", "schedule": "0 */3 * * *" }, { "path": "/api/cron/devto-sync", "schedule": "30 3 * * *" }, { "path": "/api/cron/daily-worklog", "schedule": "55 23 * * *" } ] } ``` The cron routes should call the same service functions the in-process scheduler does (`syncHackerNewsForWorkspace`, `syncDevtoForWorkspace`, etc.) and check `req.headers["x-vercel-cron"]` to confirm Vercel is the caller. Vercel runs cron jobs in UTC, so adjust the schedule if you care about local time. For Postgres you'll either point `DATABASE_URL` at Neon / Supabase (recommended — see the migration section below) or accept that the database lives on a separate provider from the app. ## Fly.io Fly is the most "real server" option that still has a friendly developer experience. The provided `bun run build` + `bun run start` works on Fly without modification. Generate a `fly.toml` with `fly launch`, accept the defaults, then add an internal `[deploy]` step that runs `bunx prisma migrate deploy` on every release so your schema stays in sync. Set the env variables with `fly secrets set OWNER_EMAIL=… OWNER_PASSWORD=… SESSION_SECRET=… TOKEN_ENC_KEY=… DATABASE_URL=…`. The in-process cron works as expected because the Fly machine is a real long-running VM. For Postgres on Fly, you can use `fly postgres create` to spin up a managed Postgres cluster on the same network, then attach it with `fly postgres attach`. Fly's Postgres offering is solid for small workloads. For larger ones, point `DATABASE_URL` at Neon or Supabase and skip Fly Postgres entirely. ## Railway Railway is the most batteries-included option. Connect your private repository, add a Postgres plugin from the marketplace (Railway provisions one in seconds and injects `DATABASE_URL` automatically), set the rest of the env variables in the project's Variables tab, and deploy. The build command is `bun run build`, the start command is `bun run start`. The in-process cron works on Railway because the process is long-running. If you'd rather use Neon / Supabase for Postgres, skip the marketplace plugin and paste your external `DATABASE_URL` instead — both work identically. ## Self-host (DigitalOcean / Hetzner / your own server) If you want full control, deploy to any VM with Node 20 and a reverse proxy. The standard recipe is: provision an Ubuntu 22.04 or 24.04 box, install Node and Bun, install Postgres (or point at a cloud Postgres), clone the repo, install dependencies, build, and run `bun run start` behind nginx or Caddy with a Let's Encrypt certificate. Use `pm2` or a systemd unit to keep the process alive: ```ini # /etc/systemd/system/rsopshub.service [Unit] Description=RsOpsHub Next.js server After=network.target [Service] WorkingDirectory=/srv/rsopshub Environment=NODE_ENV=production EnvironmentFile=/srv/rsopshub/.env.local ExecStart=/usr/bin/bun run start Restart=on-failure User=rsopshub [Install] WantedBy=multi-user.target ``` Reload with `systemctl daemon-reload && systemctl enable --now rsopshub` and you have a Postgres-backed Next app running on port 3000 ready to be fronted by nginx. The in-process cron works perfectly here because the server process literally never restarts unless you tell it to. ## Docker If your host wants a container, the simplest Dockerfile is below. Build with `docker build -t rsopshub .` and run with `docker run -p 3000:3000 --env-file .env.local rsopshub`. For Docker Compose with Postgres, drop in a stock `postgres:16` service alongside. ```dockerfile FROM oven/bun:1 AS deps WORKDIR /app COPY package.json bun.lockb ./ RUN bun install --frozen-lockfile FROM oven/bun:1 AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN bun run build FROM oven/bun:1 AS run WORKDIR /app ENV NODE_ENV=production COPY --from=build /app ./ EXPOSE 3000 CMD ["bun", "run", "start"] ``` ## Migrating Postgres to Neon [Neon](https://neon.tech) is a serverless Postgres provider with a generous free tier that's perfect for personal RsOpsHub deployments. Sign up, create a project (pick the region closest to your app host), and copy the connection string Neon gives you. Then back up your local database and restore it: ```bash pg_dump postgresql://postgres:password@localhost:5432/rsopshub > rsopshub.sql psql "" < rsopshub.sql ``` Neon's connection string already includes `sslmode=require` which is what Prisma expects. Paste the full URL into `DATABASE_URL`, redeploy, and you're now running against Neon with no other code changes. The first time the app connects, Neon spins up a compute instance (cold start ~500 ms). For the second-and-later request the connection is pooled. If you see Prisma complain about prepared statements after enabling pooling, switch the Prisma client to use Neon's pooler endpoint (the URL ending in `-pooler`) and add `?pgbouncer=true&connection_limit=1` to the connection string — those are the standard Prisma + PgBouncer flags. ## Migrating Postgres to Supabase [Supabase](https://supabase.com) gives you Postgres plus a bunch of extras (auth, storage, edge functions) you don't need for RsOpsHub, but their free Postgres tier is excellent and the dashboard is friendly. Create a Supabase project, grab the connection string from **Project settings → Database → Connection string**, and use the "Session mode" string (port 5432) for Prisma. Then dump and restore exactly like the Neon path: ```bash pg_dump postgresql://postgres:password@localhost:5432/rsopshub > rsopshub.sql psql "" < rsopshub.sql ``` Set `DATABASE_URL` to the Supabase connection string in your host's env variables and redeploy. The "Session mode" string is the right one for Prisma; the "Transaction mode" string (port 6543, pooled by PgBouncer) requires `?pgbouncer=true` and disables prepared statements which Prisma can handle as long as you add the flag. ## Migrating to any other managed Postgres The pattern is identical for AWS RDS, Google Cloud SQL, Azure Database for PostgreSQL, DigitalOcean Managed Databases, Render Postgres, and Heroku Postgres. Provision the database, dump from your source with `pg_dump`, restore with `psql`, and update `DATABASE_URL`. If the new host requires SSL (most do), add `?sslmode=require` to the connection string. ## Custom domain and TLS Whatever host you pick, point your domain's `A` / `AAAA` / `CNAME` record at the host, then enable TLS. Vercel, Fly, Railway, and Render handle the certificate automatically. On a self-hosted VM, install Caddy (it does TLS by default) or nginx + certbot. Once you have HTTPS working, update `APP_URL` in your env to match the real `https://…` URL — Google OAuth uses this value verbatim for the redirect URI, so a stale `localhost:3000` value will cause the integration to fail with an `Error 400: redirect_uri_mismatch` from Google. ## After deploying Sign in as the owner once to make sure the session round-trips. Connect Google in Integrations (the OAuth flow will redirect to `/api/integrations/google/callback`, which must be in your authorised redirect URIs). Connect Linear and dev.to per workspace. Set up your Notion integration if you want documents. Watch the server logs for the first cron tick (HN sync runs every three hours starting at the top of an hour) to confirm the scheduler is alive. That's it — the workspace is live. ## Backups You're now responsible for backing up your data. The trivial backup is a daily `pg_dump` written to S3 / R2 / Backblaze: ```bash pg_dump "" | gzip > rsopshub-$(date -I).sql.gz ``` Stick that in a cron job on the app host or use the managed database's built-in backups (Neon, Supabase, RDS, Cloud SQL all provide automated daily snapshots). If you're paranoid, restore one of those snapshots into a clean Postgres instance every month to confirm it's actually restorable — backups you never test are not backups. # Auth The authentication model is intentionally minimal. There is one owner per deployment defined by environment variables, and any number of viewers who join a workspace by typing a 16-character access code. There is no public sign-up, no third-party OAuth for sign-in, no password reset email path, and no role hierarchy beyond owner / viewer. Every guard the application has comes from this two-principal model, and the rest of this document describes how it works end-to-end. ## Owner The owner is the email address listed in `OWNER_EMAIL` and the password listed in `OWNER_PASSWORD`. There is no concept of an owner table in the database; the credentials live entirely in environment variables. Rotating either value implicitly invalidates every existing owner session everywhere — the application embeds a fingerprint of `OWNER_PASSWORD` into the session payload and checks it on every request, so a stale cookie issued before the rotation will fail validation the next time it's used. Signing in calls `POST /api/auth/login` with the email and password. The server does a constant-time scrypt comparison with a 250 ms artificial delay to make timing attacks impractical, then issues an AES-256-GCM encrypted session cookie. The cookie is HTTP-only, SameSite=Strict, Secure in production, and lasts seven days. The payload inside the encrypted blob includes the role, the email, the issue and expiry timestamps, and the password fingerprint. Login is rate-limited at two layers. The first layer is a per-IP throttle that caps any single IP at eight login attempts per minute. The second layer is a per-(email, IP) exponential backoff that kicks in only on failed attempts: zero or one failure incurs no penalty, two failures locks the pair out for five seconds, three for fifteen, four for one minute, five for five minutes, and six or more for fifteen minutes. A successful login clears the backoff bucket. Both layers live in memory so a real attacker could scale across IPs to bypass them, but the combination is sufficient for the personal-app threat model the app is built for. ## Viewer A viewer joins a workspace with a 16-character access code that the owner generates from the workspace's Settings page. Each workspace has its own code, and each workspace tracks a monotonically increasing `codeVersion` integer. When the owner rotates the code, the `codeVersion` increments and every previously-issued viewer session for that workspace immediately stops working — the session payload carries the `codeVersion` at issue time, and the server compares it against the workspace's current value on every request. There are two ways a viewer ends up with a valid join. The first is the access-code flow: they paste the code into `/access` along with their email, and on success the server writes a `ViewerJoin` row recording the email, the workspace, the codeVersion that was active, the IP address, and the user agent. The second is owner-initiated invite: from Settings → Viewer joins, the owner types the viewer's email and clicks Add. The server writes a `ViewerJoin` row at the workspace's current codeVersion without an IP / UA — these rows are marked "invited" in the UI to distinguish them from real sign-ins. Both paths end up with the same database shape and behave identically afterward. Viewers can switch between any workspace they hold a valid `ViewerJoin` for, straight from the sidebar dropdown, without having to re-enter the access code. The application looks up every join that matches their email and the workspace's current codeVersion, and the sidebar shows all of them. ### Returning viewer When a viewer revisits `/access` and types their email, the lookup endpoint returns the list of workspaces they can still re-enter — that is, every workspace where they have a `ViewerJoin` at the workspace's current `codeVersion` and where the workspace is not soft-deleted. If the list is non-empty the UI offers a "Choose workspace" picker; clicking a workspace re-issues a session without prompting for the code. If the list is empty (unknown email, all joins revoked, or workspaces deleted) the UI falls through to the access-code prompt. The returning viewer flow is what makes the day-to-day UX feel like a logged-in account even though there's no password. Once a viewer has joined once, they can come back to any of their workspaces just by typing their email — the same email always lands in the same workspaces — until the owner either removes their join or rotates the access code. ### Removing a viewer The owner can remove a viewer's joins for a workspace from Settings. The action deletes every `ViewerJoin` row for that email under that workspace. It does NOT bump `codeVersion`, so other viewers keep their sessions; only the targeted email is forced through a fresh access code on next login. If you want to invalidate every viewer's session at once, rotate the access code instead. ## Sessions The session cookie is opaque ciphertext. The payload inside it is HMAC-SHA256-signed first, then AES-256-GCM encrypted with `TOKEN_ENC_KEY`. Forging a session therefore requires both `SESSION_SECRET` (for the signature) and `TOKEN_ENC_KEY` (for the encryption); reading the cookie value off a hijacked logging server reveals nothing about the user. The cookie name is `rs_session`, HTTP-only, SameSite=Strict, Secure in production, and seven days long. On every request, the application decrypts the cookie, verifies the HMAC, checks the expiry, and for viewers, looks up the workspace to confirm the codeVersion still matches. Owner sessions also verify the embedded password fingerprint against the current `OWNER_PASSWORD`. Failure at any step is treated as no session — middleware redirects to `/sign-in`. The application also accepts the legacy unencrypted format for backward compatibility, so sessions issued before the encryption upgrade still work until they expire naturally. New sessions always use the encrypted format (`v2:` prefix). ## Guards There are four layers of authorisation enforcement. **Middleware** runs on every request and short-circuits to a redirect if the session cookie is missing or shaped wrong. It's a cheap pre-filter — it doesn't decrypt the cookie or hit the database, just checks the shape so unauthenticated requests don't waste cycles on a real handler. **Server components** call `getSession()` and decide what to render based on the role. Owner-only pages (`/settings`, `/integrations`, `/notifications`, `/profile`) redirect to the workspace home if the session is a viewer. Read-allowed pages (`/`, `/content`, `/tasks`, `/work`, etc.) render for both but hide owner-only controls. **Server actions** call `requireOwner()` before mutating anything. There's a single exception: `requireWorkspaceAccess(workspaceId)` lets viewers through for explicitly read-only actions (fetching Linear issue detail, comments, sub-issues, labels, team metadata). Every mutating action in the codebase still calls `requireOwner()` — there's no path through which a viewer can write data, even a forged action call. **API routes** use a layered guard: `requireSameOrigin(req)` to reject cross-origin POST attempts, then `getSession()` for authentication, then the route-specific role check. Image uploads (`/api/uploads`) additionally validate the file's magic bytes against the declared mime type to refuse a renamed `.html` claiming to be an image. ## Stored credentials Four third-party tokens are stored in Postgres, all encrypted with `TOKEN_ENC_KEY`: | Credential | Storage | |---|---| | Linear personal API key (per workspace) | `Workspace.linearApiKey`, AES-256-GCM ciphertext | | dev.to API key (per workspace) | `Workspace.devtoApiKey`, AES-256-GCM ciphertext | | PostHog personal API key (per workspace) | `Workspace.posthogApiKey`, AES-256-GCM ciphertext | | Cloudflare API token (per workspace) | `Workspace.cloudflareApiToken`, AES-256-GCM ciphertext | | Google OAuth refresh token (per workspace) | `Workspace.googleRefreshToken`, AES-256-GCM ciphertext | The workspace access code is stored in plain text — it's only useful in combination with the workspace, and codeVersion rotation invalidates it anyway, so encrypting it would add complexity without meaningful security benefit. The owner password lives in env as plain text and is never written to the database. Rotating `TOKEN_ENC_KEY` makes every previously-encrypted value unreadable. Each workspace owner will have to re-connect their integrations after a rotation. There is no migration path — the rotation is intentionally destructive so a leaked key is forced to be cycled with consequences. ## Destructive actions Anything that destroys data demands the owner password again. Deleting a workspace from the Danger zone requires re-typing the owner password and the exact workspace name. This is a deliberate layer above the session check: even if a session cookie has been stolen and somehow the SameSite + encryption layers fail, the attacker still cannot delete the workspace without knowing the password. The same pattern is reused for any future destructive action. ## CSRF The cookie is SameSite=Strict, which blocks cross-site cookie attachment by default in every modern browser. As belt-and-braces protection, every mutating API route also calls `requireSameOrigin(req)`, which rejects the request if `Sec-Fetch-Site` or the `Origin` header indicates a cross-site origin. Server actions go through Next's own action ID + Next-Action header gate, which provides equivalent CSRF protection for that surface. ## What's not in scope Two things deliberately are not in scope. First, there is no audit log of owner actions — the application records `ActivityLog` entries for content / task changes but does not capture every owner mutation. If you need a full audit trail, add a Prisma middleware that logs every write. Second, there is no two-factor authentication. The threat model assumes the owner controls their device and password; if you need 2FA, the cleanest path is to put a reverse proxy (Cloudflare Access, Tailscale Funnel, an auth proxy) in front of `/sign-in` and let it handle the second factor before traffic reaches the app. # Data model The Prisma schema lives at [`prisma/schema.prisma`](../prisma/schema.prisma) and is the single source of truth for the database. Every workspace-scoped table carries a `workspaceId` foreign key with `onDelete: Cascade`, so deleting a workspace cleans up the entire content tree it owned in one transaction. Most tables also use a `deletedAt?` timestamp instead of hard deletes so a re-sync from a third-party source can't accidentally resurrect a row the owner had archived. This document walks through the models grouped by area. It's not a generated reference — it's a hand-written description of why each model exists and what each field is doing, so you can navigate the schema with intent rather than spelunking field-by-field. ## Models by area | Area | Models | What it stores | |---|---|---| | Workspace | `Workspace`, `ViewerJoin`, `OwnerProfile` | Workspace identity, viewer login history, owner profile | | Content | `ContentRecord`, `ContentMetric`, `Campaign`, `Tag`, `ContentTag` | Posts / articles / comments + their time-series metrics | | Tasks & work | `Task`, `WorkLog` | Local tasks + per-day daily summary rows | | Documents & sheets | `Document`, `Spreadsheet`, `SharedDocOffer` | Local + Notion + Google Docs/Sheets links and uploads | | Calendar | `CalendarEvent` | Events created inside RsOpsHub (Google Meet is fetched live) | | Notifications | `Notification` | Unified inbox across Linear, Drive, internal events | | Views | `View` | Per-resource saved filters / sorts / layouts | | Linear | `LinearTeam`, `LinearIssue`, `LinearIssueTemplate` | Cached Linear data for instant rendering | | Audit | `ActivityLog` | Per-workspace history of significant entity events | ## Workspace The Workspace model is the root of the tree. Each row has identity fields (`slug`, `name`, `description`, `logoPath`, `logoData`, `logoMime`, `iconEmoji`, `color`), the viewer access controls (`accessCode`, `codeVersion`), and the per-workspace integration credentials. The `logoPath` column accepts either a `/public` URL (e.g. `/tessl.png`) for assets shipped with the repo, or an in-app `/api/workspaces//logo` URL pointing at bytes stored on the row itself (`logoData` + `logoMime`). The DB-stored path is what makes deployed instances survive ephemeral filesystems — Vercel and Fly wipe `public/uploads` on every deploy. The integration columns include `notionParentPageId`, `googleDocsFolderId`, `googleRefreshToken` + `googleAccountEmail` + `googleConnectedAt`, `hnUsername` + `hnEnabled` + `hnLastSyncAt`, `redditUsername` + `redditEnabled` + `redditLastSyncAt`, `devtoUsername` + `devtoEnabled` + `devtoLastSyncAt` + `devtoApiKey` + `devtoAccountLabel`, `linearApiKey` + `linearAccountLabel` + `linearAccountEmail` + `linearUserId` + `linearDefaultTeamId` + `linearEnabled` + `linearLastSyncAt`, and `posthogApiKey` + `posthogProjectId` + `posthogProjectLabel` + `posthogHost` + `posthogEnabled`, and `cloudflareApiToken` + `cloudflareZoneId` + `cloudflareZoneLabel` + `cloudflareAccountId` + `cloudflareEnabled`. The "owned by me" pattern is consistent: a single workspace row owns every credential needed to talk to every provider on that workspace's behalf. Soft delete is implemented as `deletedAt`. The application's listing helpers filter for `deletedAt: null` everywhere, so a soft-deleted workspace effectively disappears from the UI. Restore by setting the column back to `null` in the database; there's no in-app UI for this, by design. ## ViewerJoin Every successful viewer login (and every owner-initiated invite) writes a `ViewerJoin` row capturing the email, the workspace, the `codeVersion` that was active at issue time, the IP, and the user agent. The codeVersion snapshot is the entire reason rotating the workspace access code instantly invalidates that workspace's viewer sessions — the session payload also carries codeVersion, and the server checks both numbers match on every request. Old rows at lower codeVersions are kept as audit history but no longer authenticate. ## OwnerProfile A single row keyed by lowercased `OWNER_EMAIL` storing the owner's display name and avatar path. Created lazily on first read. Viewers see this profile on `/access` and at the top of the sidebar in every workspace so they always know whose hub they've joined. ## Content `ContentRecord` is the main content table. Every Reddit post / comment / reply, Hacker News submission / comment, dev.to article, and any blog post the owner adds manually lives here. The `type` enum identifies the kind (`REDDIT_POST`, `REDDIT_COMMENT`, `REDDIT_REPLY`, `HACKERNEWS_POST`, `HACKERNEWS_COMMENT`, `BLOG_POST`, `SOCIAL_POST`, `CUSTOM`); `platform` identifies the source platform (`REDDIT`, `HACKERNEWS`, `DEV_TO`, `TWITTER`, `LINKEDIN`, `BLOG`, `YOUTUBE`, `OTHER`); `status` tracks the publishing pipeline (`BACKLOG`, `IDEA`, `DRAFT`, `SCHEDULED`, `PUBLISHED`, `ARCHIVED`). Engagement metrics (`impressions`, `upvotes`, `downvotes`, `comments`, `shares`, `engagementRate`, `ctr`, `traffic`) are all numeric columns the application syncs from APIs where possible and lets the owner edit manually where not. The `externalId` column is `provider:id` (e.g. `hn:12345`, `devto:67890`) and there is a unique constraint on `(workspaceId, externalId)` so the same article can never be inserted twice — every sync path uses Prisma's `upsert` against this constraint. `ContentMetric` stores time-series snapshots of a single record's engagement at a given timestamp. This is what the analytics charts read from when they show trends over time. `Campaign` is an optional grouping mechanism for `ContentRecord`s — you can tag a series of related posts as one campaign and analyse their combined performance. `Tag` plus the `ContentTag` join table provide free-form tags. ## Tasks and work `Task` is a local task with `title`, `description`, `status` (`TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`, `ARCHIVED`), `priority` (`LOW`, `MEDIUM`, `HIGH`, `URGENT`), and an optional `dueAt`. Soft delete via `deletedAt`. Completion sets `completedAt` so the heatmap and analytics can plot it. `WorkLog` is one row per workspace per day with a `summary` text field, an optional `hoursLogged`, and a `metricsJson` blob recording the day's task count / Linear closures / content created and published. The unique constraint is `(workspaceId, date)` so the daily cron upserts cleanly and never duplicates. Manual summaries (typed by the owner) are detected by checking the `summary` field is non-empty and skipped by the cron — your writeup is never overwritten by AI. ## Documents and sheets `Document` has a `provider` enum (`LOCAL`, `NOTION`, `GOOGLE_DOCS`) and an `externalId` for non-local providers. Local documents store their body inline as markdown; Notion and Google Docs are pulled on demand from their respective APIs. The `isShared` boolean controls whether viewers can see the row. `ownedByMe` flags whether the workspace's connected account is the original creator (matters for delete semantics — deleting a not-owned-by-me Google Doc only unlinks it from the workspace; the source file stays untouched in Drive). `Spreadsheet` follows the same pattern with a `SheetProvider` enum (`LOCAL`, `GOOGLE_SHEETS`). Local uploads keep the raw bytes in the `data` `Bytes?` column up to a hard 10 MB cap; Google Sheets store only metadata + the Drive `externalId`. The `data` column makes the upload feature stateless from the host's filesystem perspective — restoring from a Postgres backup restores the files too. `SharedDocOffer` represents a Drive document that's been shared with the owner's Google account but not yet claimed into any workspace. The Drive sync surfaces these as "claim me" cards in the notifications inbox; once claimed, a `Document` row is created and the offer is consumed. ## Calendar `CalendarEvent` stores generic events with `title`, `description`, `startsAt`, `endsAt`, `eventType` (`GENERAL`, `PUBLISHING`, `DEADLINE`, `MEETING`, `REMINDER`), and a free-form `metadataJson`. Note that Google Meet meetings shown on the calendar do NOT live in this table — they're fetched live from the Google Calendar API on each render so they're always fresh. `CalendarEvent` is for events the owner creates inside RsOpsHub itself. ## Notifications `Notification` is the unified inbox. Each row carries a `source` enum (`LINEAR`, `NOTION`, `GOOGLE_DOCS`, `SHEETS`, `INTERNAL`, `SYSTEM`), a free-form `kind` string for the specific event ("issueAssignedToYou", "viewer_joined", etc.), a title, an optional body, an optional URL the row links to (preferentially an in-app deep link, not the third party), an `externalId` for dedup, and a `readAt` timestamp. The Linear sync writes these whenever a new Linear notification arrives; the Drive sync writes them when a new shared document offer appears; internal events (viewer joined, owner action) write them directly. ## Views `View` is a per-resource saved filter / sort / column layout. The `resource` enum (`CONTENT`, `TASK`, `LINEAR`) plus `kind` enum (`TABLE`, `KANBAN`, `CALENDAR`) plus a `filtersJson` blob lets the owner save "my open urgent tasks" once and re-apply it. This is the model behind the table view tabs at the top of `/content` and `/tasks`. ## Linear `LinearTeam`, `LinearIssue`, and `LinearIssueTemplate` cache the Linear data so the UI renders instantly without waiting on Linear's GraphQL. Each row carries the Linear `externalId` plus enough denormalised fields (state name and color, assignee name and avatar URL, label JSON, due date, parent issue id, subscriber list) that the board page can render hundreds of issues without a single round-trip to Linear. The sync uses upsert against `(workspaceId, externalId)` to stay idempotent. `LinearIssueTemplate` mirrors Linear's own templates plus any local-only templates the owner creates inside the app — the origin enum distinguishes them. ## ActivityLog Generic per-workspace audit log of significant events: `content.created`, `content.updated`, `content.published`, `task.created`, `task.completed`, `task.updated`. Each row carries an `entityType` + `entityId` so the recent activity feed on the home page can join back to the actual row and render its title alongside the verb. Hard-deleted entities are filtered out at render time so you never see "Updated a task" with no clue which task. ## Enums summary For quick reference, here are the enums and their values: - `ContentType`: `REDDIT_POST`, `REDDIT_COMMENT`, `REDDIT_REPLY`, `HACKERNEWS_POST`, `HACKERNEWS_COMMENT`, `BLOG_POST`, `SOCIAL_POST`, `CUSTOM` - `Platform`: `REDDIT`, `HACKERNEWS`, `DEV_TO`, `TWITTER`, `LINKEDIN`, `BLOG`, `YOUTUBE`, `OTHER` - `ContentStatus`: `BACKLOG`, `IDEA`, `DRAFT`, `SCHEDULED`, `PUBLISHED`, `ARCHIVED` - `TaskStatus`: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`, `ARCHIVED` - `Priority`: `LOW`, `MEDIUM`, `HIGH`, `URGENT` - `DocProvider`: `LOCAL`, `NOTION`, `GOOGLE_DOCS` - `SheetProvider`: `LOCAL`, `GOOGLE_SHEETS` - `EventType`: `GENERAL`, `PUBLISHING`, `DEADLINE`, `MEETING`, `REMINDER` - `NotifSource`: `LINEAR`, `NOTION`, `GOOGLE_DOCS`, `SHEETS`, `INTERNAL`, `SYSTEM` - `ViewResource`: `CONTENT`, `TASK`, `LINEAR` - `ViewKind`: `TABLE`, `KANBAN`, `CALENDAR` ## Migration discipline For local development you can run `bunx prisma db push` to push schema changes directly. For production, use `bunx prisma migrate dev` to create a versioned migration during development, commit the generated migration files, and run `bunx prisma migrate deploy` on the production server during deployment. The migration files live in `prisma/migrations/` and form an append-only history of every schema change. Never edit a migration that's already been deployed — create a new one to fix any mistake. # Integrations Every external service the application talks to falls into one of two categories. **Per-workspace** integrations live as encrypted credentials on the workspace row, configured from each workspace's `/integrations` page. They never leak across workspaces. **Global** integrations live in environment variables on the server and apply to every workspace — they're for services where the credential is a shared piece of infrastructure (a Notion internal integration token, the Google OAuth app's client id, an OpenAI key) rather than a per-account credential. This document covers each integration end-to-end: what setup the user has to do, what the app actually does with the credential, what gets synced into Postgres, and what stays at the source. Read top to bottom on first setup; on day-to-day operation only the per-workspace section is interesting. ## Hacker News **Setup**: enter your HN username on the integrations page. No API key, no OAuth — Hacker News exposes a public Firebase-backed JSON API that anyone can read with just a username. Comma-separated usernames work too (`rohansrma, tessl`) so you can pull both your personal handle and a company handle into the same workspace. **What it syncs**: every story you've ever submitted (becomes `HACKERNEWS_POST` content records, classified as ask / show / post HN based on the title prefix) and every comment you've written (becomes `HACKERNEWS_COMMENT` records grouped under their root story). The "Add by link" button on the content toolbar accepts any HN URL (story or comment) and pulls just that single item. **Sync schedule**: every three hours by the in-process cron. Each run upserts on `(workspaceId, externalId)` so the same item never duplicates; `upvotes` (HN score) and `comments` (descendant count) are refreshed on every sync so the analytics charts stay current. ## Reddit **Setup**: enter your Reddit username (or comma-separated list) on the integrations page. Like HN, no auth required — Reddit's public JSON API gives anonymous access to a user's posts and comments, just with tight rate limits. **What it syncs**: posts (`REDDIT_POST`), top-level comments (`REDDIT_COMMENT`), and nested replies (`REDDIT_REPLY`). The score, upvote ratio, comment count, and subreddit are pulled and stored alongside each row. The "Add by link" button accepts any Reddit URL. **Sync schedule**: refresh runs only on demand because Reddit's anonymous API rate-limits aggressively and there's no operational benefit to polling. Click Sync on the integration card or the content toolbar to refresh; otherwise add new items by link. ## dev.to **Setup**: connect with your dev.to API key from Settings → Extensions on dev.to. Without the key the integration falls back to dev.to's public listing endpoint, which is enough to pull article metadata but not page views, reactions detail, or drafts. With the key you get full analytics. Each workspace stores its own API key encrypted with `TOKEN_ENC_KEY` — there is no global env override. **What it syncs**: every published article (and every draft when the API key is connected) as a `BLOG_POST` content record with `platform: DEV_TO`. The article's markdown body, canonical URL, organization publication (if any, stored in `community`), tags, page views, reactions, and comments are all pulled. Titles are prefixed `Repurpose:` so the content table makes it obvious which rows came from dev.to and what they're for. **Sync schedule**: once a day at 03:30 by the in-process cron. The schedule runs two passes per workspace: first `syncDevtoForWorkspace` walks newest-first and stops at the first known article (cheap on subsequent runs), then `refreshDevtoForWorkspace` walks every existing row and refreshes the API-driven fields. Refresh only touches articles the workspace's API key owns — articles added by URL from a different author have their metrics left alone so manually-edited values don't get clobbered. The `shares` field is special: dev.to's API does not expose per-article bookmark counts, so the sync never touches `shares`. Whatever you type in the content detail drawer survives every sync indefinitely. ## Linear **Setup**: generate a personal API key in Linear at Settings → API → Personal API keys. Paste it into the Linear integration card on the workspace's integrations page. Each workspace stores its own key encrypted with `TOKEN_ENC_KEY` — no global env override, no shared key. **What it syncs**: every Linear team you have access to (becomes `LinearTeam` rows), every issue (`LinearIssue` rows with denormalised state, assignee, labels, due date, parent / sub-issue links so the board renders instantly), every issue template you've defined (`LinearIssueTemplate` rows), and every Linear notification (`Notification` rows with `source: LINEAR` that deep-link into the in-app Linear tab instead of opening linear.app). The sync is idempotent — re-running it never duplicates an issue. **Sync schedule**: refresh on demand. Linear's GraphQL API rate-limits per-key, so polling isn't free; the integration runs when the owner clicks Sync on the integration card, when the notifications page is refreshed, or when a new comment is posted from inside the app. Mutations (create issue, update issue, archive, post comment, reply to comment, save template, apply template) are owner-only and go through Linear's API in real time. Viewers see issues, sub-issues, comments, labels, and assignees but cannot create or modify anything. ## PostHog **Setup**: create a personal API key in PostHog under Settings → Personal API keys with the `query:read` and `project:read` scopes, then paste it into the PostHog card on the workspace's integrations page along with the numeric project id from your PostHog URL (`app.posthog.com/project/12345`). The host field is optional — leave it blank for US cloud, or set `https://eu.posthog.com` or your self-hosted URL. Each workspace stores its own key encrypted with `TOKEN_ENC_KEY`; there is no global env override, so different workspaces can point at entirely different PostHog accounts. **What it syncs**: nothing is stored. Traffic is queried live via PostHog's HogQL query API each time an analytics page loads. `$pageview` events are grouped by day and classified into Reddit, Hacker News, dev.to, LinkedIn, X, and Discord purely from `properties.$referring_domain` — no UTM tagging required on your links. Mobile-app referrers are matched too (`com.reddit.frontpage`, `com.linkedin.android`, `com.discord`), and X covers `t.co`, `twitter.com`, and `x.com`. The source list lives in `src/lib/traffic.ts`; adding a new one means adding an entry there plus a branch in the `SOURCE_CASE` multiIf in `src/server/integrations/posthog.ts`. **Where it shows up**: the "Referral traffic" panel on the workspace analytics page, the overlaid traffic area on the content analytics chart, and the **Detailed breakdown** page (see below). When PostHog is connected the traffic KPI switches from the stored `contentRecord.traffic` column to the live PostHog sum. **Gotcha**: HogQL queries default to roughly 100 rows. Long date ranges grouped by day and source blow past that and silently truncate, so the traffic query pins `LIMIT 100000` explicitly. ## Cloudflare **Setup**: in the Cloudflare dashboard go to My Profile → API Tokens → Create Token, start from a custom token, and grant **Zone · Analytics · Read** scoped to the zone you want to track. Adding **Zone · Zone · Read** is optional but recommended, because it lets the app show the zone's domain name and deep-link into the right dashboard. Copy the 32-character Zone ID from the zone's Overview page (right-hand sidebar), then paste both into the Cloudflare card on the workspace's integrations page. The token is verified against Cloudflare's GraphQL Analytics API before it's saved, encrypted with `TOKEN_ENC_KEY`, and stored on the workspace row. Each workspace can point at a different zone or a different Cloudflare account. **What it syncs**: nothing is stored. Edge traffic is queried live from Cloudflare's GraphQL Analytics API (`httpRequests1hGroups` for the last 24 hours, `httpRequests1dGroups` for longer ranges) whenever the breakdown page opens. You get requests, cached requests and cache hit rate, bandwidth, page views, unique visitors, threats, and 4xx / 5xx error rates, plus top countries, response status classes, browsers, HTTP protocols, content types, TLS versions, IP classes, and threat types. **Where it shows up**: the Cloudflare tab of the **Detailed breakdown** page. Cloudflare sees every request that hits the edge (including bots, assets, and visitors who block JavaScript), so its numbers are always higher than PostHog's; the two are meant to be read side by side, not reconciled. The toggle on the card pauses the integration without deleting the token. Disconnecting wipes the token, zone id, and account id from the row. ## Detailed breakdown The analytics page has a **Detailed breakdown** button on the Referral traffic chart (or on the engagement chart when only Cloudflare is connected). It opens the `/analytics/detailed-breakdown` page, which pulls live data from whichever of PostHog and Cloudflare the workspace has connected, with no extra setup. The page starts on the window closest to the dashboard's date range, and the dashboard's filters, granularity, and chart toggles are restored when you go back. - **Ranges**: 24 hours, 7 days, 30 days, 90 days. Hourly buckets for 24 hours, daily otherwise. - **Filters**: URL path (prefix or exact match), referrer source (`direct` or a domain), country (ISO-2 code), and device type. Filters are validated on the server and passed to HogQL as bound placeholders, never string-interpolated. - **Tabs**: Overview (KPIs, pageviews / visitors over time, top pages, sources, countries), Sources (referrers and UTM campaigns), Pages & events (searchable page and full-URL lists, custom events), Audience (countries, devices, browsers, OS), and Cloudflare. - **URL drill-down**: click any page or full URL to filter the whole dashboard to that exact path. A banner shows which URL you're looking at, lets you flip between exact and prefix match, and has a one-click "Back to all pages". Full URLs are built from host + path only, so query strings (which can carry tokens) never appear. Results are cached in memory per workspace and filter set for five minutes. The Refresh button bypasses the cache, rate-limited to once every 30 seconds per filter set. Viewers can open the breakdown too; it's read-only and gated by `requireWorkspaceAccess`. ## Notion **Setup**: the token is global, the parent page is per-workspace. Create an internal integration at [notion.so/profile/integrations](https://www.notion.so/profile/integrations), give it read access to whatever pages you want mirrored, copy the secret, and put it in `NOTION_TOKEN` in your env. Then, on each workspace that wants Notion documents, paste the parent page ID (or the full page URL) into the Notion card on the workspace's integrations page. **What it syncs**: child pages of the configured parent become `Document` rows with `provider: NOTION`. Clicking a Notion document in the documents list fetches the page's blocks on demand and renders them inline — no need to bounce to Notion.so. Updates inside the app push back to Notion via the REST API when supported (text edits do; tables, embeds, and toggles are read-only at the moment). **Sync schedule**: pull on demand. Notion's API doesn't push so the cheapest model is to fetch the latest blocks each time the user opens the document. ## Google Docs and Sheets **Setup**: this is partly global, partly per-workspace. The OAuth client itself (the thing that defines what your app is asking permission for) is global — set `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` in env, enable the Drive API, Docs API, Sheets API, and Calendar API in the same Google Cloud project, and authorise the redirect URI `/api/integrations/google/callback`. Then each workspace owner clicks "Connect Google" on their integrations page, completes the OAuth dance, and the workspace gets its own encrypted refresh token. Each workspace can connect a different Google account. **What it syncs**: Drive files in the configured folder become `Document` rows for `.gdoc` files and `Spreadsheet` rows for `.gsheet` files. Documents you've been shared on (but don't own) surface as `SharedDocOffer` notifications you can claim into a workspace. **How the embed works**: owners load Drive's `/edit?embedded=true` URL inside an iframe — the full editable Google Docs / Sheets UI ships with the page, so the owner can type, format, comment, and share without leaving the app. Viewers load `/preview` instead, which is the read-only viewer; `/edit?embedded=true` would force them through a Google sign-in screen. **Third-party cookies (important)**: the embed authenticates the owner via cookies on `docs.google.com`. Chrome's default "Block third-party cookies" setting hides those cookies from the iframe, producing **"Allow Google Docs access to your necessary cookies"** or **"Can't access your Google Account"** in place of the editor. There is no server-side fix because the cookie policy is enforced by the browser against Google's own origin, not ours. Each user has to allow third-party cookies for `[*.]google.com` once per browser profile: 1. Open **Chrome → ⋮ → Settings → Privacy and security → Third-party cookies** 2. Either pick **"Allow third-party cookies"** globally, or 3. Stay on **"Block third-party cookies"** and click **"Add"** under *Sites allowed to use third-party cookies* — add the pattern `[*.]google.com`. During local development also add `[*.]localhost` (or whatever host `APP_URL` resolves to) so the embed can run cookies for the parent page too. Refresh the document page after changing the setting — the editable Drive UI loads with the owner's account session. **Sync schedule**: pull on demand. Click Sync on the documents or sheets page to refresh the Drive folder; click Refresh on the notifications page to scan for new shared-document offers. ## Google Calendar / Google Meet **Setup**: same Google OAuth flow as Docs / Sheets. The calendar scope (`https://www.googleapis.com/auth/calendar.events`) is requested alongside Drive and Docs scopes. If you connected Google before the calendar scope was added you'll need to disconnect and reconnect once to grant it — the integration shows a clear error message telling you so. **What it shows**: the upcoming 14-day window of events on your primary Google calendar appears in the Meetings widget on `/home`, capped at six. The widget also displays them inside `/calendar`'s month / week view alongside content. Each row links straight to the Meet conference if one is attached. **What it does**: owners can create a new event with attendees and a Google Meet conference link from the home widget's "New" button — the event lands on the primary calendar with `conferenceData.createRequest` so Google attaches a Meet link automatically. Optionally send a calendar invite email by ticking the "Email attendees" checkbox in the create dialog. Owners can also edit and cancel events; viewers can only join. The same widget will swap to Microsoft Teams or Zoom in the future depending on the workspace; the integration code is structured so adding a new provider only requires writing the equivalent of `createMeeting` / `updateMeeting` / `deleteMeeting` against that provider's API. The UI is provider-agnostic. ## OpenAI (work-log summaries) **Setup**: set `OPENAI_API_KEY` in env. Optional — without it the daily work log falls back to a deterministic template summary (still useful, just less narratively interesting). **What it does**: every night at 23:55, for each workspace that doesn't already have a manual summary for today, the cron computes the day's activity (tasks done, content created, content published, Linear issues closed) and asks `gpt-4o-mini` to write a one-paragraph summary in the owner's voice. The same call is available on demand via the "Generate" button in the work tracker. Manual summaries are never overwritten. If you type your own writeup, the cron sees a non-empty summary and skips that workspace for the day. ## Per-workspace versus global summary A quick reference for which credential lives where: | Provider | Where the credential lives | |---|---| | Hacker News (handle) | Per-workspace | | Reddit (handle) | Per-workspace | | dev.to (API key) | Per-workspace | | Linear (API key) | Per-workspace | | PostHog (personal API key + project id) | Per-workspace | | Cloudflare (API token + zone id) | Per-workspace | | Notion (token) | Global env (`NOTION_TOKEN`) | | Google OAuth client | Global env (`GOOGLE_OAUTH_CLIENT_ID` + `_SECRET`) | | Google refresh token (per account) | Per-workspace | | OpenAI (API key) | Global env (`OPENAI_API_KEY`) | The split exists because some credentials are intrinsically global (the OAuth client identifies your app to Google, not a particular user's account) and others are intrinsically personal (your Linear API key authenticates as you specifically and should never be shared across workspaces that might represent different clients or projects). ## Manual "Add by link" Independent of any sync, each of Hacker News, Reddit, and dev.to offers a small icon button on the content page toolbar that takes a single URL and adds that one item. Useful for content that the public listing missed, items from accounts other than the one you're syncing, or anything you want to track without setting up a full integration. The button works for all three providers; you don't need to configure anything beyond pasting a URL. ## Provider-agnostic abstractions A few helpers worth knowing about if you're extending the integrations: - `gfetch(workspaceId, url, init)` in `server/integrations/google-docs.ts` is the shared Google API client — it manages OAuth token refresh, caches access tokens for ~50 minutes, and surfaces precise error messages (insufficient scope, expired token, API not enabled). Calendar, Docs, Sheets, and Drive all use it. - `withLinearKey(workspaceId, fn)` in `server/services/linear-sync.ts` decrypts the workspace's Linear API key once, passes it to the callback, and wraps any error into a consistent shape. - `resolveDevtoApiKey(encryptedKey)` returns the decrypted dev.to key for a workspace or null if not connected. - `requireSameOrigin(req)` in `server/ratelimit.ts` is the CSRF guard every mutating API route calls. Add a new integration by following the same shape: store the credential on the workspace row (encrypted if it's sensitive), write a service module under `server/services/` for the sync, expose a server action under `app/(app)/w/[workspace]/integrations/actions.ts` plus optionally a card under `features/integrations/`, and hook the sync into `src/server/cron.ts` if it needs to run on a schedule. # Cron The application runs three background jobs inside the Next.js server process using [`node-cron`](https://github.com/node-cron/node-cron). The schedules are declared in [`src/server/cron.ts`](../src/server/cron.ts) and start automatically the first time the server boots. Killing the Node process stops every job; restarting brings them back. There is no external worker required, no Redis queue, no separate scheduler — the cron lives in the same process as the HTTP server because the workloads are small, idempotent, and bounded by your own data, not by a fleet of users. This document describes what runs, when it runs, why the schedule was chosen, and how each job behaves operationally. If you ever want to swap the in-process scheduler for a real queue (Vercel Cron, BullMQ, Inngest, etc.) the service functions the cron calls (`syncHackerNewsForWorkspace`, `syncDevtoForWorkspace`, `refreshDevtoForWorkspace`, `computeDayActivity`, `summarizeDay`, `upsertWorkLog`) are all directly callable from anywhere — the cron file is just the thin scheduling layer. ## Schedules The three jobs and their cron expressions: | Job | Cron expression | When (server local time) | |---|---|---| | Hacker News sync | `0 */3 * * *` | Every three hours at the top of the hour | | dev.to sync + metric refresh | `30 3 * * *` | Daily at 03:30 | | Daily work log | `55 23 * * *` | Daily at 23:55 | The cron expressions are evaluated in the server's local time zone. If you deploy to a host whose timezone is UTC and you want the daily worklog to run at 23:55 local for you specifically, either set the host's timezone via `TZ` env variable or adjust the cron expression to match the offset. ## Hacker News sync Every three hours, the job iterates every workspace where `hnEnabled` is true and `hnUsername` is set, and calls `syncHackerNewsForWorkspace(workspaceId)` for each one. The service walks the submitted item list for each configured username (comma-separated handles supported), pulls every story and comment via the public Firebase API, and upserts a `ContentRecord` for each one keyed on `(workspaceId, externalId)` where `externalId` is `hn:`. The sync is fully idempotent: re-running it touches existing rows via the update branch of `upsert`, refreshing `upvotes` (HN score) and `comments` (descendant count) while preserving any manually edited fields the user has set. Three-hour cadence was chosen because HN scores stop moving meaningfully after a story falls off the front page, which usually happens within 24 hours of submission, and intra-day three-hour granularity is enough to capture peak-traffic windows without hammering the Firebase API for nothing. The Firebase API is free and aggressive caching makes our request budget effectively unlimited, but politeness matters. Failures are logged but never block other workspaces — each workspace gets its own try/catch so one workspace's broken HN handle doesn't stop the others from syncing. ## dev.to sync Once a day at 03:30, the job iterates every workspace where `devtoEnabled` is true and `devtoUsername` is set, and runs two passes per workspace: first `syncDevtoForWorkspace` pulls new articles newest-first (stopping at the first article it has already seen), then `refreshDevtoForWorkspace` walks every existing `DEV_TO` content record and refreshes its metrics from the API. Refresh is selective. It only touches articles the workspace's API key actually owns — the article must appear in the `/articles/me/all` listing for the key holder. Articles added by URL where the workspace's API key doesn't own them (a public blog you bookmarked from another author) are skipped entirely so their owner-curated metrics aren't overwritten. The `shares` field is never touched by either pass since dev.to's API doesn't expose per-article bookmark counts — whatever the owner manually entered survives every sync. Daily cadence was chosen because blog post metrics move slowly. Page views and reactions tick up over days, not minutes, and dev.to's free API tier rate-limits per key. Running the refresh once a day at 03:30 (when nobody is staring at the dashboard) gives clean fresh numbers without ever being intrusive. ## Daily work log Every day at 23:55, the job iterates every non-deleted workspace and tries to write today's work log entry. For each workspace it first checks whether the day already has a non-empty `summary` — if it does (the owner typed their own writeup), the cron skips that workspace entirely. Otherwise it calls `computeDayActivity(workspaceId, today)` to count the day's task completions, content creations and publishes, and Linear issue closures, then `summarizeDay(activity)` to generate prose. If `OPENAI_API_KEY` is set in env the summarisation hits `gpt-4o-mini` for a one-paragraph natural-language summary; without the key it falls back to a deterministic template like "Shipped 3 tasks, closed 2 issues, published 1 piece of content." The result is upserted into the `WorkLog` row keyed on `(workspaceId, date)`. The schedule runs at 23:55 local time so the day is essentially over but the date hasn't ticked yet — if you finish a task at 23:50 the cron picks it up; if you finish one at 00:05 it goes into tomorrow's log. Adjust the cron expression if you want a different cutoff. ## Why these aren't on a real queue The in-process scheduler is the right choice for RsOpsHub because the jobs are small (each takes seconds, not minutes), bounded (number of workspaces, not number of users), idempotent (re-running on failure is safe), and don't fan out (each workspace is processed sequentially, never in parallel against the same downstream API). A real queue introduces operational complexity (a Redis instance, a worker process, a dead-letter queue, observability) that you don't need at personal scale. If you outgrow the in-process model — e.g. you want to spread cron across multiple server instances, or you want jobs to retry with exponential backoff — the service functions are designed to be called directly from a queue worker. Wrap them in a BullMQ producer or Inngest handler, comment out `startCron()` in the server entry, and you're done. ## Reddit is not on the cron Reddit content is added by handle but the refresh runs only on demand. Reddit's anonymous JSON API rate-limits aggressively and the data doesn't change after a post falls off the home feed, so polling earns you nothing. Click Sync on the integration card or the content toolbar to refresh; otherwise add new items by URL. ## Linear is not on the cron Linear sync also runs only on demand. The triggers are: clicking Sync on the Linear integration card, clicking Refresh on the notifications page, posting a new comment from inside the app (the sync is awaited after the post so the thread updates instantly), and the moments where the page needs to be sure the local cache is fresh. Linear's GraphQL API is generous but explicit refresh control is the right shape because most workspace changes happen in Linear itself and we'd rather sync immediately on a write than poll every few hours. ## Operational notes The cron is started exactly once via a `globalThis.__cronStarted` guard, so Next.js dev mode's hot reloading doesn't spin up duplicate jobs. The guard means that if you ever want to disable the cron in development (to avoid hammering APIs during local work) you can set `globalThis.__cronStarted = true` before `startCron()` is called, or just comment out the call in the server entry. `NODE_ENV === "test"` short-circuits the entire cron initialisation, so test suites don't accidentally fire real syncs. If you want to observe the cron in production, the simplest tool is the console — every successful tick logs to stdout (`[cron][hn] my-workspace synced 5 items`, `[cron][devto] my-workspace synced 0 new, refreshed 12`, `[cron][worklog] my-workspace logged via openai: t=3 l=1 c=2 p=0`). Pipe stdout into your host's logging system (Datadog, Better Stack, plain CloudWatch) and you have a free observability layer. For an external uptime ping, expose a tiny `/api/health` route that returns 200 and check it from UptimeRobot or BetterStack — if the route stops responding, the cron has stopped too (since both live in the same process). # 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. | Route | Owner | Viewer | What it shows | |---|---|---|---| | `/w/[workspace]` | yes | yes | Home page: greeting, KPI cards, today's focus, Meetings widget, recent activity, work heatmap | | `/w/[workspace]/content` | yes | yes | Content table / kanban / calendar with filters | | `/w/[workspace]/content/[id]` | yes | yes | Single content record drawer with body + metrics | | `/w/[workspace]/tasks` | yes | yes | Local tasks + Linear board in a tabbed layout | | `/w/[workspace]/work` | yes | yes | Daily work log editor + heatmap | | `/w/[workspace]/calendar` | yes | yes | Month / week calendar of content + meetings | | `/w/[workspace]/documents` | yes | viewers see `isShared=true` only | Document list and detail (Notion / Google Docs / local) | | `/w/[workspace]/documents/[id]` | yes | yes (if shared) | Document detail editor or iframe | | `/w/[workspace]/sheets` | yes | viewers see `isShared=true` only | Spreadsheet list | | `/w/[workspace]/sheets/[id]` | yes | yes (if shared) | CSV / PDF preview, Google Sheets iframe, XLSX download fallback | | `/w/[workspace]/analytics` | yes | yes | Per-platform charts, tasks-shipped trend, and referral traffic | | `/w/[workspace]/analytics/detailed-breakdown` | yes | yes | Live PostHog + Cloudflare breakdown with URL drill-down (needs one of them connected) | | `/w/[workspace]/notifications` | yes | no | Unified inbox | | `/w/[workspace]/integrations` | yes | no | Per-workspace integration cards | | `/w/[workspace]/settings` | yes | no | Identity, 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 | Endpoint | Method | What it does | |---|---|---| | `/api/auth/login` | POST | Owner login (rate-limited, locked out on repeated failure) | | `/api/auth/logout` | POST | Destroy session cookie | | `/api/auth/viewer` | POST | Viewer login with email + access code | | `/api/auth/viewer/lookup` | POST | Returning-viewer lookup: workspaces this email can re-enter | | `/api/auth/viewer/select` | POST / PUT | Pick a workspace, re-issue session | | `/api/uploads` | POST | Image upload (mime + magic-bytes + owner gate) | | `/api/spreadsheets/[id]` | GET | Serve a local `Spreadsheet.data` blob (auth + share gated) | | `/api/integrations/google/start` | POST | Begin Google OAuth flow | | `/api/integrations/google/callback` | GET | OAuth callback (cookie-state validation) | | `/api/integrations/google/disconnect` | POST | Revoke + clear workspace's Google connection | | `/api/integrations/notion/image` | GET | CORS / referrer proxy for Notion-hosted images | | `/api/linear/proxy` | GET | Proxy Linear-hosted file URLs through the workspace's API key | | `/api/copilotkit` | POST | AI assistant runtime (CopilotKit + AG-UI, owner-gated). See [AI Assistant](09-assistant.md) | 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. # AI Assistant The assistant turns the app into something you can drive with plain language. Instead of clicking into a screen, filling a form, and hitting save, you tell the assistant what you want — "add a task to prepare the Q3 investor update by next Friday", "do I have meetings today?", "show me analytics for the last three days", "sync the dev.to articles" — and it runs the action, asks a follow-up only when something genuinely can't be inferred, and shows the result inline (a task card, a chart, a meetings list, a confirmation). It's built on [CopilotKit](https://copilotkit.ai) over the **AG-UI** protocol for tool calling and streaming, but every pixel of the UI is our own — there's no third-party widget styling anywhere. The launcher, panel, message bubbles, tool cards, and charts all use the product's design tokens. ## Where AG-UI is used AG-UI (the Agent–UI event/message protocol CopilotKit speaks) is the contract between the browser and the runtime. We use it in two concrete places: 1. **The message stream.** We render the conversation off `useCopilotChatInternal()`, whose `messages` are **AG-UI messages** — plain objects with `role: "user" | "assistant" | "tool"`, optional `content`, and `toolCalls` on assistant turns. Our renderer ([`use-rendered-messages.tsx`](../src/features/assistant/use-rendered-messages.tsx)) walks that AG-UI list and pairs each assistant `toolCall` with its `tool` result message (by `toolCallId`) to drive the card's `inProgress → executing → complete` state. 2. **Tool calls.** Each `useCopilotAction` is exposed to the model as an AG-UI tool. When the model calls one, the runtime emits an AG-UI tool-call event, our handler runs the server action, and the result is streamed back as an AG-UI `tool` message — which our renderer turns into a card. > Note on the headless hook: in this CopilotKit build (`1.60.2`) the public `useCopilotChat()` wrapper exposes a `visibleMessages` field that is empty, so we use `useCopilotChatInternal()` (open-source, no API key) which returns the real AG-UI `messages`. We do **not** use `@copilotkit/react-ui` — the entire UI is hand-built so it matches the product. Because of that, tool cards aren't auto-rendered for us; we keep a small render registry ([`render-registry.tsx`](../src/features/assistant/render-registry.tsx)) that maps each action name to its card. ## How it's wired The flow of a single turn: 1. The browser provider (``) streams the conversation over **AG-UI** to the runtime route. 2. The route (`CopilotRuntime` + `OpenAIAdapter`) calls **OpenAI** (`gpt-4o-mini`) with the registered tools and the readable context. 3. If the model calls a tool, the runtime sends an AG-UI **tool-call** back to the browser; our handler runs the matching **owner-gated server action**. 4. The action's result streams back as an AG-UI **tool** message, which our renderer turns into a **card** in the panel. The pieces: - **`/api/copilotkit`** ([route.ts](../src/app/api/copilotkit/route.ts)) — a single Next.js route hosting the `CopilotRuntime`. Uses `OpenAIAdapter` (`gpt-4o-mini`) when `OPENAI_API_KEY` is set, and an empty adapter otherwise so the build never breaks when the key is missing. See **Security** below for the request guards. - **The provider** ([assistant.tsx](../src/features/assistant/assistant.tsx)) — wraps the workspace UI in `` pointed at that route. Mounted once per workspace from the workspace layout for **both owners and viewers**, with a `canWrite` flag derived from the session role. Rendered client-only (after mount) to avoid SSR hydration mismatches. - **The UI** — [`assistant-launcher.tsx`](../src/features/assistant/assistant-launcher.tsx) (floating circular button, CopilotKit mark), [`assistant-panel.tsx`](../src/features/assistant/assistant-panel.tsx) (the conversational panel on the headless hook), and the cards in [`assistant-cards.tsx`](../src/features/assistant/assistant-cards.tsx) / [`assistant-analytics-card.tsx`](../src/features/assistant/assistant-analytics-card.tsx). - **The tools** — [`use-assistant-actions.tsx`](../src/features/assistant/use-assistant-actions.tsx) registers every front-end action plus the readable context. This is the extensibility seam. - **The server actions** — [`assistant/actions.ts`](<../src/app/(app)/w/[workspace]/assistant/actions.ts>) — owner-gated writes and access-checked reads. They reuse the same services the rest of the app uses, so the assistant can never do something a normal screen can't. ## What it can do today | Action | Type | What happens | | --- | --- | --- | | `showAnalytics` | read | Fetches tasks/content/engagement for the last N days and renders charts + a short trend summary. | | `listMeetings` | read | Lists upcoming meetings from the connected Google Calendar (with Join Meet / Open in Calendar links). | | `createTask` | write | Creates a local task; infers title, priority, status, and resolves relative deadlines. | | `updateTaskStatus` | write | Moves a task to a new status (done / blocked / reopened). | | `deleteTask` | write | Deletes a task — **only after you type `yes`** in a confirmation card. | | `createDocument` | write | Creates a local note, or a real Notion / Google Doc when that integration is connected; returns the real URL. | | `syncDocuments` | write | Pulls latest docs from connected Notion / Google. | | `syncContent` | write | Syncs the Content page sources — Hacker News, Reddit, or dev.to (or all). | | `createMeeting` | write | Creates a Google Calendar event with a Meet link and emails invites to attendees. | | `createLinearIssue` | write | Creates a Linear issue on the workspace's default team; returns the issue URL. | | `assignLinearIssue` | write | Assigns a Linear issue (`ENG-123`) to a person by name/email or `me`. | | `syncLinear` | write | Pulls the latest issues from the connected Linear account. | | `navigate` | nav | Opens a section of the workspace. | Created/updated entities (tasks, docs, meetings, Linear issues) render an **Open** button using the real link the tool returned — never a fabricated one. ### Deletion is double-confirmed Destructive actions don't execute on the model's say-so. `deleteTask` only *stages* the delete; the card requires you to **type `yes`** and click **Delete** before anything is removed (`DeleteConfirmCard` in [`assistant-cards.tsx`](../src/features/assistant/assistant-cards.tsx)). This pattern is reusable for any future destructive tool. ## Permissions — owners vs viewers The assistant is available to everyone, but **viewers are strictly read-only**, enforced in three independent layers (defense in depth): 1. **Client tools.** Write tools are registered with `available: "disabled"` for viewers, so the model isn't even told they exist. Only `showAnalytics`, `listMeetings`, the readable context, and `navigate` are offered. The system prompt also switches to an explicit read-only instruction. 2. **Server actions.** Every write action calls `requireOwner()` and throws `FORBIDDEN` for a viewer. The read actions use `requireWorkspaceAccess(workspaceId)`, which lets owners through and viewers through only for a workspace they hold a valid join for. 3. **The runtime route.** Authenticated-only, same-origin-only, rate-limited (see below). So a viewer can ask "how many tasks did Rohan ship in the last 7 days?", "analytics for the last 3 days", "what Linear issues are open?", "any meetings today?" — and get answers — but any create/update/delete/sync/assign/schedule request is refused and impossible to execute. ## Security The runtime endpoint and actions are hardened for the app's threat model: - **Authentication** — `/api/copilotkit` rejects any request without a valid session, so the OpenAI key is never exposed to anonymous traffic. - **CSRF / cross-origin** — the route runs `requireSameOrigin(req)`; a cross-site page can't drive the model on a logged-in user's cookie. - **Rate limiting** — per-IP sliding window (40 req/min) caps cost and abuse of the model endpoint. - **Authorization** — writes are `requireOwner()`-gated; reads are `requireWorkspaceAccess()`-gated. The client toolset is also role-scoped (`canWrite`). - **Input validation** — every action zod-validates its input (length caps on titles/bodies, enum'd statuses, clamped analytics windows) before touching Prisma or an integration. - **No fabricated side effects** — the model is instructed never to claim success or invent links unless a tool returned `ok: true`; integration tools relay the real "not connected" error instead of pretending. - **Least privilege at the data layer** — the assistant only calls the same services the UI uses; it has no raw DB access and can't reach another workspace's data. ## Grounding context Each turn the model is given live, read-only context via `useCopilotReadable`: - the current workspace (slug, name, whether hour tracking is on, today's date for relative deadlines), - open tasks (id + title + status + priority + due date), - recent tasks of **any** status incl. DONE/CANCELED (so it can resolve a completed/cancelled task's id, e.g. to delete it), - task counts by status. ## Using it - Click the CopilotKit button in the bottom-right, or press **⌘/Ctrl + J**. **Esc** closes it. - Type a request and press **Enter** (**Shift+Enter** for a newline). Responses stream in; the stop button cancels generation. - The header has an **inspector** button (bug icon) and **+** to start a fresh conversation. - Responsive: docked panel on desktop, near-fullscreen sheet on mobile. ## Configuration The assistant needs `OPENAI_API_KEY` in the environment (the same key used for work-log summaries — see [Integrations](06-integrations.md)). Without it the launcher still appears but the runtime can't generate responses. Integration-backed actions additionally require that integration to be connected for the workspace (Google for meetings/docs, Notion for Notion docs, Linear for issues); when it isn't, the assistant relays a clear "connect it in Settings" message. No CopilotKit API key is required — we self-host the runtime. ## Extending it To add a new capability: 1. If it writes data, add an owner-gated server action in [`assistant/actions.ts`](<../src/app/(app)/w/[workspace]/assistant/actions.ts>): `requireOwner()` (or `requireWorkspaceAccess` for a read), zod-validate input, call the existing service, `revalidatePath`, return `{ ok, … }`. 2. Register a `useCopilotAction` in [`use-assistant-actions.tsx`](../src/features/assistant/use-assistant-actions.tsx) with a clear `description`, typed `parameters`, a `handler`, and `available: writeAvail` if it's a write tool (so viewers don't get it). 3. Add the card's render to the registry (`registerToolRender("yourAction", …)`), returning a card from [`assistant-cards.tsx`](../src/features/assistant/assistant-cards.tsx). For destructive actions, stage-only in the handler and confirm in a `DeleteConfirmCard`-style card. 4. If the model needs new context, add a `useCopilotReadable`. No UI changes are needed — the new tool is immediately controllable through natural language.