Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 1 | /** |
| 2 | * Email subscribers: double opt-in and one-click leaving. Only hashes are | |
| 3 | * kept. A confirmation token is random and stored as its SHA-256; an | |
| 4 | * unsubscribe link is the subscriber's id signed with STATUS_SECRET, so it | |
| 5 | * is never stored at all and a copy of the database cannot unsubscribe | |
| 6 | * anyone. Web Crypto only, so it is tested under Node. | |
| 7 | */ | |
| 8 | ||
| 9 | /** How long a confirmation link works. */ | |
| 10 | export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000; | |
| 11 | /** No second confirmation email to one address sooner than this. */ | |
| 12 | export const RESEND_AFTER_MS = 10 * 60 * 1000; | |
| 13 | ||
| 14 | const EMAIL = /^[^\s@<>"(),;:]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,}$/; | |
| 15 | ||
| 16 | /** An address as kept: trimmed and lowercased; null when it is not one. */ | |
| 17 | export function normalizeEmail(raw: unknown): string | null { | |
| 18 | const email = typeof raw === "string" ? raw.trim().toLowerCase() : ""; | |
| 19 | return email.length <= 254 && EMAIL.test(email) ? email : null; | |
| 20 | } | |
| 21 | ||
| 22 | function base64url(bytes: Uint8Array): string { | |
| 23 | let text = ""; | |
| 24 | for (const b of bytes) text += String.fromCharCode(b); | |
| 25 | return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 26 | } | |
| 27 | ||
| 28 | /** A new random token, as it goes in a link. */ | |
| 29 | export function newToken(): string { | |
| 30 | return base64url(crypto.getRandomValues(new Uint8Array(32))); | |
| 31 | } | |
| 32 | ||
| 33 | /** What is kept of a token: its SHA-256, hex. */ | |
| 34 | export async function hashToken(token: string): Promise<string> { | |
| 35 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); | |
| 36 | return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 37 | } | |
| 38 | ||
| 39 | async function hmac(secret: string, message: string): Promise<string> { | |
| 40 | const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 41 | return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)))); | |
| 42 | } | |
| 43 | ||
| 44 | /** The token in a subscriber's unsubscribe link: `<id>.<signature>`. */ | |
| 45 | export async function unsubscribeToken(secret: string, id: string): Promise<string> { | |
| 46 | return `${id}.${await hmac(secret, `unsubscribe:${id}`)}`; | |
| 47 | } | |
| 48 | ||
| 49 | /** The subscriber an unsubscribe token is for, or null when it is not genuine. */ | |
| 50 | export async function readUnsubscribeToken(secret: string, token: string): Promise<string | null> { | |
| 51 | const dot = token.lastIndexOf("."); | |
| 52 | if (dot <= 0) return null; | |
| 53 | const id = token.slice(0, dot); | |
| 54 | const expected = await unsubscribeToken(secret, id); | |
| 55 | if (expected.length !== token.length) return null; | |
| 56 | let diff = 0; | |
| 57 | for (let i = 0; i < token.length; i++) diff |= expected.charCodeAt(i) ^ token.charCodeAt(i); | |
| 58 | return diff === 0 ? id : null; | |
| 59 | } | |
| 60 | ||
| 61 | /** A subscriber's parts: null for everything. */ | |
| 62 | export function parseParts(json: string | null): string[] | null { | |
| 63 | if (!json) return null; | |
| 64 | try { | |
| 65 | const value = JSON.parse(json); | |
| 66 | return Array.isArray(value) && value.length ? value.map(String) : null; | |
| 67 | } catch { | |
| 68 | return null; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /** Whether a subscriber wants news about these parts. */ | |
| 73 | export function wants(parts: string[] | null, about: string[]): boolean { | |
| 74 | return parts == null || about.length === 0 || about.some((key) => parts.includes(key)); | |
| 75 | } | |
| 76 | ||
| 77 | /** The parts chosen in the form, known ones only; null (everything) when none or all are. */ | |
| 78 | export function chosenParts(raw: string[], known: string[]): string[] | null { | |
| 79 | const parts = [...new Set(raw.filter((key) => known.includes(key)))]; | |
| 80 | return parts.length === 0 || parts.length === known.length ? null : parts; | |
| 81 | } |