USPS Address Validation - Common

Library used between the Add/Edit page and the View page.

2025-11-11 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyfork.org/scripts/555040/1693621/USPS%20Address%20Validation%20-%20Common.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         USPS Address Validation - Common
// @namespace    https://github.com/nate-kean/
// @version      2025.11.11.2
// @description  Library used between the Add/Edit page and the View page.
// @author       Nate Kean
// @match        https://jamesriver.fellowshiponego.com/members/add*
// @match        https://jamesriver.fellowshiponego.com/members/edit/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=fellowshiponego.com
// @grant        none
// @license      MIT
// ==/UserScript==

// @ts-check

document.head.insertAdjacentHTML("beforeend", `
	<style id="jrc-address-validator-css">
		.address-panel > .panel-heading {
			width: 50% !important;
			background-color: unset !important;

			& > .panel-title {
				padding-right: 50px;
				display: flex;
				justify-content: space-between;
			}
		}

		button.jrc-address-validation-indicator {
			float: right;
			width:  32px;
			height: 32px;
			text-align: center;
			padding: 0;
			border: none;
			margin-top: -4px;

			& + .tooltip > .tooltip-inner {
				max-width: 250px !important;
			}

			& > i {
				margin-top: 3px;
				font-size: 16px;
				font-weight: 600;
			}

			& > i.fa-check {
				color: #00c853;
			}
			& > i.fa-exclamation {
				color: #ff8f00;
				background-color: hsla(0, 0%, 100%, .1);
				border-radius: 6px;
				transition: background-color 100ms;
			}
			& > i.fa-times {
				color: #c84040;
				/* This icon is just smaller than the others for some reason */
				font-size: 20px;
			}
		}

		.address-2-header > .jrc-address-validation-indicator {
			margin-right: 20px;
		}
	</style>
`);


/**
 * @typedef {Object} QueriedAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip
 * @property {string} country
 */

/**
 * @typedef {Object} CanonicalAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip5
 * @property {string} zip4
 */

/**
 * Validation Result
 * @typedef {Object} ValResult
 * @property {Validator.Code[keyof Validator.Code]} code
 * @property {string} message
 * @property {number} correctionCount
 * @property {CanonicalAddress?} address
 */


/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function delay(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}


/**
 * @param {string} str
 * @returns {string}
 */
function toTitleCase(str) {
	return str.replace(
		/\w\S*/g,
		text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
	);
}


/**
 * @param {Element | Document} el
 * @param {string} selector
 * @returns {Element}
 */
function tryQuerySelector(el, selector) {
	const maybeEl = el.querySelector(selector);
	if (maybeEl === null) {
		console.error(el);
		throw new Error(`Failed to find '${selector}' in given element`);
	}
	return maybeEl;
}


/**
 * This really turned into quite the behemoth
 */
class Validator {
	static Code = Object.freeze({
		__proto__: null,
		MATCH: 0,
		CORRECTION: 1,
		NOT_FOUND: 2,
		NOPE: 3,
		ERROR: 4,
		LOADING: 5,
		EMPTY: 6,
		NOT_IMPL: 7,
	});

	static #CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
	static #CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";
	static #API_ROOT = "https://corsproxy.io/?url=https://apis.usps.com";

	static #DEFAULT_BACKOFF = 4000;
	static #backoff = Validator.#DEFAULT_BACKOFF;

	/**
	 * Call when there is a new address to validate.
	 * @param {Indicator} indicator
	 * @param {QueriedAddress} address
	 * @returns {Promise<void>}
	 */
	static async onNewAddressQuery(indicator, address) {
		// Really a wrapper around #validate that uses cache
		const cached = Validator.#getFromCache(address);
		if (cached !== null) {
			indicator.onValidationResult(cached);
			return;
		};
		const result = await Validator.#validate(address);
		Validator.#sendToCache(address, result);
		indicator.onValidationResult(result);
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {Promise<ValResult>}
	 */
	static async #validate(address) {
		let { streetAddress, city, state, zip } = address;
		const trivialChecksResult = Validator.#trivialChecks(address);
		if (trivialChecksResult !== null) return trivialChecksResult;

		await Validator.#pickUpTimeout();

		const accessToken = await Validator.#getAccessToken();

		// Normalize and format the address to prepare to send to USPS
		streetAddress = toTitleCase(streetAddress);
		// #'s confuse the API. Official USPS addresses will never contain #.
		// USPS will correct "apartment" to "Apt", or "Unit", or whatever else
		// it's supposed to be. We could replace #'s with "Apt" to keep from
		// confusing the API, but we also want USPS to correct it in case it
		// really is supposed to be "Apt", so we normalize them to "apartment"
		// instead.
		streetAddress = streetAddress.replaceAll("#", "apartment");
		city = toTitleCase(city);
		/**
		 * @type {Record<string, string>}
		 */
		const params = { streetAddress, city, state };
		const zipParts = zip?.split("-") ?? [];
		// Only include the ZIP code if it's properly formatted (as a 5 or 5+4).
		// Otherwise the API throws an HTTP 400
		if (zipParts[0]?.length === 5) {
			params.ZIPCode = zipParts[0];
			if (zipParts[1]?.length === 4) {
				params.ZIPPlus4 = zipParts[1];
			}
		}

		const response = await fetch(
			`${Validator.#API_ROOT}/addresses/v3/address?${new URLSearchParams(params)}`,
			{ headers: { "Authorization": `Bearer ${accessToken}` } },
		);

		const earlyResult = await Validator.#parseStatus(response);
		if (earlyResult !== null) return earlyResult;

		const json = await response.json();
		return Validator.#makeCorrectionResult(json, address, zipParts);
	}

	static async #pickUpTimeout() {
		// Handle being timed out on a previous page
		const prevBackoff = window.sessionStorage.getItem("ndk retry");
		if (prevBackoff !== null) {
			const prevBackoffDate = new Date(prevBackoff);
			const prelimBackoff = (
				prevBackoffDate.getMilliseconds()
				- (new Date().getMilliseconds())
			);
			await delay(prelimBackoff);
			window.sessionStorage.removeItem("ndk retry");
		}
	}

	/**
	 * @param {Response} response
	 * @returns {Promise<ValResult?>}
	 */
	static async #parseStatus(response) {
		switch (response.status) {
			case 200:
				return null;
			case 400: {
				const json = await response.json();
				switch (json.error.message) {
					case "Address Not Found":
						return {
							code: Validator.Code.NOT_FOUND,
							message: "",
							correctionCount: 0,
							address: null,
						};
					default:
						console.error(json);
						return {
							code: Validator.Code.ERROR,
							message: "USPS returned error True 400",
							correctionCount: 0,
							address: null,
						};
				}
			}
			case 401:
				await Validator.#regenerateToken();
				// @ts-ignore -- tuple type error nonsense
				return await Validator.#validate(...arguments);
			case 404:
				return {
					code: Validator.Code.NOT_FOUND,
					message: "",
					correctionCount: 0,
					address: null,
				};
			case 429:
			case 503:
				return await Validator.#retry(
					// @ts-ignore -- you can totally do this and i don't know
					// what it's talking about
					() => Validator.#validate(...arguments)
				);
			default:
				console.error(await response.json());
				return {
					code: Validator.Code.ERROR,
					message: `USPS returned status code ${response.status}`,
					correctionCount: 0,
					address: null,
				};
		}
	}

	/**
	 * @param {any} json
	 * @param {QueriedAddress} query
	 * @param {string[]} zipParts
	 * @returns {ValResult}
	 */
	static #makeCorrectionResult(json, query, zipParts) {
		let note = "";
		let correctionCount = 0;
		const code = json.corrections[0]?.code || json.matches[0]?.code;
		switch (code) {
			case "31":
				break;
			case "32":
				note = (
					"Missing or incorrectly-formatted apartment, suite, or "
					+ "box number."
				);
				correctionCount++;
				break;
			case "22":
				note = json.corrections[0].text;
				correctionCount++;
				break;
			default:
				return {
					code: Validator.Code.NOT_IMPL,
					message: `Status code ${code} not implemented`,
					correctionCount: 0,
					address: null,
				};
		}
		/**
		 * @type {CanonicalAddress}
		 */
		const canon = {
			streetAddress: toTitleCase(
				`${json.address.streetAddress} ${json.address.secondaryAddress}`
			).trim(),
			city: toTitleCase(json.address.city),
			state: json.address.state,
			zip5: json.address.ZIPCode,
			zip4: json.address.ZIPPlus4,
		};
		let new_addr = "";
		if (canon.streetAddress === query.streetAddress) {
			new_addr += query.streetAddress;
		} else {
			new_addr += `<strong>${canon.streetAddress}</strong>`;
			correctionCount++;
		}
		new_addr += "<br>";
		if (canon.city === query.city) {
			new_addr += query.city;
		} else {
			new_addr += `<strong>${canon.city}</strong>`;
			correctionCount++;
		}
		new_addr += ", ";
		if (canon.state === query.state) {
			new_addr += query.state;
		} else {
			new_addr += `<strong>${canon.state}</strong>`;
			correctionCount++;
		}
		new_addr += " ";
		if (canon.zip5 === zipParts[0] && canon.zip4 === zipParts[1]) {
			new_addr += `${canon.zip5}-${canon.zip4}`;
		} else {
			new_addr += `<strong>${canon.zip5}-${canon.zip4}</strong>`;
			correctionCount++;
		}
		if (correctionCount > 0) {
			return {
				code: Validator.Code.CORRECTION,
				message: `<span>${new_addr}${note ? `<br><i>${note}</i>` : ""}</span>`,
				correctionCount: correctionCount,
				address: canon,
			};
		} else {
			return {
				code: Validator.Code.MATCH,
				message: "",
				correctionCount: 0,
				address: canon,
			};
		}
	}

	/**
	 * Do some trivial checks that we don't need the API for ourselves.
	 * @param {QueriedAddress} address
	 * @returns {ValResult?}
	 */
	static #trivialChecks({ streetAddress, city, state, zip, country }) {

		// Missing required field
		if (!streetAddress || !city || !state) {
			return {
				code: Validator.Code.EMPTY,
				message: "",
				correctionCount: 0,
				address: null,
			};
		}

		// Improper state abbreviation case
		if (state !== state.toUpperCase()) {
			const [zip4, zip5] = zip.split("-");
			return {
				code: Validator.Code.CORRECTION,
				message: (
					`${streetAddress.replace("\n", "<br>")}<br>${city}, `
					+ `<strong>${state.toUpperCase()}</strong> ${zip}`
				),
				correctionCount: 1,
				address: { streetAddress, city, state, zip4, zip5 },
			};
		}

		// Non-US country code (except blank is fine)
		if (country.length > 0 && country !== "US") {
			return {
				code: Validator.Code.NOPE,
				message: "",
				correctionCount: 0,
				address: null,
			};
		}

		return null;
	}

	/**
	 * @returns {Promise<string | null>}
	 */
	static async #getAccessToken() {
		let accessToken = window.localStorage.getItem("natesUSPSAccessToken");
		if (accessToken === "null" || accessToken === null) {
			await Validator.#regenerateToken();
			accessToken = window.localStorage.getItem("natesUSPSAccessToken");
		}
		return accessToken;
	}

	/**
	 * @returns {Promise<void>}
	 */
	static async #regenerateToken() {
		const response = await fetch(
			"https://corsproxy.io/?url=https://apis.usps.com/oauth2/v3/token", {
			method: "POST",
			headers: new Headers({
				"Content-Type": "application/json",
			}),
			body: JSON.stringify({
				grant_type: "client_credentials",
				scope: "addresses",
				client_id: Validator.#CLIENT_ID,
				client_secret: Validator.#CLIENT_SECRET,
			}),
		});
		switch (response.status) {
			case 200:
				break;
			case 429:
			case 503:
				return await Validator.#retry(Validator.#regenerateToken);
			default:
				throw Error(`${response.status} ${response.statusText}`);
		}
		const json = await response.json();
		window.localStorage.setItem("natesUSPSAccessToken", json.access_token);
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {string}
	 */
	static #serializeAddress({ streetAddress, city, state, zip, country }) {
		return `ndk ${streetAddress} ${city} ${state} ${zip} ${country}`;
	}

	/**
	 * @param {QueriedAddress} address
	 * @returns {ValResult?}
	 */
	static #getFromCache(address) {
		const key = Validator.#serializeAddress(address);
		const value = window.sessionStorage.getItem(key);
		if (value === null) return null;
		return JSON.parse(value);
	}

	/**
	 * @param {QueriedAddress} address
	 * @param {ValResult} result
	 * @returns {void}
	 */
	static #sendToCache(address, result) {
		if (
			result.code === Validator.Code.ERROR
			|| result.code === Validator.Code.NOT_IMPL
		) return;
		const key = Validator.#serializeAddress(address);
		const value = JSON.stringify(result);
		window.sessionStorage.setItem(key, value);
	}

	/**
	 * Exponential backoff retry
	 * @template T
	 * @param {() => Promise<T>} callback 
	 * @returns {Promise<T>}
	 */
	static async #retry(callback) {
		const timeoutDate = new Date();
		timeoutDate.setMilliseconds(
			timeoutDate.getMilliseconds() + Validator.#backoff
		);
		window.sessionStorage.setItem("ndk retry", timeoutDate.toISOString());
		await delay(Validator.#backoff);
		Validator.#backoff **= 2;
		const promise = callback();
		Validator.#backoff = Validator.#DEFAULT_BACKOFF;
		window.sessionStorage.removeItem("ndk retry");
		return await promise;
	}
}


class Indicator {
	#icon;
	#typing;

	/**
	 * @param {Node} parent
	 */
	constructor(parent) {
		/**
		 * @type {ValResult}
		 */
		this.status = {
			address: null,
			code: Validator.Code.LOADING,
			correctionCount: 0,
			message: "",
		};

		this.#typing = false;

		/**
		 * @type {HTMLButtonElement}
		 */
		this.button = document.createElement("button");
		this.button.classList.add("jrc-address-validation-indicator");
		this.button.setAttribute("data-toggle", "tooltip");
		this.button.setAttribute("data-placement", "top");
		this.button.setAttribute("data-html", "true");
		this.button.type = "button";
		this.button.disabled = true;
		// @ts-ignore -- environment will have jquery
		$(this.button).tooltip();
		this.#setIcon("fal", "fa-spinner-third", "fa-spin");

		this.#icon = document.createElement("i");
		this.button.appendChild(this.#icon);

		parent.appendChild(this.button);
	}

	/**
	 * @param {ValResult} result
	 * @returns {void}
	 */
	onValidationResult(result) {
		if (this.#typing) return;

		this.status = result;
		const { code, message: msg, correctionCount: correctionCount } = this.status;
		let tooltipContent = "";

		this.#icon.classList.remove("fa-spinner-third", "fa-spin");
		switch (code) {
			case Validator.Code.LOADING:
				this.#setIcon("fa-spinner-third", "fa-spin");
				tooltipContent = "";
				break;
			case Validator.Code.MATCH:
				this.#setIcon("fa-check");
				tooltipContent = "USPS&thinsp;—&thinsp;Verified valid";
				break;
			case Validator.Code.CORRECTION:
				this.#setIcon("fa-exclamation");
				const s = correctionCount > 1 ? "s" : "";
				tooltipContent = `USPS&thinsp;—&thinsp;Correction${s} suggested:<br>${msg}`;
				this.button.disabled = false;
				break;
			case Validator.Code.NOT_FOUND:
				this.#setIcon("fa-times");
				tooltipContent = "USPS&thinsp;—&thinsp;Address not found";
				break;
			case Validator.Code.NOPE:
				this.#setIcon("fa-circle");
				tooltipContent = "USPS validation skipped: incompatible country";
				break;
			case Validator.Code.EMPTY:
				this.#setIcon("fa-circle");
				tooltipContent = "";
				break;
			case Validator.Code.ERROR:
				this.#setIcon("fa-times");
				tooltipContent = `ERROR: ${msg}. Contact Nate`;
				break;
			case Validator.Code.NOT_IMPL:
				this.#setIcon("fa-times");
				tooltipContent = `ERROR: ${msg}. Contact Nate`;
				break;
			default:
				this.#setIcon("fa-times");
				tooltipContent = "PLUGIN ERROR: contact Nate";
				break;
		}
		this.button.setAttribute("data-original-title", tooltipContent);
	}

	/**
	 * Set an icon class while removing all the other icon classes.
	 * @param  {...string} classNames
	 */
	#setIcon(...classNames) {
		this.#icon.classList.remove(
			"fa-spinner-third", "fa-spin",
			"fa-check",
			"fa-exclamation",
			"fa-times",
			"fa-circle",
		);
		this.#icon.classList.add(...classNames);
	}

	onTyping() {
		this.#typing = true;
		this.#setIcon("fa-spinner-third", "fa-spin");
		this.button.removeAttribute("data-original-title");
		this.button.disabled = true;
	}

	onEmptyField() {
		this.#typing = false;
		this.#setIcon("fa-circle");
	}
}