Overview
UbuntuScamBank is a crowdsourced threat intelligence platform where anyone can report scams — phishing emails, smishing messages, investment fraud, and more — and the submissions get automatically triaged by an AI pipeline, deduplicated, stored, and surfaced on a public feed. The name comes from the Ubuntu philosophy: I am because we are. The platform only works if people contribute. That's not a tagline — it's the architecture.
I joined the project as backend and application engineer when Quadri Omoloju, the founder, had already defined the vision and built an initial brainstorming specification. My scope was everything from the data pipeline to the deployment infrastructure: the AI triage system, file upload handling, privacy-preserving EXIF stripping, the points and badge system, the researcher API, the ops admin console, and the deployment configuration across two platforms.
The platform runs on Next.js 16 with Supabase handling auth, database, and file storage. The triage pipeline calls Claude via the Anthropic API — every submission gets classified, severity-scored, and scanned for threat indicators (domains, phone numbers, sender names, URLs) before being written to the feed. Reports that Claude can't confidently classify get routed to a moderation queue instead of being published. The pipeline never blocks a submission; it only determines where that submission lands.
What made the engineering interesting wasn't the feature list — it was the two constraints running underneath all of it. The first: everything the AI pipeline touches had to fail safe, because a platform that lets blacklisted users through or publishes unverified reports is worse than no platform. The second: users uploading screenshots of scam messages are also uploading files that can carry their GPS coordinates and device identifiers in EXIF metadata. Building something that protects the people reporting scams, not just the people reading about them, shaped every decision touching file uploads.
The Problem
Scams targeting people across Africa are underreported — not because people aren't being targeted, but because there's nowhere useful to report them. The options are to ignore it, warn contacts on WhatsApp, or submit to a form that feels like filing something into a void. Meanwhile, security researchers and threat intelligence teams lack the kind of crowdsourced, Africa-specific data that would let them track campaigns, identify repeat actors, and warn people before they're hit rather than after.
UbuntuScamBank fills that gap. But a crowdsourced threat intelligence platform only works if the data in it is trustworthy, and trustworthy data from crowdsourced submissions is not a given. Left unfiltered, the feed becomes noise — duplicate reports of the same phishing campaign, submissions that are too vague to be actionable, and eventually bad actors submitting garbage to pollute the dataset. There's also a problem the platform's own design creates: asking people to paste scam messages or upload screenshots means asking them to share content that might still have their personal information attached. A platform that exposes victims while trying to protect them is a net negative.
So the engineering problems weren't primarily about features. They were about three guarantees the platform had to make before it could be useful at all. First: every submission gets analysed and categorised, regardless of what it contains or whether the AI pipeline succeeds. Second: duplicate reports of the same campaign get recorded and credited, not silently dropped, because volume confirmation is itself intelligence. Third: files uploaded by users get stripped of identifying metadata before they ever reach storage.
The fourth problem was operational. The platform was built during a Vercel paywall constraint — private repos under a GitHub organisation required a Pro plan for deployment previews, which wasn't viable for a project still finding its footing. The initial deployment used Cloudflare Workers via the OpenNext adapter, which introduced its own set of runtime constraints: no native Node.js module access, tight CPU time limits per request, and a two-section environment variable configuration in the Cloudflare dashboard that cost a day of production debugging when secrets were placed in the wrong section. By the time the platform needed a custom domain under ubuntubridgeinitiatives.org, the deployment moved to Vercel — but the Workers constraints had already shaped several architectural decisions that stayed in place regardless of where the app runs.
Technical Approach
The submission pipeline is the core of the platform — everything else (the feed, the leaderboard, the researcher API, the ops console) is built on top of what happens when someone submits a report. The pipeline runs as a single POST handler at /api/submit and does sixteen distinct steps in sequence: authenticate the user, parse and validate the form, upload any attached file, hash the content for deduplication, call the AI triage pipeline, calculate points, insert the report, insert extracted threat indicators, insert a submission record, write to the points ledger, and increment the user's total. Sixteen steps with multiple external calls — Supabase Storage, the Anthropic API, two Supabase database operations — all in one request.
The rule that holds it together: distinguish between failures that should stop the request and failures that should be logged and skipped. A failed file upload stops the request — there's no point triaging content that didn't arrive. A failed points ledger write doesn't stop anything — the submission is already recorded, the user's report is already live, and a missed ledger entry is an ops concern, not a user-facing error. Every step in the pipeline is annotated with its failure mode, and the non-fatal ones are wrapped in their own try/catch with console logging so they surface in ops tooling without blocking the response.
The AI triage pipeline calls Claude with a structured system prompt that instructs the model to return only valid JSON — no preamble, no markdown fences, exact field names matching the schema. In practice, Claude sometimes wraps its output in markdown fences despite being told not to, so the parser strips them before attempting JSON.parse. Every field gets validated individually with safe defaults, and numeric values get clamped to their allowed ranges rather than rejected. The FALLBACK_RESULT constant handles everything else — any Claude API failure, any unparseable response, any network timeout returns the same typed object with triage_failed: true, and the submit route routes those reports to under_review status for human moderation instead of publishing them.
The deduplication hash is computed over the normalised submission content before triage runs. Two users submitting the same phishing email — trimmed, lowercased, hashed with SHA-256 — produce the same hash. The second submission doesn't create a new report, but it does create a new submission record and award points at the duplicate rate. Duplicate submissions are intelligence: independent confirmation that a campaign is still active is worth recording, just not at full points value. The hash function uses the Web Crypto API (crypto.subtle.digest) rather than Node's crypto module — Web Crypto is available natively in both the Cloudflare Workers runtime and standard browser environments, which meant no runtime compatibility issues when the deployment platform changed.
The database schema follows the same failure-isolation principle. Foreign key constraints use ON DELETE RESTRICT throughout — a report with indicators can't be deleted without first removing those indicators, a submission record can't exist without a valid report. Row-level security is enabled on every table, but the submit route runs database mutations through the service-role admin client that bypasses RLS, while read paths use the session-aware client that respects it. Two tables that caused production bugs: the reports table had RLS enabled with no SELECT policy, which in Postgres means zero rows returned rather than an error — the feed was empty in production for exactly this reason until a published-reports policy was added. The indicators table had the same gap and got the same fix.
The points system is a pure function — calculatePoints(input, isDuplicate) takes a typed input and returns a breakdown with no side effects and no external calls. This was a deliberate choice: points logic is easy to get subtly wrong, and a pure function with no dependencies is the kind of thing you can exhaustively unit test without mocking anything. The scoring rules live in a single POINTS constants object so future changes have one place to go. Phase 2 bonuses (streaks, community voting rewards, featured digest) are defined as constants now and wired up when the features ship — no magic numbers appearing inline later.
Key Decisions
Technical Approach covered how the pipeline works. This section is about the specific places where the obvious implementation would have been wrong — either for correctness, for privacy, or for the runtime environment the project was running in.
Strip metadata before it reaches storage
Every JPEG a user uploads passes through an EXIF stripper before it touches Supabase Storage. A screenshot of a phishing message taken on someone's phone can carry GPS coordinates, device serial numbers, and timestamps that identify the victim. The platform exists to protect people reporting scams — that protection has to start at the file boundary, not in the UI.
The stripper is pure JavaScript — no sharp, no exiftool, no native dependencies. It works by walking the JPEG's binary structure directly, keeping only the segments that carry image data and the JFIF APP0 header, and dropping everything else:
function stripJpegExif(buffer: ArrayBuffer): ArrayBuffer {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if (bytes.length < 2 || view.getUint16(0) !== 0xffd8) {
return buffer; // Not a JPEG — return unchanged
}
const out: number[] = [0xff, 0xd8]; // Write SOI
let i = 2;
while (i < bytes.length - 1) {
if (bytes[i] !== 0xff) break;
const marker = view.getUint16(i);
if (marker === 0xffd9) {
out.push(0xff, 0xd9);
break;
} // EOI
if (marker === 0xffda) {
for (let j = i; j < bytes.length; j++) out.push(bytes[j]);
break; // SOS — rest is compressed image data, copy verbatim
}
// APP0–APP15: only keep APP0 (JFIF), strip everything else
// APP1 = EXIF, APP2–15 = ICC profiles, XMP, Photoshop metadata
if (marker >= 0xffe0 && marker <= 0xffef) {
const segLen = view.getUint16(i + 2);
if (marker === 0xffe0) {
for (let j = i; j < i + 2 + segLen; j++) out.push(bytes[j]);
}
i += 2 + segLen;
continue;
}
const segLen = view.getUint16(i + 2);
for (let j = i; j < i + 2 + segLen; j++) out.push(bytes[j]);
i += 2 + segLen;
}
return new Uint8Array(out).buffer;
}Choosing pure JavaScript over a native library was necessary for Cloudflare Workers compatibility, where native modules aren't available. But it's also the better choice regardless of runtime — no build-time native compilation, no node_modules binary that breaks across environments, no version incompatibility surface. The stripped image is visually identical to the original. Only the metadata is gone.
Claude's triage prompt handles PII in text submissions separately — it's instructed to strip victim-identifying information from the content it analyses while preserving attacker indicators (domains, phone numbers, sender addresses). The binary metadata stripping and the AI PII scrub are independent layers addressing different threat surfaces.
Use Web Crypto, not Node's crypto
The deduplication hash runs over every submission's normalised content. Two users submitting the same phishing campaign produce the same hash, and the second submission is correctly recorded as a duplicate without creating a redundant report. The hash function looks like this:
export async function hashContent(rawContent: string): Promise<string> {
const normalised = rawContent.trim().toLowerCase();
const encoded = new TextEncoder().encode(normalised);
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}crypto.subtle instead of require('node:crypto') — the Web Crypto API is available natively in Cloudflare Workers, standard browsers, and Node.js 18+ without any import. node:crypto isn't available in the Workers runtime at all. The practical effect: when the deployment moved from Cloudflare Workers to Vercel, this function needed zero changes. Runtime-portable code doesn't need updating when the runtime changes.
The same principle applied to the country dropdown dataset. The original implementation imported i18n-iso-countries and processed the full 249-country dataset on every request. On Cloudflare Workers, that hit the per-request CPU time limit on the free tier and caused timeouts. The fix was a prebuild script that generates a static countries-data.json at build time — the runtime reads a JSON file, doing no processing at all. That JSON file now travels with the deployment regardless of platform.
The Cloudflare dashboard has two separate secret stores
Cloudflare's settings page has two distinct sections for environment variables: "Build Variables and Secrets" (used during the Next.js build step) and "Variables and Secrets" (used by the running Worker). Secrets placed only in the Build section never reach the deployed Worker. The application will appear to deploy successfully and then fail at runtime with cryptic errors about missing environment variables — because the variables genuinely are missing, just from the wrong section.
This cost a full day of production debugging on day 17 of the build. Profile edits were silently returning 500 errors and report pages were crashing. Local dev and the preview environment were both stable. Cloudflare Workers logs — once Workers observability was explicitly enabled in the dashboard, which is not on by default — showed one line: SUPABASE_SERVICE_ROLE_KEY is not set. The key was in the dashboard. It was in the wrong section.
The fix: move every secret to the "Variables and Secrets" runtime section. NEXT_PUBLIC_* variables belong in both sections — they're inlined at build time and also needed at runtime. Server-side secrets belong in the runtime section only. Enabling Workers observability before debugging anything on Cloudflare is now a first step, not an afterthought — without it, the only visible symptom is a 500 status code with no context.
Triage failure is a routing decision, not an error
When Claude is unavailable, times out, or returns something that can't be parsed as valid JSON, the submit route doesn't fail. It routes differently:
const FALLBACK_RESULT: TriageResult = Object.freeze({
type: 'other',
severity: 1,
confidence: 0,
summary: '',
ai_tags: [],
indicators: [],
is_novel: false,
triage_failed: true,
// ...
});A report with triage_failed: true gets status: 'under_review' instead of 'published', lands in the ops moderation queue for human review, and still generates a submission record and awards points. The user's submission is never lost. The triage failure surfaces in ops tooling where someone can act on it, rather than surfacing to the user as an error they can't do anything about.
Object.freeze on the fallback is intentional — the constant should behave like a constant. Any code path that accidentally mutates the fallback object instead of spreading it would silently corrupt every subsequent triage failure response. The freeze makes that a thrown error instead of a silent bug.
Points are a pure function
export function calculatePoints(
input: PointsInput,
isDuplicate: boolean,
): PointsResult {
const breakdown: PointsLineItem[] = [];
const base = isDuplicate ? POINTS.DUPLICATE : POINTS.BASE_SUBMISSION;
breakdown.push({
reason: isDuplicate
? 'Duplicate submission — confirms campaign volume'
: 'Base submission',
delta: base,
});
let bonus = 0;
if (input.severity >= 4) {
bonus += POINTS.HIGH_SEVERITY;
breakdown.push({
reason: `High severity report (severity ${input.severity})`,
delta: POINTS.HIGH_SEVERITY,
});
}
if (input.is_novel) {
bonus += POINTS.NOVEL_CAMPAIGN;
breakdown.push({
reason: 'Novel campaign identified',
delta: POINTS.NOVEL_CAMPAIGN,
});
}
if (input.has_metadata && !isDuplicate) {
bonus += POINTS.FULL_METADATA;
breakdown.push({
reason: 'Full metadata included',
delta: POINTS.FULL_METADATA,
});
}
return {
total: base + bonus,
base,
bonus,
breakdown,
ledger_reason: buildLedgerReason(base, bonus, breakdown),
};
}No database calls, no external dependencies, typed input and output. The scoring rules live in one POINTS constants object so future changes have one place to go. Duplicate submissions earn points at a reduced rate — not zero — because a duplicate submission is independent confirmation that a campaign is still active, which is intelligence worth recording. Phase 2 bonuses (streak rewards, community vote confirm, featured digest) are defined as constants now and wired into code paths when those features ship. No magic numbers appearing inline later.
Outcome
The platform shipped an MVP in 18 days from the point I picked up the codebase — submission pipeline, AI triage, file upload with EXIF stripping, points and badge system, public feed, and report detail pages all working end to end. The submission that exposed the Cloudflare env vars bug was day 17. The fix took an hour once Workers observability was enabled and the actual error was visible. The lesson wasn't a technical one — it was that ops tooling has to be the first thing you set up, not something you reach for when production is already broken.
The triage pipeline held up under real submissions. Reports with clear indicators — phishing domains, spoofed sender addresses, smishing phone numbers — came back from Claude with structured extraction that would have taken a human analyst minutes to produce manually. Reports that were too vague or that hit the Claude API during a timeout landed in the moderation queue without losing the submission. No user ever saw a triage failure; they just saw their report submitted.
The points system did what a points system is supposed to do: it changed submission behaviour in the right direction. Contributors who attached files and wrote context alongside their reports earned more, which meant richer data reaching the feed. Duplicate submissions — independent confirmation of active campaigns — got credited rather than silently dropped, which meant contributors weren't penalised for reporting something that had already been reported. The platform's data is more useful precisely because the incentive structure was aligned with data quality from the start.
Two things didn't ship for the MVP and are documented as known gaps rather than discovered later. The researcher API exists and works, but rate limiting is defined in constants and returned in response headers without being enforced — that's a Cloudflare rate limiting rule or a Redis implementation away from being real. Community voting shipped, but the confirm_count and dispute_count columns that the voting system updates aren't yet feeding back into report ranking or feed ordering. Both are Phase 4 items. They're not bugs; they're the honest boundary of what an 18-day build is supposed to deliver.
The Ubuntu ethos — I am because we are — isn't marketing copy for this one. The platform is only useful at scale, and scale only comes from people contributing. Every engineering decision that made contribution lower-friction (file-only submissions without required text, points for duplicates, triage that never blocks a report from being recorded) was also a decision that made the dataset more complete. The incentive structure and the architecture are pointing in the same direction, which is the only version of a crowdsourced platform that actually works.