diff --git a/apps/api/src/age/imvu.ts b/apps/api/src/age/imvu.ts new file mode 100644 index 0000000..33238b3 --- /dev/null +++ b/apps/api/src/age/imvu.ts @@ -0,0 +1,35 @@ +// Age from IMVU itself: an IMVU account that IMVU has age verified, or that has Access Pass, counts as adult. Read +// from IMVU's public API; IMVU can change it (verification revoked, Access Pass lapsed), so it's checked again +// about daily. +import type { ImvuClient } from "../imvu"; +import type { AgeResult, Provider, Subject } from "./index"; + +const recheckAfter = 24 * 60 * 60 * 1000; + +export function imvuAgeProvider(imvu: ImvuClient): Provider { + const check = async (subject: Subject): Promise => { + if (subject.imvuId === undefined) throw new Error("IMVU checks an IMVU account"); + const u = await imvu.byId(subject.imvuId); + return { + overEighteen: u.ageVerified || u.accessPass, + method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none", + expiresAt: new Date(Date.now() + recheckAfter), + }; + }; + return { + id: "imvu", + label: "IMVU's age verification or Access Pass", + scope: "imvu_account", + start: async (subject) => ({ kind: "done", result: await check(subject) }), + recheck: check, + }; +} + +/** The IMVU result from a lookup already made (when linking). */ +export function imvuResult(u: { ageVerified: boolean; accessPass: boolean }): AgeResult { + return { + overEighteen: u.ageVerified || u.accessPass, + method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none", + expiresAt: new Date(Date.now() + recheckAfter), + }; +} diff --git a/apps/api/src/age/index.ts b/apps/api/src/age/index.ts new file mode 100644 index 0000000..58d4687 --- /dev/null +++ b/apps/api/src/age/index.ts @@ -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; + /** 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; + /** Checks a subject again, for answers that can change. */ + recheck?(subject: Subject): Promise; +}; + +export class AgeError extends Error {} + +const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + +export class AgeService { + private providers = new Map(); + + 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 { + 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> { + 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(); + 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 { + 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; + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 39fa594..9c2859c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 = {}) => 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(); + 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["app"]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1a117d2..6c53759 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,6 +22,11 @@ const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const auth = createAuth(db, { apiURL, webURL, secret }); -const { app } = createApp({ db, auth, imvu: new ImvuClient() }); +const { app, age } = createApp({ db, auth, imvu: new ImvuClient() }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); + +// Age answers that can change (IMVU's) are checked again when they expire, a few at a time. +setInterval(() => { + age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); +}, 10 * 60 * 1000); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); diff --git a/apps/api/test/age.test.ts b/apps/api/test/age.test.ts new file mode 100644 index 0000000..89eeffa --- /dev/null +++ b/apps/api/test/age.test.ts @@ -0,0 +1,136 @@ +// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed +// webhooks), and IMVU's answers changing. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { openDatabase, schema, type Database } from "@t5de/db"; +import { AgeService, type Provider } from "../src/age"; +import { imvuAgeProvider } from "../src/age/imvu"; +import { createApp } from "../src/app"; +import { createAuth } from "../src/auth"; +import { ImvuClient, type ImvuUser } from "../src/imvu"; + +const webURL = "http://web.test"; +const accounts = new Map(); +const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const u = [...accounts.values()].find( + (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), + ); + return Response.json({ + denormalized: u + ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } + : {}, + }); +}) as typeof fetch; + +// A document-check vendor: sends the person to its page, then reports by a webhook signed with a shared secret. +const webhookSecret = "vendor-secret"; +const fakeVendor: Provider = { + id: "vendor", + label: "ID document", + scope: "person", + start: async () => { + const ref = `session-${crypto.randomUUID()}`; + return { kind: "redirect", url: `https://vendor.test/check/${ref}`, ref }; + }, + webhook: async (request) => { + if (request.headers.get("x-signature") !== webhookSecret) throw new Error("bad signature"); + const body = (await request.json()) as { session: string; approved: boolean; over18: boolean }; + return { ref: body.session, result: body.approved ? { overEighteen: body.over18, method: "id_document" } : "failed" }; + }, +}; + +let db: Database; +let close: () => Promise; +let base: string; +let age: AgeService; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); + const imvu = new ImvuClient("http://imvu.test", fakeIMVU); + age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]); + const hub = createApp({ db, auth, imvu, age }); + hub.app.listen({ port: 0, hostname: "127.0.0.1" }); + const server = hub.app.server as unknown as { port: number; stop: () => void }; + stop = () => server.stop(); + base = `http://127.0.0.1:${server.port}`; +}); + +afterAll(async () => { + stop(); + await close(); +}); + +async function call(cookie: { v: string }, method: string, path: string, body?: unknown, headers: Record = {}) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}), ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const set = res.headers.getSetCookie(); + if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; "); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : undefined }; +} + +async function personWithAccounts(name: string, ids: { id: number; ageVerified?: boolean; accessPass?: boolean }[]) { + const cookie = { v: "" }; + await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name }); + for (const a of ids) { + accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass }); + const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` }); + accounts.get(a.id)!.tagline = started.json.challenge.code; + expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); + } + return cookie; +} + +const adultOf = async (cookie: { v: string }) => + Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult])); + +test("IMVU's answer is per account, and re-checked when it expires", async () => { + const a = await personWithAccounts("ann", [{ id: 1, ageVerified: true }, { id: 2 }, { id: 3, accessPass: true }]); + expect(await adultOf(a)).toEqual({ 1: true, 2: false, 3: true }); + + // IMVU revokes account 1's verification; once its answer expires, the re-check sees it. + accounts.get(1)!.ageVerified = false; + await db.update(schema.ageCheck).set({ expiresAt: new Date(Date.now() - 1000) }); + expect(await age.recheckDue()).toBeGreaterThanOrEqual(3); + expect(await adultOf(a)).toEqual({ 1: false, 2: false, 3: true }); +}); + +test("a person's check covers all their accounts; webhooks must be signed", async () => { + const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]); + expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor"]); + // IMVU checks accounts, not people: it can't be started this way. + expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400); + + const started = (await call(b, "POST", "/api/v1/age/vendor/start", { returnURL: `${webURL}/account` })).json; + expect(started.kind).toBe("redirect"); + expect((await call(b, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ provider: "vendor", status: "pending" }); + + const hook = (signature: string, approved: boolean) => + call({ v: "" }, "POST", "/api/v1/age/webhook/vendor", { session: started.ref, approved, over18: true }, { "x-signature": signature }); + expect((await hook("forged", true)).status).toBe(401); + expect(await adultOf(b)).toEqual({ 10: false, 11: false }); + expect((await hook(webhookSecret, true)).status).toBe(204); + expect(await adultOf(b)).toEqual({ 10: true, 11: true }); + // A repeated webhook is harmless. + expect((await hook(webhookSecret, true)).status).toBe(204); +}); + +test("a moved account takes nobody's age with it", async () => { + const c = await personWithAccounts("cal", [{ id: 20, ageVerified: true }]); + expect(await adultOf(c)).toEqual({ 20: true }); + // Dee proves the account is hers; IMVU now says it isn't verified (say, the verification was the old owner's). + accounts.get(20)!.ageVerified = false; + const d = { v: "" }; + await call(d, "POST", "/api/auth/sign-up/email", { email: "dee@example.test", password: "a long password", name: "dee", username: "dee" }); + const started = await call(d, "POST", "/api/v1/account/links", { username: "cal20" }); + accounts.get(20)!.tagline = started.json.challenge.code; + expect((await call(d, "POST", "/api/v1/account/links/20/check")).json.moved).toBe(true); + expect(await adultOf(d)).toEqual({ 20: false }); + const leftovers = await db.select().from(schema.ageCheck); + expect(leftovers.filter((c) => c.imvuId === 20)).toHaveLength(1); +}); diff --git a/docs/api.md b/docs/api.md index 0443908..f2c3872 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,6 +48,18 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `DELETE /api/v1/account/links/:imvu` | unlink | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | +## Age verification + +Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again; +person-level providers (phone wallets, document checks) are started here. + +| | | +| --- | --- | +| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks | +| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) | +| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | +| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature | + ## Console (moderators; roles: admins) | | | diff --git a/docs/design.md b/docs/design.md index 687a8f6..64aefaf 100644 --- a/docs/design.md +++ b/docs/design.md @@ -101,16 +101,43 @@ age, not verified) and `tagline`. offer to link. Linked to another hub account: no community features that session. - **Bans** apply to the person, so all linked accounts. -## Age (**Decided**: IMVU's verification) +## Age (**Decided**: free, several providers, vendor-neutral) -- Adult content requires the **IMVU account in use** to be age verified (`is_ageverified`) or to have Access Pass - (`is_ap`; **Decided**: it counts), checked by the hub - itself (never reported by the proxy, which anyone can change): at linking and at most daily after, cached. - Revocations show up at the next check. -- Per IMVU account, not per person, so one verified account doesn't unlock others a minor might use. +No payment for verification (adult content rules out mainstream merchants of record; see the discussion of +2026-10-29). Several ways to prove age, tried in order of privacy, behind one interface so any can be swapped: + +1. **IMVU** (live): the IMVU account is age verified (`is_ageverified`) or has Access Pass (`is_ap`), read by the hub + from IMVU's public API. Scope: that IMVU account. Re-checked about daily; a revocation shows at the next check. + Self-reported `is_adult` doesn't count. +2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet, + asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or + birth date reach it. Scope: the person. +3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person + (the liveness step stops someone using another person's ID). + +### How it's built + +- **One table of results**, `age_check`: subject (a person, or one of their linked IMVU accounts), provider, + method (e.g. `imvu_age_verified`, `imvu_access_pass`, `wallet_mdoc`, `id_document`), status (`pending`, + `complete`, `failed`, `revoked`), over 18 yes/no, the provider's reference, when it was checked and when it + expires. Never a birth date, document, photo or name: providers that see those keep them. +- **One rule**: an IMVU account counts as adult if its person has a current complete check that says over 18, or + the account itself has one. Everything (website, tokens for catalogs, room channels) asks this one function. +- **Providers** implement a small interface: `start` (begin a check: done at once, a page to send the person to, + or a request for the browser to hand to a wallet), `complete` (finish from the provider's callback or signed + webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is + configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is + one new adapter; nothing else changes. +- Webhooks arrive at `/api/v1/age/webhook/` and each provider checks its own signature. - Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room - channels for viewers who aren't verified and opted in), and the viewer's proxy. -- Self-reported `is_adult` doesn't count. + channels for viewers who aren't adult and opted in), and the viewer's proxy. + +### To add the planned providers + +- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning + only "over 18". Confirm its free monthly allowance covers age checks. +- Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP, + mdoc); Apple and the EU wallet through the same browser API where supported. ## Products and catalogs diff --git a/packages/db/drizzle/0001_age_checks.sql b/packages/db/drizzle/0001_age_checks.sql new file mode 100644 index 0000000..1d557a0 --- /dev/null +++ b/packages/db/drizzle/0001_age_checks.sql @@ -0,0 +1,27 @@ +CREATE TABLE "age_check" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "imvu_id" bigint, + "provider" text NOT NULL, + "method" text DEFAULT '' NOT NULL, + "status" text NOT NULL, + "over_eighteen" boolean DEFAULT false NOT NULL, + "provider_ref" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "checked_at" timestamp with time zone, + "expires_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "age_check" ADD CONSTRAINT "age_check_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "age_check_user" ON "age_check" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "age_check_imvu" ON "age_check" USING btree ("imvu_id");--> statement-breakpoint +CREATE UNIQUE INDEX "age_check_provider_ref" ON "age_check" USING btree ("provider","provider_ref");--> statement-breakpoint +-- Existing links' IMVU results become IMVU age checks (expiring at once, so they're checked again soon). +INSERT INTO "age_check" ("id", "user_id", "imvu_id", "provider", "method", "status", "over_eighteen", "checked_at", "expires_at") +SELECT md5(random()::text || "imvu_id"::text), "user_id", "imvu_id", 'imvu', + CASE WHEN "age_verified" THEN 'imvu_age_verified' WHEN "access_pass" THEN 'imvu_access_pass' ELSE 'imvu_none' END, + 'complete', "age_verified" OR "access_pass", "checked_at", now() +FROM "imvu_link";--> statement-breakpoint +ALTER TABLE "imvu_link" DROP COLUMN "age_verified";--> statement-breakpoint +ALTER TABLE "imvu_link" DROP COLUMN "access_pass";--> statement-breakpoint +ALTER TABLE "imvu_link" DROP COLUMN "checked_at"; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4a63322 --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,999 @@ +{ + "id": "6311568f-0471-47a9-8199-229d365d3dc4", + "prevId": "47a7aacf-4e5f-4181-9ac1-0100ba416d0c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ae7ce4f..85bd4c1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1793273462704, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1793277687785, + "tag": "0001_age_checks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index eb5ef82..db362ec 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -137,15 +137,39 @@ export const imvuLink = pgTable( pairwiseId: text("pairwise_id").notNull().unique(), username: text("username").notNull(), linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(), - ageVerified: boolean("age_verified").notNull().default(false), - accessPass: boolean("access_pass").notNull().default(false), - checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(), showBadge: boolean("show_badge").notNull().default(false), showPresence: boolean("show_presence").notNull().default(false), }, (t) => [index("imvu_link_user").on(t.userId)], ); +// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a +// birth date, document, photo or name (providers that see those keep them). +export const ageCheck = pgTable( + "age_check", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + // Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person. + imvuId: bigint("imvu_id", { mode: "number" }), + provider: text("provider").notNull(), // "imvu", "didit", "wallet", ... + method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc" + status: text("status").notNull(), // "pending", "complete", "failed", "revoked" + overEighteen: boolean("over_eighteen").notNull().default(false), + providerRef: text("provider_ref"), // the provider's session or transaction ID + createdAt: createdAt(), + checkedAt: timestamp("checked_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + }, + (t) => [ + index("age_check_user").on(t.userId), + index("age_check_imvu").on(t.imvuId), + uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef), + ], +); + // A code the person is to put in an IMVU account's tagline, proving it's theirs. export const linkChallenge = pgTable( "link_challenge",