Age: a Didit provider behind the vendor-neutral age interface. A person verifies once (ID document, passive liveness, face match) and all their linked IMVU accounts count as adult. Webhooks are checked against Didit's signatures; only "over 18" is kept. A later answer replaces an earlier one (an approval taken back is revoked). Checks are limited: one unfinished check per person, 3 in 30 days, none once verified, and a monthly cap (DIDIT_MONTHLY_LIMIT, default 30: Didit's free plan is $10 of credit a month and a check costs $0.30). The Account page gets an Age Verification card. Registration: open or closed, an admin's setting in the console (default from REGISTRATION). Closed: new accounts can only sign in until a moderator approves them (or declines, deleting the account); the website shows them a waiting page and the API treats them as signed out. Co-Authored-By: Claude Opus 5.5 <[email protected]>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
// 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<Started>;
|
|
/**
|
|
* 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<AgeResult | "failed">;
|
|
/** Checks a subject again, for answers that can change. */
|
|
recheck?(subject: Subject): Promise<AgeResult>;
|
|
};
|
|
|
|
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<string, Provider>();
|
|
|
|
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<Started> {
|
|
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<typeof and>) =>
|
|
(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<Set<number>> {
|
|
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<number, typeof ageCheck.$inferSelect>();
|
|
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<number> {
|
|
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;
|
|
}
|
|
}
|