feat: age verification with an ID document (Didit), and closed registration (#4)
## Age verification with Didit A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date. - Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit. - A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending. - Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30). - People are only ever sent back to the hub's website. - The Account page gets an Age Verification card. - Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`. ## Closed registration Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`. - When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account). - The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`. - The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting. - The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`. ## Testing - New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration. - `bun run test`: 35 pass. `bun run check-types` passes. - Checked locally: the account, waiting, sign-up and console pages render. - Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Age verification with Didit A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date. - Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit. - A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending. - Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30). - People are only ever sent back to the hub's website. - The Account page gets an Age Verification card. - Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`. ## Closed registration Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`. - When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account). - The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`. - The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting. - The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`. ## Testing - New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration. - `bun run test`: 35 pass. `bun run check-types` passes. - Checked locally: the account, waiting, sign-up and console pages render. - Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #4.
This commit is contained in:
1 parent
86ebe976ba
commit
d5cd2be5d0
28 files changed
+3260
-89
No files matched your search
+118
-19
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user