Vendor-neutral age verification, with IMVU as the first provider
CI / test (push) Successful in 35s

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:
dhkatzandClaude Opus 5.5 committed 2026-10-29 05:44:36 -07:00
1 parent 339ba8e2d6
commit 7db9fc0370
11 files changed
+1602 -48

No files matched your search

+121 -36
View File
@@ -4,12 +4,14 @@
import { Elysia, t } from "elysia";
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime };
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService };
/** An error shown to the person: the website displays message as is. */
export class Problem extends Error {
@@ -59,7 +61,8 @@ class Limiter {
}
}
export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const realtime =
rt ??
new Realtime(async (imvuId) => {
@@ -79,22 +82,31 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
banReason: p.banReason ?? "",
});
const linkView = (l: Link) => {
const live = realtime.online().find((o) => o.imvuId === l.imvuId);
return {
imvuId: l.imvuId,
username: l.username,
linkedAt: l.linkedAt.toISOString(),
ageVerified: l.ageVerified,
accessPass: l.accessPass,
adult: l.ageVerified || l.accessPass,
checkedAt: l.checkedAt.toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
/** Links as the website shows them: with live presence, and age by the one rule. */
const linkViews = async (userId: string, links: Link[]) => {
const ids = links.map((l) => l.imvuId);
const adult = await age.adultAccounts(userId, ids);
const imvuChecks = await age.accountChecks("imvu", ids);
const online = realtime.online();
return links.map((l) => {
const live = online.find((o) => o.imvuId === l.imvuId);
const check = imvuChecks.get(l.imvuId);
return {
imvuId: l.imvuId,
username: l.username,
linkedAt: l.linkedAt.toISOString(),
ageVerified: check?.method === "imvu_age_verified",
accessPass: check?.method === "imvu_access_pass",
adult: adult.has(l.imvuId),
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
});
};
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
db.insert(audit).values({ actorId, action, target, detail });
@@ -105,6 +117,10 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
if (online.length === 0) return [];
const links = await db.select().from(imvuLink).where(inArray(imvuLink.imvuId, online.map((o) => o.imvuId)));
const mod = viewer !== undefined && isModerator(viewer);
const adult = new Set<number>();
if (mod) {
for (const l of links) if ((await age.adultAccounts(l.userId, [l.imvuId])).has(l.imvuId)) adult.add(l.imvuId);
}
return online.flatMap((o) => {
const l = links.find((l) => l.imvuId === o.imvuId);
if (!l || (!l.showPresence && !mod)) return [];
@@ -115,7 +131,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
badge: l.showBadge,
since: o.since.toISOString(),
outfit: o.outfit,
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: l.ageVerified || l.accessPass } : {}),
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: adult.has(o.imvuId) } : {}),
},
];
});
@@ -249,7 +265,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
.where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return {
person: personView(me),
links: links.map(linkView),
links: await linkViews(me.id, links),
challenges: challenges.map((c) => ({
imvuId: c.imvuId,
username: c.username,
@@ -322,13 +338,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
throw new Problem(403, "link_cap", `You can link up to ${me.linkCap} IMVU accounts. Ask a moderator if you need more.`);
const [prev] = await tx.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
const moved = prev !== undefined && prev.userId !== me.id;
const values = {
userId: me.id,
username: found.username,
ageVerified: found.ageVerified,
accessPass: found.accessPass,
checkedAt: new Date(),
};
const values = { userId: me.id, username: found.username };
let link: Link;
if (!prev) {
[link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link];
@@ -352,9 +362,12 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
});
return { link, moved, previousOwner: prev?.userId };
});
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
if (result.moved) await age.forgetAccount(found.id);
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
realtime.linked(me.id, found.id);
return { link: linkView(result.link), moved: result.moved };
return { link: await linkView(result.link), moved: result.moved };
},
{ signedIn: true, params: imvuIdParam },
)
@@ -378,7 +391,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
})
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: linkView(l!) };
return { link: await linkView(l!) };
},
{
signedIn: true,
@@ -391,12 +404,9 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
async ({ me, params }) => {
await ownLink(me, params.imvu);
const found = await imvuCall(me, () => imvu.byId(params.imvu));
const [l] = await db
.update(imvuLink)
.set({ username: found.username, ageVerified: found.ageVerified, accessPass: found.accessPass, checkedAt: new Date() })
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: linkView(l!) };
await age.record({ userId: me.id, imvuId: params.imvu }, "imvu", imvuResult(found));
const [l] = await db.update(imvuLink).set({ username: found.username }).where(eq(imvuLink.imvuId, params.imvu)).returning();
return { link: await linkView(l!) };
},
{ signedIn: true, params: imvuIdParam },
)
@@ -405,6 +415,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
async ({ me, params, set }) => {
const l = await ownLink(me, params.imvu);
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
await age.forgetAccount(l.imvuId);
realtime.unlinked(me.id, l.imvuId);
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
set.status = 204;
@@ -439,6 +450,80 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
)
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
.get(
"/age",
async ({ me }) => ({
providers: age.list(),
checks: (await age.personChecks(me.id)).map((c) => ({
provider: c.provider,
method: c.method,
status: c.status,
overEighteen: c.overEighteen,
createdAt: c.createdAt.toISOString(),
checkedAt: c.checkedAt?.toISOString() ?? null,
expiresAt: c.expiresAt?.toISOString() ?? null,
})),
}),
{ signedIn: true },
)
.post(
"/age/:provider/start",
async ({ me, params, body }) => {
let p;
try {
p = age.provider(params.provider);
} catch {
throw new Problem(404, "not_found", "That way of verifying age isn't available.");
}
if (p.scope !== "person") throw new Problem(400, "invalid", "That one checks an IMVU account: link it instead.");
const started = await age.start({ userId: me.id }, p.id, body.returnURL);
return started.kind === "done" ? { kind: "done" as const } : started;
},
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) },
)
.post(
"/age/:provider/complete",
async ({ params, body }) => {
const p = age.provider(params.provider);
if (!p.complete) throw new Problem(404, "not_found", "Not found.");
try {
const c = await age.finish(p.id, body.ref, await p.complete(body.ref, body.response));
return { status: c.status, overEighteen: c.overEighteen };
} catch (e) {
if (e instanceof AgeError) throw new Problem(404, "not_found", "That check isn't waiting for an answer.");
throw e;
}
},
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ ref: t.String(), response: t.Unknown() }) },
)
// Providers' webhooks: each checks the request is really from it.
.post(
"/age/webhook/:provider",
async ({ params, request, set }) => {
let p;
try {
p = age.provider(params.provider);
} catch {
throw new Problem(404, "not_found", "Not found.");
}
if (!p.webhook) throw new Problem(404, "not_found", "Not found.");
let answer;
try {
answer = await p.webhook(request);
} catch {
throw new Problem(401, "forbidden", "Not from the provider.");
}
try {
await age.finish(p.id, answer.ref, answer.result);
} catch (e) {
if (!(e instanceof AgeError)) throw e; // already finished: webhooks can repeat
}
set.status = 204;
},
{ params: t.Object({ provider: t.String() }), parse: "none" },
)
// The console.
.get(
"/console",
@@ -496,7 +581,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
.select({ n: count() })
.from(session)
.where(and(eq(session.userId, p.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return { person: personView(p), links: links.map(linkView), computers: computers?.n ?? 0 };
return { person: personView(p), links: await linkViews(p.id, links), computers: computers?.n ?? 0 };
},
{ moderator: true },
)
@@ -578,7 +663,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime };
return { app, realtime, age };
}
export type App = ReturnType<typeof createApp>["app"];