From dab677f8452a992f9eb35fd9f272970048491973 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Tue, 23 Jun 2026 19:22:03 +0530 Subject: [PATCH 1/6] feat(audit): include the inviter's score in invite emails Thread the current audit score from the dashboard through to the api-server so the invite email can show "my agent scored a N/100". - sendInvites() takes an optional score and forwards it as { to, score } - /api/audit/invite reads the browser-supplied score and clamps it to 0-100 (rounded; non-numeric dropped) before forwarding upstream - AuditDashboard -> ComeBackBetterSection -> InviteDialog pass the score down to the POST; the prop is optional end-to-end so it degrades to score-free copy when absent - tests: sendInvites body shape + route score coercion/clamping Co-Authored-By: Claude Opus 4.8 --- __tests__/api/audit-invite-route.test.ts | 75 +++++++++++++++++++ __tests__/lib/api-server-client.test.ts | 39 ++++++++++ app/api/audit/invite/route.ts | 11 ++- app/audit/_components/audit-dashboard.tsx | 1 + .../_components/come-back-better-section.tsx | 5 +- app/audit/_components/invite-dialog.tsx | 9 ++- lib/auth/api-server-client.ts | 7 +- 7 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 __tests__/api/audit-invite-route.test.ts diff --git a/__tests__/api/audit-invite-route.test.ts b/__tests__/api/audit-invite-route.test.ts new file mode 100644 index 00000000..974e8d64 --- /dev/null +++ b/__tests__/api/audit-invite-route.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { NextRequest } from "next/server"; + +// Exercise the proxy route in isolation: a stub session (whoAmI) and a +// recording upstream client (sendInvites) so we can assert exactly what score +// the route forwards after its defensive coercion. +const { whoAmIMock, sendInvitesMock } = vi.hoisted(() => ({ + whoAmIMock: vi.fn(), + sendInvitesMock: vi.fn(), +})); + +vi.mock("@/lib/auth/auth-store", () => ({ whoAmI: whoAmIMock })); +vi.mock("@/lib/auth/api-server-client", () => ({ + sendInvites: sendInvitesMock, + // The route only checks `instanceof AuthApiError`; a stub class suffices. + AuthApiError: class AuthApiError extends Error {}, +})); +vi.mock("@/lib/telemetry", () => ({ + initTelemetry: vi.fn(async () => {}), + trackEvent: vi.fn(), +})); + +import { POST } from "@/app/api/audit/invite/route"; + +function req(body: unknown): NextRequest { + return { text: async () => JSON.stringify(body) } as unknown as NextRequest; +} + +describe("POST /api/audit/invite — score forwarding", () => { + beforeEach(() => { + whoAmIMock.mockReset(); + sendInvitesMock.mockReset(); + whoAmIMock.mockResolvedValue({ + me: { id: "u1", email: "alice@example.com" }, + auth: { access_token: "at-1" }, + }); + sendInvitesMock.mockResolvedValue({ sent: ["bob@x.co"], failed: [] }); + }); + + // The score is the 3rd positional arg to sendInvites(accessToken, to, score). + const scoreArg = () => sendInvitesMock.mock.calls[0][2]; + + it("forwards a valid in-range score to the api-server", async () => { + const res = await POST(req({ to: ["bob@x.co"], score: 18 })); + expect(res.status).toBe(200); + expect(sendInvitesMock).toHaveBeenCalledWith("at-1", ["bob@x.co"], 18); + }); + + it("clamps out-of-range scores and rounds fractions", async () => { + await POST(req({ to: ["bob@x.co"], score: 250 })); + expect(scoreArg()).toBe(100); + + sendInvitesMock.mockClear(); + await POST(req({ to: ["bob@x.co"], score: -5 })); + expect(scoreArg()).toBe(0); + + sendInvitesMock.mockClear(); + await POST(req({ to: ["bob@x.co"], score: 72.6 })); + expect(scoreArg()).toBe(73); + }); + + it("forwards undefined when the score is absent or non-numeric", async () => { + await POST(req({ to: ["bob@x.co"] })); + expect(scoreArg()).toBeUndefined(); + + sendInvitesMock.mockClear(); + await POST(req({ to: ["bob@x.co"], score: "lots" })); + expect(scoreArg()).toBeUndefined(); + + sendInvitesMock.mockClear(); + await POST(req({ to: ["bob@x.co"], score: Number.NaN })); + expect(scoreArg()).toBeUndefined(); + }); +}); diff --git a/__tests__/lib/api-server-client.test.ts b/__tests__/lib/api-server-client.test.ts index 999e0025..c67803db 100644 --- a/__tests__/lib/api-server-client.test.ts +++ b/__tests__/lib/api-server-client.test.ts @@ -13,6 +13,7 @@ import { decodeJwt, requestLoginCode, scheduleReminder, + sendInvites, } from "@/lib/auth/api-server-client"; describe("api-server-client fetchWithTimeout telemetry", () => { @@ -130,6 +131,44 @@ describe("cancelReminder", () => { }); }); +describe("sendInvites", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + trackEventMock.mockClear(); + }); + + const okResult = { sent: ["b@x.co"], failed: [] }; + const mockFetch = () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(okResult), { status: 200 }), + ) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + return fetchMock; + }; + const bodyOf = (fetchMock: typeof fetch) => { + const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]; + return JSON.parse(init.body as string) as { to: string[]; score?: number }; + }; + + it("includes the score in the POST body when provided", async () => { + const fetchMock = mockFetch(); + const out = await sendInvites("at-1", ["b@x.co"], 18); + expect(out).toEqual(okResult); + const body = bodyOf(fetchMock); + expect(body.to).toEqual(["b@x.co"]); + expect(body.score).toBe(18); + }); + + it("omits the score key entirely when not provided", async () => { + const fetchMock = mockFetch(); + await sendInvites("at-1", ["b@x.co"]); + const body = bodyOf(fetchMock); + expect(body.to).toEqual(["b@x.co"]); + expect("score" in body).toBe(false); + }); +}); + describe("retry-after parsing in parseError", () => { const originalFetch = globalThis.fetch; afterEach(() => { diff --git a/app/api/audit/invite/route.ts b/app/api/audit/invite/route.ts index 3ccd76d1..a1859aec 100644 --- a/app/api/audit/invite/route.ts +++ b/app/api/audit/invite/route.ts @@ -28,6 +28,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; interface InviteBody { to?: unknown; + score?: unknown; } export async function POST(req: NextRequest): Promise { @@ -132,8 +133,16 @@ export async function POST(req: NextRequest): Promise { ); } + // Optional sender audit score (0–100), surfaced in the invite body. Coerce + // defensively — the value is client-supplied: ignore non-finite garbage, + // round, then clamp into range before forwarding upstream. + let score: number | undefined; + if (typeof body.score === "number" && Number.isFinite(body.score)) { + score = Math.max(0, Math.min(100, Math.round(body.score))); + } + try { - const result = await sendInvites(who.auth.access_token, normalised); + const result = await sendInvites(who.auth.access_token, normalised, score); trackEvent("audit_invite_sent", { status: "success", source: "dashboard", diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index 3da689fa..00cb096d 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -353,6 +353,7 @@ function MainReport({ onRerun("return_section")} + score={score} /> diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index 7739ab41..32a9b60f 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -27,6 +27,8 @@ import { InviteDialog } from "./invite-dialog"; interface Props { isRunning: boolean; onRerun: () => void; + /** Current audit score (0–100), forwarded into the invite email body. */ + score?: number; } const DEFAULT_REMINDER_DAYS = 7; @@ -68,7 +70,7 @@ function formatNextAudit(unixSecs: number): string { }); } -export function ComeBackBetterSection({ isRunning, onRerun }: Props) { +export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { const { capture } = usePostHog(); const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); const [reminder, setReminder] = useState(null); @@ -306,6 +308,7 @@ export function ComeBackBetterSection({ isRunning, onRerun }: Props) { setInviteDialogOpen(false)} onUnauthorized={() => { // Session expired between probe and submit — flip back to anon diff --git a/app/audit/_components/invite-dialog.tsx b/app/audit/_components/invite-dialog.tsx index 53956ede..2e1b6315 100644 --- a/app/audit/_components/invite-dialog.tsx +++ b/app/audit/_components/invite-dialog.tsx @@ -24,6 +24,9 @@ interface Props { * submit). The parent should re-open the AuthDialog so the user can * re-authenticate; without this, a 401 dead-ends with an inline error. */ onUnauthorized?: () => void; + /** Sender's audit score (0–100), forwarded to the api-server so the invite + * body can show "mine came out at N/100". Omitted → score-free copy. */ + score?: number; } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -46,7 +49,7 @@ function parseEmails(input: string): { valid: string[]; invalid: string[] } { return { valid, invalid }; } -export function InviteDialog({ open, onClose, source, onUnauthorized }: Props): React.ReactElement | null { +export function InviteDialog({ open, onClose, source, onUnauthorized, score }: Props): React.ReactElement | null { const { capture } = usePostHog(); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); @@ -102,7 +105,7 @@ export function InviteDialog({ open, onClose, source, onUnauthorized }: Props): const res = await fetch("/api/audit/invite", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ to: valid }), + body: JSON.stringify({ to: valid, score }), }); const body = (await res.json().catch(() => ({}))) as { sent?: string[]; @@ -141,7 +144,7 @@ export function InviteDialog({ open, onClose, source, onUnauthorized }: Props): setBusy(false); } }, - [busy, valid, invalid, capture, source, onClose, onUnauthorized], + [busy, valid, invalid, capture, source, onClose, onUnauthorized, score], ); if (!open) return null; diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts index 7dc8e752..a8b307ce 100644 --- a/lib/auth/api-server-client.ts +++ b/lib/auth/api-server-client.ts @@ -249,17 +249,20 @@ export interface InviteSendResult { /** * Send invite emails to a batch of friends. The api-server pulls the sender's * email from the access-token claims and Cc's them on every outbound message - * so the recipient sees who invited them. + * so the recipient sees who invited them. `score` (the sender's audit score, + * 0–100) is forwarded so the invite body can show "mine came out at N/100"; + * omit it and the api-server renders score-free copy. * * Contract is handed over to the platform team separately. */ export async function sendInvites( accessToken: string, to: string[], + score?: number, ): Promise { return postJson( "/v0/invite", - { to }, + score === undefined ? { to } : { to, score }, { accessToken }, ); } From 295775efb049b2fffc5fca4d3af63babc29a027c Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Tue, 23 Jun 2026 19:22:03 +0530 Subject: [PATCH 2/6] feat(audit): rewrite the X/LinkedIn share templates Replace the 10 X + 10 LinkedIn templates with new copy that leads on the score and archetype and ends on the npx CTA plus handle (npx -y failproofai audit, @failproofai on X / @Failproof AI on LinkedIn). No URLs in the copy: a bare link would render a preview card and swallow the pasted audit-card image. The clipboard paste hint still appends via pickTemplate, and the deterministic seed-based selection is unchanged. Co-Authored-By: Claude Opus 4.8 --- __tests__/audit/share-templates.test.ts | 22 +++++-- app/audit/_components/share-templates.ts | 78 +++++++++++++----------- 2 files changed, 57 insertions(+), 43 deletions(-) diff --git a/__tests__/audit/share-templates.test.ts b/__tests__/audit/share-templates.test.ts index 1aff87d2..ee7a4d52 100644 --- a/__tests__/audit/share-templates.test.ts +++ b/__tests__/audit/share-templates.test.ts @@ -16,17 +16,28 @@ describe("share templates", () => { expect(LI_TEMPLATES).toHaveLength(10); }); - it("every template carries score, archetype, the npx command, and no bare site URL", () => { + it("every template ends on the npx CTA, references score or archetype, and embeds no URL", () => { for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { const out = t(ctx); - expect(out).toContain("72"); - expect(out).toContain("the cowboy"); expect(out).toContain("npx -y failproofai audit"); + // No URLs anywhere — a link would trigger a preview card and swallow the + // pasted image. (Catches befailproof.ai and any http(s) link.) expect(out).not.toContain("befailproof.ai"); + expect(out).not.toMatch(/https?:\/\//); + // Each template surfaces the score and/or the archetype (a couple lean + // on just one). + expect(out.includes("72") || out.includes("the cowboy")).toBe(true); expect(out.length).toBeGreaterThan(40); } }); + it("references the score on most templates and the archetype on most templates", () => { + const withScore = [...X_TEMPLATES, ...LI_TEMPLATES].filter((t) => t(ctx).includes("72")); + const withArch = [...X_TEMPLATES, ...LI_TEMPLATES].filter((t) => t(ctx).includes("the cowboy")); + expect(withScore.length).toBeGreaterThanOrEqual(15); + expect(withArch.length).toBeGreaterThanOrEqual(15); + }); + it("tags the channel's handle (@failproofai on X, @Failproof AI on LinkedIn)", () => { for (const t of X_TEMPLATES) expect(t(ctx)).toContain("@failproofai"); for (const t of LI_TEMPLATES) expect(t(ctx)).toContain("@Failproof AI"); @@ -48,10 +59,9 @@ describe("share templates", () => { } }); - it("X copy is emoji-forward; LinkedIn copy is emoji-free", () => { + it("all copy is emoji-free (clean, professional tone on both channels)", () => { const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u; - expect(X_TEMPLATES.every((t) => emoji.test(t(ctx)))).toBe(true); - for (const t of LI_TEMPLATES) { + for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { expect(emoji.test(t(ctx))).toBe(false); } }); diff --git a/app/audit/_components/share-templates.ts b/app/audit/_components/share-templates.ts index d402cffe..811b5c78 100644 --- a/app/audit/_components/share-templates.ts +++ b/app/audit/_components/share-templates.ts @@ -1,12 +1,16 @@ /** * Social-share copy for the audit identity card. * - * Ten short, curiosity-forward templates for X and ten short, professional - * templates for LinkedIn. `pickTemplate` selects one deterministically from a - * seed (the behaviour fingerprint), so a given audit run always renders the - * same post while different runs / personas vary — then appends a clipboard - * paste hint. Pure — no React, no DOM — so it's unit-testable and shared by the - * client poster component. + * Ten templates for X and ten for LinkedIn. `pickTemplate` selects one + * deterministically from a seed (the behaviour fingerprint), so a given audit + * run always renders the same post while different runs / personas vary — then + * appends a clipboard paste hint. Pure — no React, no DOM — so it's + * unit-testable and shared by the client poster component. + * + * Each template references the score and/or archetype and ends on the + * install-free CTA + handle. We intentionally never include a URL: a bare link + * would make X/LinkedIn render a link-preview card and swallow the copied + * image. The npx command is the call to action instead. */ import type { Grade } from "@/src/audit/scoring"; @@ -19,60 +23,60 @@ export interface ShareCtx { missing: number; } -const pol = (n: number) => (n === 1 ? "policy" : "policies"); +/** Call-to-action + handle per channel. No URL by design (see file header). */ +const X_CTA = "npx -y failproofai audit · @failproofai"; +const LI_CTA = "npx -y failproofai audit · @Failproof AI"; /** Appended to every picked share so the user knows the audit-card PNG is on * their clipboard and just needs pasting into the post. */ const PASTE_LINE = "[ audit card copied to your clipboard — paste it into the post ]"; -/** Short, catchy, curiosity-forward — for X / Twitter. Emoji-forward, no - * grade-tier framing (it sounds bad at the low end). Subtly tags @failproofai. */ +/** Short, curiosity-forward — for X / Twitter. Ends on the npx CTA + @failproofai. */ export const X_TEMPLATES: ((c: ShareCtx) => string)[] = [ ({ score, arch }) => - `my AI coding agent has a personality and it's "${arch}" 🤠\n\n${score}/100 on the failproofai audit. what's yours?\n\nnpx -y failproofai audit · @failproofai`, + `my coding agent has a personality and it's ${arch}. did not see that coming.\n\nran a failproof audit, scored ${score}/100, all local. find out what yours is → ${X_CTA}`, ({ score, arch }) => - `turns out my coding agent is ${arch} 💀\n\n${score}/100 — it read my own session logs and did not miss.\n\nnpx -y failproofai audit · @failproofai`, + `spent 30 seconds auditing my coding agent. learned more about it than i did all month.\n\nit's ${arch}. scored ${score}/100. → ${X_CTA}`, + ({ score }) => + `my agent scored ${score}/100. think yours can beat it?\n\n30 sec audit, runs local, gives you a personality too → ${X_CTA}`, + ({ arch }) => + `turns out my coding agent is ${arch} and honestly it explains everything.\n\n→ ${X_CTA}`, ({ score, arch }) => - `my AI agent's archetype: ${arch} 👀\n\nscored ${score}/100. unsettlingly accurate.\n\nrun yours → npx -y failproofai audit · @failproofai`, - ({ score, arch, missing }) => - `plot twist: my coding agent is ${arch} 🎭\n\n${score}/100${missing > 0 ? `, ${missing} ${pol(missing)} from clean` : `, somehow spotless`}.\n\nnpx -y failproofai audit · @failproofai`, + `ran failproof audit on my agent. it reads how it actually behaves when things break, not just the happy path.\n\npersonality: ${arch}. score: ${score}/100. all local → ${X_CTA}`, ({ score, arch }) => - `@failproofai audited my coding agent and called it ${arch} ${score >= 80 ? "😎" : "😬"}\n\n${score}/100. brutal, accurate, no notes.\n\nnpx -y failproofai audit`, + `got to know my coding agent today. it's ${arch} and scored ${score}/100. wow.\n\naudit yours in 30s, nothing leaves your machine → ${X_CTA}`, ({ score, arch }) => - `every AI agent has a tell. mine is "${arch}" 🎲\n\n${score}/100. find out what yours does when you're not looking.\n\nnpx -y failproofai audit · @failproofai`, + `your coding agent has a personality. you just haven't met it.\n\ni met mine today: ${arch}, ${score}/100. → ${X_CTA}`, ({ score, arch }) => - `${score}/100. archetype: ${arch}. 🔍\n\n@failproofai reverse-engineered my agent's whole vibe from its logs.\n\nnpx -y failproofai audit`, + `everyone talks about what their agent can build. nobody talks about how it acts when it breaks.\n\nmine's ${arch}, scored ${score}/100, all local → ${X_CTA}`, ({ score, arch }) => - `apparently my AI agent is "${arch}" and that explains a lot 😅\n\n${score}/100. audit yours in 30s:\n\nnpx -y failproofai audit · @failproofai`, + `every agent builder should run this once.\n\n30 sec, local, gives your agent a personality and a quality score. mine: ${arch}, ${score}/100.\n\n${X_CTA}`, ({ score, arch }) => - `ran my coding agent through @failproofai → ${arch}, ${score}/100 🎯\n\nbet yours has skeletons too. go find them:\n\nnpx -y failproofai audit`, - ({ score, arch, missing }) => - `my coding agent is officially "${arch}" 🪞\n\n${score}/100${missing > 0 ? ` · ${missing} ${pol(missing)} to a clean run` : ` · every guardrail live`}.\n\nnpx -y failproofai audit · @failproofai`, + `i'll go first: ${arch}, ${score}/100.\n\nwhat's your coding agent? 30 sec audit, no signup → ${X_CTA}`, ]; -/** Short, professional, well-said — for LinkedIn. No emoji, no grade-tier - * framing. Subtly references the @Failproof AI page. */ +/** Longer, reflective — for LinkedIn. Ends on the npx CTA + @Failproof AI. */ export const LI_TEMPLATES: ((c: ShareCtx) => string)[] = [ - ({ score, arch, missing }) => - `I ran a failproofai audit on our AI coding agents: ${score}/100, behavioural archetype "${arch}". ${missing > 0 ? `${missing} prescribed ${pol(missing)} would close the gaps.` : `Every prescribed policy is already live.`}\n\nHow your agents behave unsupervised is finally measurable. Run yours: npx -y failproofai audit — @Failproof AI`, - ({ score, arch, missing }) => - `Security posture check on our coding-agent stack: ${score}/100, profile "${arch}". ${missing > 0 ? `${missing} ${pol(missing)} flagged as real attack surface.` : `No open policy gaps.`}\n\nThirty seconds to assess your own: npx -y failproofai audit — @Failproof AI`, ({ score, arch }) => - `Most teams can't say how their AI agents behave when no one is watching. failproofai profiled ours as "${arch}" — ${score}/100, mapped to the exact policies that close each gap.\n\nRun yours: npx -y failproofai audit — @Failproof AI`, - ({ score, arch, missing }) => - `Agent security isn't a vibe — it's measurable. We scored ${score}/100, archetype "${arch}". ${missing > 0 ? `${missing} prescribed ${pol(missing)} left to harden.` : `Full policy coverage.`}\n\nnpx -y failproofai audit — @Failproof AI`, + `I've spent more hours with my coding agent this month than with most people I know. Today I realized I couldn't tell you a single thing about how it actually behaves.\n\nSo I audited it. Turns out it's ${arch}, and it scored ${score}/100.\n\nThe audit reads its real history, including how it handles failures and not just the clean runs. 30 seconds, runs entirely local.\n\nMeet yours: ${LI_CTA}`, + ({ score, arch }) => + `Took a personality test today. It wasn't for me, it was for my AI coding agent.\n\nThe result: ${arch}, with a quality score of ${score}/100.\n\nIt works by reading the agent's actual run history, so the personality comes from how it really behaves, not a vibe. Took 30 seconds and stayed local.\n\nTry it on yours: ${LI_CTA}`, + ({ score, arch }) => + `Everyone asks what their AI agent can build. I just found out mine has a temper.\n\nAfter watching how it reacts to a failed command, the audit called it ${arch}. It also scored its quality at ${score}/100.\n\nOddly useful to see it written down. 30 seconds, all local: ${LI_CTA}`, + ({ score, arch }) => + `My agent does this very specific thing every time a command fails. Today I learned there's basically a name for it.\n\nRan a quick audit and it came back ${arch}, scored ${score}/100. The whole read is based on how the agent handles things going wrong, which is where its real character shows.\n\n30 seconds, nothing leaves your machine: ${LI_CTA}`, + ({ score, arch }) => + `My AI coding agent scored ${score}/100 today. I didn't know you could even measure that.\n\nThe same audit also handed it a personality: ${arch}.\n\nIt reads the agent's real history and tells you where it's solid and where it slips. Took 30 seconds and ran fully local: ${LI_CTA}`, ({ score, arch }) => - `Ran our coding agents through a failproofai behavioural audit: ${score}/100, "${arch}". It maps every risky pattern to the policy that prevents it, so remediation becomes a checklist.\n\nnpx -y failproofai audit — @Failproof AI`, + `I'm usually the first to scroll past "audit your X" tools. This one took 30 seconds and actually told me something.\n\nMy coding agent is ${arch}, and it scored ${score}/100.\n\nIt reads the agent's real run history rather than asking me anything, and none of it leaves the machine: ${LI_CTA}`, ({ score, arch }) => - `What does your AI coding agent actually do when a command fails? Ours scored ${score}/100 as "${arch}" on the failproofai audit.\n\nFind out in 30 seconds: npx -y failproofai audit — @Failproof AI`, - ({ score, arch, missing }) => - `Shipping with AI agents means owning their failure modes. failproofai profiled ours as "${arch}", ${score}/100${missing > 0 ? `, with ${missing} ${pol(missing)} still to close` : `, fully covered`}.\n\nnpx -y failproofai audit — @Failproof AI`, + `What is your coding agent's personality? I realized today that I had no answer.\n\nSo I checked. Mine is ${arch}, scored ${score}/100, with a clear read on where it shines and where it slips.\n\nIf you work with agents, run it and drop yours in the comments. 30 seconds, fully local: ${LI_CTA}`, ({ score, arch }) => - `Observability for AI coding agents, distilled to one number: ${score}/100. Archetype "${arch}", every risk mapped to a preventive policy.\n\nOpen-source: npx -y failproofai audit — @Failproof AI`, + `I trusted my coding agent a little less after this morning, and quite a bit more by the afternoon.\n\nI ran an audit on it. It showed me exactly where it's reliable and where it isn't, gave it a personality (${arch}), and scored it ${score}/100.\n\nKnowing the gaps is what made me trust it more. 30 seconds, all local: ${LI_CTA}`, ({ score, arch }) => - `We let failproofai read our agents' own session logs. Result: "${arch}", ${score}/100 — evidence-backed and mapped to the policies that prevent each pattern.\n\nAssess yours: npx -y failproofai audit — @Failproof AI`, + `I almost skipped this because I assumed it would quietly ship my code off somewhere. It doesn't. Everything runs local.\n\n30 seconds later I had my agent's personality (${arch}) and a quality score (${score}/100).\n\nIf privacy is usually what stops you trying these, this one is different: ${LI_CTA}`, ({ score, arch }) => - `The first step to securing AI agents is understanding how they behave. failproofai scored ours ${score}/100 ("${arch}") and prescribed every fix.\n\nnpx -y failproofai audit — @Failproof AI`, + `Found out my AI coding agent has a personality type. Found out it's ${arch}. Found out that explains a lot.\n\nIt scored ${score}/100 too. The audit reads how the agent handles failures, not just the runs where everything goes fine.\n\n30 seconds, runs local: ${LI_CTA}`, ]; /** djb2 hash — stable per seed so the template choice is deterministic. */ From ed9813b3a414986a0b84b18347caa4feb3d71145 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Tue, 23 Jun 2026 19:22:03 +0530 Subject: [PATCH 3/6] style(audit): enlarge the poster's four corner labels Bump the header wordmark/meta and footer brand/CTA font sizes so the poster corners read clearly at share size, with the bottom bar sized consistently with the header. Applies to both the dashboard render and the downloaded PNG. Co-Authored-By: Claude Opus 4.8 --- app/audit/audit-styles.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 571d1fdc..8b504e2f 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -617,7 +617,7 @@ padding-bottom: 12px; border-bottom: 1px dashed var(--line); font-family: var(--font-mono); - font-size: 11px; + font-size: 14px; letter-spacing: 0.04em; } .poster-wordmark { @@ -625,7 +625,7 @@ display: inline-flex; align-items: center; gap: 8px; } .poster-wordmark .poster-logo { - height: 14px; + height: 18px; width: auto; display: block; } @@ -730,7 +730,7 @@ padding-top: 10px; border-top: 1px dashed var(--line); font-family: var(--font-mono); - font-size: 10px; + font-size: 14px; } .poster-brand { color: var(--ink); @@ -802,7 +802,7 @@ .poster { padding: 24px 22px; } .poster-body { gap: 28px; padding: 12px 0; } .poster-share-row { grid-template-columns: 1fr; } - .poster-head { font-size: 10px; } + .poster-head { font-size: 12px; } .poster-score .score-n { font-size: clamp(72px, 18vw, 120px); } .poster-persona .persona-name { font-size: clamp(36px, 9vw, 56px); } } @@ -1175,4 +1175,4 @@ padding: 16px 0; } .quirks-thead { display: none; } -} +} \ No newline at end of file From 01bc277a27125e01158007a982c9446175275ef7 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Tue, 23 Jun 2026 19:44:26 +0530 Subject: [PATCH 4/6] docs: add CHANGELOG entries for the invite-score work (#456) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c310fd..76ec390d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features - Add a PR-level MDX parse check (`bun run validate:mdx`, wired into the CI `docs` job) that compiles every `docs/**/*.mdx` with the same MDX engine Mintlify runs at deploy time. `mintlify validate` only checks `docs.json` structure and nav links — it never parses page content — so syntax errors slipped through to the post-merge deploy. This catches them on the PR instead (#455). +- Invite emails now include the inviter's audit score: `sendInvites()` and the `/api/audit/invite` proxy forward a clamped (0–100) score to the api-server, which renders "my agent scored a N/100" in the body. Threaded `AuditDashboard → ComeBackBetterSection → InviteDialog`; optional end-to-end so it degrades to score-free copy when absent (#456). +- Rewrite the X/LinkedIn share templates (10 each): lead on the score and archetype and end on the `npx -y failproofai audit` CTA + handle (`@failproofai` / `@Failproof AI`), with no URLs in the copy so the pasted audit-card image isn't replaced by a link-preview card (#456). +- Enlarge the audit poster's four corner labels so they read clearly at share size, on both the dashboard render and the downloaded PNG (#456). ### Fixes - Fix three translated docs pages that failed the Mintlify deploy parse. `docs/tr/cli/audit.mdx` had a dropped closing backtick that pushed `` out of its inline-code span (parsed as an unclosed JSX tag); `docs/ja/built-in-policies.mdx` and `docs/zh/built-in-policies.mdx` carried translator-injected `{#id}` heading anchors that MDX reads as JS expressions. All three now match the other 12 locales (#455). From f7b4c13c74c831b543b6546da67f5b92bf553ec8 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Wed, 24 Jun 2026 01:23:12 +0530 Subject: [PATCH 5/6] test(audit): anchor the share-template CTA assertion to end-of-copy Addresses CodeRabbit review on #456: the 'ends on the npx CTA' test used toContain (presence only); switch to an end-anchored regex so a CTA that drifts mid-body is caught. Co-Authored-By: Claude Opus 4.8 --- __tests__/audit/share-templates.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/__tests__/audit/share-templates.test.ts b/__tests__/audit/share-templates.test.ts index ee7a4d52..dc25d287 100644 --- a/__tests__/audit/share-templates.test.ts +++ b/__tests__/audit/share-templates.test.ts @@ -19,7 +19,9 @@ describe("share templates", () => { it("every template ends on the npx CTA, references score or archetype, and embeds no URL", () => { for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { const out = t(ctx); - expect(out).toContain("npx -y failproofai audit"); + // Anchored: the CTA must *terminate* the copy (matches the test name), + // not merely appear somewhere in it. + expect(out.trimEnd()).toMatch(/npx -y failproofai audit · @[^\n]+$/); // No URLs anywhere — a link would trigger a preview card and swallow the // pasted image. (Catches befailproof.ai and any http(s) link.) expect(out).not.toContain("befailproof.ai"); From d4b6af00f5dc2824bdc5624db568d18343aea418 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Wed, 24 Jun 2026 01:54:33 +0530 Subject: [PATCH 6/6] fix(dashboard): suppress benign Server Action deployment-skew noise in the server log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser tab left open across a dashboard rebuild/upgrade keeps POSTing a stale Server Action ID (IDs are hashed per build). Next handles it gracefully on the client — a 404 with `x-nextjs-action-not-found: 1`, which the client recovers from — but the standalone server still throws + logs a 3-line "Failed to find Server Action " block to stderr per stale request, which floods the failproofai terminal. The earlier attempt to fix this client-side was the wrong layer (a browser handler can't suppress a server-process log). This filters at the only place failproofai controls that output: the launcher. In `start` mode it now pipes the spawned Next server's stdout/stderr through a stateful per-stream filter that drops exactly the skew block and passes everything else through verbatim (color preserved via FORCE_COLOR). `dev` keeps `stdio: "inherit"` so Next's interactive compile output is untouched. Verified against the real standalone build: 3 stale-action POSTs produced 0 leaked error lines while the banner/startup logs (and color) came through. Unit-tested incl. the case where a genuine error following a skew block is NOT swallowed. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + __tests__/scripts/skew-log-filter.test.ts | 53 +++++++++++++++++++++++ scripts/launch.ts | 28 +++++++++++- scripts/skew-log-filter.ts | 46 ++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 __tests__/scripts/skew-log-filter.test.ts create mode 100644 scripts/skew-log-filter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ec390d..ff3f602d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes - Fix three translated docs pages that failed the Mintlify deploy parse. `docs/tr/cli/audit.mdx` had a dropped closing backtick that pushed `` out of its inline-code span (parsed as an unclosed JSX tag); `docs/ja/built-in-policies.mdx` and `docs/zh/built-in-policies.mdx` carried translator-injected `{#id}` heading anchors that MDX reads as JS expressions. All three now match the other 12 locales (#455). +- Stop the failproofai server log from repeating the benign Next.js "Failed to find Server Action" deployment-skew error. A browser tab left open across a dashboard rebuild/upgrade POSTs a stale Server Action ID; the client recovers via Next's graceful 404, but the standalone server still logged a 3-line error block to stderr per stale request. The `start` launcher now pipes the server's output through a filter (`scripts/skew-log-filter.ts`) that drops just that block — all other output, and color via `FORCE_COLOR`, passes through untouched; `dev` is unchanged (#456). ### Dependencies - Add `@mdx-js/mdx` as a dev dependency, used by the new `validate:mdx` docs parse check (#455). diff --git a/__tests__/scripts/skew-log-filter.test.ts b/__tests__/scripts/skew-log-filter.test.ts new file mode 100644 index 00000000..e19111f7 --- /dev/null +++ b/__tests__/scripts/skew-log-filter.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { makeSkewLogFilter } from "../../scripts/skew-log-filter"; + +// The exact 3-line block Next's standalone server prints to stderr for a stale +// server-action request (reproduced empirically against the real build). +const SKEW_BLOCK = [ + 'Error: Failed to find Server Action "402714e0127fd96b57ab64b8f734f9316459cc9edc". This request might be from an older or newer deployment.', + "Read more: https://nextjs.org/docs/messages/failed-to-find-server-action", + " at ignore-listed frames", +]; + +describe("makeSkewLogFilter", () => { + it("drops the entire 3-line skew block", () => { + const f = makeSkewLogFilter(); + for (const line of SKEW_BLOCK) expect(f(line)).toBeNull(); + }); + + it("passes unrelated server output through unchanged", () => { + const f = makeSkewLogFilter(); + expect(f("▲ Next.js 16.2.9")).toBe("▲ Next.js 16.2.9"); + expect(f("- Local: http://127.0.0.1:8020")).toBe("- Local: http://127.0.0.1:8020"); + expect(f("✓ Ready in 0ms")).toBe("✓ Ready in 0ms"); + expect(f("GET /audit 200 in 12ms")).toBe("GET /audit 200 in 12ms"); + }); + + it("resumes emitting immediately after a skew block ends", () => { + const f = makeSkewLogFilter(); + SKEW_BLOCK.forEach((l) => expect(f(l)).toBeNull()); + expect(f("GET /policies 200 in 8ms")).toBe("GET /policies 200 in 8ms"); + }); + + it("does NOT swallow a genuine error (and its stack) that follows a skew block", () => { + const f = makeSkewLogFilter(); + SKEW_BLOCK.forEach((l) => f(l)); + expect(f("Error: database connection refused")).toBe("Error: database connection refused"); + expect(f(" at Object.connect (db.ts:10:5)")).toBe(" at Object.connect (db.ts:10:5)"); + }); + + it("never drops stack frames outside a skew block", () => { + const f = makeSkewLogFilter(); + expect(f("Error: something else broke")).toBe("Error: something else broke"); + expect(f(" at foo (bar.ts:1:1)")).toBe(" at foo (bar.ts:1:1)"); + }); + + it("handles multiple skew blocks in one stream", () => { + const f = makeSkewLogFilter(); + SKEW_BLOCK.forEach((l) => expect(f(l)).toBeNull()); + expect(f("GET / 200")).toBe("GET / 200"); + SKEW_BLOCK.forEach((l) => expect(f(l)).toBeNull()); + expect(f("done")).toBe("done"); + }); +}); diff --git a/scripts/launch.ts b/scripts/launch.ts index 2ab6d1b3..c0b68a35 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -5,8 +5,10 @@ import { spawn } from "child_process"; import { realpathSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; import { parseScriptArgs } from "./parse-script-args"; import { diagnoseShadow } from "./install-diagnosis.mjs"; +import { makeSkewLogFilter } from "./skew-log-filter"; import { version } from "../package.json"; export function launch(mode: "dev" | "start"): void { @@ -79,16 +81,40 @@ export function launch(mode: "dev" | "start"): void { cmdArgs = ["--bun", "next", "dev", ...remainingArgs]; } + // In `start` (the shipped standalone server) we pipe + filter the child's + // output to drop the benign "Failed to find Server Action" deployment-skew + // block — a stale browser tab POSTing an old action ID after a rebuild, which + // the client recovers from via Next's graceful 404 (see skew-log-filter.ts). + // `dev` keeps "inherit" so Next's interactive compile output is untouched. + // FORCE_COLOR keeps the piped child's output colored despite the non-TTY pipe. + const filterLogs = mode === "start"; + const nextProcess = spawn(cmd, cmdArgs, { - stdio: "inherit", + stdio: filterLogs ? ["inherit", "pipe", "pipe"] : "inherit", env: { ...process.env, + ...(filterLogs ? { FORCE_COLOR: process.env.FORCE_COLOR ?? "1" } : {}), ...(loggingLevel ? { FAILPROOFAI_LOG_LEVEL: loggingLevel } : {}), ...(disableTelemetry ? { FAILPROOFAI_TELEMETRY_DISABLED: "1" } : {}), ...(allowedDevOrigins ? { FAILPROOFAI_ALLOWED_DEV_ORIGINS: allowedDevOrigins.join(",") } : {}), }, }); + if (filterLogs) { + // One filter instance per stream — the skew block is multi-line and stateful. + for (const [src, dest] of [ + [nextProcess.stdout, process.stdout], + [nextProcess.stderr, process.stderr], + ] as const) { + if (!src) continue; + const filter = makeSkewLogFilter(); + createInterface({ input: src }).on("line", (line) => { + const out = filter(line); + if (out !== null) dest.write(out + "\n"); + }); + } + } + nextProcess.on("error", (error) => { console.error("Error starting Next.js:", error); process.exit(1); diff --git a/scripts/skew-log-filter.ts b/scripts/skew-log-filter.ts new file mode 100644 index 00000000..f5d9e856 --- /dev/null +++ b/scripts/skew-log-filter.ts @@ -0,0 +1,46 @@ +/** + * Filters the benign Next.js "Failed to find Server Action" deployment-skew + * block out of the standalone server's forwarded output. + * + * Why this exists: the dashboard ships as a per-version Next standalone build, + * and Server Action IDs are hashed at build time. A browser tab left open + * across a rebuild/upgrade keeps POSTing a stale action ID the running build no + * longer has, so Next throws + logs (server-side, to stderr) a 3-line block: + * + * Error: Failed to find Server Action "". This request might be from an older or newer deployment. + * Read more: https://nextjs.org/docs/messages/failed-to-find-server-action + * at ignore-listed frames + * + * The client receives a graceful 404 (`x-nextjs-action-not-found: 1`) and + * recovers on its own, so the server-side log is pure noise. `launch.ts` pipes + * the spawned server's stdout/stderr through this filter (one instance per + * stream) to drop that block while passing everything else through verbatim. + * + * Stateful by design: the block spans multiple lines, so each stream needs its + * own filter instance. Call the returned function with each line; it returns + * the line to emit, or `null` to drop it. + */ +export function makeSkewLogFilter(): (line: string) => string | null { + let inSkewBlock = false; + return (line: string): string | null => { + if (line.includes("Failed to find Server Action")) { + inSkewBlock = true; + return null; + } + if (inSkewBlock) { + // Continuation lines of the same error block — drop them too. + const trimmed = line.trimStart(); + if ( + trimmed.startsWith("Read more:") || + line.includes("failed-to-find-server-action") || + line.includes("ignore-listed frames") || + /^\s+at\s/.test(line) + ) { + return null; + } + // First line that isn't part of the block — stop dropping and emit it. + inSkewBlock = false; + } + return line; + }; +}