diff --git a/app/api/careers/apply/route.ts b/app/api/careers/apply/route.ts
index c03dcd62..48fe99b4 100644
--- a/app/api/careers/apply/route.ts
+++ b/app/api/careers/apply/route.ts
@@ -3,14 +3,27 @@
//
// Three fields and a link — no file upload, so we never take custody of a
// resume. Writes with the service role because applicants have no session.
+//
+// Being unauthenticated and on the open internet, this endpoint carries two
+// spam defences. The (job_id, email) unique constraint only stops an honest
+// double-submit; a script that varies the address walks straight past it.
+// 1. A honeypot field the widget renders hidden. Humans never fill it.
+// 2. A per-source hourly cap, counted off a salted hash of the client IP.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { serviceClient } from "@/lib/supabase/service";
import { isValidEmail, normalizeLink } from "@/lib/careers/jobs";
+import { notifyNewApplication } from "@/lib/careers/notify";
+import { clientIpFromHeaders } from "@/lib/tracker/geo";
+import { hashIp } from "@/lib/rateLimit";
export const runtime = "nodejs";
+// Applications allowed from one source per hour. A real person applying to
+// several roles at one company stays well under it; a scripted flood does not.
+export const APPLY_HOURLY_CAP = 8;
+
const bodySchema = z.object({
site: z.string().uuid(),
job: z.string().uuid(),
@@ -19,6 +32,8 @@ const bodySchema = z.object({
link: z.string().max(500).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
url: z.string().max(2048).nullable().optional(),
+ // Honeypot. Named to look worth filling in to a bot scanning field names.
+ company: z.string().max(200).nullable().optional(),
});
function corsHeaders(request: Request) {
@@ -55,6 +70,12 @@ export async function POST(request: NextRequest) {
if (!parsed.success) return fail(request, "Check the form and try again.");
const body = parsed.data;
+ // Honeypot tripped: answer exactly as we would on success, so whatever is
+ // filling it gets no signal to adapt. Nothing is written.
+ if (body.company && body.company.trim()) {
+ return NextResponse.json({ ok: true }, { headers: corsHeaders(request) });
+ }
+
const fullName = body.fullName.trim().replace(/\s+/g, " ");
const email = body.email.trim().toLowerCase();
if (!fullName) return fail(request, "Enter your name.");
@@ -69,13 +90,26 @@ export async function POST(request: NextRequest) {
}
const supabase = serviceClient();
+ const ipHash = hashIp(clientIpFromHeaders(request.headers));
+
+ // Per-source hourly cap. Counted before the posting lookup so a flood costs
+ // one indexed count() rather than the full write path.
+ const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+ const { count } = await supabase
+ .from("job_applications")
+ .select("id", { count: "exact", head: true })
+ .eq("ip_hash", ipHash)
+ .gte("created_at", since);
+ if ((count ?? 0) >= APPLY_HOURLY_CAP) {
+ return fail(request, "Too many applications from here. Try again later.", 429);
+ }
// The posting must be open and belong to a project with the module on —
// otherwise a stale widget could keep posting to a closed role.
const [{ data: job }, { data: project }] = await Promise.all([
supabase
.from("job_postings")
- .select("id, status")
+ .select("id, status, title")
.eq("id", body.job)
.eq("project_id", body.site)
.maybeSingle(),
@@ -104,6 +138,7 @@ export async function POST(request: NextRequest) {
link,
note: body.note?.trim().slice(0, 2000) || null,
source_url: body.url?.slice(0, 2048) ?? null,
+ ip_hash: ipHash,
referrer: request.headers.get("referer")?.slice(0, 2048) ?? null,
user_agent: request.headers.get("user-agent")?.slice(0, 500) ?? null,
updated_at: new Date().toISOString(),
@@ -117,5 +152,15 @@ export async function POST(request: NextRequest) {
return fail(request, "Could not submit right now. Try again shortly.", 500);
}
+ // The applicant is done either way — a mail failure must not surface to them
+ // as a failed application, so this is awaited but never throws.
+ await notifyNewApplication({
+ projectId: body.site,
+ jobTitle: (job as { title?: string }).title ?? "a role",
+ fullName,
+ email,
+ link,
+ });
+
return NextResponse.json({ ok: true }, { headers: corsHeaders(request) });
}
diff --git a/app/careers.js/route.ts b/app/careers.js/route.ts
index e771cc4d..f063b4d3 100644
--- a/app/careers.js/route.ts
+++ b/app/careers.js/route.ts
@@ -89,7 +89,8 @@ const snippet = `(function(){
'.cp-careers-msg{font-size:.85em}',
'.cp-careers-empty{padding:20px 0;opacity:.7}',
'.cp-careers-credit{margin-top:14px;font-size:.75em;opacity:.55}',
- '.cp-careers-credit a{color:inherit}'
+ '.cp-careers-credit a{color:inherit}',
+ '.cp-hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}'
].join('');
function injectStyle() {
@@ -167,6 +168,10 @@ const snippet = `(function(){
html += '';
html += '';
html += '';
+ // Honeypot. Positioned off-screen rather than display:none, which some
+ // bots skip; aria-hidden and tabindex=-1 keep it away from screen
+ // readers and the tab order so no real applicant can reach it.
+ html += '
';
html += '
';
html += '';
}
@@ -262,6 +267,7 @@ const snippet = `(function(){
fullName: form.fullName.value,
email: form.email.value,
link: form.link.value,
+ company: form.company ? form.company.value : '',
url: location.origin + location.pathname
};
if (!payloadBody.fullName.trim()) { msg.textContent = 'Enter your name.'; return; }
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 441b10bb..83f6e69a 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -65,5 +65,56 @@ export default async function sitemap(): Promise {
// Don't 500 the sitemap if Supabase is briefly unreachable.
}
- return [...staticEntries, ...reportEntries];
+ // Hosted job boards and the individual postings under them. These exist so
+ // that a client-rendered careers widget still has crawlable HTML behind it —
+ // which only pays off if crawlers can find the pages, so they belong here.
+ let careerEntries: MetadataRoute.Sitemap = [];
+ try {
+ const svc = serviceClient();
+ // Two queries rather than an embedded join: the serving RPC gates on both
+ // flags, so the sitemap has to gate on them too or it advertises boards
+ // that 404 — and resolving the enabled set first says that plainly.
+ const { data: enabled } = await svc
+ .from("projects")
+ .select("id")
+ .eq("careers_enabled", true)
+ .eq("tracker_enabled", true);
+ const enabledIds = ((enabled ?? []) as Array<{ id: string }>).map((p) => p.id);
+
+ if (enabledIds.length > 0) {
+ const { data } = await svc
+ .from("job_postings")
+ .select("slug, project_id, published_at, updated_at")
+ .eq("status", "open")
+ .in("project_id", enabledIds)
+ .order("published_at", { ascending: false })
+ .limit(500);
+
+ const rows = (data ?? []) as Array<{
+ slug: string;
+ project_id: string;
+ published_at: string | null;
+ updated_at: string | null;
+ }>;
+
+ const boards = new Set(rows.map((r) => r.project_id));
+ careerEntries = [
+ ...Array.from(boards).map((projectId) => ({
+ url: `${base}/c/${projectId}`,
+ changeFrequency: "daily" as const,
+ priority: 0.7,
+ })),
+ ...rows.map((row) => ({
+ url: `${base}/c/${row.project_id}/${row.slug}`,
+ lastModified: new Date(row.updated_at ?? row.published_at ?? Date.now()),
+ changeFrequency: "weekly" as const,
+ priority: 0.6,
+ })),
+ ];
+ }
+ } catch {
+ // Same rule as above — a sitemap missing job pages beats a 500.
+ }
+
+ return [...staticEntries, ...reportEntries, ...careerEntries];
}
diff --git a/lib/careers/notify.ts b/lib/careers/notify.ts
new file mode 100644
index 00000000..6520634d
--- /dev/null
+++ b/lib/careers/notify.ts
@@ -0,0 +1,58 @@
+// Notification for a new job application.
+//
+// Without this, applications land in the dashboard silently and the employer
+// has to think to go and look — which, for a hiring inbox, means good
+// candidates go stale. Best-effort throughout: a mail failure must never turn
+// a successfully-recorded application into an error for the applicant.
+
+import { env } from "@/lib/env";
+import { sendCareersApplicationEmail } from "@/lib/email";
+import { serviceClient } from "@/lib/supabase/service";
+
+export interface ApplicationNotice {
+ projectId: string;
+ jobTitle: string;
+ fullName: string;
+ email: string;
+ link: string | null;
+}
+
+/**
+ * Email the project owner that someone applied.
+ *
+ * Resolves the recipient from the project owner's profile. Returns quietly if
+ * mail isn't configured, the owner has no address, or the send fails — the
+ * caller has already written the row and the applicant is already done.
+ */
+export async function notifyNewApplication(notice: ApplicationNotice): Promise {
+ try {
+ const supabase = serviceClient();
+ const { data: project } = await supabase
+ .from("projects")
+ .select("name, owner_id")
+ .eq("id", notice.projectId)
+ .maybeSingle();
+ const ownerId = (project as { owner_id?: string } | null)?.owner_id;
+ if (!ownerId) return;
+
+ const { data: profile } = await supabase
+ .from("profiles")
+ .select("email")
+ .eq("id", ownerId)
+ .maybeSingle();
+ const to = (profile as { email?: string | null } | null)?.email;
+ if (!to) return;
+
+ await sendCareersApplicationEmail({
+ to,
+ projectName: (project as { name?: string } | null)?.name ?? "your site",
+ jobTitle: notice.jobTitle,
+ applicantName: notice.fullName,
+ applicantEmail: notice.email,
+ link: notice.link,
+ inboxUrl: `${env.siteUrl.replace(/\/+$/, "")}/projects/${notice.projectId}/stats/careers`,
+ });
+ } catch {
+ // Swallowed on purpose — see the module comment.
+ }
+}
diff --git a/lib/email.ts b/lib/email.ts
index 4a97c56a..bed8d756 100644
--- a/lib/email.ts
+++ b/lib/email.ts
@@ -1247,3 +1247,69 @@ export function broadcastEmailHtml(input: {
footerNote: "You're receiving this because you have a CrawlProof account.",
});
}
+
+// New job application landed in a project's careers inbox.
+//
+// Every field here is applicant-controlled and arrives from an unauthenticated
+// public form, so all of it goes through escapeHtml. The portfolio link is
+// rendered as text rather than an anchor: it has been normalized to http(s)
+// server-side, but there is no reason to make a stranger's URL one click away
+// inside the owner's mail client.
+export async function sendCareersApplicationEmail(input: {
+ to: string;
+ projectName: string;
+ jobTitle: string;
+ applicantName: string;
+ applicantEmail: string;
+ link: string | null;
+ inboxUrl: string;
+}): Promise<{ sent: boolean; error?: string }> {
+ const c = client();
+ if (!c) return { sent: false, error: "RESEND_API_KEY not set" };
+
+ const linkRow = input.link
+ ? `