Age checks are recorded in one table (age_check) by provider, method and result, never with documents or birth dates; one rule decides whether an IMVU account counts as adult (its person's check, or its own). Providers implement a small interface (start, webhook or browser completion, re-check) and are chosen by configuration, so phone wallets and Didit can be added, or swapped, as adapters. IMVU's answer per account is re-checked when it expires (about daily). Existing links' results move into the new table. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
339ba8e2d6
commit
7db9fc0370
11 files changed
+1602
-48
No files matched your search
@@ -0,0 +1,197 @@
|
||||
// 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";
|
||||
|
||||
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. */
|
||||
label: string;
|
||||
/** Whether it checks a person, or an IMVU account. */
|
||||
scope: "person" | "imvu_account";
|
||||
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" }>;
|
||||
/** 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 {}
|
||||
|
||||
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, 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. */
|
||||
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
|
||||
const p = this.provider(providerId);
|
||||
const started = await p.start(subject, { returnURL });
|
||||
if (started.kind === "done") {
|
||||
await this.record(subject, p.id, started.result);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Finishes a pending check (from a webhook or the browser). */
|
||||
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
|
||||
const done =
|
||||
result === "failed"
|
||||
? { status: "failed", overEighteen: false, checkedAt: new Date() }
|
||||
: {
|
||||
status: "complete",
|
||||
overEighteen: result.overEighteen,
|
||||
method: result.method,
|
||||
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]!;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user