Skip to content

fix: one /v1, owned by the base URL - #200

Merged
0xdevcollins merged 1 commit into
mainfrom
fix/single-v1-prefix
Aug 9, 2026
Merged

0xdevcollins merged 1 commit into
mainfrom
fix/single-v1-prefix

Conversation

@0xdevcollins

Copy link
Copy Markdown
Owner

Follow-up to #199. That PR fixed 20 call sites; this one removes the reason they kept appearing.

The root cause

NEXT_PUBLIC_API_URL meant two different things:

reads the env var as appends
dashboard origin /v1
checkout origin including /v1 nothing

Both were self-consistent, which is why neither looked wrong in isolation. But the variable is set once per deployment, so no value could satisfy both — and ci.yml builds both apps with NEXT_PUBLIC_API_URL: https://api.useroutr.com, meaning production checkout was built with no version segment at all.

Separately, four checkout call sites wrote /v1/payments/… on top of a base that already ended in /v1, requesting /v1/v1/payments/…. Those are the card and bank rails: bank-session, bank-session/regenerate, bank-sent, card-session.

The fix

@useroutr/types now owns the contract — both apps already depend on it:

resolveApiBaseUrl(origin, fallback)  // always exactly one /v1, accepts either
                                     // spelling, collapses an already-doubled one
assertVersionlessPath(path)          // throws outside production if a caller
                                     // re-adds the version

One rule, stated once: the base URL owns the version, call sites never write it. All three clients (dashboard api.ts, dashboard auth.ts, checkout api.ts) now go through it, so a future /v2 is a one-line change rather than a sweep.

The guard matters more than the resolver. A doubled prefix produces no type error and no lint error — it 404s at runtime and nowhere else, and the mocked hook tests assert the wrong string right alongside it. That is precisely how twenty call sites accumulated without anyone noticing.

Server side

Already correct and unchanged: one setGlobalPrefix('v1'), and no controller repeats it (the one that did, notifications, was fixed in #199).

Verified

undefined                     -> http://localhost:3333/v1
http://localhost:3333         -> http://localhost:3333/v1
http://localhost:3333/v1      -> http://localhost:3333/v1
https://api.useroutr.com      -> https://api.useroutr.com/v1
https://api.useroutr.com/v1   -> https://api.useroutr.com/v1

Guard: /v1/payouts and /v1/invoices/abc throw with the corrected path named in the message; /payouts and /v1beta/experiments pass (it does not false-positive on a resource whose name merely starts with the version string).

In the browser, checkout now requests GET /v1/checkout/<id> — single prefix.

Tests

  • 12 new in packages/types, including the case where someone who has already been bitten pastes the doubled URL into the env var
  • New Test Packages CI job — nothing outside apps/api and the contracts was ever run there, which is why the stale assertions survived
  • 287 API unit + 15 API e2e still green; dashboard and checkout typecheck and lint clean (0 errors)
  • Fixed 4 stale usePayouts assertions still pinning /v1/payouts. Dashboard suite goes from 14 failed / 6 passed to 10 failed / 10 passed — the remaining 10 fail on clean main too, for unrelated reasons (see below)

Deliberately not fixed here

  • apps/dashboard's vitest suite is red independently of this change: @useroutr/ui doesn't resolve under vitest (the package ships raw TSX with no build, handled in Next by transpilePackages but not configured for vitest), plus a jsdom container issue. Needs its own change before it can join CI.
  • apps/checkout/__tests__/crypto-payment.test.tsx exists but the app has no test script, no test deps and no config — it has never run once.
  • The five dashboard files that bypass the api client with bare fetch() and no auth header, already tracked separately.

One carried-in change worth naming: useInvoiceCheckout.ts had already been corrected in the working tree by someone else. I included it because the new guard would now throw on those paths rather than let them 404 quietly.

`NEXT_PUBLIC_API_URL` meant two different things. The dashboard read it as an
origin and appended `/v1`; checkout read it as already including `/v1` and
appended nothing. Both were self-consistent, which is why neither looked wrong
on its own — but the variable is set once per deployment, so no value could
satisfy both. ci.yml builds both apps with
`NEXT_PUBLIC_API_URL: https://api.useroutr.com`, so production checkout was
being built with no version segment at all.

On top of that, four call sites in checkout wrote `/v1/payments/...` against a
base that already ended in `/v1`, requesting `/v1/v1/payments/...`: the bank
session, its regenerate, bank-sent, and the card session. Those are the
card and bank rails.

`@useroutr/types` now owns the contract, since both apps already depend on it:

  resolveApiBaseUrl(origin, fallback)  → always exactly one /v1, accepting an
                                         origin written either way, and
                                         collapsing one that already doubled
  assertVersionlessPath(path)          → throws outside production if a caller
                                         re-adds the version

The rule is now stated in one place: the base URL owns the version, call sites
never write it. A future /v2 is a one-line change here instead of a sweep.

The guard matters more than the resolver. A doubled prefix produces no type
error and no lint error — it 404s at runtime and nowhere else, and the mocked
hook tests happily assert the wrong string alongside it. That is exactly how
twenty call sites accumulated.

Also here: the four `usePayouts` test assertions still pinning the old
`/v1/payouts` paths, and `useInvoiceCheckout.ts`, which someone had already
corrected in the working tree — carried in because the new guard would now
throw on it rather than let it 404 quietly.

CI gains a Test Packages job. Nothing outside apps/api and the contracts was
ever run there, which is why the stale assertions went unnoticed; 12 tests
cover both helpers, including the case where someone pastes the already-doubled
URL into the env var.

Not fixed here: apps/dashboard's vitest suite is red for unrelated reasons
(@useroutr/ui does not resolve under vitest; a jsdom container issue), and
apps/checkout has a test file with no runner wired at all. Both need their own
change before they can join CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
useroutr-www Ready Ready Preview Aug 9, 2026 2:35pm

@0xdevcollins
0xdevcollins merged commit 41e99ad into main Aug 9, 2026
7 checks passed
@0xdevcollins
0xdevcollins deleted the fix/single-v1-prefix branch August 9, 2026 17:55

This branch was successfully deployed

1 active deployment
Preview – useroutr-www — 237b6156 Deployed Aug 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant