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
+121
-36
@@ -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"];
|
||||
Reference in new issue
Block a user