Files
t5de-hub/apps/api/src/age/index.ts
T
dhkatzandClaude Opus 5.5 c75a4c9afc
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 58s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: age verification with an ID document (Didit), and closed registration
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]>
2026-11-03 04:15:47 -08:00

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;
}
}