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]>
This commit is contained in:
1 parent
86ebe976ba
commit
c75a4c9afc
28 files changed
+3260
-89
No files matched your search
+80
-17
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user