Files
t5de-hub/apps/web/components/status.tsx
T
dhkatz d5cd2be5d0
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m19s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 7s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 20s
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]>
2026-11-04 11:37:21 +00:00

83 lines
2.7 KiB
TypeScript

// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned.
import type { Link } from "@/lib/api";
import { cn } from "@/lib/utils";
const tones = {
neutral: "border-border bg-muted/60 text-muted-foreground",
strong: "border-foreground/15 bg-foreground/5 text-foreground",
online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400",
danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400",
};
export function Tag({
tone = "neutral",
dot = false,
className,
children,
}: {
tone?: keyof typeof tones;
dot?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
tones[tone],
className,
)}
>
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
<span>{children}</span>
</span>
);
}
/** A round initial standing in for an avatar picture. */
export function Initial({ name }: { name: string }) {
return (
<span
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
aria-hidden
>
{name.slice(0, 1)}
</span>
);
}
/** The community products an account wears. */
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Wearing</div>
<ul className="flex flex-wrap gap-1.5">
{items.map((item) => (
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
{item.name}
</li>
))}
</ul>
</div>
);
}
/** A page's title and the line under it. */
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="mb-8 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
{children && <div className="text-muted-foreground">{children}</div>}
</div>
);
}
/** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>;
}