feat: age verification with an ID document (Didit), and closed registration
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

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-03 04:15:47 -08:00
1 parent 86ebe976ba
commit c75a4c9afc
28 files changed
+3260 -89

No files matched your search

+80 -17
View File
@@ -1,7 +1,7 @@
// 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, eq, gt, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
const { ageCheck } = schema;
@@ -27,13 +27,19 @@ export type Started =
export type Provider = {
id: string;
/** Shown to people. */
/** 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>;
/** Finishes a check from the provider's webhook; throws if the request isn't really from the provider. */
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" }>;
/**
* 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. */
@@ -41,6 +47,12 @@ export type Provider = {
};
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");
@@ -56,7 +68,7 @@ export class AgeService {
/** The providers in use, for the website. */
list() {
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope }));
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope }));
}
provider(id: string): Provider {
@@ -87,13 +99,25 @@ export class AgeService {
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. */
/**
* 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 {
} 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,
@@ -106,11 +130,55 @@ export class AgeService {
return started;
}
/** Finishes a pending check (from a webhook or the browser). */
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
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: "failed", overEighteen: false, checkedAt: new Date() }
? {
status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed",
overEighteen: false,
checkedAt: new Date(),
}
: {
status: "complete",
overEighteen: result.overEighteen,
@@ -118,13 +186,8 @@ export class AgeService {
checkedAt: new Date(),
expiresAt: result.expiresAt ?? null,
};
const rows = await this.db
.update(ageCheck)
.set(done)
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref), eq(ageCheck.status, "pending")))
.returning();
if (rows.length === 0) throw new AgeError("no such pending check");
return rows[0]!;
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). */