DocDoeAI / src /lib /beta /betaAccess.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
4.3 kB
/**
* 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<string, unknown>).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 };
}