Wave 1: add deps, crypto random utility, i18n helper, privacy fix, CI gates

- fix(deps): add zod and typescript to package.json
- feat(lib): add cryptographically secure random utility with TDD
- feat(lib): add client i18n helper with TDD
- fix(i18n): update privacy policy for crypto accuracy
- ci(deploy): add test and lint gates, fix SSH security
This commit is contained in:
emil
2026-05-13 19:34:42 +03:00
parent 2c400abb0a
commit 515c4a6168
15 changed files with 1568 additions and 6 deletions
+2 -2
View File
@@ -75,7 +75,7 @@ export const translations = {
aboutHistory:
"The idea for Randify came from a simple frustration: every time we needed a quick random number or a coin flip, we ended up on cluttered websites full of ads, pop-ups, and unnecessary steps. We wanted something cleaner — a tool that just works, without distractions.\n\nWe started with a single number generator and gradually expanded the collection based on what people actually use: dice for board games, wheels for raffles, passwords for security, and more. Each tool was built with the same philosophy — minimal interface, instant results, zero friction.",
aboutHowItWorks:
"Everything on Randify runs entirely in your browser. No data is sent to any server, no personal information is collected, and no account is required. The random values are generated locally using the Web Crypto API, which provides cryptographically secure randomness. This means your passwords, numbers, and choices stay private — we never see them.",
"Everything on Randify runs entirely in your browser. No data is sent to any server, no personal information is collected, and no account is required. The random values are generated locally using crypto.getRandomValues from the Web Crypto API, which provides cryptographically secure randomness. This means your passwords, numbers, and choices stay private — we never see them.",
aboutContact:
"Have a suggestion, found a bug, or just want to say hello? Drop us a line at hello@randify.pro — we read every message.",
aboutCta:
@@ -169,7 +169,7 @@ export const translations = {
aboutHistory:
"Идея Randify родилась из простого раздражения: каждый раз, когда нам нужно было быстро сгенерировать случайное число или подбросить монетку, мы попадали на перегруженные рекламой сайты с всплывающими окнами и лишними шагами. Мы хотели создать что-то чище — инструмент, который просто работает, без отвлекающих элементов.\n\nМы начали с одного генератора чисел и постепенно расширяли коллекцию, ориентируясь на реальные потребности: кубики для настольных игр, колесо фортуны для розыгрышей, генератор паролей для безопасности и многое другое. Каждый инструмент создавался по одному принципу — минимальный интерфейс, мгновенный результат, никаких препятствий.",
aboutHowItWorks:
"Всё на Randify работает полностью в вашем браузере. Данные не отправляются на сервер, личная информация не собирается, регистрация не требуется. Случайные значения генерируются локально с помощью Web Crypto API, который обеспечивает криптографически стойкую случайность. Это означает, что ваши пароли, числа и выбор остаются приватными — мы их не видим.",
"Всё на Randify работает полностью в вашем браузере. Данные не отправляются на сервер, личная информация не собирается, регистрация не требуется. Случайные значения генерируются локально с помощью crypto.getRandomValues из Web Crypto API, который обеспечивает криптографически стойкую случайность. Это означает, что ваши пароли, числа и выбор остаются приватными — мы их не видим.",
aboutContact:
"Есть предложение, нашли ошибку или просто хотите поздороваться? Напишите нам на hello@randify.pro — мы читаем каждое сообщение.",
aboutCta:
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from "vitest";
import { getClientLang, getClientT } from "./i18n";
import type { T } from "@/i18n/translations";
const mockDocument = {
documentElement: {
lang: "en",
},
};
(globalThis as Record<string, unknown>).document = mockDocument;
describe("getClientLang", () => {
beforeEach(() => {
mockDocument.documentElement.lang = "en";
});
it('returns "en" when document.documentElement.lang is "en"', () => {
mockDocument.documentElement.lang = "en";
expect(getClientLang()).toBe("en");
});
it('returns "ru" when document.documentElement.lang is "ru"', () => {
mockDocument.documentElement.lang = "ru";
expect(getClientLang()).toBe("ru");
});
it('defaults to "en" for unsupported languages', () => {
mockDocument.documentElement.lang = "fr";
expect(getClientLang()).toBe("en");
});
});
describe("getClientT", () => {
beforeEach(() => {
mockDocument.documentElement.lang = "en";
});
it("returns English translations when lang is 'en'", () => {
mockDocument.documentElement.lang = "en";
const t: T = getClientT();
expect(t.allGenerators).toBe("All generators");
});
it("returns Russian translations when lang is 'ru'", () => {
mockDocument.documentElement.lang = "ru";
const t: T = getClientT();
expect(t.allGenerators).toBe("Все генераторы");
});
});
+10
View File
@@ -0,0 +1,10 @@
import { useT, type T, type Lang } from "@/i18n/translations";
export function getClientLang(): Lang {
const lang = document.documentElement.lang;
return lang === "ru" ? "ru" : "en";
}
export function getClientT(): T {
return useT(getClientLang());
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { randomInt, randomFloat, randomBytes, shuffleArray } from "./random";
describe("randomInt", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns an integer within [min, max]", () => {
for (let i = 0; i < 100; i++) {
const result = randomInt(1, 6);
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(6);
expect(Number.isInteger(result)).toBe(true);
}
});
it("handles min === max", () => {
expect(randomInt(5, 5)).toBe(5);
});
it("handles negative ranges", () => {
for (let i = 0; i < 50; i++) {
const result = randomInt(-10, -5);
expect(result).toBeGreaterThanOrEqual(-10);
expect(result).toBeLessThanOrEqual(-5);
}
});
it("handles range crossing zero", () => {
for (let i = 0; i < 50; i++) {
const result = randomInt(-5, 5);
expect(result).toBeGreaterThanOrEqual(-5);
expect(result).toBeLessThanOrEqual(5);
}
});
it("throws when min > max", () => {
expect(() => randomInt(5, 1)).toThrow();
});
it("uses rejection sampling for uniform distribution", () => {
const spy = vi
.spyOn(globalThis.crypto, "getRandomValues")
.mockImplementation((buffer) => {
const arr = buffer as Uint32Array;
arr[0] = 0;
return buffer;
});
expect(randomInt(0, 10)).toBe(0);
spy.mockRestore();
});
});
describe("randomFloat", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a value in [0, 1)", () => {
for (let i = 0; i < 100; i++) {
const result = randomFloat();
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(1);
}
});
it("maps 0xFFFFFFFF to just under 1", () => {
const spy = vi
.spyOn(globalThis.crypto, "getRandomValues")
.mockImplementation((buffer) => {
const arr = buffer as Uint32Array;
arr[0] = 0xffffffff;
return buffer;
});
const result = randomFloat();
expect(result).toBeGreaterThan(0.999);
expect(result).toBeLessThan(1);
spy.mockRestore();
});
it("maps 0 to 0", () => {
const spy = vi
.spyOn(globalThis.crypto, "getRandomValues")
.mockImplementation((buffer) => {
const arr = buffer as Uint32Array;
arr[0] = 0;
return buffer;
});
expect(randomFloat()).toBe(0);
spy.mockRestore();
});
});
describe("randomBytes", () => {
it("returns a Uint8Array of the requested length", () => {
const result = randomBytes(16);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(16);
});
it("returns empty array for length 0", () => {
const result = randomBytes(0);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(0);
});
});
describe("shuffleArray", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a new array with the same elements", () => {
const original = [1, 2, 3, 4, 5];
const shuffled = shuffleArray(original);
expect(shuffled).toHaveLength(original.length);
expect([...shuffled].sort((a, b) => a - b)).toEqual(original);
expect(shuffled).not.toBe(original);
});
it("does not mutate the original array", () => {
const original = [1, 2, 3];
const copy = [...original];
shuffleArray(original);
expect(original).toEqual(copy);
});
it("handles empty arrays", () => {
const result = shuffleArray([]);
expect(result).toEqual([]);
});
it("handles single-element arrays", () => {
const result = shuffleArray([42]);
expect(result).toEqual([42]);
});
it("produces deterministic shuffle with mocked randomness", () => {
const spy = vi
.spyOn(globalThis.crypto, "getRandomValues")
.mockImplementation((buffer) => {
const arr = buffer as Uint32Array;
arr[0] = 0;
return buffer;
});
const original = [1, 2, 3];
const shuffled = shuffleArray(original);
expect(shuffled).toEqual([2, 3, 1]);
spy.mockRestore();
});
});
+38
View File
@@ -0,0 +1,38 @@
const MAX_UINT32_PLUS_ONE = 4294967296;
export function randomInt(min: number, max: number): number {
if (min > max) {
throw new Error("min must be less than or equal to max");
}
const range = max - min + 1;
const limit = MAX_UINT32_PLUS_ONE - (MAX_UINT32_PLUS_ONE % range);
const buf = new Uint32Array(1);
let value: number;
do {
crypto.getRandomValues(buf);
value = buf[0];
} while (value >= limit);
return min + (value % range);
}
export function randomFloat(): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] / MAX_UINT32_PLUS_ONE;
}
export function randomBytes(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
export function shuffleArray<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = randomInt(0, i);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}