Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<slug>` 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).
Expand Down
75 changes: 75 additions & 0 deletions __tests__/api/audit-invite-route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 16 additions & 6 deletions __tests__/audit/share-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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");
Expand All @@ -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);
}
});
Expand Down
39 changes: 39 additions & 0 deletions __tests__/lib/api-server-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
decodeJwt,
requestLoginCode,
scheduleReminder,
sendInvites,
} from "@/lib/auth/api-server-client";

describe("api-server-client fetchWithTimeout telemetry", () => {
Expand Down Expand Up @@ -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(() => {
Expand Down
11 changes: 10 additions & 1 deletion app/api/audit/invite/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

interface InviteBody {
to?: unknown;
score?: unknown;
}

export async function POST(req: NextRequest): Promise<NextResponse> {
Expand Down Expand Up @@ -132,8 +133,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
);
}

// 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",
Expand Down
1 change: 1 addition & 0 deletions app/audit/_components/audit-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ function MainReport({
<ComeBackBetterSection
isRunning={isRunning}
onRerun={() => onRerun("return_section")}
score={score}
/>
</div>
<ReportFooter cachedAt={cachedAt} />
Expand Down
5 changes: 4 additions & 1 deletion app/audit/_components/come-back-better-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AuthStatus>({ kind: "unknown" });
const [reminder, setReminder] = useState<Reminder | null>(null);
Expand Down Expand Up @@ -306,6 +308,7 @@ export function ComeBackBetterSection({ isRunning, onRerun }: Props) {
<InviteDialog
open={inviteDialogOpen}
source="come_back_better_section"
score={score}
onClose={() => setInviteDialogOpen(false)}
onUnauthorized={() => {
// Session expired between probe and submit — flip back to anon
Expand Down
9 changes: 6 additions & 3 deletions app/audit/_components/invite-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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@]+$/;
Expand All @@ -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);
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
Expand Down
Loading