Public Feedback, Private Repo

A TECHNICAL POST FROM A NON-TECHNICAL* PERSON

We’ve (me and my pal “Claude”) been making KmikeyM more agentic — giving AI agents real ways to read the market, and eventually to trade and vote. It’s not ready for a full release, so we’ve been testing quietly. During that testing I wanted a way for people to tell us what’s broken.

The obvious answer was: just have folks report bugs in Discord. But the KmikeyM Discord is shareholders-only — you need at least one share to get in. I didn’t want the feedback pipe to be that narrow. I wanted anyone who stumbled onto the site to be able to file a bug or drop an idea.

The next obvious answer was: put a “file an issue” link to GitHub. Except the repo is private. You can’t send a stranger to open an issue on a repo they can’t see. They’d hit a 404, and even if they could see it, I’d be handing GitHub accounts to the world just to collect a sentence of feedback.

So: I want public submissions to land in a private issue tracker, and I don’t want submitters to need a GitHub account, an invite, or any access at all. Now what?

This post is exactly how we solved it — the working code you can copy, and the way the whole thing got built (by Claude Code, in an afternoon) with a workflow that graded its own homework and caught two of its own bugs before I saw them.

The shape of the answer

The trick is to never let the browser touch GitHub at all. Instead:

  1. A plain HTML form on the public site collects a category, a message, and optional contact info.
  2. It POSTs that to a tiny serverless function running on the same domain.
  3. The function holds a GitHub token (scoped to just this one repo, issues only) and creates the issue on the submitter’s behalf.
  4. The issue lands in the private repo. The submitter never sees it, never needs an account, never gets access.

The token lives only on the server. The public gets a form; the private repo gets clean, labeled issues. Spam is kept out with a honeypot field, a Cloudflare Turnstile check, and a per-IP rate limit.

Our site runs on Cloudflare Pages, so the serverless piece is a Pages Function — a file in a functions/ directory that deploys automatically with the static site. No separate server, no extra deploy step. If you’re on Netlify, Vercel, or bare Cloudflare Workers, the same shape applies; only the handler wrapper changes.

Here’s the whole thing.

The code

1. The pure logic (so you can test it without a network)

The most bug-prone parts — input validation and building the issue body — don’t need the network at all, so they live in their own file. That makes them unit-testable in isolation. (We used Bun‘s built-in test runner; the module is plain ES modules and works anywhere.)

One Cloudflare gotcha worth knowing: every file under functions/ becomes a route. So shared helpers go outside it — here, in a repo-root lib/ — and the handler imports them.

// lib/feedback.js
export const CATEGORIES = ["bug", "idea", "question"];

const MAX_MESSAGE = 5000;
const MAX_CONTACT = 200;

// A hidden form field real users never fill. If it's filled, it's a bot.
export function isHoneypotTripped(payload) {
  return typeof payload?.website === "string" && payload.website.trim() !== "";
}

export function validate(payload) {
  const category = typeof payload?.category === "string" ? payload.category : "";
  const message = typeof payload?.message === "string" ? payload.message.trim() : "";
  const contact = typeof payload?.contact === "string" ? payload.contact.trim() : "";
  if (!CATEGORIES.includes(category)) return { ok: false, reason: "Invalid category." };
  if (message.length === 0) return { ok: false, reason: "Message is required." };
  if (message.length > MAX_MESSAGE) return { ok: false, reason: "Message is too long." };
  if (contact.length > MAX_CONTACT) return { ok: false, reason: "Contact is too long." };
  return { ok: true, value: { category, message, contact } };
}

export function buildIssue(clean, meta) {
  const firstLine = clean.message.replace(/\s+/g, " ").trim().slice(0, 60);
  const cat = clean.category;
  const title = `[Feedback: ${cat.charAt(0).toUpperCase() + cat.slice(1)}] ${firstLine}`;
  const body = [
    clean.message,
    "",
    "---",
    `- Category: ${cat}`,
    `- Contact: ${clean.contact || "—"}`,
    `- Submitted: ${meta.timestamp}`,
    `- Country: ${meta.country || "—"}`,
    "- Source: https://your-site.example/feedback",
  ].join("\n");
  return { title, body, labels: ["feedback", cat] };
}

2. The serverless function (the part that holds the token)

This is where the security lives. Read it top to bottom — the order is the whole point:

// functions/api/feedback.js
import { isHoneypotTripped, validate, buildIssue } from "../../lib/feedback.js";

const RL_MAX = 5;         // max submissions...
const RL_WINDOW_S = 600;  // ...per IP per 10 minutes

export async function onRequestPost({ request, env }) {
  let payload;
  try {
    payload = await request.json();
  } catch {
    return json({ error: "Bad request." }, 400);
  }

  // 1. Honeypot: if a bot filled the hidden field, pretend success and do nothing.
  //    (Returning an error would teach the bot what tripped it.)
  if (isHoneypotTripped(payload)) return json({ ok: true }, 200);

  const ip = request.headers.get("CF-Connecting-IP") || "";

  // 2. Turnstile: verify the token SERVER-SIDE before doing anything else.
  const token = payload["cf-turnstile-response"];
  const human = await verifyTurnstile(env.TURNSTILE_SECRET, token, ip);
  if (!human) return json({ error: "Verification failed." }, 403);

  // 3. Rate limit per IP using a KV store.
  if (env.FEEDBACK_RL && (await rateLimited(env.FEEDBACK_RL, ip))) {
    return json({ error: "Too many submissions." }, 429);
  }

  // 4. Validate the content.
  const v = validate(payload);
  if (!v.ok) return json({ error: v.reason }, 400);

  // 5. Create the issue with the private-repo token.
  const issue = buildIssue(v.value, {
    timestamp: new Date().toISOString(),
    country: request.headers.get("CF-IPCountry") || "",
  });
  const res = await createIssue(env.GITHUB_TOKEN, issue);
  if (!res.ok) return json({ error: "Could not file feedback." }, 502);
  const created = await res.json();
  return json({ ok: true, number: created.number }, 200);
}

function json(obj, status) {
  return new Response(JSON.stringify(obj), {
    status,
    headers: { "content-type": "application/json" },
  });
}

async function verifyTurnstile(secret, token, ip) {
  if (!secret || !token) return false;
  const form = new FormData();
  form.append("secret", secret);
  form.append("response", token);
  if (ip) form.append("remoteip", ip);
  const r = await fetch(
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    { method: "POST", body: form }
  );
  const data = await r.json().catch(() => ({ success: false }));
  return data.success === true;
}

async function rateLimited(kv, ip) {
  const key = `rl:${ip}`;
  const current = parseInt((await kv.get(key)) || "0", 10);
  if (current >= RL_MAX) return true;
  await kv.put(key, String(current + 1), { expirationTtl: RL_WINDOW_S });
  return false;
}

async function createIssue(token, issue) {
  // Replace OWNER/REPO with your private repo.
  return fetch("https://api.github.com/repos/OWNER/REPO/issues", {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
      accept: "application/vnd.github+json",
      "user-agent": "feedback-form",
    },
    body: JSON.stringify(issue),
  });
}

The non-negotiables here: the GitHub token is only ever sent to api.github.com, Turnstile is checked on the server before any issue is created, and a tripped honeypot returns a fake success so bots learn nothing.

3. The form

Nothing exotic — a form, a hidden honeypot, the Turnstile widget, and a submit handler that reads the response as JSON.

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="feedback-form">
  <select name="category">
    <option value="bug">Bug</option>
    <option value="idea">Idea</option>
    <option value="question">Question</option>
  </select>

  <textarea name="message" required placeholder="What's on your mind?"></textarea>

  <input type="text" name="contact" placeholder="Contact (optional)">

  <!-- Honeypot: positioned off-screen, hidden from humans, catnip for bots. -->
  <input type="text" name="website" tabindex="-1" autocomplete="off"
         aria-hidden="true" style="position:absolute; left:-9999px">

  <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY"></div>

  <button type="submit">Send feedback</button>
  <p class="status" role="status"></p>
</form>

<script>
  const form = document.getElementById("feedback-form");
  const status = form.querySelector(".status");
  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const btn = form.querySelector("button");
    const token = form.querySelector('[name="cf-turnstile-response"]')?.value || "";
    btn.disabled = true;
    status.textContent = "Sending…";
    try {
      const res = await fetch("/api/feedback", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          category: form.category.value,
          message: form.message.value,
          contact: form.contact.value,
          website: form.website.value,               // honeypot
          "cf-turnstile-response": token,
        }),
      });
      const out = await res.json().catch(() => ({}));
      if (res.ok && out.ok) {
        form.innerHTML = `<p class="status">Thanks — logged as #${out.number}.</p>`;
      } else {
        status.textContent = out.error || "Something went wrong.";
        btn.disabled = false;
        window.turnstile?.reset();
      }
    } catch {
      status.textContent = "Network error — try again.";
      btn.disabled = false;
    }
  });
</script>

4. The provisioning (the part that isn’t code)

Three secrets/bindings, all set in the Cloudflare Pages project — never in git:

  • GITHUB_TOKEN — a GitHub fine-grained personal access token, scoped to only this one repo, with Issues: Read and write and nothing else. (GitHub also auto-adds read-only Metadata; that’s required and fine.) Set a calendar reminder for its expiry — when it lapses, submissions start returning 502 until you rotate it.
  • TURNSTILE_SECRET — the secret key for a free Cloudflare Turnstile widget. The matching site key is public and goes in the form’s data-sitekey.
  • FEEDBACK_RL — a Cloudflare KV namespace binding, used for the rate-limit counter.

Pre-create four issue labels so the API never rejects an unknown one: feedbackbugideaquestion.

That’s the whole system. A public form, a token that never leaves the server, issues in a private repo.

How it got built: one agent, a fleet of helpers

Here’s the part I find more interesting than the code.

I didn’t write any of the above by hand. I described the problem to Claude Code — a single AI agent working in my terminal — and it drove the whole thing: asked me clarifying questions, wrote a spec, turned the spec into a plan, built it, tested it, and walked me through deploying it. One agent, orchestrating.

But “one agent” undersells the machinery. When there was independent work to do in parallel, Claude spun up short-lived helper agents — each in its own isolated copy of the repo — to build pieces at the same time, then merged their work back together. Think of it less as an AI swarm and more as one contractor who knows when to bring in extra hands and, crucially, how to check their work.

The workflow had four beats worth stealing:

1. Design before code. Before writing a line, Claude ran a short back-and-forth with me — public form or GitHub link? which repo? how much spam protection? — and wrote the answers into a spec document. Boring, and it saved us later.

2. A plan split into parallel “waves.” The spec became a task plan: the pure logic, the form, and the serverless handler. The logic and the form don’t depend on each other, so they were marked to run in parallel; the handler, which imports the logic, waited for it. Two waves instead of three sequential steps.

3. A held-out exam it wasn’t allowed to see. This is the trick that makes agent-written code trustworthy. Before building, a separate helper agent — given only the spec, never the plan — wrote a test suite encoding what the feature should do, and proved the tests failed against the empty repo. That suite was sealed away and only run at the end, as an impartial grader. An agent grading its own work with tests it also wrote is a rubber stamp; a held-out exam written from the spec by a different agent is an actual check.

4. A gate before anything merged. When the pieces were built and reviewed, everything ran against that sealed exam plus the unit tests, and I got a report to approve before a single line hit the main branch.

The honest part: it caught two of its own bugs

The reason I trust this workflow isn’t that it went smoothly. It’s that it didn’t, and the process caught the problems instead of me.

Bug one: an interface mismatch, caught by the sealed exam. The plan Claude wrote named a function buildIssuePayload and had validation return an error field. But the independent exam-writer, reading only the spec, expected buildIssue and a reason field. Two reasonable readings of the same spec — that disagreed. Because the exam was written independently, the mismatch surfaced before implementation instead of as a confusing failure later. The exam is the authority, so the plan was corrected to match it. If Claude had written both the code and its own tests, they’d have agreed with each other and been wrong together.

Bug two: a missing secret, caught by the smoke test. After deploying, we tested the live form. It rendered, Turnstile issued a token — and the submission came back 403 Verification failed. The code was fine. The cause: I’d added the TURNSTILE_SECRET to Cloudflare after the deploy that was serving the page, and Cloudflare bakes environment variables in at build time. A fresh deploy picked it up, and the next submission filed issue #1 cleanly. Worth knowing if you build this: set your secrets, then redeploy.

Neither bug reached production. One was caught by an impartial test suite before code existed; the other by actually submitting the form and watching what happened. That’s the whole lesson: decompose the work, verify it against something you didn’t also write, and don’t call it done until you’ve watched it work for real.


Should you build it this way?

The feedback form is genuinely useful — if you’ve got a private repo and want open feedback, copy the code above and you’re most of the way there. But the workflow is the transferable part. You don’t need our exact tools to steal the shape:

  • Write the spec first, even a paragraph. It’s the thing your tests and your code both answer to.
  • Verify with something you didn’t write. A test suite authored from the spec, by a different person or a different agent, is worth ten you wrote alongside the code.
  • Smoke-test the real thing. Deployed, in a browser, with the real services. The bugs that survive review live in the seams between systems — exactly where a unit test can’t reach.

We’re building toward a KmikeyM that agents can actually use. It’s fitting that the first piece of that — a way to tell us what’s broken — was itself built by an agent, checked its own work, and owned up to what it got wrong. That’s the standard we’re aiming for.

Spotted a bug on the site? There’s a form for that now.


The toolbox

Everything used to build this, if you want to reproduce it:

The agent

  • Claude Code — Anthropic’s agentic coding tool that runs in your terminal. It drove the entire build: the questions, the spec, the code, the tests, the deploy walkthrough.

The workflow (Claude Code plugins that structure how it works)

  • Superpowers — a set of skills for disciplined development: a brainstorming pass that turns a vague idea into a written spec, plan-writing, test-driven development, and code review. It’s what stopped Claude from just diving into code.
  • Ultrapowers — adds parallel execution on top: it compiles a plan into “waves” of independent tasks, runs each in its own isolated copy of the repo, reviews them separately, and gates everything behind a sealed acceptance exam — a test suite written by a separate agent that only saw the spec — before anything merges. This is the piece that caught the interface-mismatch bug before implementation.

Together they’re the difference between “an AI wrote some code” and “an AI decomposed the work, built it in parallel, and graded it against a test it wasn’t allowed to see.”

The infrastructure

  • Cloudflare Pages + Pages Functions — static hosting plus the serverless endpoint, deployed together from one repo. No separate backend.
  • Cloudflare Turnstile — the free, privacy-friendly CAPTCHA alternative that keeps bots out.
  • Cloudflare KV — a key-value store, used here for the per-IP rate limit.
  • GitHub fine-grained personal access token — scoped to a single repo, Issues-only, held server-side and never exposed to the browser.
  • GitHub REST API — the Issues endpoint that actually files the issue.
  • Bun — the test runner for the pure-logic unit tests.

None of it is exotic, and the whole system is a few hundred lines. The hard part was never the code — it was the shape of the idea: let public feedback flow into a private repo without handing anyone access.

*Well, with Claude I’m pretty technical now. 👨‍💻


Posted

in

, ,

by