Chapter 31. 2026-07-26: The Test File That Answered the Internet
On 2026-07-26 I sent a GET to
https://scan.suedeai.ai/api/check.test and got back
200 text/plain with a body of partial. Every
.js and .ts file under a Vercel Root
Directory’s api/ folder becomes a public serverless
function, so check.test.js, colocated beside the request
handlers in suede-geo, was live as
/api/check.test and ran its suite on every request.
partial is a fixture. It was the canned response from a
mock server defined inside a test file, check.test.js,
which was sitting next to the request handlers in the api/
directory of suede-geo. The file was written to be run by a
test runner on my machine. It was answering the public internet
instead.
Nothing about that URL required a key, a session, or a header. Anyone who guessed the path, or crawled it, or read the repo, could hit it as many times as they wanted.
Why
every .js and .ts file in a bare
api/ folder becomes a public route
Vercel turns every .js and .ts file under
the Root Directory’s api/ folder into a public serverless
function. That is the whole mechanism. There is no manifest to register,
no export to opt in with, no route table to edit. Filename becomes path.
api/check.test.js becomes /api/check.test, and
it is live the moment the deploy finishes.
The convention is convenient when the folder holds handlers, which is
what I had built it for. suede-geo’s Root Directory is
site/, so site/api/ holds the functions that
serve scan.suedeai.ai. Dropping a new handler in that folder and pushing
is the entire deploy step. That property is also what made the failure
invisible: the platform did not distinguish between a file I wrote to
serve traffic and a file I wrote to exercise the file next to it. Both
are .js under api/. Both ship.
I want to be precise about the mistake, because it was mine and it
was not exotic. I put a test beside the code it tests. That is standard
practice in most repos I work in, and in a repo where tests live under
tests/ or beside a module in src/, it costs
nothing. Under a bare api/ directory on Vercel, colocation
is publication. The habit was correct and the location made it a
deploy.
What a request to the test file executed in production
The status code was the least interesting part of the response. The interesting part is what the platform did to produce it.
A request to /api/check.test loaded
check.test.js into a production serverless function and ran
it. Running it executed the test suite. The suite booted the mock HTTP
server it defines, inside the production function, and then held the
invocation open well past the response window a request on that domain
is supposed to occupy, heading toward the function timeout ceiling. It
hung past 25 seconds, holding the function open toward the 300-second
ceiling. The test never intended to return to an HTTP caller. It
intended to run under a test runner, assert, and exit. Handed a request,
it started its fixture server, produced partial, and sat
there.
Two properties of that made it worse than a slow endpoint. The work
is asymmetric: my side spends a full suite run and a booted server per
request, the caller spends one GET. And the route sits under a path
prefix that a scanner walking /api/ on a live domain will
reach without knowing anything about my repo, since the name is short
and sits alongside handlers that answer normally.
That is the shape of the exposure. It was not an information leak, and the fixture body gave away nothing worth having. The cost was compute and availability. A single request pinned a function for the length of a test suite’s lifetime. A loop of requests pins many, on a plan billed by execution, against a product surface I sell. I do not have a record of anyone finding it before I did, and I am not going to claim it was never hit. I found it live on 2026-07-26 and I do not know how long it had been answering.
The fix:
.vercelignore at the Root Directory
I excluded the file from the deploy with a .vercelignore
at the Root Directory, which for suede-geo means
site/.vercelignore. That landed in PR #27.
There were two other options and I rejected both. Deleting the test
throws away coverage to fix a packaging problem. Moving the test outside
api/ works and stays on the table as a legitimate remedy,
but it fights the reason the file was there: I want the test adjacent to
the handler so the next person editing the handler sees it.
.vercelignore keeps the file in git, keeps it runnable on
my machine, and stops the platform from treating it as a route. The
build no longer sees a file it would turn into a function. Local
node --test and the runner still see it fine.
The rule I wrote down after that is narrow and I am going to keep it
narrow: a test colocated next to an api/ handler must be
excluded from the deploy, by .vercelignore at the Root
Directory or by moving it out of api/ entirely. Not “be
careful with test files.” An explicit exclusion, checked into the repo,
that a future deploy cannot forget.
The sweep
across every repo with a bare api/ folder
One exposed file in one repo is a bug. The convention that produced it applies to every repo I have on Vercel with that folder layout, which meant the fix was not finished until I had looked at all of them.
I enumerated the repos with a bare api/ directory and
listed the contents of each one, treating every filename as a public URL
rather than as a file.
Root Directory with a bare api/ |
Result on 2026-07-26 |
|---|---|
suede-geo/site |
Colocated test file exposed at /api/check.test; fixed
via .vercelignore |
suede-scan |
Real handlers plus _-prefixed helpers; no exposure |
fretpulse |
Real handlers plus _-prefixed helpers; no exposure |
suede-x402-acp |
Real handlers plus _-prefixed helpers; no exposure |
suede-promo |
Real handlers plus _-prefixed helpers; no exposure |
suedeai-org |
Real handlers plus _-prefixed helpers; no exposure |
Six repos, one hit. The helper filenames I found across the clean
five were _lib, _shared.js, and
_data.
The underscore prefix is the load-bearing detail in that table, and
it is the reason the sweep did not turn into five days of false alarms.
Vercel does not route files and folders whose names start with an
underscore. _shared.js under api/ is a module,
not an endpoint. Without that exemption, half the estate’s shared code
would be public functions and the audit would flag them all. With it, an
underscore-prefixed file is a positive signal: someone already made the
routing decision on purpose.
What I was hunting for in each listing was anything that is not a
handler and does not start with an underscore. Test files are the
obvious case. Fixtures are the same class. So are scratch handlers left
behind from a debugging session, and *.bak files, which are
safe from routing on extension alone but tell you a real handler was
edited in place and the old copy is sitting in the deploy directory.
One detail in the mapping deserves attention when you build the list
of URLs to check. The platform strips the extension and keeps the rest
of the name, so check.test.js produced
/api/check.test, dot included. A basename here means the
filename with its final extension removed, not the part before the first
dot. If you generate your candidate URLs by splitting on the first
., you will test /api/check, get a 404, and
write down that the folder is clean while /api/check.test
keeps answering. That is the exact wrong answer, arrived at by a
plausible method, which is the kind of miss that survives an audit.
Two habits from the same folder shape that I now treat as suspicious:
any filename containing .test., .spec.,
fixture, or mock, and any file whose extension
is not .js or .ts but whose neighbors are. The
second one is not a routing risk. It is a signal that the directory has
been used as a scratch space.
Since the layout is what creates the risk, the check has to run when
the layout changes and not on a calendar. I re-run it when I add a file
to any of the six, and on any new repo that uses a bare
api/ directory.
Why Next.js App Router repos are not exposed
Next.js App Router repos are out of scope for this rule, and knowing why keeps the audit from spreading into places it does not belong.
An App Router repo defines endpoints at
app/api/**/route.ts. Only a file named
route.ts becomes a route. The folder path determines the
URL and the filename is fixed. A test dropped into that directory is
named something else, and something else is not a route, so it is inert.
Colocation there is safe in exactly the way I assumed it was safe in
suede-geo.
The risk belongs to the bare api/ directory convention,
where filename maps to path with no other gate. Those are two different
deploy conventions living in one estate under folder names that read
alike, and I mixed them up because api/ looked like
api/ from a distance. The App Router repos in the estate
are the Suede-AI-App frontend, landing-site, and suede-home, plus
strumly, suede-muse, and suede-agent-studio. In those, only a file named
route.ts becomes a route, so a colocated test there is
safe. The risk belongs to the bare api/ convention
alone.
Curl production for
every basename in api/
Everything above is a file-tree exercise, and the file tree is not
the thing serving traffic. It is a good proxy right up until it is
stale, which is the case that matters: a route deployed from a branch
that no longer exists, a file deleted locally but present in the last
production build, an alias pinned to an older deployment that still
carries a route the current tree does not have. I have been bitten by
that last one before on ip.suedeai.ai, which sat
alias-pinned to an ancient deployment on 2026-06-10 while newly deployed
email routes were absent from the domain. vercel alias set
fixed the pin. The lesson generalizes: what the repo says is deployed
and what the domain is serving are two claims, and only one of them is
checked by looking at files.
the standing verification is a request against production for every
basename in api/, and the expected result for anything that
is not a real endpoint is 404.
# for each file in the Root Directory's api/ folder
curl -s -o /dev/null -w '%{http_code}' \
https://<domain>/api/<basename>The shape is a status-code-only request per candidate URL, discarding the body. A 404 means the platform is not routing that name. Any 2xx on a name you did not intend to publish is the finding, and you have it in the form that matters: the production response, not an inference from the repo.
That distinction is the part I want to carry out of this incident, because it keeps recurring in my own logs under different costumes. The convenient layer answers fast and it answers about the wrong thing.
On 2026-07-16 I found Agent Studio’s production environment variables pulling as empty strings, which sent the serverless runtime down a silent fallback into a non-durable SQLite repository. The config intent said Supabase. The deployment said something else, and no error was raised to tell me. It took curl against production to establish which datastore was serving. Earlier, on 2026-06-11, that same surface had been recorded as verified live on the strength of a gateway 200 and a 402 challenge with a real payTo. Five weeks later the Supabase runtime turned out never to have been implemented. My source does not resolve which of those two records is wrong: either the June checks passed against the SQLite fallback, since 200s and 402 challenges return either way, or the production environment regressed between the dates. The endpoint was green in both readings. A green endpoint does not identify the backend that served it.
On 2026-07-18 a duplicate articlePath declaration in
magazine.ts, introduced by a rebase auto-merge with no
conflict markers, got caught by tsc --noEmit and fixed with
an editor change. Every check I ran afterward came back clean, because
every check read the working tree. The fix was never committed before
the force-push and the merge. Production builds then failed on every
deploy with
Module parse failed: Identifier 'articlePath' has already been declared
until hotfix PR #673 landed. The site itself stayed up, since Vercel
does not promote a failed build, and I only caught it by checking deploy
status after the merge rather than by trusting the green local run.
On 2026-06-08, auditing which repos carried the preview-skip
ignoreCommand, my detection tooling lied by omission.
GitHub code search lags and skips repos. Rapid recursive tree calls hit
secondary rate limits and return empty rather than an error. Root-only
contents checks miss nested configs in web/
and frontend/. An audit tool that fails by returning
nothing hands you a clean bill of health for a broken estate. The
working method was a per-repo authoritative git-tree sweep, throttled
with a sleep between calls, checking
.truncated on each response.
The api/ incident is the same failure with a different
proxy. The repo is not the deploy. The deploy is not the domain. The
domain answers when you ask it.
A test artifact that was wrong as a deployed object
One more entry belongs next to this, from 2026-07-16, because it is the same species at a smaller scale and it cost real money rather than compute. I was paying $20 a month for Pinata’s Picnic plan with no functional need for it. The reason was a single leftover 188-byte test metadata record that pushed my pinned-file count to 501, one file over the 500-file Free-tier cap. Unpinning that record put the account back under the ceiling, with the downgrade to Free scheduled to take effect 2026-07-24.
A 188-byte test artifact bought a paid plan. A colocated test file became an unauthenticated production endpoint that executed a suite and held a function open. In both cases the artifact was correct as a test and wrong as a deployed object, and in both cases the deploy or upload path had no concept of the difference. The platform is not going to make that distinction on my behalf. The exclusion has to be written down in the repo, and the check has to run against production.
The full standing discipline, as it now reads:
- A test colocated next to an
api/handler is excluded from the deploy, via.vercelignoreat the Root Directory or by moving it out ofapi/. - On any Vercel repo with a bare
api/folder, runls <root>/api/and read every filename as a public URL. Look for tests, fixtures, scratch handlers, and*.bak. Underscore-prefixed names are not routed and do not need action. - Verify against production and not the file tree: one status-code
request per basename in
api/, expecting 404 for anything that is not a real endpoint. - Re-run steps 2 and 3 when files are added to
suede-geo/site,suede-scan,fretpulse,suede-x402-acp,suede-promo, orsuedeai-org, and on any new repo that adopts the bareapi/layout.
Next.js App Router repos are excluded from all four. Only
route.ts becomes a route there, and a test file in that
tree stays a test file.