Files
puter/src/backend/util/referralCode.ts
T

89 lines
3.6 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Shape and minting of `user.referral_code`.
*
* A referral code is a public, shareable handle for one account, so the only
* mechanism here is "what a code may look like" and "produce a fresh one". What
* a redemption is worth, and when a pattern of redemptions is abuse, is decided
* elsewhere.
*
* Codes have exactly ONE canonical form — upper case — and every boundary
* normalizes to it before touching a database or a counter. Two reasons:
*
* - Collations disagree. MySQL's `latin1_swedish_ci` matches a code
* case-insensitively, SQLite and Postgres do not, so `ab12cd34` resolving to
* an account would depend on the engine.
* - Anything that counts redemptions per code keys off the code string. Left
* un-normalized, `AB12CD34` and `Ab12cd34` are one code to the lookup and two
* to the counters, which is a free way around any per-code cap.
*/
import crypto from 'crypto';
/** `user.referral_code` is `varchar(16)`; a longer value can't be stored. */
export const REFERRAL_CODE_MAX_LENGTH = 16;
/** Length of a newly minted code. 32^8 ≈ 1.1e12 possibilities. */
export const REFERRAL_CODE_LENGTH = 8;
/**
* Accepted canonical shape. Deliberately wider than the mint alphabet below:
* codes minted by earlier versions of the program drew from the full
* alphanumeric set, and they must keep resolving.
*/
export const REFERRAL_CODE_SHAPE = new RegExp(
`^[A-Z0-9]{4,${REFERRAL_CODE_MAX_LENGTH}}$`,
);
/**
* Crockford base32 — the alphanumerics minus `I`, `L`, `O` and `U`. Dropping
* the letters that read as digits keeps a code transcribable from a screen or
* over the phone, and dropping `U` keeps most accidental profanity out of a
* code users are asked to share.
*/
const MINT_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
/**
* The canonical form of a client- or DB-supplied code, or null when the value
* can't be a code at all. Everything that looks a code up, stores one, or
* counts against one goes through here first.
*/
export const normalizeReferralCode = (value: unknown): string | null => {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase();
return REFERRAL_CODE_SHAPE.test(normalized) ? normalized : null;
};
/**
* Mint a fresh code. Uniqueness is not this function's job — it is held by the
* unique index on `user.referral_code`, and callers retry on violation.
*
* `crypto.randomInt` rather than `Math.random`: a code that can be derived from
* an account id (as an earlier seeded-RNG version could) lets anyone
* reconstruct another user's code and spend their referral allowance for them.
*/
export const generateReferralCode = (length = REFERRAL_CODE_LENGTH): string => {
let code = '';
for (let i = 0; i < length; i++) {
code += MINT_ALPHABET[crypto.randomInt(0, MINT_ALPHABET.length)];
}
return code;
};