6 minute read

This fix note captures the Phase 3 hardening work for the DataInsideData™ Builder Showcase intake pipeline.

By this point, the core system already worked:

submit form
    ↓
Supabase Edge Function
    ↓
builder_project_submissions
    ↓
review queue
    ↓
promote into builder_projects
    ↓
export to YAML
    ↓
generate project detail page
    ↓
render on DataInsideData™

Phase 3 was about making that system safer and smoother.

This is where things got interestingly granular:

frontend preview-before-submit
backend validation
checkbox acknowledgements
honeypot
metadata logging
IP hash with salt
duplicate detection
Deno type checking
Supabase CLI deploy

This post is written as a debugging and engineering note for anyone building a similar static-site-to-Supabase intake workflow.

The Form Submission Worked

The form could submit successfully.

But there was additional tasks that needed addressing

Specifically, form preview and make edits buttons:

What if the user misspells the title?
What if the repo URL is wrong?
What if the tags are messy?
What if the README URL is long and hard to review?

This preview step flow became:

fill form
    ↓
preview cleaned submission
    ↓
make edits or confirm
    ↓
submit to Supabase

This is less like a raw HTML form and more like an intentional intake workflow.

That changed the feel of the form immediately, but presented small issues that if not addressed, breaks the UX flow and undermines the intake workflow.

After adding preview, the Make Edits button worked, but it left the user near the bottom of the page.

This was an obvious UX problem.

The fix was to scroll focus back to the top of the form.

Example snippet of the javascript that accomplished this:

if (editButton) {
  editButton.addEventListener("click", function () {
    previewPanel.hidden = true;
    pendingPayload = null;

    status.textContent = "Make your edits, then preview again.";

    const firstField = document.getElementById("first_name");

    form.scrollIntoView({
      behavior: "smooth",
      block: "start"
    });

   });
}

This small fix was a big UX win, making the intake workflow a seamless User experience.

The Preview Panel Alignment and Overflow Issues

The preview panel looked good, but:

long README URLs spilled outside the box
Confirmed values looked slightly misaligned
Make Edits button was too faint
Confirm & Submit was not left-positioned as desired

The fix was a grid layout using custom.scss:

.did-builder-preview-row {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr);
  column-gap: 1.25rem;
  align-items: center;
  padding: 1.1rem 0;
  border-bottom: 1px solid #e5e7eb;
}

.did-builder-preview-row dd {
  margin: 0;
  line-height: 1.35;
  align-self: center;
  min-width: 0;
  overflow-wrap: anywhere;
  word-break: break-word;
}

The important piece was:

grid-template-columns: 220px minmax(0, 1fr);

This minmax(0, 1fr) is what lets long values shrink and wrap correctly.

Frontend validation was tightened

The frontend now checks:

required fields
category selection
tags/tools present
GitHub repo URL shape
raw README URL shape if provided
public showcase acknowledgement

But the key principle stayed the same:

Frontend validation helps the user.
Backend validation protects the system.

Backend validation became the real gate

The Edge Function validates:

first name
last name
email
GitHub repo URL
project title
project category
project description
tags/tools
checkbox acknowledgements
live URL if provided
raw README URL if provided
field lengths
honeypot

The backend system is now much more secure.

This caught bad payloads even when someone tried to bypass the browser.

Supabase CLI Deployment Login

When deploying supabase function using npx:

npx supabase functions deploy builder-project

the CLI returned:

Access token not provided.
Supply an access token by running supabase login or setting the SUPABASE_ACCESS_TOKEN environment variable.

So the Issue was a missing token from the working environment.

The fix was logging into supabase using:

npx supabase login

That opened the browser and also left a url link in the terminal.

  • Copy / paste url link into browser if default browser doesn’t open automatically.

After login, the CLI allowed project selection and deployment:

npx supabase functions deploy builder-project

That was a good reminder:

The browser-facing publishable key is not the Supabase CLI access token.

Different keys, different jobs.

Deno/Supabase typing threw a never error

After adding duplicate checks, TypeScript complained:

Type '"public"' is not assignable to type 'never'.

This came from Supabase client typing.

The helper signature used:

ReturnType<typeof createClient>

Without generated database types, Supabase could infer table names too narrowly.

The fix was to type the client more loosely:

import { createClient } from "npm:@supabase/supabase-js@2";
import type { SupabaseClient } from "npm:@supabase/supabase-js@2";

type SupabaseAdminClient = SupabaseClient<any>;

Then:

async function checkDuplicateSubmission(
  supabaseAdmin: SupabaseAdminClient,
  payload: BuilderProjectPayload
): Promise<DuplicateCheckResult> {
  // duplicate logic
}

And:

const supabaseAdmin = createClient<any>(supabaseUrl, writeKey);

This was another good win, clearing the type error while keeping the function readable.

Anti-spam Balancing

The anti-spam needed to protect builders, not punish them

The first idea was a bit too heavy-handed:

same email in last 10 minutes → block
same repo URL in last 10 minutes → block
same IP hash in last 10 minutes → block

Why?

  • Productive builders may submit multiple projects
  • Monorepos exist
  • One repo can hold many projects

So the more balanced logic became:

same actual project evidence → block
same person submitting multiple projects → allow

This makes for a more elegant approach.

Duplicate submission check was added

The duplicate function checks:

active README URL duplicate
recent exact duplicate by email + repo + title

It returns 409 Conflict when the submission conflicts with an existing/recent submission. That is better than 429 Too Many Requests, because this is not just a rate problem. It is a duplicate/conflict problem.

README URL became the strongest project identity marker

If a raw README URL is provided, it usually points to the specific project evidence.

So duplicate logic became:

same raw README URL already active
    → block

same email + same repo URL + same project title within 10 minutes
    → block

same email + same repo + different project title
    → allow

same email + same monorepo + different README URL
    → allow

This protects the system from duplicate submissions without blocking serious builders.

Frontend cooldown stays light

The frontend gets a simple local cooldown:

same browser
successful submit
wait about 60 seconds before another submit
  • This helps prevent accidental rapid resubmits
  • But the real protection still lives in the backend
  • Frontend cooldown is UX
  • Backend checks are enforcement

IP hash logging was added carefully

Instead of storing raw IP addresses, the system stores a salted hash.

That means the system can detect repeated request patterns later without casually storing the plain IP.

The helper:

async function getClientIpHash(req: Request): Promise<string | null> {
  const ip = getClientIp(req);

  if (!ip) return null;

  const salt = Deno.env.get("DID_IP_HASH_SALT");

  if (!salt) {
    console.warn("DID_IP_HASH_SALT is not set. Skipping IP hash.");
    return null;
  }

  return await sha256Hex(`${salt}:${ip}`);
}

The salt is stored as a Supabase secret:

npx supabase secrets set DID_IP_HASH_SALT="PASTE_RANDOM_HEX_STRING_HERE"

This is separate from:

DID_SUPABASE_WRITE_KEY
DID_PUBLIC_FORM_KEY
SUPABASE_URL

Each secret serves a different role.

Test results

The hardening implementation was tested against the following, among other targets:

valid form
unchecked ownership
unchecked showcase acknowledgement
blank category
blank tags/tools
bad repo URL
valid GitHub repo URL
bad README URL
duplicate same README URL
duplicate same email + repo + title
productive same-email multi-project scenario

The successful behavior:

valid form → succeeds
bad required fields → fail
bad repo URL → fail
bad README URL → fail only if README URL is provided
same exact project → blocked
different project from same builder → allowed

That became the right balance for this phase.

Final lesson

This phase clarified the real job of hardening.

Hardening is not just blocking things.

Hardening is designing the rules so the right people can still do the right work.

For this system, the goal is:

block spam
block duplicates
protect the database
preserve trust
encourage productive builders

That is why the duplicate logic is based on project evidence, not simply email, repo, or IP.

And that is the difference between a form that technically works and an intake system that is starting to behave like a real product.


Data Inside Data™.

Tech Hands, a Science Mind, and a Heart for Community.

Updated: