Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,29 @@ jobs:
# that break soroban-env-host's testutils.
run: cargo test --workspace --locked

# ── Test shared packages ─────────────────────────────────────────────────────
# Added because nothing outside apps/api and the contracts was ever run here.
# `@useroutr/types` now owns the API base-URL contract that the dashboard and
# checkout both depend on; when the two disagreed about whether
# NEXT_PUBLIC_API_URL included `/v1`, four features 404'd in production and no
# job in this file could have noticed.
test-packages:
name: Test Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci

- name: Run tests
working-directory: packages/types
run: npm test

# ── Build ────────────────────────────────────────────────────────────────────
build:
name: Build
Expand Down
6 changes: 3 additions & 3 deletions apps/checkout/components/BankInstructions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function BankInstructions() {

try {
const result = await api.post<BankSessionResult>(
`/v1/payments/${paymentId}/bank-session`,
`/payments/${paymentId}/bank-session`,
);
if (cancelled) return;
setSession(result.session);
Expand Down Expand Up @@ -93,7 +93,7 @@ export function BankInstructions() {

try {
const result = await api.post<BankSessionResult>(
`/v1/payments/${paymentId}/bank-session/regenerate`,
`/payments/${paymentId}/bank-session/regenerate`,
);
setSession(result.session);
setExpired(Boolean(result.expired));
Expand All @@ -117,7 +117,7 @@ export function BankInstructions() {
setError(null);

try {
await api.post(`/v1/payments/${paymentId}/bank-sent`);
await api.post(`/payments/${paymentId}/bank-sent`);
router.push(`/${paymentId}/confirm`);
} catch {
setError("Unable to mark transfer as sent. Please try again.");
Expand Down
2 changes: 1 addition & 1 deletion apps/checkout/components/CardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function CardForm({

try {
const session = await api.post<CardSessionResponse>(
`/v1/payments/${paymentId}/card-session`
`/payments/${paymentId}/card-session`
);

const result = await stripe.confirmCardPayment(session.clientSecret, {
Expand Down
5 changes: 2 additions & 3 deletions apps/checkout/hooks/useInvoiceCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export interface InvoiceCheckoutData {
export function useInvoiceCheckout(invoiceId: string) {
return useQuery<InvoiceCheckoutData>({
queryKey: ["invoice-checkout", invoiceId],
queryFn: () => api.get(`/v1/invoices/${invoiceId}/checkout`),
queryFn: () => api.get(`/invoices/${invoiceId}/checkout`),
enabled: !!invoiceId,
retry: false,
staleTime: 30_000,
Expand All @@ -56,7 +56,6 @@ export function useInvoiceCheckout(invoiceId: string) {

export function useInitiateInvoicePayment() {
return useMutation<{ paymentId: string }, Error, string>({
mutationFn: (invoiceId) =>
api.post(`/v1/invoices/${invoiceId}/pay`),
mutationFn: (invoiceId) => api.post(`/invoices/${invoiceId}/pay`),
});
}
20 changes: 18 additions & 2 deletions apps/checkout/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3333/v1";
import { assertVersionlessPath, resolveApiBaseUrl } from "@useroutr/types";

/**
* The version prefix lives here, not at call sites — the same rule the
* dashboard follows. This file used to read `NEXT_PUBLIC_API_URL` expecting it
* to *include* `/v1` while the dashboard read the same variable expecting it
* not to, so no single deployment value could satisfy both. Meanwhile four
* call sites in this app wrote `/v1/payments/...` on top of a base that
* already ended in `/v1`, and requested `/v1/v1/payments/...`.
*/
const BASE_URL = resolveApiBaseUrl(
process.env.NEXT_PUBLIC_API_URL,
"http://localhost:3333",
);

interface RequestOptions {
params?: Record<string, unknown>;
Expand Down Expand Up @@ -33,8 +46,11 @@ async function request<T>(
path: string,
options: RequestOptions & { body?: unknown } = {},
): Promise<T> {
// Fails loudly in development if a caller re-adds the version prefix.
assertVersionlessPath(path.startsWith("/") ? path : `/${path}`);

// Use string concat rather than `new URL(path, BASE_URL)` so an absolute
// `path` like `/v1/links/abc` doesn't replace BASE_URL's pathname when
// `path` like `/links/abc` doesn't replace BASE_URL's pathname when
// BASE_URL itself carries a path prefix (e.g. behind an ingress).
const queryString = options.params
? "?" +
Expand Down
8 changes: 4 additions & 4 deletions apps/dashboard/src/hooks/__tests__/usePayouts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('usePayouts', () => {

await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(api.get).toHaveBeenCalledWith('/v1/payouts', {
expect(api.get).toHaveBeenCalledWith('/payouts', {
params: { limit: 20, offset: 0 },
})
expect(result.current.data).toEqual(mockResponse)
Expand All @@ -88,7 +88,7 @@ describe('usePayouts', () => {
})

await waitFor(() => {
expect(api.get).toHaveBeenCalledWith('/v1/payouts', { params: filters })
expect(api.get).toHaveBeenCalledWith('/payouts', { params: filters })
})
})
})
Expand All @@ -114,7 +114,7 @@ describe('useRetryPayout', () => {

await result.current.mutateAsync('payout-1')

expect(api.post).toHaveBeenCalledWith('/v1/payouts/payout-1/retry')
expect(api.post).toHaveBeenCalledWith('/payouts/payout-1/retry')
})
})

Expand All @@ -139,6 +139,6 @@ describe('useCancelPayout', () => {

await result.current.mutateAsync('payout-1')

expect(api.post).toHaveBeenCalledWith('/v1/payouts/payout-1/cancel')
expect(api.post).toHaveBeenCalledWith('/payouts/payout-1/cancel')
})
})
26 changes: 16 additions & 10 deletions apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { assertVersionlessPath, resolveApiBaseUrl } from "@useroutr/types";
import {
getToken,
refreshAccessToken,
Expand All @@ -6,14 +7,16 @@ import {
} from "./auth";

/**
* All HTTP calls hit the versioned surface (`/v1/*`). The env var points
* at the origin only — we append the prefix here so callers can keep
* writing `api.get("/auth/me")` and not think about versioning.
* All HTTP calls hit the versioned surface (`/v1/*`). `resolveApiBaseUrl` owns
* the prefix so callers keep writing `api.get("/auth/me")` — and so this app
* and checkout agree on what `NEXT_PUBLIC_API_URL` means, which they did not
* before. It accepts the env var with or without a trailing `/v1`.
*/
const API_ORIGIN = (
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3333"
).replace(/\/$/, "");
const BASE_URL = `${API_ORIGIN}/v1`;
const BASE_URL = resolveApiBaseUrl(
process.env.NEXT_PUBLIC_API_URL,
"http://localhost:3333",
);
const API_ORIGIN = BASE_URL.replace(/\/v1$/, "");

function getApiConnectionErrorMessage() {
return `Cannot reach the Useroutr API at ${API_ORIGIN}. Start it with \`npm run start:api\` or set NEXT_PUBLIC_API_URL.`;
Expand Down Expand Up @@ -91,6 +94,9 @@ async function request<T>(
// pathname replacement, which would silently strip the "/v1" version
// prefix from BASE_URL. The string-join approach is unambiguous.
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// Fails loudly in development if a caller re-adds the version. Nineteen call
// sites here had, and every one of them 404'd silently in the browser.
assertVersionlessPath(normalizedPath);
const url = new URL(`${BASE_URL}${normalizedPath}`);

if (options.params) {
Expand Down Expand Up @@ -169,9 +175,9 @@ async function request<T>(
}

if (!retryRes.ok) {
const retryErrorBody = await parseResponse<ApiErrorBody>(
retryRes,
).catch(() => ({}) as ApiErrorBody);
const retryErrorBody = await parseResponse<ApiErrorBody>(retryRes).catch(
() => ({}) as ApiErrorBody,
);
throw new Error(
extractErrorMessage(
retryErrorBody,
Expand Down
18 changes: 10 additions & 8 deletions apps/dashboard/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { resolveApiBaseUrl } from "@useroutr/types";

const TOKEN_KEY = "useroutr-token";
const REFRESH_KEY = "useroutr-refresh-token";
const VERIFICATION_EMAIL_KEY = "useroutr-verification-email";

// Origin only (no path) — we append `/v1` ourselves so this file stays
// in sync with lib/api.ts's BASE_URL construction. Fallback is the local
// API port (:3333), NOT the marketing site (:3000) — getting that wrong
// makes every refresh hit a 404 and silently log the user out.
const API_ORIGIN = (
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3333"
).replace(/\/$/, "");
const BASE_URL = `${API_ORIGIN}/v1`;
// Shares `resolveApiBaseUrl` with lib/api.ts rather than re-deriving the base,
// so the two cannot drift apart. Fallback is the local API port (:3333), NOT
// the marketing site (:3000) — getting that wrong makes every refresh hit a
// 404 and silently log the user out.
const BASE_URL = resolveApiBaseUrl(
process.env.NEXT_PUBLIC_API_URL,
"http://localhost:3333",
);

interface JwtPayload {
exp?: number;
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
"version": "0.0.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"clean": "rm -rf dist",
"prepare": "npm run build"
"prepare": "npm run build",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^1"
}
}
97 changes: 97 additions & 0 deletions packages/types/src/api-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { API_VERSION, assertVersionlessPath, resolveApiBaseUrl } from './api-url';

/**
* These cases are the bug that prompted this module, written down.
*
* Both spellings of the origin have to work, because the two apps that read
* `NEXT_PUBLIC_API_URL` used to disagree about which one it was, and the
* variable is set once per deployment.
*/
describe('resolveApiBaseUrl', () => {
const fallback = 'http://localhost:3333';

it('appends the version to a bare origin', () => {
expect(resolveApiBaseUrl('https://api.useroutr.com', fallback)).toBe(
'https://api.useroutr.com/v1',
);
});

it('leaves an origin that already carries the version alone', () => {
expect(resolveApiBaseUrl('https://api.useroutr.com/v1', fallback)).toBe(
'https://api.useroutr.com/v1',
);
});

it('tolerates trailing slashes on either spelling', () => {
expect(resolveApiBaseUrl('https://api.useroutr.com/', fallback)).toBe(
'https://api.useroutr.com/v1',
);
expect(resolveApiBaseUrl('https://api.useroutr.com/v1/', fallback)).toBe(
'https://api.useroutr.com/v1',
);
});

// Someone who has already been bitten by the doubled prefix may well "fix"
// it by pasting the doubled value into the env var. Repair it rather than
// faithfully reproduce it.
it('collapses an origin that already doubled the version', () => {
expect(resolveApiBaseUrl('https://api.useroutr.com/v1/v1', fallback)).toBe(
'https://api.useroutr.com/v1',
);
});

it('falls back when the variable is unset, empty or whitespace', () => {
expect(resolveApiBaseUrl(undefined, fallback)).toBe(`${fallback}/v1`);
expect(resolveApiBaseUrl(null, fallback)).toBe(`${fallback}/v1`);
expect(resolveApiBaseUrl('', fallback)).toBe(`${fallback}/v1`);
expect(resolveApiBaseUrl(' ', fallback)).toBe(`${fallback}/v1`);
});

it('preserves a path prefix in front of the version, for ingress setups', () => {
expect(resolveApiBaseUrl('https://useroutr.com/api', fallback)).toBe(
'https://useroutr.com/api/v1',
);
});

it('never returns a base that lacks exactly one version segment', () => {
for (const input of [
'https://x.com',
'https://x.com/',
'https://x.com/v1',
'https://x.com/v1/',
'https://x.com/v1/v1',
]) {
const matches = resolveApiBaseUrl(input, fallback).match(
new RegExp(`/${API_VERSION}`, 'g'),
);
expect(matches).toHaveLength(1);
}
});
});

describe('assertVersionlessPath', () => {
it('rejects a path that re-adds the version', () => {
expect(() => assertVersionlessPath('/v1/payouts')).toThrow(/already includes it/);
});

it('names the path the caller should have written', () => {
expect(() => assertVersionlessPath('/v1/invoices/abc')).toThrow(
/Pass "\/invoices\/abc" instead/,
);
});

it('rejects the bare version segment', () => {
expect(() => assertVersionlessPath('/v1')).toThrow();
});

it('allows ordinary resource paths', () => {
expect(() => assertVersionlessPath('/payouts')).not.toThrow();
expect(() => assertVersionlessPath('/invoices/abc/pdf')).not.toThrow();
});

// The guard must not fire on a resource whose name merely starts with "v1".
it('does not confuse a resource prefixed with the version string', () => {
expect(() => assertVersionlessPath('/v1beta/experiments')).not.toThrow();
});
});
Loading
Loading