Files
t5de-hub/apps/web
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
..

T5DE Hub website

The hub's website: accounts, linked IMVU accounts, who's online, signing T5DE in, and the moderation console. Next.js (App Router), Tailwind CSS and shadcn/ui. Pages are rendered on the server, which calls the hub's API (apps/api) with the visitor's cookie; forms use server actions. The browser only talks to this app: /api/* is rewritten to the API, so the API's session cookies belong to the website's origin.

Develop

From the repository root, bun install, then start the API (cd apps/api && bun src/index.ts) and the website:

cd apps/web
bun run dev          # http://localhost:4100
bun run check-types
bun run build

The first account created is the hub's admin.

Configuration

Variable Default What
API_URL http://localhost:4101 The API's address as this app reaches it. Needed at build time and at run time (below).
PORT 4100 The standalone server's port (next start and bun run dev use 4100).

The API's WEB_URL must be this website's public address (it's where Better Auth trusts requests from, and where T5DE's sign-in code page is).

API_URL is read in two places: by server code on every request, and by next.config.ts's /api rewrite, which Next fixes when it builds. So build with the same API_URL the app will run with.

Deploy

Container

The Dockerfile builds a standalone server (output: "standalone"). Build it from the repository root:

docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web

Next's own gzip is off (it held back the API's live event stream), so put a reverse proxy that compresses in front, and don't let it buffer text/event-stream responses (/api/v1/online/events).

Vercel

Import the repository with the project's root directory set to apps/web (Vercel installs the Bun workspace from the root). Set API_URL to the API's public address for both builds and runtime. Nothing else is needed: there's no custom server.