/** * Day 5 — controlled beta access gate. * * This is a SOFT, frontend-only gate so the builder can share DocDoe with a * small, selected group of SSLC beta students. It is intentionally NOT strong * security: it is a shared access code checked entirely in the browser, with * no backend, no account system, and no payment. Do not describe this as * "secure authentication" anywhere in the UI or docs. * * Privacy: only a beta code, an optional nickname, class, board and language * preference are stored — all on this device. No email, phone, full name, * exact school, location, or parent contact is ever asked for or stored. */ export type BetaAccessState = { hasAccess: boolean; accessCodeLabel?: string; grantedAt?: string; studentNickname?: string; classLevel?: string; board?: string; languagePreference?: string; }; export type BetaAccessProfile = { studentNickname?: string; classLevel?: string; board?: string; languagePreference?: string; }; export type BetaAccessGrantResult = | { ok: true; state: BetaAccessState } | { ok: false; error: "invalid_code" | "storage_unavailable" }; export const BETA_ACCESS_STORAGE_KEY = "docdoe:beta-access"; /** * Small local allowlist for this beta. Easy to update: add/remove strings. * Codes are matched case-insensitively after trimming. */ export const BETA_ACCESS_CODES = ["DOCDOE-BETA", "SSLC-PHYSICS", "ASSESS-CLASS10"]; export const BETA_ACCESS_DEFAULTS = { classLevel: "Class 10", board: "Kerala SSLC", languagePreference: "English", }; function hasStorage(): boolean { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function normalizeCode(code: string): string { return code.trim().toUpperCase(); } /** True if the code matches an active beta allowlist entry (case-insensitive). */ export function validateBetaAccessCode(code: string): boolean { const normalized = normalizeCode(code ?? ""); if (!normalized) return false; return BETA_ACCESS_CODES.some((allowed) => normalizeCode(allowed) === normalized); } function isAccessStateLike(value: unknown): value is BetaAccessState { return Boolean(value) && typeof value === "object" && typeof (value as Record).hasAccess === "boolean"; } /** Load access state. Corrupt/absent storage yields "no access", never throws. */ export function loadBetaAccess(): BetaAccessState { if (!hasStorage()) return { hasAccess: false }; try { const raw = window.localStorage.getItem(BETA_ACCESS_STORAGE_KEY); if (!raw) return { hasAccess: false }; const parsed = JSON.parse(raw); if (!isAccessStateLike(parsed)) return { hasAccess: false }; return parsed; } catch { return { hasAccess: false }; } } export function saveBetaAccess(state: BetaAccessState): void { if (!hasStorage()) return; try { window.localStorage.setItem(BETA_ACCESS_STORAGE_KEY, JSON.stringify(state)); } catch { // Best-effort only; the gate simply re-prompts on the next load. } } export function clearBetaAccess(): void { if (!hasStorage()) return; try { window.localStorage.removeItem(BETA_ACCESS_STORAGE_KEY); } catch { // ignore } } export function hasBetaAccess(): boolean { return loadBetaAccess().hasAccess; } /** * Validate a code and, if active, grant + persist access with the given * (fully optional, non-identifying) profile fields. Returns a friendly error * on failure without explaining exactly why the code was rejected. */ export function grantBetaAccess(code: string, profile: BetaAccessProfile = {}): BetaAccessGrantResult { if (!validateBetaAccessCode(code)) { return { ok: false, error: "invalid_code" }; } if (!hasStorage()) { return { ok: false, error: "storage_unavailable" }; } const state: BetaAccessState = { hasAccess: true, accessCodeLabel: normalizeCode(code), grantedAt: new Date().toISOString(), studentNickname: profile.studentNickname?.trim() || undefined, classLevel: profile.classLevel?.trim() || BETA_ACCESS_DEFAULTS.classLevel, board: profile.board?.trim() || BETA_ACCESS_DEFAULTS.board, languagePreference: profile.languagePreference?.trim() || BETA_ACCESS_DEFAULTS.languagePreference, }; saveBetaAccess(state); return { ok: true, state }; }