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
+146
-7
@@ -1,8 +1,10 @@
|
||||
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed
|
||||
// webhooks), and IMVU's answers changing.
|
||||
// Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
|
||||
// and Didit's adapter against a fake Didit), limits on checks, and IMVU's answers changing.
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { AgeService, type Provider } from "../src/age";
|
||||
import { eq, openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
|
||||
import { ageOn, diditAgeProvider } from "../src/age/didit";
|
||||
import { imvuAgeProvider } from "../src/age/imvu";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
@@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret";
|
||||
const fakeVendor: Provider = {
|
||||
id: "vendor",
|
||||
label: "ID document",
|
||||
vendor: "Vendor",
|
||||
scope: "person",
|
||||
start: async () => {
|
||||
const ref = `session-${crypto.randomUUID()}`;
|
||||
@@ -39,6 +42,45 @@ const fakeVendor: Provider = {
|
||||
},
|
||||
};
|
||||
|
||||
// Didit: sessions per person (an unfinished one is given again), and each session's decision.
|
||||
const diditSecret = "didit-secret";
|
||||
const diditSessions = new Map<string, { id: string; vendor: string; finished: boolean; decision?: unknown }>();
|
||||
const fakeDidit: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (new Headers(init?.headers).get("x-api-key") !== "didit-key") return new Response("no key", { status: 401 });
|
||||
if (init?.method === "POST" && url.pathname === "/v3/session/") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body.workflow_id).toBe("workflow");
|
||||
let s = [...diditSessions.values()].find((s) => s.vendor === body.vendor_data && !s.finished);
|
||||
if (!s) {
|
||||
s = { id: crypto.randomUUID(), vendor: body.vendor_data, finished: false };
|
||||
diditSessions.set(s.id, s);
|
||||
}
|
||||
return Response.json({ session_id: s.id, url: `https://verify.didit.test/session/${s.id}`, status: "Not Started" }, { status: 201 });
|
||||
}
|
||||
const m = /^\/v3\/session\/([^/]+)\/decision\/$/.exec(url.pathname);
|
||||
if (m && diditSessions.get(m[1]!)) return Response.json(diditSessions.get(m[1]!)!.decision ?? {});
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const didit = diditAgeProvider({ apiKey: "didit-key", workflowId: "workflow", webhookSecret: diditSecret, baseURL: "https://didit.test", fetch: fakeDidit });
|
||||
|
||||
/** A Didit webhook request, signed as Didit signs it (the raw body, or V2), or forged. */
|
||||
function diditHook(body: Record<string, unknown>, sign: "raw" | "v2" | "forged" = "raw", timestamp = Math.floor(Date.now() / 1000)) {
|
||||
const raw = JSON.stringify(body);
|
||||
const hmac = (s: string, key = diditSecret) => createHmac("sha256", key).update(s).digest("hex");
|
||||
const sortKeys = (v: unknown): unknown =>
|
||||
Array.isArray(v)
|
||||
? v.map(sortKeys)
|
||||
: v && typeof v === "object"
|
||||
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
|
||||
: v;
|
||||
const headers: Record<string, string> = { "X-Timestamp": String(timestamp), "Content-Type": "application/json" };
|
||||
if (sign === "raw") headers["X-Signature"] = hmac(raw);
|
||||
if (sign === "v2") headers["X-Signature-V2"] = hmac(JSON.stringify(sortKeys(body)));
|
||||
if (sign === "forged") headers["X-Signature"] = hmac(raw, "wrong");
|
||||
return fetch(`${base}/api/v1/age/webhook/didit`, { method: "POST", headers, body: raw });
|
||||
}
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
@@ -49,8 +91,8 @@ 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 });
|
||||
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
|
||||
const hub = createApp({ db, auth, imvu, age, webURL });
|
||||
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();
|
||||
@@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const userOf = async (name: string) => (await db.select().from(schema.user)).find((u) => u.username === name)!.id;
|
||||
const startedThisMonth = async () => {
|
||||
const now = new Date();
|
||||
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
return (await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.provider, "didit"))).filter((r) => r.createdAt >= month).length;
|
||||
};
|
||||
|
||||
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]));
|
||||
|
||||
@@ -102,7 +151,7 @@ test("IMVU's answer is per account, and re-checked when it expires", async () =>
|
||||
|
||||
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"]);
|
||||
expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor", "didit"]);
|
||||
// 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);
|
||||
|
||||
@@ -118,6 +167,96 @@ test("a person's check covers all their accounts; webhooks must be signed", asyn
|
||||
expect(await adultOf(b)).toEqual({ 10: true, 11: true });
|
||||
// A repeated webhook is harmless.
|
||||
expect((await hook(webhookSecret, true)).status).toBe(204);
|
||||
// Verified already: no more checks, with this provider or another.
|
||||
const again = await call(b, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
|
||||
expect(again).toMatchObject({ status: 409, json: { error: "refused" } });
|
||||
});
|
||||
|
||||
test("people are sent back only to the hub's website", async () => {
|
||||
const e = await personWithAccounts("eve", []);
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "https://evil.test/account" })).status).toBe(400);
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "//evil.test/account" })).status).toBe(400);
|
||||
// A path is on the website.
|
||||
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "/account?age=back" })).json.kind).toBe("redirect");
|
||||
});
|
||||
|
||||
test("Didit: signed webhooks, the age on the document, and answers changed later", async () => {
|
||||
const f = await personWithAccounts("fay", [{ id: 30 }]);
|
||||
const started = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account?age=didit` })).json;
|
||||
expect(started).toMatchObject({ kind: "redirect", url: `https://verify.didit.test/session/${started.ref}` });
|
||||
// Going back to an unfinished check gives the same session, and doesn't count as another.
|
||||
const resumed = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
expect(resumed.ref).toBe(started.ref);
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks).toHaveLength(1);
|
||||
|
||||
const event = (status: string, decision?: unknown) => ({
|
||||
event_id: crypto.randomUUID(),
|
||||
webhook_type: "status.updated",
|
||||
session_id: started.ref,
|
||||
status,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
...(decision ? { decision } : {}),
|
||||
});
|
||||
const approved = event("Approved", { id_verifications: [{ status: "Approved", age: 31, date_of_birth: "1995-01-01" }] });
|
||||
expect((await diditHook(approved, "forged")).status).toBe(401);
|
||||
expect((await diditHook(approved, "raw", Math.floor(Date.now() / 1000) - 3600)).status).toBe(401); // stale
|
||||
// In review: no answer yet.
|
||||
expect((await diditHook(event("In Review"))).status).toBe(204);
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("pending");
|
||||
expect(await adultOf(f)).toEqual({ 30: false });
|
||||
|
||||
expect((await diditHook(approved, "v2")).status).toBe(204);
|
||||
expect(await adultOf(f)).toEqual({ 30: true });
|
||||
const [row] = await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.providerRef, started.ref));
|
||||
expect(row).toMatchObject({ provider: "didit", method: "id_document", status: "complete", overEighteen: true, imvuId: null });
|
||||
|
||||
// A reviewer at Didit declines it afterwards: the answer is taken back.
|
||||
expect((await diditHook(event("Declined"))).status).toBe(204);
|
||||
expect(await adultOf(f)).toEqual({ 30: false });
|
||||
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("revoked");
|
||||
});
|
||||
|
||||
test("Didit: under 18, and a decision fetched when the webhook has none", async () => {
|
||||
const g = await personWithAccounts("gus", [{ id: 40 }]);
|
||||
const started = (await call(g, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
const session = diditSessions.get(started.ref)!;
|
||||
session.finished = true;
|
||||
session.decision = { id_verifications: [{ date_of_birth: `${new Date().getUTCFullYear() - 17}-12-31` }] };
|
||||
const body = { webhook_type: "status.updated", session_id: started.ref, status: "Approved", timestamp: 0 };
|
||||
expect((await diditHook(body)).status).toBe(204);
|
||||
expect(await adultOf(g)).toEqual({ 40: false });
|
||||
expect((await call(g, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ status: "complete", overEighteen: false });
|
||||
});
|
||||
|
||||
test("checks are limited per person and by a provider's monthly allowance", async () => {
|
||||
const h = await personWithAccounts("hal", []);
|
||||
// Each abandoned check is a new one.
|
||||
for (let i = 0; i < attemptsPerPerson; i++) {
|
||||
const s = (await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
|
||||
diditSessions.get(s.ref)!.finished = true;
|
||||
const body = { webhook_type: "status.updated", session_id: s.ref, status: "Abandoned", timestamp: 0 };
|
||||
expect((await diditHook(body)).status).toBe(204);
|
||||
}
|
||||
const limited = await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
|
||||
expect(limited).toMatchObject({ status: 409, json: { error: "refused" } });
|
||||
expect(limited.json.message).toContain("30 days");
|
||||
|
||||
// The month's allowance is everyone's (here a service with room for one more).
|
||||
const capped = new AgeService(db, [{ ...didit, monthlyLimit: (await startedThisMonth()) + 1 }]);
|
||||
await personWithAccounts("ivy", []);
|
||||
await personWithAccounts("joe", []);
|
||||
const ivy = await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`);
|
||||
await expect(capped.start({ userId: await userOf("joe") }, "didit", `${webURL}/account`)).rejects.toThrow("used up");
|
||||
// Ivy can still go back to hers.
|
||||
expect(await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`)).toEqual(ivy);
|
||||
});
|
||||
|
||||
test("birth dates to ages", () => {
|
||||
const day = new Date("2026-11-03T12:00:00Z");
|
||||
expect(ageOn("2008-11-03", day)).toBe(18);
|
||||
expect(ageOn("2008-11-04", day)).toBe(17);
|
||||
expect(ageOn("2008-12-01", day)).toBe(17);
|
||||
expect(ageOn("nonsense", day)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a moved account takes nobody's age with it", async () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's
|
||||
// approval); admins switch it in the console, and the environment gives the default.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient } from "../src/imvu";
|
||||
import { Settings } from "../src/settings";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
// Closed by default (as on staging), until an admin changes it.
|
||||
const settings = new Settings(db, { registration: "closed" });
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings });
|
||||
const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch);
|
||||
const hub = createApp({ db, auth, imvu, webURL, settings });
|
||||
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) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) },
|
||||
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 signUp(name: string) {
|
||||
const cookie = { v: "" };
|
||||
expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
test("closed: new accounts wait for approval; the first one runs the hub", async () => {
|
||||
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" });
|
||||
const admin = await signUp("ada");
|
||||
expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true });
|
||||
expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200);
|
||||
|
||||
const bo = await signUp("bo_");
|
||||
// Signed in, but nothing else.
|
||||
expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false });
|
||||
expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } });
|
||||
expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403);
|
||||
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
|
||||
|
||||
// Moderators see who's waiting, and approve them.
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1);
|
||||
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
|
||||
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
|
||||
expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200);
|
||||
expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]);
|
||||
// An approved account can't be declined.
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400);
|
||||
});
|
||||
|
||||
test("declining deletes the account", async () => {
|
||||
const admin = { v: "" };
|
||||
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
|
||||
const cy = await signUp("cyd");
|
||||
const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
|
||||
expect(p.username).toBe("cyd");
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200);
|
||||
expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401);
|
||||
expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401);
|
||||
});
|
||||
|
||||
test("only admins open or close registration", async () => {
|
||||
const admin = { v: "" };
|
||||
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
|
||||
const mod = await signUp("dot");
|
||||
const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0];
|
||||
await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`);
|
||||
await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" });
|
||||
expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" });
|
||||
expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403);
|
||||
|
||||
expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" });
|
||||
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" });
|
||||
// Open: new accounts can use the hub at once.
|
||||
const eve = await signUp("eve");
|
||||
expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200);
|
||||
const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action);
|
||||
expect(audit).toContain("hub.registration");
|
||||
});
|
||||
Reference in new issue
Block a user