## 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]>
78 lines
3.0 KiB
TypeScript
78 lines
3.0 KiB
TypeScript
"use server";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { api, unwrap } from "@/lib/api";
|
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
|
|
|
const roles = ["user", "moderator", "admin"] as const;
|
|
|
|
/** Runs a change to a person from the console, then shows their page again. */
|
|
async function onPerson(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
|
|
const id = field(form, "id");
|
|
return attempt(async () => {
|
|
const notice = await change(id);
|
|
revalidatePath(`/console/people/${id}`);
|
|
return notice;
|
|
});
|
|
}
|
|
|
|
export async function setLinkCap(_: ActionState, form: FormData): Promise<ActionState> {
|
|
return onPerson(form, async (id) => {
|
|
const linkCap = Number(field(form, "linkCap"));
|
|
const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap }));
|
|
return `Link cap set to ${person.linkCap}.`;
|
|
});
|
|
}
|
|
|
|
export async function ban(_: ActionState, form: FormData): Promise<ActionState> {
|
|
return onPerson(form, async (id) => {
|
|
unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") }));
|
|
});
|
|
}
|
|
|
|
export async function unban(_: ActionState, form: FormData): Promise<ActionState> {
|
|
return onPerson(form, async (id) => {
|
|
unwrap(await api.console.people({ id }).unban.post());
|
|
});
|
|
}
|
|
|
|
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
|
|
return onPerson(form, async (id) => {
|
|
const role = roles.find((r) => r === field(form, "role")) ?? "user";
|
|
const { person } = unwrap(await api.console.people({ id }).role.post({ role }));
|
|
return `Role set to ${person.role}.`;
|
|
});
|
|
}
|
|
|
|
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
|
|
const id = field(form, "id");
|
|
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
|
|
return attempt(async () => {
|
|
unwrap(await api.console.catalogs({ id })({ decision }).post());
|
|
revalidatePath("/console/catalogs");
|
|
revalidatePath("/catalogs");
|
|
});
|
|
}
|
|
|
|
/** An account waiting for approval: approve it, or decline it (deleting it). */
|
|
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
|
|
const id = field(form, "id");
|
|
const decline = field(form, "decision") === "decline";
|
|
const state = await attempt(async () => {
|
|
if (decline) unwrap(await api.console.people({ id }).decline.post());
|
|
else unwrap(await api.console.people({ id }).approve.post());
|
|
revalidatePath("/console", "layout");
|
|
});
|
|
if (decline && !state?.error) redirect("/console/people?waiting=true");
|
|
return state;
|
|
}
|
|
|
|
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
|
|
return attempt(async () => {
|
|
const registration = field(form, "registration") === "closed" ? "closed" : "open";
|
|
unwrap(await api.console.settings.put({ registration }));
|
|
revalidatePath("/console");
|
|
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
|
|
});
|
|
}
|