Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

SEO Fix Kit is a proof-backed SEO repair tool for sites that need clear fixes, not generic audit homework.

The public homepage is currently a private-beta access page. Visitors can request a secure one-use email link; anonymous public audits stay disabled.
The public homepage is currently a private-beta access page. Visitors can request a secure one-use email link or check one public page anonymously before requesting access; full multi-page audits stay inside the private beta.

It is not trying to replace Ahrefs or Semrush keyword and backlink databases. The first wedge is narrower and sharper:

Expand Down Expand Up @@ -41,6 +41,7 @@ It is not trying to replace Ahrefs or Semrush keyword and backlink databases. Th
- Founder-friendly React interface.
- Cloudflare Worker target using Workers Static Assets and Browser Run.
- Locked private-beta homepage with `/api/waitlist` and `/api/access/request` backed by D1.
- Public anonymous one-page URL check at `/check` and `POST /api/public-check`: real browser rendering of one public page, static-vs-rendered proof, guarded false positives, actionable findings when present, per-network and per-site rate limits, nothing stored, and a handoff into private beta access with no ranking promise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Narrow the storage claim.

The rate-limit design stores hashed network and target identifiers in audit_usage, and README.md, Line 57 documents quota-bucket cleanup. The phrase nothing stored can mislead users about retention. State that no report or page contents are stored. Apply the same wording to public/.well-known/skill.md, Line 10, and the /check copy in worker/routes/public-check.js, Lines 192-351.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 44, Replace the broad “nothing stored” claim with explicit
wording that no report or page contents are stored, while retaining the existing
rate-limit behavior. Apply this wording consistently in the README public-check
description, public/.well-known/skill.md, and the /check response copy in the
public-check route.

- Public `/demo`, `/methodology`, and `/packages` pages showing the proof loop, limits, and package ladder before payment.
- Hidden `/beta` private audit workbench protected by invite code login or a secure one-use email access link.
- Expiring beta sessions backed by D1 `beta_sessions`.
Expand Down Expand Up @@ -74,9 +75,9 @@ Open `http://127.0.0.1:5173`.
npm run check
```

For a live spot-check that the public `/demo`, `/methodology`, and `/packages`
pages on the deployed site still show the proof loop, stated limits, and
package ladder the README promises:
For a live spot-check that the public `/check`, `/demo`, `/methodology`, and `/packages`
pages on the deployed site still show the anonymous proof check, proof loop, stated
limits, and package ladder the README promises:

```bash
npm run audit:live-promise
Expand All @@ -87,8 +88,9 @@ npm run audit:live-promise
Cloudflare cannot run the local Express + Chromium server directly. The deployable path is:

- React UI served by Workers Static Assets from `dist/`
- Public `/demo`, `/methodology`, `/packages`, `/privacy`, `/support`, `/terms`, `/sitemap.xml`, and `/llms.txt` stay served by the Worker/public asset path
- Public `/check`, `/demo`, `/methodology`, `/packages`, `/privacy`, `/support`, `/terms`, `/sitemap.xml`, and `/llms.txt` stay served by the Worker/public asset path
- `/api/health` is a shallow public runtime check; `/api/deep-health` is a public-safe readiness check for bindings, D1 schema, Dodo checkout/webhook config, and self-serve repair capabilities without exposing secrets, provider ids, checkout URLs, customer data, or table counts
- `/api/public-check` runs the anonymous one-page URL check for any visitor: one public page rendered in a real browser, proof fields and guarded false positives from the shared audit engine, findings when present, per-network and per-site rate limits, and no stored report; `/check` is the indexable public entry page
- `/api/waitlist` handled by `worker/index.js` and stored in D1
- `/admin/summary` powers the private ops dashboard, and `/admin/leads.csv` exports waitlist leads when called with the admin export token
- `/admin/invites` creates invite codes for specific emails
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test:worker-dispatch": "node --test worker/index.test.mjs",
"test:worker-email": "node --test worker/lib/email.test.mjs",
"test:public-pages": "node --test worker/routes/pages.test.mjs",
"test:public-check": "node --test worker/routes/public-check.test.mjs",
"test:audit-engine": "node --test shared/audit-engine.test.mjs",
"test:account": "node --test worker/routes/account.test.mjs",
"test:ai-answer-readiness": "node --test shared/ai-answer-readiness.test.mjs",
Expand All @@ -39,7 +40,7 @@
"test:promise-audit": "node --test shared/promise-audit.test.mjs",
"test:live-promise-spot-check": "node --test scripts/live-promise-spot-check.test.mjs",
"test:app-contract": "node --test src/app-contract.test.mjs",
"check": "npm run test:billing && npm run test:billing-route && npm run test:product-truth && npm run test:audit && npm run test:large-crawl-security && npm run test:report-retention-security && npm run test:local-developer-api-security && npm run test:worker-dispatch && npm run test:worker-email && npm run test:public-pages && npm run test:audit-engine && npm run test:account && npm run test:ai-answer-readiness && npm run test:growth-opportunities && npm run test:repair-queue && npm run test:repair-proof-receipt && npm run test:repair-agent && npm run test:developer-api && npm run test:remediation-brief && npm run test:audit-batch-runner && npm run test:webhooks && npm run test:app-contract && npm run test:promise-audit && npm run test:live-promise-spot-check && npm run test:large-crawl && npm run build"
"check": "npm run test:billing && npm run test:billing-route && npm run test:product-truth && npm run test:audit && npm run test:large-crawl-security && npm run test:report-retention-security && npm run test:local-developer-api-security && npm run test:worker-dispatch && npm run test:worker-email && npm run test:public-pages && npm run test:public-check && npm run test:audit-engine && npm run test:account && npm run test:ai-answer-readiness && npm run test:growth-opportunities && npm run test:repair-queue && npm run test:repair-proof-receipt && npm run test:repair-agent && npm run test:developer-api && npm run test:remediation-brief && npm run test:audit-batch-runner && npm run test:webhooks && npm run test:app-contract && npm run test:promise-audit && npm run test:live-promise-spot-check && npm run test:large-crawl && npm run build"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.0",
Expand Down
9 changes: 5 additions & 4 deletions public/.well-known/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ SEO Fix Kit is a private-beta SEO audit and paid Fix Pack workflow.

- Request access at https://seofixkit.com/.
- Public health surfaces are https://seofixkit.com/api/health and https://seofixkit.com/api/deep-health. Deep health reports safe readiness booleans only, not secrets, provider ids, checkout URLs, customer data, or table counts.
- Public proof pages: https://seofixkit.com/demo, https://seofixkit.com/methodology, https://seofixkit.com/packages, https://seofixkit.com/support, and https://seofixkit.com/terms.
- Public proof pages: https://seofixkit.com/demo, https://seofixkit.com/check, https://seofixkit.com/methodology, https://seofixkit.com/packages, https://seofixkit.com/support, and https://seofixkit.com/terms.
- Anyone can check one public page anonymously at https://seofixkit.com/check via POST https://seofixkit.com/api/public-check; results are ephemeral (nothing stored) and rate-limited per network and per site.
- Use SEO Fix Kit only for sites you own or are authorized to audit.
- Private audits create proof-backed reports from rendered page evidence.
- Private reports include proof-derived AI Answer Readiness checks; this is not live AI-engine sampling.
Expand All @@ -23,11 +24,11 @@ SEO Fix Kit is a private-beta SEO audit and paid Fix Pack workflow.

## Agent Action Catalog

- Public context for agents: https://seofixkit.com/llms.txt, https://seofixkit.com/.well-known/skill.md, https://seofixkit.com/demo, https://seofixkit.com/methodology, https://seofixkit.com/packages, https://seofixkit.com/support, and https://seofixkit.com/terms.
- Owner setup starts inside the private beta workspace. Anonymous public audits and unauthenticated repair actions are not live.
- Public context for agents: https://seofixkit.com/llms.txt, https://seofixkit.com/.well-known/skill.md, https://seofixkit.com/demo, https://seofixkit.com/check, https://seofixkit.com/methodology, https://seofixkit.com/packages, https://seofixkit.com/support, and https://seofixkit.com/terms.
- Owner setup starts inside the private beta workspace. Anonymous one-page checks are live at https://seofixkit.com/check; full multi-page audits, saved reports, and unauthenticated repair actions are not live.
- Self-serve API setup is owner-scoped at `GET /api/developer`; API keys are created from `POST /api/developer/tokens`; lifecycle webhooks are created from `POST /api/developer/webhooks`.
- Bearer-token API actions live today: `POST /v1/audits`, `GET /v1/audits/{audit_id}`, `GET /v1/audits/{audit_id}/issues`, `GET /v1/audits/{audit_id}/report`, `GET /v1/audits/{audit_id}/repair-queue`, `PATCH /v1/audits/{audit_id}/repair-queue`, `POST /v1/audits/{audit_id}/repair-actions`, `PATCH /v1/audits/{audit_id}/repair-actions/{action_id}`, `GET /v1/audits/{audit_id}/repair-actions/{action_id}/implementation.md`, `GET /v1/audits/{audit_id}/repair-actions/{action_id}/proof.md`, `GET /v1/projects`, `POST /v1/large-crawls`, and `GET /v1/large-crawls/{large_crawl_id}`.
- Webhook events live today: `audit.completed`, `audit.failed`, `repair_action.drafted`, `repair_action.approved`, `repair_action.applied`, `repair_action.fixed`, and `repair_action.regressed`.
- Worker-only large-crawl batch claim/process/proof endpoints require `x-seofixkit-worker-token`; normal bearer API keys cannot lease or submit rendered proof.
- There is no live SEO Fix Kit MCP endpoint today. Agents should use the documented REST and markdown proof endpoints.
- Agents must not claim SEO Fix Kit publishes CMS changes, opens GitHub pull requests, calls provider admin APIs, runs anonymous public audits, or exposes unauthenticated agent actions.
- Agents must not claim SEO Fix Kit publishes CMS changes, opens GitHub pull requests, calls provider admin APIs, runs anonymous multi-page audits, or exposes unauthenticated agent actions. The only anonymous surface is the single-page proof check at https://seofixkit.com/check.
1 change: 1 addition & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://seofixkit.com/</loc></url>
<url><loc>https://seofixkit.com/demo</loc></url>
<url><loc>https://seofixkit.com/check</loc></url>
<url><loc>https://seofixkit.com/methodology</loc></url>
<url><loc>https://seofixkit.com/packages</loc></url>
<url><loc>https://seofixkit.com/privacy</loc></url>
Expand Down
12 changes: 12 additions & 0 deletions scripts/live-promise-spot-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ export function publicPageSpotChecks(baseUrl) {
{ reason: "link to package ladder", match: `href="${baseUrl}/packages"` }
]
},
{
path: "/check",
name: "one-page check page shows the anonymous proof entry",
expectations: [
{ reason: "one-page check headline", match: "Check One Page for SEO Proof" },
{ reason: "URL check form", match: 'id="check-form"' },
{ reason: "no-account proof promise", match: "No account, no email, no stored report" },
{ reason: "guarded false positives promise", match: "Guarded false positives" },
{ reason: "no-overclaim section", match: "What this check does not claim" },
{ reason: "handoff into private access", match: "Request private access" }
]
},
{
path: "/methodology",
name: "methodology page states limits up front",
Expand Down
8 changes: 5 additions & 3 deletions scripts/live-promise-spot-check.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { demoHtml, methodologyHtml, packagesHtml } from "../worker/routes/pages.js";
import { checkHtml } from "../worker/routes/public-check.js";
import { publicPageSpotChecks, spotCheckPublicPages } from "./live-promise-spot-check.mjs";

const origin = "https://seofixkit.com";
const pages = {
"/demo": demoHtml(origin),
"/check": checkHtml(origin),
"/methodology": methodologyHtml(origin),
"/packages": packagesHtml(origin)
};
Expand All @@ -27,16 +29,16 @@ function htmlResponse(body, status = 200) {
return new Response(body, { status, headers: { "content-type": "text/html" } });
}

test("live spot-check covers the three promised public pages", () => {
test("live spot-check covers the four promised public pages", () => {
assert.deepEqual(
publicPageSpotChecks(origin).map((check) => check.path),
["/demo", "/methodology", "/packages"]
["/demo", "/check", "/methodology", "/packages"]
);
});

test("live spot-check passes against the shipped public page copy", async () => {
const results = await spotCheckPublicPages({ baseUrl: origin, fetcher: pageFetcher() });
assert.equal(results.length, 3);
assert.equal(results.length, 4);
Comment on lines 39 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that all spot-check expectations pass.

spotCheckPublicPages returns failures, but this test only checks the number of result objects. It remains green when /check returns 404 or misses every expected string. Assert that every failure list is empty.

Suggested assertion
   assert.equal(results.length, 4);
+  assert.deepEqual(
+    results.flatMap(({ path, failures }) =>
+      failures.map((failure) => `${path}: ${failure}`)
+    ),
+    []
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("live spot-check passes against the shipped public page copy", async () => {
const results = await spotCheckPublicPages({ baseUrl: origin, fetcher: pageFetcher() });
assert.equal(results.length, 3);
assert.equal(results.length, 4);
test("live spot-check passes against the shipped public page copy", async () => {
const results = await spotCheckPublicPages({ baseUrl: origin, fetcher: pageFetcher() });
assert.equal(results.length, 4);
assert.deepEqual(
results.flatMap(({ path, failures }) =>
failures.map((failure) => `${path}: ${failure}`)
),
[]
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/live-promise-spot-check.test.mjs` around lines 39 - 41, Update the
test around spotCheckPublicPages to assert that every returned result has an
empty failures list, rather than only checking results.length. Keep the existing
result-count assertion if needed, and ensure failures from missing pages or
expected strings cause the test to fail.

for (const result of results) {
assert.deepEqual(result.failures, [], `${result.path} must pass: ${result.name}`);
}
Expand Down
67 changes: 67 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {
supportHtml,
termsHtml
} from "../worker/routes/pages.js";
import {
buildPublicCheckResponse,
checkHtml,
publicCheckQuotaChecks,
validatePublicCheckUrl
} from "../worker/routes/public-check.js";
import {
backlinkRowsKey,
parseBacklinkRows
Expand All @@ -43,6 +49,7 @@ import {
buildCrawlInventory
} from "../shared/crawl-inventory.js";
import { resolvesToPrivateAddress } from "../shared/url-safety.js";
import { requestIpHash } from "../worker/lib/security.js";
import {
agentActionResponse,
apiRepairQueueStatusResponse,
Expand Down Expand Up @@ -130,6 +137,28 @@ const localRepairQueueRows = new Map();
const localRepairActionRows = new Map();
const siteClaims = new Map();
const fixRequests = [];
// In-memory rate-limit counters for the anonymous one-page check, mirroring
// the Worker's D1-backed buckets (worker/routes/public-check.js). Entries
// expire by age so a long-running dev server does not leak memory.
const publicCheckQuotaCounts = new Map();

function localPublicCheckQuota(checks) {
const now = Date.now();
for (const check of checks) {
const entry = publicCheckQuotaCounts.get(check.bucket) || { count: 0, createdAt: now };
if (entry.count >= check.limit) {
return { ok: false, error: check.error, resetAt: check.resetAt.toISOString() };
}
entry.count += 1;
publicCheckQuotaCounts.set(check.bucket, entry);
}
for (const [key, entry] of publicCheckQuotaCounts) {
if (now - entry.createdAt > 48 * 60 * 60 * 1000) {
publicCheckQuotaCounts.delete(key);
}
}
return { ok: true };
}
const VERSION = "0.9.0";
const SESSION_COOKIE = "sfk_beta_session";
const ADMIN_SESSION_COOKIE = "sfk_admin_session";
Expand Down Expand Up @@ -1791,6 +1820,11 @@ app.get("/demo", (req, res) => {
res.set("content-type", "text/html; charset=utf-8").send(demoHtml(origin));
});

app.get("/check", (req, res) => {
const origin = `http://${req.get("host")}`;
res.set("content-type", "text/html; charset=utf-8").send(checkHtml(origin));
});

app.get("/methodology", (req, res) => {
const origin = `http://${req.get("host")}`;
res.set("content-type", "text/html; charset=utf-8").send(methodologyHtml(origin));
Expand Down Expand Up @@ -1845,6 +1879,39 @@ app.get("/api/demo-audit", async (req, res) => {
}
});

app.post("/api/public-check", async (req, res) => {
try {
const body = req.body || {};
const input = typeof body.url === "string" ? body.url : "";
const validated = validatePublicCheckUrl(input);
if (!validated.ok) {
res.status(400).set("cache-control", "no-store").json({ error: validated.error });
return;
}
const hostname = new URL(validated.url).hostname;
if (await resolvesToPrivateAddress(hostname)) {
res.status(400).set("cache-control", "no-store").json({
error: "This URL points at a private or internal address and cannot be checked."
});
return;
}
const ipHash = await requestIpHash({
headers: { get: (name) => String(req.headers[name.toLowerCase()] || "") }
});
const quota = localPublicCheckQuota(publicCheckQuotaChecks(ipHash, hostname));
if (!quota.ok) {
res.status(429).set("cache-control", "no-store").json({ error: quota.error, resetAt: quota.resetAt });
return;
}
const origin = `http://${req.get("host")}`;
const report = await auditUrl(validated.url, { maxPages: 1, appOrigin: origin });
res.set("cache-control", "no-store").json(buildPublicCheckResponse(report));
} catch (error) {
const message = String(error?.message || "The check failed. Try another public URL.").slice(0, 260);
res.status(422).set("cache-control", "no-store").json({ error: message });
}
});

app.post("/api/audit", async (req, res) => {
try {
const access = localBetaAccess(req);
Expand Down
2 changes: 1 addition & 1 deletion shared/audit-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -2874,7 +2874,7 @@ function wait(ms) {
}

export function rootSitemap(origin) {
const urls = ["/", "/demo", "/methodology", "/packages", "/privacy", "/support", "/terms"];
const urls = ["/", "/demo", "/check", "/methodology", "/packages", "/privacy", "/support", "/terms"];
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls
.map((path) => `<url><loc>${origin}${path}</loc></url>`)
.join("")}</urlset>`;
Expand Down
22 changes: 19 additions & 3 deletions shared/promise-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ test("README weekly monitor promise matches the schedule interval", () => {

test("README public page promise matches Worker routing and copy", () => {
assert.match(liveSection, /Public `\/demo`, `\/methodology`, and `\/packages` pages/i);
for (const path of ["/demo", "/methodology", "/packages"]) {
for (const path of ["/demo", "/methodology", "/packages", "/check"]) {
assert.ok(workerIndex.includes(`url.pathname === "${path}"`), `Worker must route ${path}`);
}
assert.match(pagesSource, /FIX_PACK_PUBLIC_PRICE/, "packages page price constant exists");
Expand Down Expand Up @@ -156,6 +156,8 @@ test("README Cloudflare path routes are actually registered in the Worker", () =
"/demo",
"/methodology",
"/packages",
"/check",
"/api/public-check",
"/beta"
];
for (const route of claimedRoutes) {
Expand All @@ -165,12 +167,26 @@ test("README Cloudflare path routes are actually registered in the Worker", () =

test("README abuse-control claim matches D1 buckets for every listed surface", () => {
assert.match(liveSection, /D1-backed abuse controls/i);
for (const bucket of ["waitlist:ip", "login:ip", "access:ip", "audit:ip", "audit:session", "audit:target", "audit:lite-day"]) {
const source = bucket.startsWith("audit") ? auditsSource : readFileSync(new URL("../worker/routes/access.js", import.meta.url), "utf8");
for (const bucket of ["waitlist:ip", "login:ip", "access:ip", "audit:ip", "audit:session", "audit:target", "audit:lite-day", "check:ip-hour", "check:target-hour"]) {
let source = auditsSource;
if (bucket.startsWith("audit")) source = auditsSource;
else if (bucket.startsWith("check")) source = readFileSync(new URL("../worker/routes/public-check.js", import.meta.url), "utf8");
else source = readFileSync(new URL("../worker/routes/access.js", import.meta.url), "utf8");
assert.ok(source.includes(`bucket: \`${bucket}`), `abuse control must cover ${bucket}`);
}
});

test("README anonymous one-page check claim matches the Worker, page, and rate limits", () => {
assert.match(liveSection, /anonymous one-page URL check/i);
assert.ok(workerIndex.includes('url.pathname === "/check"'), "Worker must route /check");
assert.ok(workerIndex.includes('url.pathname === "/api/public-check"'), "Worker must route /api/public-check");
const publicCheckSource = readFileSync(new URL("../worker/routes/public-check.js", import.meta.url), "utf8");
assert.match(publicCheckSource, /maxPages: 1/, "the public check is a one-page run");
assert.match(publicCheckSource, /check:target-day/, "the check is rate-limited per site");
assert.match(publicCheckSource, /does not guarantee rankings, traffic, indexing, revenue, AI citations/i, "the public check keeps the no-ranking boundary");
assert.match(wranglerJsonc, /"\/check"/, "/check is served by the Worker before SPA assets");
});

// State-truthfulness pins. The numeric caps and routes above are locked to
// code; these claims describe what is NOT live or how limits are computed, so
// they were spot-checked once (PR #60) and are locked here so the README
Expand Down
Loading
Loading