Add agency edition implementation plan for team handover
16-week plan for taking the CrawlerX fork to external agency sales: decisions, phased tasks with code pointers, risks, and a handover checklist. Provided as Markdown and a standalone HTML page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
05e64ccd36
commit
a3b13a112a
676
docs/AGENCY_EDITION_PLAN.html
Normal file
676
docs/AGENCY_EDITION_PLAN.html
Normal file
@ -0,0 +1,676 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CrawlerX Agency Handover</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3f6f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--ink: #131a26;
|
||||||
|
--muted: #566274;
|
||||||
|
--line: #dde3ea;
|
||||||
|
--primary: #3779b9;
|
||||||
|
--primary-soft: #e6f0f9;
|
||||||
|
--navy: #0f1d34;
|
||||||
|
--navy-ink: #e8eef6;
|
||||||
|
--accent: #4faede;
|
||||||
|
--warn: #9a5b00;
|
||||||
|
--warn-soft: #fff1d6;
|
||||||
|
--crit: #a3271f;
|
||||||
|
--crit-soft: #fde6e3;
|
||||||
|
--ok: #1d6b45;
|
||||||
|
--ok-soft: #dff3e8;
|
||||||
|
--code-bg: #eef2f6;
|
||||||
|
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
--mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #0d1119;
|
||||||
|
--surface: #141a25;
|
||||||
|
--ink: #e8ecf2;
|
||||||
|
--muted: #9aa6b6;
|
||||||
|
--line: #262f3f;
|
||||||
|
--primary: #6aa8e6;
|
||||||
|
--primary-soft: #182a40;
|
||||||
|
--navy: #0a1424;
|
||||||
|
--warn: #f0b155;
|
||||||
|
--warn-soft: #3a2a10;
|
||||||
|
--crit: #f19188;
|
||||||
|
--crit-soft: #3d1c19;
|
||||||
|
--ok: #6fcf9b;
|
||||||
|
--ok-soft: #14301f;
|
||||||
|
--code-bg: #1b2331;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0d1119;
|
||||||
|
--surface: #141a25;
|
||||||
|
--ink: #e8ecf2;
|
||||||
|
--muted: #9aa6b6;
|
||||||
|
--line: #262f3f;
|
||||||
|
--primary: #6aa8e6;
|
||||||
|
--primary-soft: #182a40;
|
||||||
|
--navy: #0a1424;
|
||||||
|
--warn: #f0b155;
|
||||||
|
--warn-soft: #3a2a10;
|
||||||
|
--crit: #f19188;
|
||||||
|
--crit-soft: #3d1c19;
|
||||||
|
--ok: #6fcf9b;
|
||||||
|
--ok-soft: #14301f;
|
||||||
|
--code-bg: #1b2331;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding-inline: 16px;
|
||||||
|
padding-block: 0 64px;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 980px; margin: 0 auto; }
|
||||||
|
a { color: var(--primary); }
|
||||||
|
code, .path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header band */
|
||||||
|
.masthead {
|
||||||
|
background: var(--navy);
|
||||||
|
color: var(--navy-ink);
|
||||||
|
margin-inline: -16px;
|
||||||
|
padding: 44px 16px 40px;
|
||||||
|
}
|
||||||
|
.masthead .wrap { display: grid; gap: 14px; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(28px, 5vw, 42px);
|
||||||
|
line-height: 1.12;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
text-wrap: balance;
|
||||||
|
max-width: 22ch;
|
||||||
|
}
|
||||||
|
.lede { max-width: 66ch; color: #b9c6d8; margin: 0; font-size: 16px; }
|
||||||
|
.facts {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px 24px;
|
||||||
|
margin-top: 8px; padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.14);
|
||||||
|
font-size: 13px; color: #b9c6d8;
|
||||||
|
}
|
||||||
|
.facts b { color: #fff; font-weight: 500; }
|
||||||
|
|
||||||
|
/* nav */
|
||||||
|
.toc {
|
||||||
|
position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5;
|
||||||
|
background: var(--bg);
|
||||||
|
margin-inline: -16px; padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.toc ul {
|
||||||
|
list-style: none; margin: 0 auto; padding: 0; max-width: 980px;
|
||||||
|
display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.toc a {
|
||||||
|
display: block; white-space: nowrap; text-decoration: none;
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
padding: 5px 11px; border-radius: 999px; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.toc a:hover, .toc a:focus-visible { color: var(--ink); border-color: var(--line); background: var(--surface); outline: none; }
|
||||||
|
|
||||||
|
section { padding-top: 44px; }
|
||||||
|
h2 {
|
||||||
|
font-size: 24px; line-height: 1.2; letter-spacing: -0.015em;
|
||||||
|
font-weight: 600; margin: 0 0 6px; text-wrap: balance;
|
||||||
|
}
|
||||||
|
h3 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||||
|
.sub { color: var(--muted); margin: 0 0 20px; max-width: 68ch; }
|
||||||
|
p { margin: 0 0 12px; max-width: 72ch; }
|
||||||
|
ul.plain { margin: 0 0 12px; padding-left: 20px; max-width: 72ch; }
|
||||||
|
ul.plain li { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* tables */
|
||||||
|
.tablebox { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
|
||||||
|
table { border-collapse: collapse; width: 100%; min-width: 620px; font-size: 14px; }
|
||||||
|
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
th {
|
||||||
|
font-family: var(--mono); font-size: 11.5px; font-weight: 500;
|
||||||
|
letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted);
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
td.n { white-space: nowrap; font-weight: 500; }
|
||||||
|
|
||||||
|
/* chips */
|
||||||
|
.chip {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 500;
|
||||||
|
padding: 2px 8px; border-radius: 999px; white-space: nowrap;
|
||||||
|
background: var(--code-bg); color: var(--muted); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chip.blocker { background: var(--crit-soft); color: var(--crit); border-color: transparent; }
|
||||||
|
.chip.long { background: var(--warn-soft); color: var(--warn); border-color: transparent; }
|
||||||
|
.chip.now { background: var(--ok-soft); color: var(--ok); border-color: transparent; }
|
||||||
|
.chip.rec { background: var(--primary-soft); color: var(--primary); border-color: transparent; }
|
||||||
|
|
||||||
|
/* callout */
|
||||||
|
.callout {
|
||||||
|
border: 1px solid var(--line); border-left: 3px solid var(--crit);
|
||||||
|
background: var(--surface); padding: 14px 16px; border-radius: 4px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.callout.info { border-left-color: var(--primary); }
|
||||||
|
.callout h3 { margin-bottom: 4px; }
|
||||||
|
.callout p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* timeline */
|
||||||
|
.tl { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); padding: 14px 14px 10px; }
|
||||||
|
.tl-grid {
|
||||||
|
min-width: 720px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 190px repeat(16, 1fr);
|
||||||
|
row-gap: 6px; align-items: center;
|
||||||
|
}
|
||||||
|
.tl-h { font-family: var(--mono); font-size: 10.5px; color: var(--muted); text-align: left; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
.tl-h:first-child { border-bottom: 1px solid var(--line); }
|
||||||
|
.tl-l { font-size: 13px; padding-right: 10px; }
|
||||||
|
.tl-b { height: 20px; border-radius: 3px; background: var(--primary); opacity: 0.9; }
|
||||||
|
.tl-b.long { background: var(--warn); }
|
||||||
|
.tl-b.pilot { background: var(--ok); }
|
||||||
|
.tl-b.later { background: var(--muted); opacity: 0.55; }
|
||||||
|
.phasekey { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12.5px; color: var(--muted); margin-top: 12px; }
|
||||||
|
.phasekey i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
|
||||||
|
|
||||||
|
/* phases */
|
||||||
|
.phase-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.phase-head .wk { font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
|
||||||
|
.goal {
|
||||||
|
background: var(--primary-soft); border-radius: 4px; padding: 10px 14px;
|
||||||
|
margin: 10px 0 18px; max-width: 72ch;
|
||||||
|
}
|
||||||
|
.goal b { font-weight: 600; }
|
||||||
|
|
||||||
|
.tasks { display: grid; gap: 12px; }
|
||||||
|
.task {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.task-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; margin-bottom: 6px; }
|
||||||
|
.tid { font-family: var(--mono); font-size: 12px; color: var(--primary); font-weight: 500; }
|
||||||
|
.task-top h3 { flex: 1 1 260px; }
|
||||||
|
.task-top .chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.task p { color: var(--ink); margin-bottom: 8px; }
|
||||||
|
.meta { display: grid; grid-template-columns: 84px 1fr; gap: 4px 12px; font-size: 13.5px; margin: 0; }
|
||||||
|
.meta dt { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); padding-top: 2px; }
|
||||||
|
.meta dd { margin: 0; color: var(--ink); }
|
||||||
|
@media (max-width: 520px) { .meta { grid-template-columns: 1fr; gap: 0 } .meta dd { margin-bottom: 6px } }
|
||||||
|
|
||||||
|
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
.box { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 14px 16px; }
|
||||||
|
.box h3 { margin-bottom: 8px; }
|
||||||
|
.box ul { margin: 0; padding-left: 18px; font-size: 14px; }
|
||||||
|
.box li { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.check { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
||||||
|
.check li { display: flex; gap: 10px; padding: 7px 0; border-bottom: 1px dashed var(--line); }
|
||||||
|
.check li:last-child { border-bottom: 0; }
|
||||||
|
.check li::before {
|
||||||
|
content: ""; flex: 0 0 15px; height: 15px; margin-top: 3px;
|
||||||
|
border: 1.5px solid var(--muted); border-radius: 3px;
|
||||||
|
}
|
||||||
|
footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
||||||
|
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="eyebrow">Handover · September 2026</div>
|
||||||
|
<h1>CrawlerX for agencies: build plan</h1>
|
||||||
|
<p class="lede">Turn our internal fork of OpenSEO into a product agencies buy instead of Semrush: client workspaces, white-label reports, usage-based billing. This page is the working plan for the team taking over.</p>
|
||||||
|
<div class="facts">
|
||||||
|
<span><b>Base</b> OpenSEO 0.1.7 (MIT)</span>
|
||||||
|
<span><b>Stack</b> TanStack Start · Drizzle (SQLite + Postgres) · Better Auth · DataForSEO</span>
|
||||||
|
<span><b>Horizon</b> 16 weeks, pilot at week 8</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="toc" aria-label="Sections">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#start">Start here</a></li>
|
||||||
|
<li><a href="#decisions">Decisions</a></li>
|
||||||
|
<li><a href="#timeline">Timeline</a></li>
|
||||||
|
<li><a href="#p0">Phase 0</a></li>
|
||||||
|
<li><a href="#p1">Phase 1</a></li>
|
||||||
|
<li><a href="#p2">Phase 2</a></li>
|
||||||
|
<li><a href="#p3">Phase 3</a></li>
|
||||||
|
<li><a href="#quality">Quality bar</a></li>
|
||||||
|
<li><a href="#risks">Risks</a></li>
|
||||||
|
<li><a href="#access">Handover checklist</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
|
||||||
|
<section id="start">
|
||||||
|
<h2>Start here</h2>
|
||||||
|
<p class="sub">What the product is, where it stands, and the one architectural fact that shapes everything.</p>
|
||||||
|
|
||||||
|
<div class="cols">
|
||||||
|
<div class="box">
|
||||||
|
<h3>What works today</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Keyword research, saved keywords, scheduled rank tracking (with local locations)</li>
|
||||||
|
<li>Domain and competitor overview, backlinks</li>
|
||||||
|
<li>Site audit (own crawler, DataForSEO OnPage fallback, Lighthouse)</li>
|
||||||
|
<li>Search Console and GA4 integrations</li>
|
||||||
|
<li>AI visibility: brand lookup, prompt explorer</li>
|
||||||
|
<li>SAM in-app agent, MCP server and agent skills (hosted mode only)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h3>What we added to the fork</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CrawlerX / MetatronCube rebrand (theme in <code>src/client/styles/app.css</code>)</li>
|
||||||
|
<li><code>AUTH_MODE=team</code>: owner-provisioned users, roles, shared workspace</li>
|
||||||
|
<li>Activity log (<code>src/server/features/activity/</code>)</li>
|
||||||
|
<li>Browser-like crawler headers and DataForSEO OnPage fallback for bot-blocked sites</li>
|
||||||
|
<li>Combined SEO report: GSC + GA4 + audit (<code>/p/$projectId/report</code>)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout info" style="margin-top:16px">
|
||||||
|
<h3>The fact that shapes the plan</h3>
|
||||||
|
<p>The codebase has four auth modes, and none is "an agency with many clients". <code>team</code> is a single shared workspace with no billing and no self-serve signup. <code>hosted</code> has orgs, roles, invitations and per-org credit billing, but it is wired to the upstream author's vendors (Autumn and Stripe, Loops email, PostHog, Cloudflare KV). The plan is to <b>build the agency product on top of <code>hosted</code> mode</b>, running it on our own accounts, and to run <code>team</code>-mode instances for pilot agencies while that work happens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><b>Read first:</b> <code>CLAUDE.md</code> (engineering rules), <code>specs/0002</code> (DataForSEO metering), <code>specs/0011</code> (multi-user orgs), <code>docs/SELF_HOSTING_TEAM_MODE.md</code>, <code>docs/LOCAL_DEVELOPMENT.md</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="decisions">
|
||||||
|
<h2>Decisions to confirm before Phase 1</h2>
|
||||||
|
<p class="sub">The plan below assumes the recommended answer. If any changes, the affected phase is named.</p>
|
||||||
|
<div class="tablebox">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Decision</th><th>Assumed answer</th><th>If it changes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="n">Architecture</td>
|
||||||
|
<td><span class="chip rec">recommended</span> Multi-tenant hosted mode, deployed to Cloudflare with Postgres. Per-agency team-mode instances only for the pilot.</td>
|
||||||
|
<td>Per-agency instances forever: drop P2.4–P2.6, add instance provisioning automation.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="n">Branding depth</td>
|
||||||
|
<td>Agencies white-label the reports and client view. The app shell stays CrawlerX.</td>
|
||||||
|
<td>Full white-label (custom domain, app shell): add P3.1.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="n">Upstream</td>
|
||||||
|
<td>Hard fork. Cherry-pick security fixes and DataForSEO changes only.</td>
|
||||||
|
<td>Track upstream: do P0.5 first and keep every change additive.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="n">Pricing</td>
|
||||||
|
<td>Platform fee per agency, client seats, plus credit packs for data usage.</td>
|
||||||
|
<td>Pure usage: skip client seats in P2.4.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="n">Team size</td>
|
||||||
|
<td>Planning assumption: two full-stack engineers, one part-time devops/QA.</td>
|
||||||
|
<td>Scale the timeline; the phase order does not change.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="timeline">
|
||||||
|
<h2>Timeline</h2>
|
||||||
|
<p class="sub">Long-lead items (amber) start in week 0 because they gate launch, not because they are big.</p>
|
||||||
|
<div class="tl">
|
||||||
|
<div class="tl-grid" role="img" aria-label="Sixteen week timeline of workstreams across phases 0 to 3">
|
||||||
|
<div class="tl-h"></div>
|
||||||
|
<div class="tl-h">W1</div><div class="tl-h">2</div><div class="tl-h">3</div><div class="tl-h">4</div><div class="tl-h">5</div><div class="tl-h">6</div><div class="tl-h">7</div><div class="tl-h">8</div><div class="tl-h">9</div><div class="tl-h">10</div><div class="tl-h">11</div><div class="tl-h">12</div><div class="tl-h">13</div><div class="tl-h">14</div><div class="tl-h">15</div><div class="tl-h">16</div>
|
||||||
|
|
||||||
|
<div class="tl-l">Google verification, vendor terms</div><div class="tl-b long" style="grid-column: 2 / 12"></div>
|
||||||
|
<div class="tl-l">Cleanup, scrub, security review</div><div class="tl-b" style="grid-column: 2 / 4"></div>
|
||||||
|
<div class="tl-l">Hosted-mode platform on Cloudflare</div><div class="tl-b" style="grid-column: 3 / 8"></div>
|
||||||
|
<div class="tl-l">Branding, PDF, share links</div><div class="tl-b" style="grid-column: 4 / 9"></div>
|
||||||
|
<div class="tl-l">Agency dashboard, pilot ops</div><div class="tl-b pilot" style="grid-column: 6 / 9"></div>
|
||||||
|
<div class="tl-l">Client role, project access</div><div class="tl-b" style="grid-column: 10 / 13"></div>
|
||||||
|
<div class="tl-l">Scheduled reports, alerts</div><div class="tl-b" style="grid-column: 12 / 15"></div>
|
||||||
|
<div class="tl-l">Usage caps, agency billing</div><div class="tl-b" style="grid-column: 10 / 18"></div>
|
||||||
|
<div class="tl-l">Self-serve signup, launch</div><div class="tl-b" style="grid-column: 15 / 18"></div>
|
||||||
|
</div>
|
||||||
|
<div class="phasekey">
|
||||||
|
<span><i style="background:var(--warn)"></i>Long lead time</span>
|
||||||
|
<span><i style="background:var(--primary)"></i>Build</span>
|
||||||
|
<span><i style="background:var(--ok)"></i>Pilot agencies onboard</span>
|
||||||
|
<span><i style="background:var(--muted);opacity:.55"></i>Later</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PHASE 0 -->
|
||||||
|
<section id="p0">
|
||||||
|
<div class="phase-head"><h2>Phase 0: De-risk and clean</h2><span class="wk">Weeks 1–2</span></div>
|
||||||
|
<p class="sub">Nothing customer-facing. Remove the things that would hurt us the moment a second customer appears.</p>
|
||||||
|
<div class="goal"><b>Exit criteria:</b> no upstream branding, contact details or telemetry reach a customer. Hosting decision written down. Google verification and DataForSEO terms are in progress. Tenant isolation is tested.</div>
|
||||||
|
|
||||||
|
<div class="tasks">
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.1</span><h3>Turn off upstream telemetry today</h3><div class="chips"><span class="chip blocker">blocker</span><span class="chip now">do first</span><span class="chip">S</span></div></div>
|
||||||
|
<p><code>self-host-telemetry.ts</code> holds a hard-coded PostHog key for the upstream project and skips sending only in <code>hosted</code> mode. Our <code>team</code>-mode instance appears to send anonymous heartbeats to them unless opted out.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Do</dt><dd>Set <code>OPENSEO_TELEMETRY_DISABLED=1</code> in the production <code>.env</code> and restart. Then remove the module and its wiring, or replace the key with our own PostHog project.</dd>
|
||||||
|
<dt>Where</dt><dd><span class="path">src/server/lib/self-host-telemetry.ts</span></dd>
|
||||||
|
<dt>Done when</dt><dd>Network capture on a running instance shows no calls to <code>us.i.posthog.com</code> with the upstream key.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.2</span><h3>Scrub upstream identity, and centralise brand constants</h3><div class="chips"><span class="chip blocker">blocker</span><span class="chip">M</span></div></div>
|
||||||
|
<p>Customer support requests currently point at the upstream author's email. Links send users to the upstream product, Discord and docs. Create one <code>src/shared/brand.ts</code> (product name, support email, docs and terms URLs, crawler contact URL) and make every reference read from it.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd>
|
||||||
|
<span class="path">client/features/audit/shared.tsx</span> <span class="path">routes/_app/ai.tsx</span> <span class="path">routes/_app/support.tsx</span> <span class="path">routes/_authenticated.subscribe.tsx</span> <span class="path">routes/_auth.sign-up.tsx</span> <span class="path">client/features/onboarding/OnboardingChatParts.tsx</span> <span class="path">client/features/settings/ApiKeySettings.tsx</span> <span class="path">client/components/AuthConfigErrorCard.tsx</span> <span class="path">shared/ga4.ts</span> <span class="path">shared/gsc.ts</span> <span class="path">server/lib/scrape.ts</span> <span class="path">server/mcp/server.ts</span> <span class="path">server/features/sam/SamChatAgent.ts</span> <span class="path">server/features/rank-tracking/services/scheduledRankChecks.ts</span> <span class="path">server/features/onboarding/OnboardingChatAgent.ts</span> <span class="path">server/referrals/dub.ts</span>
|
||||||
|
</dd>
|
||||||
|
<dt>Also</dt><dd>Skills install commands in <code>ai.tsx</code> point at <code>every-app/open-seo</code>. Publish our own skills package or hide that card. Remove the Dub referral code (upstream's partner program).</dd>
|
||||||
|
<dt>Done when</dt><dd><code>grep -ri "openseo.so\|every-app\|ben@"</code> over <code>src</code> returns only tests, comments, and the licence notice. Add that grep to CI.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.3</span><h3>Hosted-mode spike and hosting decision</h3><div class="chips"><span class="chip blocker">blocker</span><span class="chip">M</span></div></div>
|
||||||
|
<p>Production today appears to run <code>vite preview</code> under pm2 (the <code>start</code> script), which emulates the Cloudflare runtime locally. That is fine for an internal tool, not for paying customers. <code>package.json</code> already has <code>deploy:postgres</code> (Alchemy, hosted-prod stage, Postgres). Prove it works end to end on our own Cloudflare account, in a staging stage.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Check</dt><dd>Sign-up, org creation, invite, project create, keyword research, site audit workflow, rank check cron, GSC connect, MCP OAuth. Note every Cloudflare binding used: KV (<code>invitation-send-limit.ts</code>, <code>oauth-provider.ts</code>, <code>progress-kv.ts</code>, <code>serp-locations.ts</code>), R2 (<code>r2.ts</code>, <code>r2-cache.ts</code>), Workflows and Durable Objects (<code>SiteAuditWorkflow.ts</code>, chat agents).</dd>
|
||||||
|
<dt>Deliverable</dt><dd>One-page decision: deploy to Cloudflare (assumed) or port off it, with the list of breakages and their cost.</dd>
|
||||||
|
<dt>Done when</dt><dd>A staging environment runs hosted mode with our own domain. A second signed-up user gets an isolated workspace.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.4</span><h3>Google OAuth verification and DataForSEO terms</h3><div class="chips"><span class="chip blocker">blocker</span><span class="chip long">long lead</span><span class="chip">S to start</span></div></div>
|
||||||
|
<p>Search Console and GA4 scopes are sensitive. Until Google verifies the app, external users see an unverified-app warning and are capped. Verification takes weeks and needs a live privacy policy, a homepage on our domain and a demo video. Separately, get written confirmation that DataForSEO allows us to resell their data in a paid product.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Do</dt><dd>Create a dedicated Google Cloud project for production. Write the privacy policy and terms (replace the upstream links in <code>_auth.sign-up.tsx</code>). Submit the consent screen. Email DataForSEO sales about reseller terms and volume pricing.</dd>
|
||||||
|
<dt>Done when</dt><dd>Verification submitted with no open questions. DataForSEO reply saved in the repo's <code>docs/</code> folder.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.5</span><h3>Tenant isolation review and tests</h3><div class="chips"><span class="chip blocker">blocker</span><span class="chip">M</span></div></div>
|
||||||
|
<p>Every server function must scope by the caller's organisation (see <code>specs/0001</code>). Run <code>/security-review</code>, then write the test that would catch a leak: two orgs, and each server function called with the other org's <code>projectId</code> must fail.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/serverFunctions/*.ts</span> <span class="path">src/server/mcp/project-auth.ts</span> <span class="path">src/middleware/ensure-user/</span></dd>
|
||||||
|
<dt>Done when</dt><dd>A single parameterised test covers every project-scoped server function and MCP tool. Findings from the review are fixed or ticketed.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P0.6</span><h3>Repo hygiene and upstream policy</h3><div class="chips"><span class="chip">S</span></div></div>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Do</dt><dd>Delete the empty root file <code>open-seo@0.1.7</code> and <code>vite.config.ts.backup</code>. Two files show as modified from line endings only: add a <code>.gitattributes</code>. Tag the fork point. Add an <code>upstream</code> remote and record the sync policy in <code>docs/MAINTAINERS.md</code>. Remove the internal hostname and server paths from <code>docs/SELF_HOSTING_TEAM_MODE.md</code>. Keep <code>LICENSE</code> and add a <code>NOTICE</code> crediting OpenSEO.</dd>
|
||||||
|
<dt>Done when</dt><dd><code>pnpm ci:check</code> passes on a clean checkout.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PHASE 1 -->
|
||||||
|
<section id="p1">
|
||||||
|
<div class="phase-head"><h2>Phase 1: Pilot MVP</h2><span class="wk">Weeks 2–8</span></div>
|
||||||
|
<p class="sub">Enough for three to five design-partner agencies to put their own clients on it. MetatronCube itself is design partner zero.</p>
|
||||||
|
<div class="goal"><b>Exit criteria:</b> an agency can send a client a branded PDF or link with real data in it, and see all their clients in one place. Pilots are invoiced by hand.</div>
|
||||||
|
|
||||||
|
<div class="tasks">
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.1</span><h3>Organisation branding</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>New table for per-organisation branding: display name, logo, primary colour, footer text. Logo stored in R2. Applied to reports and to the client view, not to the app shell.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd>New <span class="path">src/db/branding.schema.ts</span> and the Postgres twin under <span class="path">src/db/pg/</span>. Follow server function → service → repository. Run <code>npm run db:generate</code> and keep <code>schema-parity.test.ts</code> green (SQLite and Postgres must both work).</dd>
|
||||||
|
<dt>Done when</dt><dd>An owner uploads a logo in Settings and it appears on the report. Only owner and admin can edit it.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.2</span><h3>Report v2 and PDF export</h3><div class="chips"><span class="chip rec">top purchase driver</span><span class="chip">L</span></div></div>
|
||||||
|
<p><code>ProjectReportService</code> covers GSC, GA4 and the latest audit. Add rank tracking movement, a backlinks summary and AI visibility. Each section already degrades on its own when an integration is missing; keep that. Export: start with a print-optimised route (<code>@media print</code>, A4) since it is the cheapest path. Move to server-side rendering (Playwright is already a dev dependency) only if print output is not good enough.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/server/features/reports/services/ProjectReportService.ts</span> <span class="path">src/client/features/reports/ReportView.tsx</span> <span class="path">src/routes/_project/p/$projectId/report.tsx</span></dd>
|
||||||
|
<dt>Done when</dt><dd>A 4–6 page A4 PDF with the agency's logo and colours, no CrawlerX or upstream branding, and no API credits spent on render (reuse stored data).</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.3</span><h3>Shareable read-only report link</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>Store a snapshot of the report JSON at share time so a client sees stable numbers and cannot trigger spend. Public route addressed by a random token (store only its hash), with expiry and revoke. Log create and revoke in the activity log.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd>New <code>report_share</code> table (projectId, tokenHash, snapshot, expiresAt, revokedAt). New public route outside <code>_authenticated</code>. Add actions to <code>ACTIVITY_ACTIONS</code>.</dd>
|
||||||
|
<dt>Done when</dt><dd>A revoked or expired link returns 404. The page exposes nothing beyond the snapshot.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.4</span><h3>Agency dashboard across clients</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>One screen listing every client project with the few numbers an account manager scans: rank movement over 7 and 28 days, latest audit health, GSC clicks change, integration status (connected, expired, missing). Sort by "needs attention".</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/server/features/dashboard/services/DashboardService.ts</span> and <span class="path">src/routes/_app/projects.tsx</span></dd>
|
||||||
|
<dt>Done when</dt><dd>Loads in under 2 seconds with 50 projects on seeded data (<code>pnpm seed:projects</code>).</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.5</span><h3>Own transactional email</h3><div class="chips"><span class="chip">S–M</span></div></div>
|
||||||
|
<p>Invitations, password reset and verification currently go through Loops templates that belong to the upstream account. Put a small provider interface in <code>src/server/email</code> and implement it with Resend or SES. Replace <code>billing/loops-sync.ts</code> contact sync (remove, or point at our own tool).</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Done when</dt><dd>Invite, reset and verify emails arrive from our domain (SPF, DKIM set). No Loops keys required in hosted mode.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.6</span><h3>Crawler identity and allowlist guide</h3><div class="chips"><span class="chip">S</span></div></div>
|
||||||
|
<p>Many client sites sit behind Cloudflare Managed Challenge or DataDome, which the crawler cannot pass. Give agencies something to allowlist: a stable egress IP for the crawler and a user-agent that names us and links to a page explaining it. Publish a short help page with per-WAF instructions. Keep the DataForSEO OnPage fallback as the paid option.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/server/lib/audit/crawl-request.ts</span> (<code>AUDIT_USER_AGENT</code>)</dd>
|
||||||
|
<dt>Done when</dt><dd>The audit failure card on a blocked site links to the allowlist guide and to the paid fallback.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P1.7</span><h3>Pilot operations</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>Until self-serve exists, provision each pilot by script: create org and owner, apply branding, set an invoice reference. Set up production basics: nightly Postgres backup with a tested restore, error tracking, uptime check and a status page, and a shared support inbox.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd>New script beside <span class="path">scripts/seed-projects.ts</span>; runbooks in <span class="path">runbooks/</span>.</dd>
|
||||||
|
<dt>Done when</dt><dd>A new pilot agency is live in under 30 minutes by following the runbook, and a restore has been rehearsed once.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PHASE 2 -->
|
||||||
|
<section id="p2">
|
||||||
|
<div class="phase-head"><h2>Phase 2: Agency product</h2><span class="wk">Weeks 8–16</span></div>
|
||||||
|
<p class="sub">What turns a pilot into something agencies pay for monthly without hand-holding.</p>
|
||||||
|
<div class="goal"><b>Exit criteria:</b> an agency signs up, pays, adds clients, gives a client read-only access, and gets scheduled reports and alerts, with usage attributed per client.</div>
|
||||||
|
|
||||||
|
<div class="tasks">
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.1</span><h3>Client viewer role with per-project access</h3><div class="chips"><span class="chip">L</span></div></div>
|
||||||
|
<p>The <code>member</code> role exists but is not invitable: spec 0011 blocks it in <code>beforeCreateInvitation</code>, and every invitee is an admin. Add a <code>client</code> role that is read-only and limited to named projects. Model access as a <code>project_member</code> table (userId, projectId), not a JSON list. Enforce it in the project-auth middleware and the MCP layer.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/lib/org-permissions.ts</span> <span class="path">src/server/mcp/project-auth.ts</span> <span class="path">src/serverFunctions/organization.ts</span></dd>
|
||||||
|
<dt>Done when</dt><dd>A client user sees only assigned projects, cannot run credit-spending actions, and cannot see billing. Covered by the P0.5 isolation test.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.2</span><h3>Scheduled report emails</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>Per-project schedule (weekly or monthly), recipients, and whether to attach the PDF or send the share link. A cron creates a snapshot (P1.3) and sends it. Cron hooks already exist in the <code>scheduled</code> handler in <code>src/server.ts</code> and <code>wrangler.jsonc</code>.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Done when</dt><dd>A failed send is retried and visible in the activity log. Unsubscribe link works.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.3</span><h3>Alerts</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>Notify on rank drops above a threshold, audit score regressions, GSC clicks falling, and expired Google connections. Hook into <code>scheduledRankChecks.ts</code> after each run. Preferences table per user and project. Email first, in-app feed second.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Done when</dt><dd>No duplicate alert for the same condition within 24 hours.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.4</span><h3>Per-client usage attribution and caps</h3><div class="chips"><span class="chip">L</span></div></div>
|
||||||
|
<p>Credits are pooled per organisation. Agencies need to know what each client costs, and to stop one client burning the pool. Record <code>projectId</code> on every usage event, show usage by client on the billing page, and add an optional monthly cap per project checked in the same preflight as the balance check.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/server/lib/dataforseoClient.ts</span> (the single entry point per spec 0002), <span class="path">src/server/billing/subscription.ts</span></dd>
|
||||||
|
<dt>Done when</dt><dd>A capped project fails with a clear "client cap reached" error. Usage-by-client totals match the org total.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.5</span><h3>Our own billing setup and agency plans</h3><div class="chips"><span class="chip blocker">blocker for launch</span><span class="chip">L</span></div></div>
|
||||||
|
<p>Create our own Autumn and Stripe accounts and define the agency plans (platform fee, client seats, credit packs). The 1.28 markup is a constant in <code>src/shared/billing.ts</code>: make it configuration, not a code edit, so pricing can change without a deploy. Stripe checkout already collects business name and tax ID.</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Where</dt><dd><span class="path">src/shared/billing.ts</span> <span class="path">src/server/billing/</span> <span class="path">src/routes/_authenticated.subscribe.tsx</span></dd>
|
||||||
|
<dt>Done when</dt><dd>Test-mode subscribe, top-up, cancel and invoice all work. Cost estimates in the UI match what is billed.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="task-top"><span class="tid">P2.6</span><h3>Self-serve signup, onboarding, launch pages</h3><div class="chips"><span class="chip">M</span></div></div>
|
||||||
|
<p>Signup flow adapted for agencies (agency name, first client project, connect Google), a free-trial policy with a spend ceiling so trials cannot burn DataForSEO money, terms and privacy pages, a pricing page. Rework the onboarding chat copy from "personal SEO tool" to "agency workspace".</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Done when</dt><dd>A stranger can sign up, add a client, run a first audit and receive a report without talking to us. Trial spend per organisation is capped and monitored.</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PHASE 3 -->
|
||||||
|
<section id="p3">
|
||||||
|
<div class="phase-head"><h2>Phase 3: After launch</h2><span class="wk">Order by what pilots ask for</span></div>
|
||||||
|
<p class="sub">Do not start these until paying agencies have asked. Each is a real project.</p>
|
||||||
|
<div class="tablebox">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>Item</th><th>Note</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="n">P3.1</td><td>Custom domain and full white-label</td><td>Per-org hostname (Cloudflare for SaaS), branded login and emails.</td></tr>
|
||||||
|
<tr><td class="n">P3.2</td><td>Agency MCP and API</td><td>MCP and skills exist in hosted mode. Publish our own skill pack and per-org API keys.</td></tr>
|
||||||
|
<tr><td class="n">P3.3</td><td>Looker Studio connector or CSV export</td><td>Agencies keep their own dashboards. Today only export to Google Sheets exists.</td></tr>
|
||||||
|
<tr><td class="n">P3.4</td><td>Security hardening</td><td>2FA, SSO for larger agencies, audit-log export, DPA template.</td></tr>
|
||||||
|
<tr><td class="n">P3.5</td><td>Local SEO depth</td><td>Local rank grids and Business Profile data, if agencies serve local businesses.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="quality">
|
||||||
|
<h2>Quality bar for every task</h2>
|
||||||
|
<p class="sub">These come from <code>CLAUDE.md</code>. New work is reviewed against them.</p>
|
||||||
|
<div class="cols">
|
||||||
|
<div class="box">
|
||||||
|
<h3>Code</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Backend shape: TanStack server function → service → repository.</li>
|
||||||
|
<li>Every schema change works on SQLite and Postgres; run <code>npm run db:generate</code> and keep the parity test green.</li>
|
||||||
|
<li>Relations are real tables and foreign keys, not JSON.</li>
|
||||||
|
<li>Validate untrusted input with Zod at the boundary.</li>
|
||||||
|
<li>All DataForSEO calls go through the metered client. A raw <code>fetch*Raw</code> call in feature code is a billing bypass.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h3>Tests and CI</h3>
|
||||||
|
<ul>
|
||||||
|
<li>A test must protect real behaviour or a real edge case. No tests for their own sake.</li>
|
||||||
|
<li>Test at the public entry point; do not mock ORM builder chains.</li>
|
||||||
|
<li>Merge gate: <code>pnpm ci:check</code> (prettier, knip, tsc, oxlint) and <code>pnpm test</code>.</li>
|
||||||
|
<li>Log small repo friction in <code>.agents/PAPERCUTS.md</code>.</li>
|
||||||
|
<li>Changes to <code>.greptile/</code>, <code>AGENTS.md</code>, <code>CLAUDE.md</code> and <code>.github/</code> need maintainer review.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="risks">
|
||||||
|
<h2>Risks</h2>
|
||||||
|
<div class="tablebox">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Risk</th><th>Why it matters</th><th>Mitigation</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="n">DataForSEO cost overrun</td><td>We pay raw cost. Trials, rank tracking and large audits can burn money faster than revenue.</td><td>P2.4 caps, trial spend ceiling in P2.6, alert on account balance, weekly review of <code>pnpm billing:usage</code>.</td></tr>
|
||||||
|
<tr><td class="n">Google verification delay</td><td>Blocks external GSC and GA4, which the report depends on.</td><td>Submit in week 1 (P0.4). Pilots can use their own Google Cloud client in the meantime.</td></tr>
|
||||||
|
<tr><td class="n">Reseller terms</td><td>If DataForSEO disallows resale, the model needs a different arrangement.</td><td>Ask before building billing (P0.4).</td></tr>
|
||||||
|
<tr><td class="n">Cross-tenant data leak</td><td>Fatal for trust, with agency clients' data.</td><td>P0.5 test suite, and a review of every new server function.</td></tr>
|
||||||
|
<tr><td class="n">Blocked crawls</td><td>Site audit fails on many client sites.</td><td>P1.6 allowlist and identity, paid OnPage fallback.</td></tr>
|
||||||
|
<tr><td class="n">Upstream drift</td><td>Upstream ships almost daily. Our rebrand touched 42 files.</td><td>Hard fork; brand constants in one file (P0.2); cherry-pick security fixes only.</td></tr>
|
||||||
|
<tr><td class="n">Runtime not production-grade</td><td>Internal deployment runs a preview server under pm2.</td><td>P0.3 decision; do not put paying agencies on it.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="access">
|
||||||
|
<h2>Handover checklist</h2>
|
||||||
|
<p class="sub">Access the new team needs on day one, and what they should read.</p>
|
||||||
|
<div class="cols">
|
||||||
|
<div class="box">
|
||||||
|
<h3>Accounts and access</h3>
|
||||||
|
<ul class="check">
|
||||||
|
<li>Gitea repository and CI</li>
|
||||||
|
<li>Production and staging servers (SSH, pm2)</li>
|
||||||
|
<li>Postgres credentials and backup location</li>
|
||||||
|
<li>DataForSEO account and billing alerts</li>
|
||||||
|
<li>Google Cloud project (OAuth client, consent screen)</li>
|
||||||
|
<li>Cloudflare account and DNS for the product domain</li>
|
||||||
|
<li>Stripe and Autumn accounts (create if not yet)</li>
|
||||||
|
<li>Email provider, error tracking and PostHog project</li>
|
||||||
|
<li>Shared support inbox</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h3>Day-one tasks for each engineer</h3>
|
||||||
|
<ul class="check">
|
||||||
|
<li>Run the app locally (<code>docs/LOCAL_DEVELOPMENT.md</code>)</li>
|
||||||
|
<li>Read <code>CLAUDE.md</code>, <code>specs/0001</code>, <code>0002</code>, <code>0011</code></li>
|
||||||
|
<li>Run <code>pnpm test</code> and <code>pnpm ci:check</code> green</li>
|
||||||
|
<li>Create a team-mode instance and click through every module</li>
|
||||||
|
<li>Do P0.1 (telemetry off) as the first small change</li>
|
||||||
|
<li>Skim <code>.agents/PAPERCUTS.md</code> for known friction</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
Sources: repository review of the README, specs, docs, schema, billing code and git history on 21 September 2026. The app and tests were not run for this review, and tenant isolation has not been audited. P0.1 rests on reading <code>self-host-telemetry.ts</code>; confirm it with a network capture.
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
325
docs/AGENCY_EDITION_PLAN.md
Normal file
325
docs/AGENCY_EDITION_PLAN.md
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# CrawlerX for agencies: build plan
|
||||||
|
|
||||||
|
Handover document, September 2026. Turn our internal fork of OpenSEO into a product agencies buy instead of Semrush: client workspaces, white-label reports, usage-based billing.
|
||||||
|
|
||||||
|
- **Base:** OpenSEO 0.1.7 (MIT)
|
||||||
|
- **Stack:** TanStack Start, Drizzle (SQLite + Postgres), Better Auth, DataForSEO
|
||||||
|
- **Horizon:** 16 weeks, pilot agencies onboard at week 8
|
||||||
|
|
||||||
|
Sources: repository review of the README, specs, docs, schema, billing code and git history on 21 September 2026. The app and tests were not run for this review, and tenant isolation has not been audited. P0.1 rests on reading `self-host-telemetry.ts`; confirm it with a network capture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Start here
|
||||||
|
|
||||||
|
### What works today
|
||||||
|
|
||||||
|
- Keyword research, saved keywords, scheduled rank tracking (with local locations)
|
||||||
|
- Domain and competitor overview, backlinks
|
||||||
|
- Site audit (own crawler, DataForSEO OnPage fallback, Lighthouse)
|
||||||
|
- Search Console and GA4 integrations
|
||||||
|
- AI visibility: brand lookup, prompt explorer
|
||||||
|
- SAM in-app agent, MCP server and agent skills (hosted mode only)
|
||||||
|
|
||||||
|
### What we added to the fork
|
||||||
|
|
||||||
|
- CrawlerX / MetatronCube rebrand (theme in `src/client/styles/app.css`)
|
||||||
|
- `AUTH_MODE=team`: owner-provisioned users, roles, shared workspace
|
||||||
|
- Activity log (`src/server/features/activity/`)
|
||||||
|
- Browser-like crawler headers and DataForSEO OnPage fallback for bot-blocked sites
|
||||||
|
- Combined SEO report: GSC + GA4 + audit (`/p/$projectId/report`)
|
||||||
|
|
||||||
|
### The fact that shapes the plan
|
||||||
|
|
||||||
|
The codebase has four auth modes, and none is "an agency with many clients".
|
||||||
|
|
||||||
|
- `team` is a single shared workspace with no billing and no self-serve signup.
|
||||||
|
- `hosted` has orgs, roles, invitations and per-org credit billing, but it is wired to the upstream author's vendors (Autumn and Stripe, Loops email, PostHog, Cloudflare KV).
|
||||||
|
|
||||||
|
The plan is to **build the agency product on top of `hosted` mode**, running it on our own accounts, and to run `team`-mode instances for pilot agencies while that work happens.
|
||||||
|
|
||||||
|
**Read first:** `CLAUDE.md` (engineering rules), `specs/0002` (DataForSEO metering), `specs/0011` (multi-user orgs), `docs/SELF_HOSTING_TEAM_MODE.md`, `docs/LOCAL_DEVELOPMENT.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisions to confirm before Phase 1
|
||||||
|
|
||||||
|
The plan assumes the recommended answer. If any changes, the affected phase is named.
|
||||||
|
|
||||||
|
| Decision | Assumed answer | If it changes |
|
||||||
|
|---|---|---|
|
||||||
|
| Architecture | **Recommended:** multi-tenant hosted mode, deployed to Cloudflare with Postgres. Per-agency team-mode instances only for the pilot. | Per-agency instances forever: drop P2.4–P2.6, add instance provisioning automation. |
|
||||||
|
| Branding depth | Agencies white-label the reports and client view. The app shell stays CrawlerX. | Full white-label (custom domain, app shell): add P3.1. |
|
||||||
|
| Upstream | Hard fork. Cherry-pick security fixes and DataForSEO changes only. | Track upstream: do P0.6 first and keep every change additive. |
|
||||||
|
| Pricing | Platform fee per agency, client seats, plus credit packs for data usage. | Pure usage: skip client seats in P2.4. |
|
||||||
|
| Team size | Planning assumption: two full-stack engineers, one part-time devops/QA. | Scale the timeline; the phase order does not change. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Timeline (weeks 1–16)
|
||||||
|
|
||||||
|
Long-lead items start in week 0 because they gate launch, not because they are big.
|
||||||
|
|
||||||
|
| Workstream | Weeks |
|
||||||
|
|---|---|
|
||||||
|
| Google verification, vendor terms (long lead) | 1–10 |
|
||||||
|
| Cleanup, scrub, security review | 1–2 |
|
||||||
|
| Hosted-mode platform on Cloudflare | 2–6 |
|
||||||
|
| Branding, PDF, share links | 3–8 |
|
||||||
|
| Agency dashboard, pilot ops (**pilot agencies onboard at week 8**) | 5–8 |
|
||||||
|
| Client role, project access | 9–11 |
|
||||||
|
| Scheduled reports, alerts | 11–13 |
|
||||||
|
| Usage caps, agency billing | 9–16 |
|
||||||
|
| Self-serve signup, launch | 14–16 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 0: De-risk and clean (weeks 1–2)
|
||||||
|
|
||||||
|
Nothing customer-facing. Remove the things that would hurt us the moment a second customer appears.
|
||||||
|
|
||||||
|
**Exit criteria:** no upstream branding, contact details or telemetry reach a customer. Hosting decision written down. Google verification and DataForSEO terms are in progress. Tenant isolation is tested.
|
||||||
|
|
||||||
|
### P0.1 Turn off upstream telemetry today `blocker` `do first` `S`
|
||||||
|
|
||||||
|
`self-host-telemetry.ts` holds a hard-coded PostHog key for the upstream project and skips sending only in `hosted` mode. Our `team`-mode instance appears to send anonymous heartbeats to them unless opted out.
|
||||||
|
|
||||||
|
- **Do:** set `OPENSEO_TELEMETRY_DISABLED=1` in the production `.env` and restart. Then remove the module and its wiring, or replace the key with our own PostHog project.
|
||||||
|
- **Where:** `src/server/lib/self-host-telemetry.ts`
|
||||||
|
- **Done when:** a network capture on a running instance shows no calls to `us.i.posthog.com` with the upstream key.
|
||||||
|
|
||||||
|
### P0.2 Scrub upstream identity, and centralise brand constants `blocker` `M`
|
||||||
|
|
||||||
|
Customer support requests currently point at the upstream author's email. Links send users to the upstream product, Discord and docs. Create one `src/shared/brand.ts` (product name, support email, docs and terms URLs, crawler contact URL) and make every reference read from it.
|
||||||
|
|
||||||
|
- **Where:**
|
||||||
|
- `src/client/features/audit/shared.tsx`
|
||||||
|
- `src/routes/_app/ai.tsx`
|
||||||
|
- `src/routes/_app/support.tsx`
|
||||||
|
- `src/routes/_authenticated.subscribe.tsx`
|
||||||
|
- `src/routes/_auth.sign-up.tsx`
|
||||||
|
- `src/client/features/onboarding/OnboardingChatParts.tsx`
|
||||||
|
- `src/client/features/settings/ApiKeySettings.tsx`
|
||||||
|
- `src/client/components/AuthConfigErrorCard.tsx`
|
||||||
|
- `src/shared/ga4.ts`, `src/shared/gsc.ts`
|
||||||
|
- `src/server/lib/scrape.ts`
|
||||||
|
- `src/server/mcp/server.ts`
|
||||||
|
- `src/server/features/sam/SamChatAgent.ts`
|
||||||
|
- `src/server/features/rank-tracking/services/scheduledRankChecks.ts`
|
||||||
|
- `src/server/features/onboarding/OnboardingChatAgent.ts`
|
||||||
|
- `src/server/referrals/dub.ts`
|
||||||
|
- **Also:** the skills install commands in `ai.tsx` point at `every-app/open-seo`. Publish our own skills package or hide that card. Remove the Dub referral code (upstream's partner program).
|
||||||
|
- **Done when:** `grep -ri "openseo.so\|every-app\|ben@"` over `src` returns only tests, comments, and the licence notice. Add that grep to CI.
|
||||||
|
|
||||||
|
### P0.3 Hosted-mode spike and hosting decision `blocker` `M`
|
||||||
|
|
||||||
|
Production today appears to run `vite preview` under pm2 (the `start` script), which emulates the Cloudflare runtime locally. That is fine for an internal tool, not for paying customers. `package.json` already has `deploy:postgres` (Alchemy, hosted-prod stage, Postgres). Prove it works end to end on our own Cloudflare account, in a staging stage.
|
||||||
|
|
||||||
|
- **Check:** sign-up, org creation, invite, project create, keyword research, site audit workflow, rank check cron, GSC connect, MCP OAuth. Note every Cloudflare binding used:
|
||||||
|
- KV: `invitation-send-limit.ts`, `oauth-provider.ts`, `progress-kv.ts`, `serp-locations.ts`
|
||||||
|
- R2: `r2.ts`, `r2-cache.ts`
|
||||||
|
- Workflows and Durable Objects: `SiteAuditWorkflow.ts`, chat agents
|
||||||
|
- **Deliverable:** a one-page decision: deploy to Cloudflare (assumed) or port off it, with the list of breakages and their cost.
|
||||||
|
- **Done when:** a staging environment runs hosted mode with our own domain. A second signed-up user gets an isolated workspace.
|
||||||
|
|
||||||
|
### P0.4 Google OAuth verification and DataForSEO terms `blocker` `long lead` `S to start`
|
||||||
|
|
||||||
|
Search Console and GA4 scopes are sensitive. Until Google verifies the app, external users see an unverified-app warning and are capped. Verification takes weeks and needs a live privacy policy, a homepage on our domain and a demo video. Separately, get written confirmation that DataForSEO allows us to resell their data in a paid product.
|
||||||
|
|
||||||
|
- **Do:** create a dedicated Google Cloud project for production. Write the privacy policy and terms (replace the upstream links in `_auth.sign-up.tsx`). Submit the consent screen. Email DataForSEO sales about reseller terms and volume pricing.
|
||||||
|
- **Done when:** verification is submitted with no open questions. The DataForSEO reply is saved in `docs/`.
|
||||||
|
|
||||||
|
### P0.5 Tenant isolation review and tests `blocker` `M`
|
||||||
|
|
||||||
|
Every server function must scope by the caller's organisation (see `specs/0001`). Run `/security-review`, then write the test that would catch a leak: two orgs, and each server function called with the other org's `projectId` must fail.
|
||||||
|
|
||||||
|
- **Where:** `src/serverFunctions/*.ts`, `src/server/mcp/project-auth.ts`, `src/middleware/ensure-user/`
|
||||||
|
- **Done when:** a single parameterised test covers every project-scoped server function and MCP tool. Findings from the review are fixed or ticketed.
|
||||||
|
|
||||||
|
### P0.6 Repo hygiene and upstream policy `S`
|
||||||
|
|
||||||
|
- **Do:**
|
||||||
|
- Delete the empty root file `open-seo@0.1.7` and `vite.config.ts.backup`.
|
||||||
|
- Two files show as modified from line endings only: add a `.gitattributes`.
|
||||||
|
- Tag the fork point. Add an `upstream` remote and record the sync policy in `docs/MAINTAINERS.md`.
|
||||||
|
- Remove the internal hostname and server paths from `docs/SELF_HOSTING_TEAM_MODE.md`.
|
||||||
|
- Keep `LICENSE` and add a `NOTICE` crediting OpenSEO.
|
||||||
|
- **Done when:** `pnpm ci:check` passes on a clean checkout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 1: Pilot MVP (weeks 2–8)
|
||||||
|
|
||||||
|
Enough for three to five design-partner agencies to put their own clients on it. MetatronCube itself is design partner zero.
|
||||||
|
|
||||||
|
**Exit criteria:** an agency can send a client a branded PDF or link with real data in it, and see all their clients in one place. Pilots are invoiced by hand.
|
||||||
|
|
||||||
|
### P1.1 Organisation branding `M`
|
||||||
|
|
||||||
|
New table for per-organisation branding: display name, logo, primary colour, footer text. Logo stored in R2. Applied to reports and to the client view, not to the app shell.
|
||||||
|
|
||||||
|
- **Where:** new `src/db/branding.schema.ts` and the Postgres twin under `src/db/pg/`. Follow server function → service → repository. Run `npm run db:generate` and keep `schema-parity.test.ts` green (SQLite and Postgres must both work).
|
||||||
|
- **Done when:** an owner uploads a logo in Settings and it appears on the report. Only owner and admin can edit it.
|
||||||
|
|
||||||
|
### P1.2 Report v2 and PDF export `top purchase driver` `L`
|
||||||
|
|
||||||
|
`ProjectReportService` covers GSC, GA4 and the latest audit. Add rank tracking movement, a backlinks summary and AI visibility. Each section already degrades on its own when an integration is missing; keep that. Export: start with a print-optimised route (`@media print`, A4) since it is the cheapest path. Move to server-side rendering (Playwright is already a dev dependency) only if print output is not good enough.
|
||||||
|
|
||||||
|
- **Where:** `src/server/features/reports/services/ProjectReportService.ts`, `src/client/features/reports/ReportView.tsx`, `src/routes/_project/p/$projectId/report.tsx`
|
||||||
|
- **Done when:** a 4–6 page A4 PDF with the agency's logo and colours, no CrawlerX or upstream branding, and no API credits spent on render (reuse stored data).
|
||||||
|
|
||||||
|
### P1.3 Shareable read-only report link `M`
|
||||||
|
|
||||||
|
Store a snapshot of the report JSON at share time so a client sees stable numbers and cannot trigger spend. Public route addressed by a random token (store only its hash), with expiry and revoke. Log create and revoke in the activity log.
|
||||||
|
|
||||||
|
- **Where:** new `report_share` table (projectId, tokenHash, snapshot, expiresAt, revokedAt). New public route outside `_authenticated`. Add actions to `ACTIVITY_ACTIONS`.
|
||||||
|
- **Done when:** a revoked or expired link returns 404. The page exposes nothing beyond the snapshot.
|
||||||
|
|
||||||
|
### P1.4 Agency dashboard across clients `M`
|
||||||
|
|
||||||
|
One screen listing every client project with the few numbers an account manager scans: rank movement over 7 and 28 days, latest audit health, GSC clicks change, integration status (connected, expired, missing). Sort by "needs attention".
|
||||||
|
|
||||||
|
- **Where:** `src/server/features/dashboard/services/DashboardService.ts` and `src/routes/_app/projects.tsx`
|
||||||
|
- **Done when:** loads in under 2 seconds with 50 projects on seeded data (`pnpm seed:projects`).
|
||||||
|
|
||||||
|
### P1.5 Own transactional email `S–M`
|
||||||
|
|
||||||
|
Invitations, password reset and verification currently go through Loops templates that belong to the upstream account. Put a small provider interface in `src/server/email` and implement it with Resend or SES. Replace `billing/loops-sync.ts` contact sync (remove, or point at our own tool).
|
||||||
|
|
||||||
|
- **Done when:** invite, reset and verify emails arrive from our domain (SPF, DKIM set). No Loops keys are required in hosted mode.
|
||||||
|
|
||||||
|
### P1.6 Crawler identity and allowlist guide `S`
|
||||||
|
|
||||||
|
Many client sites sit behind Cloudflare Managed Challenge or DataDome, which the crawler cannot pass. Give agencies something to allowlist: a stable egress IP for the crawler and a user-agent that names us and links to a page explaining it. Publish a short help page with per-WAF instructions. Keep the DataForSEO OnPage fallback as the paid option.
|
||||||
|
|
||||||
|
- **Where:** `src/server/lib/audit/crawl-request.ts` (`AUDIT_USER_AGENT`)
|
||||||
|
- **Done when:** the audit failure card on a blocked site links to the allowlist guide and to the paid fallback.
|
||||||
|
|
||||||
|
### P1.7 Pilot operations `M`
|
||||||
|
|
||||||
|
Until self-serve exists, provision each pilot by script: create org and owner, apply branding, set an invoice reference. Set up production basics: nightly Postgres backup with a tested restore, error tracking, uptime check and a status page, and a shared support inbox.
|
||||||
|
|
||||||
|
- **Where:** new script beside `scripts/seed-projects.ts`; runbooks in `runbooks/`.
|
||||||
|
- **Done when:** a new pilot agency is live in under 30 minutes by following the runbook, and a restore has been rehearsed once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phase 2: Agency product (weeks 8–16)
|
||||||
|
|
||||||
|
What turns a pilot into something agencies pay for monthly without hand-holding.
|
||||||
|
|
||||||
|
**Exit criteria:** an agency signs up, pays, adds clients, gives a client read-only access, and gets scheduled reports and alerts, with usage attributed per client.
|
||||||
|
|
||||||
|
### P2.1 Client viewer role with per-project access `L`
|
||||||
|
|
||||||
|
The `member` role exists but is not invitable: spec 0011 blocks it in `beforeCreateInvitation`, and every invitee is an admin. Add a `client` role that is read-only and limited to named projects. Model access as a `project_member` table (userId, projectId), not a JSON list. Enforce it in the project-auth middleware and the MCP layer.
|
||||||
|
|
||||||
|
- **Where:** `src/lib/org-permissions.ts`, `src/server/mcp/project-auth.ts`, `src/serverFunctions/organization.ts`
|
||||||
|
- **Done when:** a client user sees only assigned projects, cannot run credit-spending actions, and cannot see billing. Covered by the P0.5 isolation test.
|
||||||
|
|
||||||
|
### P2.2 Scheduled report emails `M`
|
||||||
|
|
||||||
|
Per-project schedule (weekly or monthly), recipients, and whether to attach the PDF or send the share link. A cron creates a snapshot (P1.3) and sends it. Cron hooks already exist in the `scheduled` handler in `src/server.ts` and `wrangler.jsonc`.
|
||||||
|
|
||||||
|
- **Done when:** a failed send is retried and visible in the activity log. The unsubscribe link works.
|
||||||
|
|
||||||
|
### P2.3 Alerts `M`
|
||||||
|
|
||||||
|
Notify on rank drops above a threshold, audit score regressions, GSC clicks falling, and expired Google connections. Hook into `scheduledRankChecks.ts` after each run. Preferences table per user and project. Email first, in-app feed second.
|
||||||
|
|
||||||
|
- **Done when:** no duplicate alert for the same condition within 24 hours.
|
||||||
|
|
||||||
|
### P2.4 Per-client usage attribution and caps `L`
|
||||||
|
|
||||||
|
Credits are pooled per organisation. Agencies need to know what each client costs, and to stop one client burning the pool. Record `projectId` on every usage event, show usage by client on the billing page, and add an optional monthly cap per project checked in the same preflight as the balance check.
|
||||||
|
|
||||||
|
- **Where:** `src/server/lib/dataforseoClient.ts` (the single entry point per spec 0002), `src/server/billing/subscription.ts`
|
||||||
|
- **Done when:** a capped project fails with a clear "client cap reached" error. Usage-by-client totals match the org total.
|
||||||
|
|
||||||
|
### P2.5 Our own billing setup and agency plans `blocker for launch` `L`
|
||||||
|
|
||||||
|
Create our own Autumn and Stripe accounts and define the agency plans (platform fee, client seats, credit packs). The 1.28 markup is a constant in `src/shared/billing.ts`: make it configuration, not a code edit, so pricing can change without a deploy. Stripe checkout already collects business name and tax ID.
|
||||||
|
|
||||||
|
- **Where:** `src/shared/billing.ts`, `src/server/billing/`, `src/routes/_authenticated.subscribe.tsx`
|
||||||
|
- **Done when:** test-mode subscribe, top-up, cancel and invoice all work. Cost estimates in the UI match what is billed.
|
||||||
|
|
||||||
|
### P2.6 Self-serve signup, onboarding, launch pages `M`
|
||||||
|
|
||||||
|
Signup flow adapted for agencies (agency name, first client project, connect Google), a free-trial policy with a spend ceiling so trials cannot burn DataForSEO money, terms and privacy pages, a pricing page. Rework the onboarding chat copy from "personal SEO tool" to "agency workspace".
|
||||||
|
|
||||||
|
- **Done when:** a stranger can sign up, add a client, run a first audit and receive a report without talking to us. Trial spend per organisation is capped and monitored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 3: After launch
|
||||||
|
|
||||||
|
Do not start these until paying agencies have asked. Each is a real project. Order by what pilots ask for.
|
||||||
|
|
||||||
|
| ID | Item | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| P3.1 | Custom domain and full white-label | Per-org hostname (Cloudflare for SaaS), branded login and emails. |
|
||||||
|
| P3.2 | Agency MCP and API | MCP and skills exist in hosted mode. Publish our own skill pack and per-org API keys. |
|
||||||
|
| P3.3 | Looker Studio connector or CSV export | Agencies keep their own dashboards. Today only export to Google Sheets exists. |
|
||||||
|
| P3.4 | Security hardening | 2FA, SSO for larger agencies, audit-log export, DPA template. |
|
||||||
|
| P3.5 | Local SEO depth | Local rank grids and Business Profile data, if agencies serve local businesses. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Quality bar for every task
|
||||||
|
|
||||||
|
From `CLAUDE.md`. New work is reviewed against these.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
|
||||||
|
- Backend shape: TanStack server function → service → repository.
|
||||||
|
- Every schema change works on SQLite and Postgres; run `npm run db:generate` and keep the parity test green.
|
||||||
|
- Relations are real tables and foreign keys, not JSON.
|
||||||
|
- Validate untrusted input with Zod at the boundary.
|
||||||
|
- All DataForSEO calls go through the metered client. A raw `fetch*Raw` call in feature code is a billing bypass.
|
||||||
|
|
||||||
|
**Tests and CI**
|
||||||
|
|
||||||
|
- A test must protect real behaviour or a real edge case. No tests for their own sake.
|
||||||
|
- Test at the public entry point; do not mock ORM builder chains.
|
||||||
|
- Merge gate: `pnpm ci:check` (prettier, knip, tsc, oxlint) and `pnpm test`.
|
||||||
|
- Log small repo friction in `.agents/PAPERCUTS.md`.
|
||||||
|
- Changes to `.greptile/`, `AGENTS.md`, `CLAUDE.md` and `.github/` need maintainer review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risks
|
||||||
|
|
||||||
|
| Risk | Why it matters | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| DataForSEO cost overrun | We pay raw cost. Trials, rank tracking and large audits can burn money faster than revenue. | P2.4 caps, trial spend ceiling in P2.6, alert on account balance, weekly review of `pnpm billing:usage`. |
|
||||||
|
| Google verification delay | Blocks external GSC and GA4, which the report depends on. | Submit in week 1 (P0.4). Pilots can use their own Google Cloud client in the meantime. |
|
||||||
|
| Reseller terms | If DataForSEO disallows resale, the model needs a different arrangement. | Ask before building billing (P0.4). |
|
||||||
|
| Cross-tenant data leak | Fatal for trust, with agency clients' data. | P0.5 test suite, and a review of every new server function. |
|
||||||
|
| Blocked crawls | Site audit fails on many client sites. | P1.6 allowlist and identity, paid OnPage fallback. |
|
||||||
|
| Upstream drift | Upstream ships almost daily. Our rebrand touched 42 files. | Hard fork; brand constants in one file (P0.2); cherry-pick security fixes only. |
|
||||||
|
| Runtime not production-grade | Internal deployment runs a preview server under pm2. | P0.3 decision; do not put paying agencies on it. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Handover checklist
|
||||||
|
|
||||||
|
**Accounts and access**
|
||||||
|
|
||||||
|
- [ ] Gitea repository and CI
|
||||||
|
- [ ] Production and staging servers (SSH, pm2)
|
||||||
|
- [ ] Postgres credentials and backup location
|
||||||
|
- [ ] DataForSEO account and billing alerts
|
||||||
|
- [ ] Google Cloud project (OAuth client, consent screen)
|
||||||
|
- [ ] Cloudflare account and DNS for the product domain
|
||||||
|
- [ ] Stripe and Autumn accounts (create if not yet)
|
||||||
|
- [ ] Email provider, error tracking and PostHog project
|
||||||
|
- [ ] Shared support inbox
|
||||||
|
|
||||||
|
**Day-one tasks for each engineer**
|
||||||
|
|
||||||
|
- [ ] Run the app locally (`docs/LOCAL_DEVELOPMENT.md`)
|
||||||
|
- [ ] Read `CLAUDE.md`, `specs/0001`, `0002`, `0011`
|
||||||
|
- [ ] Run `pnpm test` and `pnpm ci:check` green
|
||||||
|
- [ ] Create a team-mode instance and click through every module
|
||||||
|
- [ ] Do P0.1 (telemetry off) as the first small change
|
||||||
|
- [ ] Skim `.agents/PAPERCUTS.md` for known friction
|
||||||
Loading…
x
Reference in New Issue
Block a user