feat: age verification with an ID document (Didit), and closed registration
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 58s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

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:
dhkatzandClaude Opus 5.5 committed 2026-11-03 04:15:47 -08:00
1 parent 86ebe976ba
commit c75a4c9afc
28 files changed
+3260 -89

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in
// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether
// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest.
//
// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session
// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AgeResult, Provider } from "./index";
export type DiditConfig = {
apiKey: string;
workflowId: string;
/** The webhook destination's secret_shared_key. */
webhookSecret: string;
/** Checks started a month at most (what the month's credit pays for). */
monthlyLimit?: number;
baseURL?: string;
fetch?: typeof fetch;
};
/** How far a webhook's X-Timestamp may be from now. */
const timestampTolerance = 300;
/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */
const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]);
type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] };
/** Age in whole years on a date, from an ISO birth date. */
export function ageOn(birth: string, today: Date): number | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth);
if (!m) return undefined;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
let age = today.getUTCFullYear() - y;
if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--;
return age;
}
/** The age on the session's ID document, or undefined if it has none. */
function documentAge(decision: Decision | undefined): number | undefined {
const id = decision?.id_verifications?.[0];
if (typeof id?.age === "number") return id.age;
return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined;
}
/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */
function canonical(v: unknown): unknown {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === "object")
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
return v;
}
function hmacMatches(secret: string, input: string, signature: string | null): boolean {
if (!signature) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex"));
const given = Buffer.from(signature.trim().toLowerCase());
return expected.length === given.length && timingSafeEqual(expected, given);
}
export function diditAgeProvider(config: DiditConfig): Provider {
const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, "");
const fetcher = config.fetch ?? fetch;
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetcher(base + path, {
method,
headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`);
return res.json();
};
return {
id: "didit",
label: "ID document and selfie",
vendor: "Didit",
scope: "person",
monthlyLimit: config.monthlyLimit,
async start(subject, { returnURL }) {
const s = (await call("POST", "/v3/session/", {
workflow_id: config.workflowId,
vendor_data: subject.userId,
callback: returnURL,
})) as { session_id: string; url: string };
return { kind: "redirect", url: s.url, ref: s.session_id };
},
async webhook(request) {
const raw = await request.text();
const timestamp = Number(request.headers.get("x-timestamp"));
if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook");
const body = JSON.parse(raw) as {
webhook_type?: string;
session_id?: string;
status?: string;
decision?: Decision;
};
// The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header
// during a rotation.
const v2 = JSON.stringify(canonical(body));
const signed = ["", "-previous"].some(
(suffix) =>
hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) ||
hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)),
);
if (!signed) throw new Error("bad signature");
if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null;
if (!body.session_id || !body.status) return null;
const ref = body.session_id;
if (failedStatuses.has(body.status)) return { ref, result: "failed" };
if (body.status !== "Approved") return null;
const decision =
body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision);
const age = documentAge(decision);
if (age === undefined) return { ref, result: "failed" };
const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref };
return { ref, result };
},
};
}
+1
View File
@@ -19,6 +19,7 @@ export function imvuAgeProvider(imvu: ImvuClient): Provider {
return {
id: "imvu",
label: "IMVU's age verification or Access Pass",
vendor: "IMVU",
scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check,
+80 -17
View File
@@ -1,7 +1,7 @@
// 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";
import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
const { ageCheck } = schema;
@@ -27,13 +27,19 @@ export type Started =
export type Provider = {
id: string;
/** Shown to people. */
/** Shown to people: the way of checking, and who does it. */
label: string;
vendor: string;
/** Whether it checks a person, or an IMVU account. */
scope: "person" | "imvu_account";
/** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */
monthlyLimit?: number;
start(subject: Subject, options: { returnURL: string }): Promise<Started>;
/** Finishes a check from the provider's webhook; throws if the request isn't really from the provider. */
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" }>;
/**
* A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws
* if the request isn't really from the provider.
*/
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>;
/** Finishes a check the browser completed (wallets): the browser's response for ref. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */
@@ -41,6 +47,12 @@ export type Provider = {
};
export class AgeError extends Error {}
/** A check that can't be started now; the message is for the person. */
export class AgeRefused extends Error {}
/** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */
export const attemptsPerPerson = 3;
const attemptWindow = 30 * 24 * 60 * 60 * 1000;
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
@@ -56,7 +68,7 @@ export class AgeService {
/** The providers in use, for the website. */
list() {
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope }));
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope }));
}
provider(id: string): Provider {
@@ -87,13 +99,25 @@ export class AgeService {
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. */
/**
* Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose
* check is unfinished goes back to it (providers give the same session again); new checks of a person are
* limited, per person and by the provider's monthly allowance.
*/
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
const p = this.provider(providerId);
const ofPerson = subject.imvuId === undefined;
const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined;
if (ofPerson) {
if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified.");
if (!pending) await this.checkLimits(subject.userId, p);
}
const started = await p.start(subject, { returnURL });
if (started.kind === "done") {
await this.record(subject, p.id, started.result);
} else {
} else if (pending?.providerRef !== started.ref) {
// An unfinished check the provider replaced with a new one (it expired there) won't be answered.
if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id));
await this.db.insert(ageCheck).values({
id: randomID(),
userId: subject.userId,
@@ -106,11 +130,55 @@ export class AgeService {
return started;
}
/** Finishes a pending check (from a webhook or the browser). */
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
private async pendingCheck(userId: string, provider: string) {
const [row] = await this.db
.select()
.from(ageCheck)
.where(
and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")),
);
return row;
}
private async personAdult(userId: string) {
const now = new Date();
return (await this.personChecks(userId)).some(
(c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now),
);
}
private async checkLimits(userId: string, p: Provider) {
const started = async (...where: Parameters<typeof and>) =>
(await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n;
const since = new Date(Date.now() - attemptWindow);
if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson)
throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`);
if (p.monthlyLimit !== undefined) {
const now = new Date();
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit)
throw new AgeRefused("This month's ID checks are used up. Try again next month.");
}
}
/**
* Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also
* one already answered, since the provider can change its answer (say, after a manual review). An adult answer
* taken back is revoked.
*/
async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) {
const [row] = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref)));
if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check");
const done =
result === "failed"
? { status: "failed", overEighteen: false, checkedAt: new Date() }
? {
status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed",
overEighteen: false,
checkedAt: new Date(),
}
: {
status: "complete",
overEighteen: result.overEighteen,
@@ -118,13 +186,8 @@ export class AgeService {
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]!;
const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
return updated!;
}
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
+118 -19
View File
@@ -19,7 +19,7 @@ import {
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age";
import { AgeError, AgeRefused, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
@@ -40,6 +40,7 @@ import {
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -52,6 +53,10 @@ export type Deps = {
rooms?: Rooms;
/** The hub's API URL, the issuer of its tokens. */
apiURL?: string;
/** The website's address: providers send people back only to it. */
webURL?: string;
/** The hub's settings (default: kept in the database, with no environment defaults). */
settings?: Settings;
};
/** An error shown to the person: the website displays message as is. */
@@ -102,8 +107,19 @@ class Limiter {
}
}
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) {
export function createApp({
db,
auth,
imvu,
realtime: rt,
age: ageService,
rooms: roomsService,
apiURL,
webURL,
settings: hubSettings,
}: Deps) {
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, imvu);
@@ -150,6 +166,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
createdAt: p.createdAt.toISOString(),
banned: p.banned === true,
banReason: p.banReason ?? "",
approved: p.approved,
});
/** A person's picture, for the website's header: their first linked IMVU account's avatar picture (null if none,
@@ -449,17 +466,33 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.derive(async ({ request }) => {
const s = await auth.api.getSession({ headers: request.headers });
const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined;
return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id };
// A person waiting for approval counts as signed out everywhere but their own account (/me).
const usable = person && !person.banned ? person : undefined;
return {
person: usable?.approved ? usable : undefined,
waiting: usable && !usable.approved ? usable : undefined,
sessionId: s?.session.id,
};
})
.macro({
/** Signed in, approved or still waiting. */
account: {
resolve: ({ person, waiting }) => {
const me = person ?? waiting;
if (!me) throw new Problem(401, "signed_out", "Sign in first.");
return { me };
},
},
signedIn: {
resolve: ({ person }) => {
resolve: ({ person, waiting }) => {
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person };
},
},
moderator: {
resolve: ({ person }) => {
resolve: ({ person, waiting }) => {
if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval.");
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
return { me: person };
@@ -468,7 +501,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
})
// Who's signed in, and the home page.
.get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { signedIn: true })
.get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true })
// The hub's public settings: whether new accounts wait for approval.
.get("/hub", async () => ({ registration: await settings.registration() }))
.get("/home", async ({ person }) => {
const online = (await visibleOnline(person)).length;
if (!person) return { onlineCount: online };
@@ -1283,7 +1318,23 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
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);
// A path on the website, or an address on it.
const web = new URL(webURL ?? "http://localhost:4100");
let back: URL | undefined;
try {
back = new URL(body.returnURL, web);
} catch {}
if (!back || back.origin !== web.origin)
throw new Problem(400, "invalid", "The return address must be on the hub's website.");
let started;
try {
started = await age.start({ userId: me.id }, p.id, back.href);
} catch (e) {
if (e instanceof AgeRefused) throw new Problem(409, "refused", e.message);
console.error(`age ${p.id}:`, e);
throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment.");
}
await audited(me.id, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref });
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 }) }) },
@@ -1317,13 +1368,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
let answer;
try {
answer = await p.webhook(request);
} catch {
} catch (e) {
// Not from the provider, or its answer couldn't be read: either way the provider tries again later.
console.error(`age webhook ${p.id}:`, e instanceof Error ? e.message : e);
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
if (answer) {
try {
await age.finish(p.id, answer.ref, answer.result, { anyStatus: true });
} catch (e) {
if (!(e instanceof AgeError)) throw e; // a check that isn't the hub's (another environment's)
}
}
set.status = 204;
},
@@ -1353,6 +1408,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const [counts] = await db
.select({
people: sql<number>`(select count(*) from ${user})::int`,
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`,
links: sql<number>`(select count(*) from ${imvuLink})::int`,
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
})
@@ -1381,18 +1437,61 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.select()
.from(user)
.where(
q
? or(
ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
)
: undefined,
and(
q
? or(
ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
)
: undefined,
query.waiting ? and(eq(user.approved, false), sql`coalesce(${user.banned}, false) = false`) : undefined,
),
)
.orderBy(desc(user.createdAt))
.limit(100);
return { people: rows.map(personView) };
},
{ moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) },
{
moderator: true,
query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })), waiting: t.Optional(t.BooleanString()) }),
},
)
// Accounts waiting for approval (registration closed): approve, or decline (the account is deleted).
.post(
"/console/people/:id/approve",
async ({ me, params }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning();
if (!p.approved) await audited(me.id, "person.approve", p.username ?? p.id);
return { person: personView(updated!) };
},
{ moderator: true },
)
.post(
"/console/people/:id/decline",
async ({ me, params }) => {
const p = await personById(params.id);
if (p.approved) throw new Problem(400, "invalid", "Only accounts waiting for approval can be declined.");
await db.delete(user).where(eq(user.id, p.id));
await audited(me.id, "person.decline", p.username ?? p.id);
return { declined: true };
},
{ moderator: true },
)
.get("/console/settings", async () => ({ registration: await settings.registration() }), { moderator: true })
.put(
"/console/settings",
async ({ me, body }) => {
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings.");
const from = await settings.registration();
await settings.setRegistration(body.registration);
if (from !== body.registration) await audited(me.id, "hub.registration", body.registration, { from });
return { registration: body.registration };
},
{
moderator: true,
body: t.Object({ registration: t.Union([t.Literal("open"), t.Literal("closed")]) }),
},
)
.get(
"/console/people/:id",
+9 -2
View File
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db";
import { Settings } from "./settings";
export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */
@@ -15,12 +16,15 @@ export type AuthConfig = {
secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean;
/** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */
settings?: Settings;
};
/** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) {
const settings = config.settings ?? new Settings(db);
return betterAuth({
baseURL: config.apiURL,
basePath: "/api/auth",
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
user: {
additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false },
approved: { type: "boolean", defaultValue: true, input: false },
},
},
// Proxies stay signed in for months; a session is extended when it's used.
@@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) {
databaseHooks: {
user: {
create: {
// The first account runs the hub.
// The first account runs the hub; with registration closed, the others wait for approval.
before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
const first = (row?.n ?? 0) === 0;
const approved = first || (await settings.registration()) === "open";
return { data: { ...u, role: first ? "admin" : "user", approved } };
},
},
},
+43 -2
View File
@@ -4,9 +4,19 @@
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
// WEB_URL the website's address (default http://localhost:4100)
// PORT default 4101
// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console
// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit
// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET
// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit
// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a
// check with ID document, liveness and face match costs $0.30)
import { openDatabase } from "@t5de/db";
import { AgeService, type Provider } from "./age";
import { diditAgeProvider } from "./age/didit";
import { imvuAgeProvider } from "./age/imvu";
import { createApp } from "./app";
import { createAuth } from "./auth";
import { Settings } from "./settings";
import { ImvuClient } from "./imvu";
const env = process.env;
@@ -21,8 +31,39 @@ const apiURL = env.API_URL ?? `http://localhost:${port}`;
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, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
const settings = new Settings(db, { registration: env.REGISTRATION });
const auth = createAuth(db, { apiURL, webURL, secret, settings });
const imvu = new ImvuClient();
function required(name: string): string {
const v = env[name];
if (!v) {
console.error(`${name} must be set`);
process.exit(1);
}
return v;
}
const ageProviders: Record<string, () => Provider> = {
imvu: () => imvuAgeProvider(imvu),
didit: () =>
diditAgeProvider({
apiKey: required("DIDIT_API_KEY"),
workflowId: required("DIDIT_WORKFLOW_ID"),
webhookSecret: required("DIDIT_WEBHOOK_SECRET"),
monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30),
}),
};
const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean);
if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer
for (const id of providers) {
if (!ageProviders[id]) {
console.error(`AGE_PROVIDERS: no provider ${id}`);
process.exit(1);
}
}
const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!()));
const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings });
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; revisions missing their
+29
View File
@@ -0,0 +1,29 @@
// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its
// default from the environment (so a new staging hub can start with registration closed).
import { eq, schema, type Database } from "@t5de/db";
const { hubSetting } = schema;
/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */
export type Registration = "open" | "closed";
export const registrations: readonly Registration[] = ["open", "closed"];
export class Settings {
constructor(
private readonly db: Database,
private readonly defaults: { registration?: string } = {},
) {}
async registration(): Promise<Registration> {
const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration"));
const value = row?.value ?? this.defaults.registration;
return value === "closed" ? "closed" : "open";
}
async setRegistration(value: Registration) {
await this.db
.insert(hubSetting)
.values({ key: "registration", value })
.onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } });
}
}
+146 -7
View File
@@ -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 () => {
+104
View File
@@ -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");
});