Skip to content
Merged
109 changes: 109 additions & 0 deletions packages/addresszen/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import 'dotenv/config';
import { makeAddresszenRequest } from './client';
import type {
AutocompleteAddressesResponse,
KeyAvailabilityResponse,
ResolveAddressUsaResponse,
VerifyAddressResponse,
} from './endpoints/types';
import { AddresszenEndpointOutputSchemas } from './endpoints/types';

const TEST_API_KEY = process.env.ADDRESSZEN_API_KEY;
const describeIfApiKey = TEST_API_KEY ? describe : describe.skip;

describeIfApiKey('Addresszen API Type Tests', () => {
describe('key', () => {
it('keyAvailability returns correct type', async () => {
const response = await makeAddresszenRequest<KeyAvailabilityResponse>(
`keys/${encodeURIComponent(TEST_API_KEY!)}`,
TEST_API_KEY!,
{ method: 'GET', auth: false },
);

AddresszenEndpointOutputSchemas.keyAvailability.parse(response);
expect(response.code).toBe(2000);
expect(typeof response.result.available).toBe('boolean');
});
});

describe('autocomplete', () => {
it('autocompleteAddresses returns correct type', async () => {
const response =
await makeAddresszenRequest<AutocompleteAddressesResponse>(
'autocomplete/addresses',
TEST_API_KEY!,
{
method: 'GET',
query: {
q: '10 downing',
},
},
);

AddresszenEndpointOutputSchemas.autocompleteAddresses.parse(response);
expect(response.code).toBe(2000);
});
});

describe('resolve', () => {
it('resolveAddressUsa returns correct type', async () => {
const suggestions =
await makeAddresszenRequest<AutocompleteAddressesResponse>(
'autocomplete/addresses',
TEST_API_KEY!,
{
method: 'GET',
query: { q: '1600 Garfield Aliquippa' },
},
);

const addressId = suggestions.result.hits[0]?.id;
expect(addressId).toBeTruthy();

const response = await makeAddresszenRequest<ResolveAddressUsaResponse>(
`autocomplete/addresses/${encodeURIComponent(addressId!)}/usa`,
TEST_API_KEY!,
{ method: 'GET' },
);

AddresszenEndpointOutputSchemas.resolveAddressUsa.parse(response);
expect(response.code).toBe(2000);
expect(response.result.line_1).toBeTruthy();
});
});

describe('verify', () => {
it('verifyAddress returns correct type', async () => {
const response = await makeAddresszenRequest<VerifyAddressResponse>(
'verify/addresses',
TEST_API_KEY!,
{
method: 'POST',
body: {
query: '123 Main St, Springfield, CO 81073',
},
},
);

AddresszenEndpointOutputSchemas.verifyAddress.parse(response);
expect(response.code).toBe(2000);
});

it('verifyAddress with split components returns correct type', async () => {
const response = await makeAddresszenRequest<VerifyAddressResponse>(
'verify/addresses',
TEST_API_KEY!,
{
method: 'POST',
body: {
query: '123 Main St',
city: 'Springfield',
state: 'CO',
},
},
);

AddresszenEndpointOutputSchemas.verifyAddress.parse(response);
});
});
});
87 changes: 87 additions & 0 deletions packages/addresszen/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export class AddresszenAPIError extends Error {
public readonly status?: number;
public readonly statusText?: string;
// Using unknown because Addresszen API error response bodies vary by endpoint
// and error code, making a strict type infeasible without per-endpoint handling.
public readonly body?: unknown;
public readonly retryAfter?: number;

constructor(
message: string,
public readonly code?: number,
options?: { cause?: Error },
) {
super(message, options);
this.name = 'AddresszenAPIError';

if (options?.cause instanceof ApiError) {
this.status = options.cause.status;
this.statusText = options.cause.statusText;
this.body = options.cause.body;
this.retryAfter = options.cause.retryAfter;
}
}
}

const ADDRESSZEN_API_BASE = 'https://api.addresszen.com/v1';

/**
* Performs a request to the Addresszen API.
*
* Auth: API key passed via the Authorization header to avoid leaking credentials
* into URL access logs. Addresszen also supports query-string auth, but header
* auth is preferred per their API reference.
*/
export async function makeAddresszenRequest<T>(
endpoint: string,
apiKey: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: Record<string, unknown>;
query?: Record<string, string | number | boolean | undefined>;
/** When false, skip Authorization (public endpoints that identify the key in the path). */
auth?: boolean;
} = {},
): Promise<T> {
const { method = 'GET', body, query = {}, auth = true } = options;
const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH';

const config: OpenAPIConfig = {
BASE: ADDRESSZEN_API_BASE,
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
...(auth ? { Authorization: `api_key="${apiKey}"` } : {}),
...(isWrite ? { 'Content-Type': 'application/json' } : {}),
},
};

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body: isWrite ? body : undefined,
mediaType: isWrite ? 'application/json; charset=utf-8' : undefined,
query,
};

try {
return await request<T>(config, requestOptions);
} catch (error) {
if (error instanceof ApiError) {
throw new AddresszenAPIError(error.message, error.status, {
cause: error,
});
}
if (error instanceof Error) {
throw new AddresszenAPIError(error.message, undefined, {
cause: error,
});
}
throw new AddresszenAPIError('Unknown error');
}
}
52 changes: 52 additions & 0 deletions packages/addresszen/endpoints/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { logEventFromContext } from 'corsair/core';
import { makeAddresszenRequest } from '../client';
import type { AddresszenEndpoints } from '../index';
import type { AddresszenEndpointOutputs } from './types';

/**
* Get address autocomplete suggestions for a partial query.
*
* API: GET /autocomplete/addresses
* Docs: https://docs.addresszen.com/docs/api/find-address
*/
export const addresses: AddresszenEndpoints['autocompleteAddresses'] = async (
ctx,
input,
) => {
const response = await makeAddresszenRequest<
AddresszenEndpointOutputs['autocompleteAddresses']
>('autocomplete/addresses', ctx.key, {
method: 'GET',
query: {
q: input.query,
limit: input.limit,
page: input.page,
},
});

if (ctx.db.autocompleteResults) {
try {
const { result, ...rest } = response;
await ctx.db.autocompleteResults.upsertByEntityId(input.query, {
...rest,
query: input.query,
hits: result.hits,
updatedAt: new Date(),
});
} catch (error) {
console.warn(
'[addresszen] Failed to save autocomplete results to database:',
error,
);
}
}

await logEventFromContext(
ctx,
'addresszen.autocomplete.addresses',
{ query: input.query, hitCount: response.result.hits.length },
'completed',
);

return response;
};
22 changes: 22 additions & 0 deletions packages/addresszen/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { addresses } from './autocomplete';
import { availability } from './key';
import { addressUsa } from './resolve';
import { address } from './verify';

export const Autocomplete = {
addresses,
};

export const Verify = {
address,
};

export const Key = {
availability,
};

export const Resolve = {
addressUsa,
};

export * from './types';
53 changes: 53 additions & 0 deletions packages/addresszen/endpoints/key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { logEventFromContext } from 'corsair/core';
import { makeAddresszenRequest } from '../client';
import type { AddresszenEndpoints } from '../index';
import type { AddresszenEndpointOutputs } from './types';

/**
* Get public information on an API key, including whether it is usable.
*
* API: GET /keys/:key
* Docs: https://docs.addresszen.com/docs/api/key-availability
*
* Addresszen requires the key as the path resource id for this public endpoint;
* there is no header-only variant. Auth header is omitted so the credential is
* not also sent in Authorization.
*/
export const availability: AddresszenEndpoints['keyAvailability'] = async (
ctx,
_input,
) => {
const response = await makeAddresszenRequest<
AddresszenEndpointOutputs['keyAvailability']
>(`keys/${encodeURIComponent(ctx.key)}`, ctx.key, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security API key remains in URL

When a caller invokes key.availability, this endpoint interpolates the live API key into the request path, causing the credential to be retained by provider, proxy, or access-log infrastructure despite the new Authorization header. How this was verified: The endpoint passes ctx.key in keys/{key} while the client separately sends the same key in the Authorization header.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddressZen documents this as GET /keys/:key (public availability) there is no header-only variant (verified: /keys, /keys/me, /keys/current all 404). The path segment is the resource id, not optional auth.

Mitigation in 2544721: omit the Authorization header for this call so the key isn’t sent twice. Remaining path usage is provider-mandated; dropping the op would remove catalog ADDRESSZEN_KEY_AVAILABILITY.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation already has auth: false in the call — the mitigation is in place. The original concern was about the key being sent twice (path + header), and commit 2544721 resolved exactly that by adding the auth flag to makeAddresszenRequest.

Since the path is provider-mandated and the Authorization header is now suppressed for this endpoint, the concern is addressed. Fair point — withdrawing this comment.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

method: 'GET',
auth: false,
});

if (ctx.db.keyAvailability) {
try {
const accountId = await ctx.$getAccountId();
await ctx.db.keyAvailability.upsertByEntityId(accountId, {
available: response.result.available,
context: response.result.context ?? null,
code: response.code,
message: response.message,
updatedAt: new Date(),
});
} catch (error) {
console.warn(
'[addresszen] Failed to save key availability to database:',
error,
);
}
}

await logEventFromContext(
ctx,
'addresszen.key.availability',
{ available: response.result.available },
'completed',
);

return response;
};
49 changes: 49 additions & 0 deletions packages/addresszen/endpoints/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { logEventFromContext } from 'corsair/core';
import { makeAddresszenRequest } from '../client';
import type { AddresszenEndpoints } from '../index';
import type { AddresszenEndpointOutputs } from './types';

/**
* Resolve an address autocompletion by ID and return the full US-format address.
*
* API: GET /autocomplete/addresses/:address/usa
* Docs: https://docs.addresszen.com/docs/api/retrieve-address
*/
export const addressUsa: AddresszenEndpoints['resolveAddressUsa'] = async (
ctx,
input,
) => {
const response = await makeAddresszenRequest<
AddresszenEndpointOutputs['resolveAddressUsa']
>(
`autocomplete/addresses/${encodeURIComponent(input.addressId)}/usa`,
ctx.key,
{ method: 'GET' },
);

if (ctx.db.resolvedAddresses) {
try {
const { result, ...rest } = response;
await ctx.db.resolvedAddresses.upsertByEntityId(input.addressId, {
...rest,
addressId: input.addressId,
address: result,
updatedAt: new Date(),
});
} catch (error) {
console.warn(
'[addresszen] Failed to save resolved address to database:',
error,
);
}
}

await logEventFromContext(
ctx,
'addresszen.resolve.addressUsa',
{ addressId: input.addressId },
'completed',
);

return response;
};
Loading
Loading