DEV Community

Avery Lin
Avery Lin

Posted on

A JSON Fence Around Tonight's Deploy

The founder sat with cold coffee at 1:14 a.m. The checkout flow had almost survived another late rewrite. A free coding model had touched the webhook handler again.

The compare view looked tiny on a tired screen. The change was not tiny in production terms. A new request header named X-Retry-Nonce appeared.

The payment provider had never documented that header. A zero bill does not cancel a broken checkout. Invented surface still collects refunds, logs, and lost carts.

Solo founders treat cheap generation like unpaid junior help. The help types quickly and fills every silent gap. Confidence usually arrives long before evidence ever does.

An indie shop cannot absorb invented HTTP endpoints. Each extra field becomes unpaid support work later. A surprise env key becomes a page with no on-call roster.

The model is not trying to wreck the product. It is completing a pattern from similar codebases. Completion is not the same as a ship contract.

A public surface behaves like a fence on a small farm. Animals may move freely inside that wooden fence. New gates still need a human hand on the latch.

Free models love cutting extra gates at night. The fence has to live in the repository itself. Paper rules fade once the clock passes midnight.

This workflow is a ship contract for one-person teams. The contract is JSON sitting in the repository root. It names routes, env keys, tables, and outbound hosts.

Internal helpers may change without a human debate. The public surface may not grow during generation. A short Node script compares the diff with that fence.

A laptop can run the script before every push. A free server can run the same check on each branch. No extra cloud invoice is required for the gate.

The method assumes free models will invent details. It refuses to ship those inventions without a human edit. Shipping today still matters. The bill stays at zero if the fence holds.

The boring file that saves the night

Keep allowed-surface.json beside package.json in the app. Boring files survive tired judgment better than wiki pages. The founder edits this file only with both eyes open.

{
  "routes": [
    "GET /health",
    "POST /webhooks/stripe",
    "POST /checkout/session"
  ],
  "env": [
    "STRIPE_SECRET_KEY",
    "STRIPE_WEBHOOK_SECRET",
    "DATABASE_URL"
  ],
  "tables": [
    "orders",
    "customers"
  ],
  "outbound_hosts": [
    "api.stripe.com"
  ],
  "headers_in": [
    "stripe-signature",
    "content-type"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The model may refactor handlers behind those frozen names. It may not add /webhooks/stripe/v2 in a hurry. It may not read STRIPE_RETRY_KEY from the environment.

It may not create an order_retries table for convenience. Outbound calls stay on the listed hosts. New inbound headers stay off the wire until a human adds them.

A checker small enough to read

Save the gate as scripts/check-surface.mjs and keep it short. Long policy engines do not get reviewed at 1 a.m. This script only hunts added lines in the git diff.

#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";

const contract = JSON.parse(readFileSync("allowed-surface.json", "utf8"));
const base = process.argv[2] || "origin/main";
const diff = execSync(
  `git diff ${base}...HEAD -- "*.js" "*.ts" "*.mjs"`,
  { encoding: "utf8" }
);

const added = diff
  .split("\n")
  .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
  .map((line) => line.slice(1));

const found = {
  routes: new Set(),
  env: new Set(),
  outbound_hosts: new Set(),
  tables: new Set(),
  headers_in: new Set(),
};

for (const line of added) {
  for (const m of line.matchAll(
    /\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)/gi
  )) {
    found.routes.add(`${m[1].toUpperCase()} ${m[2]}`);
  }
  for (const m of line.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g)) {
    found.env.add(m[1]);
  }
  for (const m of line.matchAll(/https?:\/\/([A-Za-z0-9.-]+)/g)) {
    found.outbound_hosts.add(m[1]);
  }
  for (const m of line.matchAll(
    /\b(?:from|into|update)\s+['"`]([a-z][a-z0-9_]+)/gi
  )) {
    found.tables.add(m[1]);
  }
  for (const m of line.matchAll(/['"`]([A-Za-z0-9-]+(?<!\s))['"`]\s*:/g)) {
    const name = m[1].toLowerCase();
    if (name.includes("-")) found.headers_in.add(name);
  }
}

function extras(foundSet, allowed) {
  const allow = new Set(allowed.map((x) => String(x).toLowerCase()));
  return [...foundSet].filter((x) => !allow.has(String(x).toLowerCase()));
}

const report = {
  routes: extras(found.routes, contract.routes),
  env: extras(found.env, contract.env),
  outbound_hosts: extras(found.outbound_hosts, contract.outbound_hosts),
  tables: extras(found.tables, contract.tables),
  headers_in: extras(found.headers_in, contract.headers_in),
};

const failed = Object.values(report).some((arr) => arr.length > 0);
if (failed) {
  console.error("Surface contract failed. New public surface in the diff:");
  console.error(JSON.stringify(report, null, 2));
  process.exit(1);
}
console.log("Surface contract passed against", base);
Enter fullscreen mode Exit fullscreen mode

Run it against the main branch before any push lands. The command below is the whole ceremony. A red exit code means the fence moved.

chmod +x scripts/check-surface.mjs
node scripts/check-surface.mjs origin/main
Enter fullscreen mode Exit fullscreen mode

Wire the same command into .git/hooks/pre-push for local defense. The hook should be executable and painfully dull. Dull hooks still stop a bad push.

#!/bin/sh
set -e
node scripts/check-surface.mjs origin/main
Enter fullscreen mode Exit fullscreen mode

A one-person shop without CI can still host the check. Push the branch to a free server and pull on a timer. The server runs the same Node command and posts the log.

#!/bin/sh
set -e
cd "$HOME/apps/checkout"
git fetch origin
git checkout "$1"
git pull --ff-only origin "$1"
node scripts/check-surface.mjs origin/main
Enter fullscreen mode Exit fullscreen mode

That shell is a proposal for a tiny worker. Label it unproven until it has failed a real bad branch. The first useful failure is the real install test.

A fixture that proves the red path

Do not wait for production to learn the checker. Drop a sample added line into a scratch file. The next block is a labeled fixture, not a live git history.

--- a/src/webhooks.js
+++ b/src/webhooks.js
@@
-app.post("/webhooks/stripe", handleStripe)
+app.post("/webhooks/stripe", handleStripe)
+app.post("/webhooks/stripe/v2", handleStripeV2)
+const retryKey = process.env.STRIPE_RETRY_KEY
+fetch("https://hooks.example.invalid/retry", {
+  headers: { "X-Retry-Nonce": retryKey }
+})
Enter fullscreen mode Exit fullscreen mode

Feed that shape through the same regular expressions by hand first. POST /webhooks/stripe/v2 is outside the JSON fence. STRIPE_RETRY_KEY is outside the JSON fence.

hooks.example.invalid is outside the JSON fence. x-retry-nonce is outside the JSON fence. Four extras should print and the process should exit one.

A green path needs a quieter diff for contrast. Rename a local function and leave the routes alone. The script should print a pass against origin/main.

If both paths cannot be shown on a laptop, stop. The fence is theater until the red path is boringly repeatable. Indie shipping needs that boredom more than another model run.

Tell the model about the fence

Paste allowed-surface.json at the top of the generation prompt. State that new surface is a hard error, not a suggestion. Ask for a patch that only moves cattle inside the fence.

The next block is a prompt template, not a measured benchmark. It has not been tuned on a public leaderboard. It exists to make the contract visible to the model.

You are editing a solo checkout service.
The file allowed-surface.json is the public contract.
Do not add routes, env keys, tables, hosts, or headers.
Do not invent retry tokens or versioned webhook paths.
Change internals only. Return a unified diff.
If the task needs new surface, stop and list the gaps.
Enter fullscreen mode Exit fullscreen mode

The last line is the whole point of cheap generation. Assumption is cheap. Silence about a missing gate is expensive. A stopped model is a successful indie outcome.

Some solo shops already generate those diffs against a free coding workspace. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option, which can host both the prompt loop and check-surface.mjs without adding a cloud invoice.

The product is optional for the method. The JSON fence still works on a laptop alone. Founders who already keep that free workspace can drop the contract beside the repo and run the same gate on the free server before a push.

Accept the limits while the bill stays at zero. Free models will still guess. The free server will still restart. The fence only claims to block new public names in a text diff.

What this fence will not catch

Regular expressions are not an API gateway. A renamed route can slip through if the string never appears. A dynamic router built from a list will look quiet.

SQL behind a query builder may hide table names. Header names built by concatenation will not match. Binary files and lockfiles are ignored on purpose.

Semantic bugs walk straight through this gate. A handler can still charge twice on the allowed path. The fence does not prove Stripe signatures, idempotency, or tax math.

Teams with many services will outgrow one JSON file. Regulated payment programs need real review, not a hobby regex. Security review is a different job than surface boredom.

Do not use this approach as a substitute for tests. Do not use it when many people edit the contract daily. Do not use it to hide a missing staging environment.

A solo founder shipping a thin app can still use it tonight. The cost is one JSON file and a short script. The gain is a refused push instead of a live invented header.

Keep the farm small. Keep the fence in git. Let the free model write inside the rails, then ship while the invoice still reads zero.

Top comments (0)