Endpoints
Every endpoint exists twice and must behave once. site/server.ts (Express, local dev only)
and site/api/*.ts (Vercel serverless, production) are two adapters over the same logic in
site/src/server/. Anything that can differ between them, will.
All commands run from site/.
1. The shape
site/src/server/<feature>.ts ← the logic. Framework-agnostic. Both callers import it.
site/server.ts ← Express route: parse req → call → map result to res
site/api/<feature>.ts ← default-export handler: same three lines
src/server/auth.ts says it outright: "framework-agnostic route logic — the Express routes and the
Vercel handlers are thin adapters over these, so the two deployment paths cannot drift apart."
auth.ts is the model — sessionStatus, attemptUnlock, logout and guardAdvisorRequest each
return a GateResult of { status, body, setCookie? }, and both adapters just spread it onto their
own response object.
The rule: if a line of an adapter makes a decision, it is in the wrong file. Method check and error-to-status mapping are adaptation; anything else is logic.
Four endpoints exist today — /api/backtest, /api/espn-scoreboard, /api/advisor-auth
(GET + POST), /api/strategy-advisor.
Two mechanical traps
api/*.tsimports carry a.jsextension ('../src/server/backtest.js');server.tsimports do not. That is the Vercel Node ESM resolver, not a typo. Copy the neighbouring file.- The Vercel runtime types are hand-rolled in
src/server/httpTypes.ts(ApiRequest/ApiResponse) rather than installed, deliberately. Use those; do not add@vercel/node.
2. Validation at the boundary
Untrusted bodies get a Zod schema in site/src/server/, applied inside the shared module —
not re-checked in each adapter. runValidatedBacktest(req.body) validates and runs; both callers
just catch BadRequestError → 400 and everything else → 500.
The reason is on the record: presence was once the only check, so
{ startYear: 1900, endYear: 3000 } asked the generator for ~1,100 seasons inside a serverless
function — a timeout at best, a denial-of-wallet at worst.
The bounds/schema split matters. The numbers live in site/src/strategyBounds.ts (no imports),
and src/server/strategySchema.ts turns them into Zod. The strategy form imports strategyBounds
directly, so the UI clamps to exactly the numbers the API enforces without pulling Zod into the
client bundle — already one 776 KB chunk, with shrinking it a tracked roadmap item. Adding a
bound means: constant in strategyBounds.ts → schema rule in strategySchema.ts → form clamp.
strategySchema.ts also carries a SameMembers<…> type trick so that adding a sport or side
selection to types.ts without listing it in the schema is a tsc --noEmit failure, not a
request the UI can build and the API then rejects. Keep those _xCovered assertions when editing
the unions.
[!NOTE]
tsconfig.jsonsets neitherstrictnorstrictNullChecks, so Zod infers every output field as optional and narrowing a discriminated union on a boolean (if (r.ok) …) does not narrow. Do not rely on either.
3. The advisor gate — it fails closed
/api/strategy-advisor spends a metered Gemini key, so it is gated twice. Full state table in
references/gate.md; the summary:
| Condition | Result |
|---|---|
EDGE_SPECTRUM_MODE is anything but the exact string full |
403, demo restriction. The advisor panel renders unavailable. |
full but ADVISOR_PASSCODE unset |
503 — fails closed. An unset env var must never expose the key. |
full + passcode set, no valid session cookie |
401 |
Empty prompt, or over MAX_PROMPT_CHARS (1000) |
400 |
ADVISOR_SECRET signs session tokens; when unset it is derived from a hash of the passcode, which
means rotating the passcode invalidates every existing session — a feature, and the reason not
to set ADVISOR_SECRET unless you want sessions to survive a rotation.
Only the advisor is gated. Every other tool works in demo mode, and the public deployment stays in demo mode and must not hold Gemini credentials.
4. npm run check:deployment is the guard
It exercises auth.ts, advisor.ts and both API handlers across every mode — including
EDGE_SPECTRUM_MODE unset, 'demo', 'FULL' (wrong case, must still be demo) and 'typo' —
and asserts zero network calls by replacing globalThis.fetch with a thrower. It restores
every env var it touched in a finally.
Extend it whenever you touch the gate. If a new endpoint spends money or reads a secret, it gets a row in that script before it gets merged.
5. Adding an endpoint
- Write the logic in
site/src/server/<feature>.ts, framework-agnostic, returning a plain{ status, body }(or throwing a typed error the adapters map). - Zod-validate any untrusted body there, with bounds in a no-import constants module if the client also needs them.
- Add the Express route in
server.ts— parse, call, map. - Add
site/api/<feature>.ts—export default function handler(req: ApiRequest, res: ApiResponse), method check, same call, same mapping..json the import. - If it spends money or touches a secret, gate it and extend
scripts/check-deployment.ts. npm run lint && npm run check:deployment, thennpm run devand hit it.- Verify on the PR's Vercel preview URL.
server.tsnever runs in production — a local pass proves the logic, not the deployment.
6. Anti-patterns
- Adding a route to
server.tsonly. It is dev-only; production never loads it. - Logic in an adapter. The two copies diverge, and only one is under CI.
- Omitting
.jsfrom anapi/*.tsimport. Resolves locally, fails on Vercel. - Re-validating in each adapter instead of inside the shared module.
- Importing Zod, or
strategySchema, from client code. ImportstrategyBounds. - Treating
EDGE_SPECTRUM_MODEas truthy-checked. Only the exact'full'counts;'FULL'is demo, andcheck:deploymentasserts it. - Failing open when a secret is missing. Unset → 503, always.
- Adding
@vercel/node.httpTypes.tsexists so the build runtime stays out of the tree. - Committing
site/.env. Gitignored; production values go in the Vercel project. - Declaring it working because
npm run devworked. Check the preview deploy.
Related
| For | See |
|---|---|
| The full gate state table, env matrix and cookie mechanics | references/gate.md |
| Self-hosting and the demo/full split, for users | the repo's Docs/self-hosting.md |
| Wiring a new tool page to a new endpoint | the edge-spectrum-hub-tool skill |
| Branch flow and the Vercel preview gate | the repo's CLAUDE.md |