// Normalise what people type into the sign-in box before it reaches the OAuth client. The client's // identity resolver only lowercases and then validates strictly, so "@name.bsky.social", a bare // "name", a pasted profile URL or an email address all failed with a raw English // `Invalid handle "..."` error. Pure, so it is unit-tested (handle-input.test.ts). export type HandleInput = { ok: true; value: string } | { ok: false; reason: 'empty' | 'email' }; const PROFILE_URL = /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/?#]+)/i; export function normalizeHandleInput(raw: string): HandleInput { // Handles never contain whitespace; mobile keyboards and pastes add spaces, NBSP and zero-width chars. let s = raw.replace(/[\s\u00a0\u200b-\u200d\ufeff]/g, ''); if (!s) return { ok: false, reason: 'empty' }; const profile = s.match(PROFILE_URL); if (profile) s = decodeURIComponent(profile[1]); // Any other URL is a PDS / entryway URL, which the OAuth client accepts as-is. else if (/^https?:\/\//i.test(s)) return { ok: true, value: s }; s = s.replace(/^at:\/\//i, ''); // DIDs are case-sensitive identifiers; pass them through untouched. if (/^did:/i.test(s)) return { ok: true, value: s }; s = s.replace(/^@+/, ''); if (s.includes('@')) return { ok: false, reason: 'email' }; s = s.toLowerCase().replace(/\.+$/, ''); if (!s) return { ok: false, reason: 'empty' }; // A bare name is almost always a bsky.social account. if (!s.includes('.')) s = `${s}.bsky.social`; return { ok: true, value: s }; } /** Classify an OAuth sign-in error for a friendly message: identity resolution failed (typo, * handle that doesn't exist, broken custom-domain handle), or anything else. */ export function loginErrorKind(err: unknown): 'handle' | 'generic' { const msg = String((err as { message?: unknown })?.message ?? err); return /Invalid handle|does not resolve to a DID|does not include the handle|resolveHandle|atproto-did|DoH/i.test( msg, ) ? 'handle' : 'generic'; }