// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface, // results go in one table (age_check), and one rule says whether an IMVU account counts as adult. // See docs/design.md, "Age". import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db"; const { ageCheck } = schema; /** What's checked: a person (all their IMVU accounts), or one of their linked IMVU accounts. */ export type Subject = { userId: string; imvuId?: number }; /** A provider's answer. Only the outcome: never a birth date, document, photo or name. */ export type AgeResult = { overEighteen: boolean; /** How it was established, e.g. "imvu_age_verified", "id_document", "wallet_mdoc". */ method: string; /** The provider's session or transaction ID, if it has one. */ ref?: string; /** When the answer should be checked again (providers whose answer can change); none: it stands. */ expiresAt?: Date; }; /** How a check begins. */ export type Started = | { kind: "done"; result: AgeResult } // answered at once (IMVU) | { kind: "redirect"; url: string; ref: string } // send the person to the provider's page (document checks) | { kind: "browser"; request: unknown; ref: string }; // hand a request to the browser (Digital Credentials API) export type Provider = { id: string; /** Shown to people: the way of checking, and who does it. */ label: string; vendor: string; /** Whether it checks a person, or an IMVU account. */ scope: "person" | "imvu_account"; /** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */ monthlyLimit?: number; start(subject: Subject, options: { returnURL: string }): Promise; /** * A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws * if the request isn't really from the provider. */ webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>; /** Finishes a check the browser completed (wallets): the browser's response for ref. */ complete?(ref: string, response: unknown): Promise; /** Checks a subject again, for answers that can change. */ recheck?(subject: Subject): Promise; }; export class AgeError extends Error {} /** A check that can't be started now; the message is for the person. */ export class AgeRefused extends Error {} /** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */ export const attemptsPerPerson = 3; const attemptWindow = 30 * 24 * 60 * 60 * 1000; const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url"); export class AgeService { private providers = new Map(); constructor( private readonly db: Database, providers: Provider[], ) { for (const p of providers) this.providers.set(p.id, p); } /** The providers in use, for the website. */ list() { return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope })); } provider(id: string): Provider { const p = this.providers.get(id); if (!p) throw new AgeError(`no age provider ${id}`); return p; } /** Records a finished check. An IMVU account's check replaces that provider's earlier check of it. */ async record(subject: Subject, provider: string, result: AgeResult) { const now = new Date(); const values = { userId: subject.userId, imvuId: subject.imvuId ?? null, provider, method: result.method, status: "complete", overEighteen: result.overEighteen, providerRef: result.ref ?? null, checkedAt: now, expiresAt: result.expiresAt ?? null, }; if (subject.imvuId !== undefined) { await this.db .delete(ageCheck) .where(and(eq(ageCheck.provider, provider), eq(ageCheck.imvuId, subject.imvuId))); } await this.db.insert(ageCheck).values({ id: randomID(), ...values }); } /** * Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose * check is unfinished goes back to it (providers give the same session again); new checks of a person are * limited, per person and by the provider's monthly allowance. */ async start(subject: Subject, providerId: string, returnURL: string): Promise { const p = this.provider(providerId); const ofPerson = subject.imvuId === undefined; const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined; if (ofPerson) { if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified."); if (!pending) await this.checkLimits(subject.userId, p); } const started = await p.start(subject, { returnURL }); if (started.kind === "done") { await this.record(subject, p.id, started.result); } else if (pending?.providerRef !== started.ref) { // An unfinished check the provider replaced with a new one (it expired there) won't be answered. if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id)); await this.db.insert(ageCheck).values({ id: randomID(), userId: subject.userId, imvuId: subject.imvuId ?? null, provider: p.id, status: "pending", providerRef: started.ref, }); } return started; } private async pendingCheck(userId: string, provider: string) { const [row] = await this.db .select() .from(ageCheck) .where( and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")), ); return row; } private async personAdult(userId: string) { const now = new Date(); return (await this.personChecks(userId)).some( (c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now), ); } private async checkLimits(userId: string, p: Provider) { const started = async (...where: Parameters) => (await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n; const since = new Date(Date.now() - attemptWindow); if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson) throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`); if (p.monthlyLimit !== undefined) { const now = new Date(); const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit) throw new AgeRefused("This month's ID checks are used up. Try again next month."); } } /** * Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also * one already answered, since the provider can change its answer (say, after a manual review). An adult answer * taken back is revoked. */ async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) { const [row] = await this.db .select() .from(ageCheck) .where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref))); if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check"); const done = result === "failed" ? { status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed", overEighteen: false, checkedAt: new Date(), } : { status: "complete", overEighteen: result.overEighteen, method: result.method, checkedAt: new Date(), expiresAt: result.expiresAt ?? null, }; const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning(); return updated!; } /** Removes an IMVU account's checks (it was unlinked or moved to another person). */ async forgetAccount(imvuId: number) { await this.db.delete(ageCheck).where(eq(ageCheck.imvuId, imvuId)); } /** * The one rule: which of a person's IMVU accounts count as adult. An account does if the person has a current * complete check saying over 18, or the account itself has one. */ async adultAccounts(userId: string, imvuIds: number[]): Promise> { const now = new Date(); const checks = await this.db .select({ imvuId: ageCheck.imvuId }) .from(ageCheck) .where( and( eq(ageCheck.userId, userId), eq(ageCheck.status, "complete"), eq(ageCheck.overEighteen, true), or(isNull(ageCheck.expiresAt), gt(ageCheck.expiresAt, now)), imvuIds.length ? or(isNull(ageCheck.imvuId), inArray(ageCheck.imvuId, imvuIds)) : isNull(ageCheck.imvuId), ), ); if (checks.some((c) => c.imvuId === null)) return new Set(imvuIds); return new Set(checks.map((c) => c.imvuId!).filter((id) => imvuIds.includes(id))); } /** The latest complete check of each of these IMVU accounts by a provider (for showing how they stand). */ async accountChecks(provider: string, imvuIds: number[]) { if (imvuIds.length === 0) return new Map(); const rows = await this.db .select() .from(ageCheck) .where(and(eq(ageCheck.provider, provider), eq(ageCheck.status, "complete"), inArray(ageCheck.imvuId, imvuIds))); return new Map(rows.map((r) => [r.imvuId!, r])); } /** The person's own checks (not of an IMVU account), newest first. */ async personChecks(userId: string) { const rows = await this.db .select() .from(ageCheck) .where(and(eq(ageCheck.userId, userId), isNull(ageCheck.imvuId))); return rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } /** Checks again the IMVU-account checks that expired (at most limit), with providers that can re-check. */ async recheckDue(limit = 50): Promise { const due = await this.db .select() .from(ageCheck) .where(and(eq(ageCheck.status, "complete"), lte(ageCheck.expiresAt, new Date()))) .limit(limit); let n = 0; for (const c of due) { const p = this.providers.get(c.provider); if (!p?.recheck) continue; const subject = { userId: c.userId, imvuId: c.imvuId ?? undefined }; try { await this.record(subject, p.id, await p.recheck(subject)); n++; } catch { // Try again next time; the old answer stays expired (not adult) meanwhile. } } return n; } }