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
+3247 -76

No files matched your search

+5 -2
View File
@@ -8,11 +8,14 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
## What works ## What works
- **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is - **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
an admin; roles user, moderator, admin. an admin; roles user, moderator, admin. Registration open or closed (new accounts wait for a moderator's
approval): an admin's setting in the console, defaulting to the API's `REGISTRATION`.
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public - **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
(default 10). (default 10).
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub. - **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub, or
if its person verified their age with an ID document and a selfie (Didit, when configured: `AGE_PROVIDERS`, see
`apps/api/src/index.ts` and docs/design.md, "Age").
- **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the - **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
+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 { return {
id: "imvu", id: "imvu",
label: "IMVU's age verification or Access Pass", label: "IMVU's age verification or Access Pass",
vendor: "IMVU",
scope: "imvu_account", scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }), start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check, 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, // 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. // results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
// See docs/design.md, "Age". // 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; const { ageCheck } = schema;
@@ -27,13 +27,19 @@ export type Started =
export type Provider = { export type Provider = {
id: string; id: string;
/** Shown to people. */ /** Shown to people: the way of checking, and who does it. */
label: string; label: string;
vendor: string;
/** Whether it checks a person, or an IMVU account. */ /** Whether it checks a person, or an IMVU account. */
scope: "person" | "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>; 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. */ /** Finishes a check the browser completed (wallets): the browser's response for ref. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">; complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */ /** Checks a subject again, for answers that can change. */
@@ -41,6 +47,12 @@ export type Provider = {
}; };
export class AgeError extends Error {} 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"); 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. */ /** The providers in use, for the website. */
list() { 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 { provider(id: string): Provider {
@@ -87,13 +99,25 @@ export class AgeService {
await this.db.insert(ageCheck).values({ id: randomID(), ...values }); 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> { async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
const p = this.provider(providerId); 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 }); const started = await p.start(subject, { returnURL });
if (started.kind === "done") { if (started.kind === "done") {
await this.record(subject, p.id, started.result); 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({ await this.db.insert(ageCheck).values({
id: randomID(), id: randomID(),
userId: subject.userId, userId: subject.userId,
@@ -106,11 +130,55 @@ export class AgeService {
return started; return started;
} }
/** Finishes a pending check (from a webhook or the browser). */ private async pendingCheck(userId: string, provider: string) {
async finish(providerId: string, ref: string, result: AgeResult | "failed") { 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 = const done =
result === "failed" 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", status: "complete",
overEighteen: result.overEighteen, overEighteen: result.overEighteen,
@@ -118,13 +186,8 @@ export class AgeService {
checkedAt: new Date(), checkedAt: new Date(),
expiresAt: result.expiresAt ?? null, expiresAt: result.expiresAt ?? null,
}; };
const rows = await this.db const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
.update(ageCheck) return updated!;
.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]!;
} }
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */ /** Removes an IMVU account's checks (it was unlinked or moved to another person). */
+110 -11
View File
@@ -19,7 +19,7 @@ import {
type RoomModerator, type RoomModerator,
} from "@t5de/db"; } from "@t5de/db";
import type { Auth } from "./auth"; import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age"; import { AgeError, AgeRefused, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu"; import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime"; import { Realtime } from "./realtime";
@@ -40,6 +40,7 @@ import {
type Viewer, type Viewer,
} from "./hosted"; } from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
const { user, session, imvuLink, linkChallenge, audit } = schema; const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -52,6 +53,10 @@ export type Deps = {
rooms?: Rooms; rooms?: Rooms;
/** The hub's API URL, the issuer of its tokens. */ /** The hub's API URL, the issuer of its tokens. */
apiURL?: string; 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. */ /** 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 age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
const settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, imvu); const products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, 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(), createdAt: p.createdAt.toISOString(),
banned: p.banned === true, banned: p.banned === true,
banReason: p.banReason ?? "", 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, /** 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 }) => { .derive(async ({ request }) => {
const s = await auth.api.getSession({ headers: request.headers }); 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; 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({ .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: { 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."); if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person }; return { me: person };
}, },
}, },
moderator: { 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 (!person) throw new Problem(401, "signed_out", "Sign in first.");
if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this."); if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
return { me: person }; 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. // 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 }) => { .get("/home", async ({ person }) => {
const online = (await visibleOnline(person)).length; const online = (await visibleOnline(person)).length;
if (!person) return { onlineCount: online }; 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."); 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."); 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; 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 }) }) }, { 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; let answer;
try { try {
answer = await p.webhook(request); 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."); throw new Problem(401, "forbidden", "Not from the provider.");
} }
if (answer) {
try { try {
await age.finish(p.id, answer.ref, answer.result); await age.finish(p.id, answer.ref, answer.result, { anyStatus: true });
} catch (e) { } catch (e) {
if (!(e instanceof AgeError)) throw e; // already finished: webhooks can repeat if (!(e instanceof AgeError)) throw e; // a check that isn't the hub's (another environment's)
}
} }
set.status = 204; set.status = 204;
}, },
@@ -1353,6 +1408,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const [counts] = await db const [counts] = await db
.select({ .select({
people: sql<number>`(select count(*) from ${user})::int`, 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`, 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`, 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() .select()
.from(user) .from(user)
.where( .where(
and(
q q
? or( ? or(
ilike(user.username, like), ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))), inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
) )
: undefined, : undefined,
query.waiting ? and(eq(user.approved, false), sql`coalesce(${user.banned}, false) = false`) : undefined,
),
) )
.orderBy(desc(user.createdAt)) .orderBy(desc(user.createdAt))
.limit(100); .limit(100);
return { people: rows.map(personView) }; 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( .get(
"/console/people/:id", "/console/people/:id",
+9 -2
View File
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins"; import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db"; import { count, schema, type Database } from "@t5de/db";
import { Settings } from "./settings";
export type AuthConfig = { export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */ /** The API's own address, e.g. https://hub-api.t5de.dev. */
@@ -15,12 +16,15 @@ export type AuthConfig = {
secret: string; secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */ /** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean; 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. */ /** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy"; export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) { export function createAuth(db: Database, config: AuthConfig) {
const settings = config.settings ?? new Settings(db);
return betterAuth({ return betterAuth({
baseURL: config.apiURL, baseURL: config.apiURL,
basePath: "/api/auth", basePath: "/api/auth",
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
user: { user: {
additionalFields: { additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false }, 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. // 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: { databaseHooks: {
user: { user: {
create: { create: {
// The first account runs the hub. // The first account runs the hub; with registration closed, the others wait for approval.
before: async (u) => { before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user); 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) // 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) // WEB_URL the website's address (default http://localhost:4100)
// PORT default 4101 // 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 { 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 { createApp } from "./app";
import { createAuth } from "./auth"; import { createAuth } from "./auth";
import { Settings } from "./settings";
import { ImvuClient } from "./imvu"; import { ImvuClient } from "./imvu";
const env = process.env; 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 webURL = env.WEB_URL ?? "http://localhost:4100";
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret }); const settings = new Settings(db, { registration: env.REGISTRATION });
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); 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" }); 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 // 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 // Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
// webhooks), and IMVU's answers changing. // 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 { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, schema, type Database } from "@t5de/db"; import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, type Provider } from "../src/age"; import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
import { ageOn, diditAgeProvider } from "../src/age/didit";
import { imvuAgeProvider } from "../src/age/imvu"; import { imvuAgeProvider } from "../src/age/imvu";
import { createApp } from "../src/app"; import { createApp } from "../src/app";
import { createAuth } from "../src/auth"; import { createAuth } from "../src/auth";
@@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret";
const fakeVendor: Provider = { const fakeVendor: Provider = {
id: "vendor", id: "vendor",
label: "ID document", label: "ID document",
vendor: "Vendor",
scope: "person", scope: "person",
start: async () => { start: async () => {
const ref = `session-${crypto.randomUUID()}`; 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 db: Database;
let close: () => Promise<void>; let close: () => Promise<void>;
let base: string; let base: string;
@@ -49,8 +91,8 @@ beforeAll(async () => {
({ db, close } = await openDatabase("memory")); ({ 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 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); const imvu = new ImvuClient("http://imvu.test", fakeIMVU);
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]); age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
const hub = createApp({ db, auth, imvu, age }); const hub = createApp({ db, auth, imvu, age, webURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" }); hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void }; const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop(); stop = () => server.stop();
@@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
return cookie; 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 }) => 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])); 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 () => { test("a person's check covers all their accounts; webhooks must be signed", async () => {
const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]); 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. // 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); 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 }); expect(await adultOf(b)).toEqual({ 10: true, 11: true });
// A repeated webhook is harmless. // A repeated webhook is harmless.
expect((await hook(webhookSecret, true)).status).toBe(204); 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 () => { 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");
});
+13
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
@@ -58,3 +59,15 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
return "Signed out everywhere."; return "Signed out everywhere.";
}); });
} }
/** Starts an age check with a provider that checks the person, and sends them to its page. */
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
let url: string | undefined;
const state = await attempt(async () => {
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
if (started.kind === "redirect") url = started.url;
done();
});
if (url) redirect(url);
return state;
}
+68
View File
@@ -0,0 +1,68 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AgeInfo } from "@/lib/api";
import { startAgeCheck } from "./actions";
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
const ways = age.providers.filter((p) => p.scope === "person");
if (ways.length === 0) return null;
const now = Date.now();
const verified = age.checks.find(
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
);
const latest = age.checks[0];
const pending = latest?.status === "pending" ? latest : undefined;
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
return (
<Card>
<CardHeader>
<CardTitle>Age Verification</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{verified ? (
<p className="flex flex-wrap items-center gap-2">
<Tag tone="adult">Age verified</Tag>
<span className="text-muted-foreground">
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
IMVU accounts count as adult.
</span>
</p>
) : (
<>
{pending ? (
<p>
{back
? "Thanks. Your check is being processed; this page shows the result when it's done."
: "Your check isn't finished yet."}
</p>
) : latest?.status === "complete" && !latest.overEighteen ? (
<p>Your last check found you're under 18.</p>
) : latest?.status === "failed" || latest?.status === "revoked" ? (
<p>Your last check didn&apos;t pass. You can try again.</p>
) : (
<p className="text-muted-foreground">
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
</p>
)}
{ways.map((p) => (
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
<div className="space-y-2">
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
</SubmitButton>
<p className="text-xs text-muted-foreground">
Done by {p.vendor}. T5DE only keeps whether you&apos;re over 18, never your ID, photo or birth date.
</p>
</div>
</ActionForm>
))}
</>
)}
</CardContent>
</Card>
);
}
+9 -5
View File
@@ -8,17 +8,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api"; import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions"; import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card"; import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card"; import { LinkCard } from "./link-card";
export const metadata: Metadata = { title: "Account" }; export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) { export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
const me = await getMe(); const me = await getMe();
if (!me) redirect("/signin?next=/account"); if (!me) redirect("/signin?next=/account");
const account = unwrap(await api.account.get()); const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]);
const { person, links, challenges, computers } = account; const { person, links, challenges, computers } = account;
const welcome = (await searchParams).welcome !== undefined; const params = await searchParams;
const welcome = params.welcome !== undefined;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,12 +80,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
</p> </p>
)} )}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Other people can&apos;t see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass) Other people can&apos;t see which IMVU accounts are yours. Adult IMVU accounts can see adult community
can see adult community products; each account is checked on its own. products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<AgeCard age={age} back={params.age === "back"} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>T5DE on your computers</CardTitle> <CardTitle>T5DE on your computers</CardTitle>
+23
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
@@ -52,3 +53,25 @@ export async function decideCatalog(_: ActionState, form: FormData): Promise<Act
revalidatePath("/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.";
});
}
+41 -4
View File
@@ -1,19 +1,24 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list"; import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status"; import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time"; import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type AuditEntry } from "@/lib/api"; import { api, unwrap, type AuditEntry } from "@/lib/api";
import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav"; import { ConsoleNav, requireModerator } from "./console-nav";
export const metadata: Metadata = { title: "Console" }; export const metadata: Metadata = { title: "Console" };
export default async function Console() { export default async function Console() {
await requireModerator("/console"); const me = await requireModerator("/console");
const data = unwrap(await api.console.get()); const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
const stats = [ const stats = [
{ label: "People", value: data.counts.people, note: "accounts" }, { label: "People", value: data.counts.people, note: "accounts" },
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" }, { label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" }, { label: "Computers", value: data.counts.computers, note: "signed in" },
]; ];
@@ -23,18 +28,50 @@ export default async function Console() {
<PageHeader title="Console">Moderation.</PageHeader> <PageHeader title="Console">Moderation.</PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => ( {stats.map((s) => (
<Card key={s.label}> <Card key={s.label}>
<CardHeader> <CardHeader>
<CardDescription>{s.label}</CardDescription> <CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent> <CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card> </Card>
))} ))}
</div> </div>
<Card>
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
{me.role === "admin" && (
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="closed">Closed</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
)}
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Online now</CardTitle> <CardTitle>Online now</CardTitle>
+31 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, ApiError, unwrap } from "@/lib/api"; import { api, ApiError, unwrap } from "@/lib/api";
import { ban, setLinkCap, setRole, unban } from "../../actions"; import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions";
import { ConsoleNav, requireModerator } from "../../console-nav"; import { ConsoleNav, requireModerator } from "../../console-nav";
export const metadata: Metadata = { title: "Person" }; export const metadata: Metadata = { title: "Person" };
@@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str
joined <DateTime iso={person.createdAt} dateOnly /> joined <DateTime iso={person.createdAt} dateOnly />
</span> </span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>} {person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
</span> </span>
</PageHeader> </PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
{!person.approved && (
<Card>
<CardHeader>
<CardTitle>Waiting for Approval</CardTitle>
<CardDescription>
{person.email}. Until approved, they can only sign in. Declining deletes the account.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decideAccount} fields={{ id: person.id }}>
<div className="flex gap-2">
<SubmitButton name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
<SubmitButton
name="decision"
value="decline"
variant="outline"
pendingLabel="Declining…"
confirm={`Decline ${person.username}? Their account is deleted.`}
>
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>IMVU accounts</CardTitle> <CardTitle>IMVU accounts</CardTitle>
+25 -8
View File
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" }; export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { export default async function People({
const raw = (await searchParams).q; searchParams,
const q = typeof raw === "string" ? raw.slice(0, 64) : ""; }: {
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people"); searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} })); }) {
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.slice(0, 64) : "";
const waiting = params.waiting === "true";
const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString();
await requireModerator(`/console/people${query ? `?${query}` : ""}`);
const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } }));
return ( return (
<> <>
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<ConsoleNav /> <ConsoleNav />
<Card> <Card>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<form action="/console/people" className="flex max-w-md gap-2"> <div className="flex flex-wrap items-center gap-4">
<form action="/console/people" className="flex max-w-md flex-1 gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" /> <Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
{waiting && <input type="hidden" name="waiting" value="true" />}
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Search Search
</Button> </Button>
</form> </form>
<Link
href={waiting ? "/console/people" : "/console/people?waiting=true"}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
{waiting ? "Show everyone" : "Waiting for approval"}
</Link>
</div>
{people.length === 0 ? ( {people.length === 0 ? (
<p className="text-sm text-muted-foreground">Nobody found.</p> <p className="text-sm text-muted-foreground">{waiting ? "Nobody is waiting." : "Nobody found."}</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly /> <DateTime iso={p.createdAt} dateOnly />
</TableCell> </TableCell>
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell> <TableCell className="text-right">
{p.banned ? <Tag tone="danger">banned</Tag> : !p.approved && <Tag tone="strong">waiting</Tag>}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
+14 -2
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { NarrowCard } from "@/components/narrow-card";
import { StagingBanner } from "@/components/staging-banner"; import { StagingBanner } from "@/components/staging-banner";
import { getMe } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "./globals.css"; import "./globals.css";
@@ -13,13 +15,23 @@ export const metadata: Metadata = {
description: "One account for all your IMVU avatars.", description: "One account for all your IMVU avatars.",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const me = await getMe();
return ( return (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}> <html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col"> <body className="flex min-h-dvh flex-col">
<StagingBanner /> <StagingBanner />
<Header /> <Header />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">
{me && !me.approved ? (
<NarrowCard
title="Waiting for Approval"
description={`Thanks for signing up, ${me.username}. A moderator will approve your account soon; until then you can't use the hub.`}
/>
) : (
children
)}
</main>
<footer className="border-t"> <footer className="border-t">
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6"> <div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
T5DE is unofficial: not made by or affiliated with IMVU. T5DE is unofficial: not made by or affiliated with IMVU.
+7 -2
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card"; import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api"; import { api, getMe, unwrap } from "@/lib/api";
import { SignUpForm } from "./signup-form"; import { SignUpForm } from "./signup-form";
export const metadata: Metadata = { title: "Create account" }; export const metadata: Metadata = { title: "Create account" };
export default async function SignUp() { export default async function SignUp() {
if (await getMe()) redirect("/account"); if (await getMe()) redirect("/account");
const { registration } = unwrap(await api.hub.get());
const description =
registration === "closed"
? "One T5DE account for all your IMVU accounts. New accounts need a moderator's approval."
: "One T5DE account for all your IMVU accounts.";
return ( return (
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts."> <NarrowCard title="Create account" description={description}>
<SignUpForm /> <SignUpForm />
</NarrowCard> </NarrowCard>
); );
+2 -2
View File
@@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr
} }
/** An IMVU account's age status: blue when it can see adult community products. */ /** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) { export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>; if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>; if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>; return <Tag>Not age verified</Tag>;
} }
+1
View File
@@ -37,6 +37,7 @@ export type Person = Data<V1["me"]["get"]>["person"];
export type Account = Data<V1["account"]["get"]>; export type Account = Data<V1["account"]["get"]>;
export type Link = Account["links"][number]; export type Link = Account["links"][number];
export type Challenge = Account["challenges"][number]; export type Challenge = Account["challenges"][number];
export type AgeInfo = Data<V1["age"]["get"]>;
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number]; export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>; export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number]; export type AuditEntry = ConsoleData["audit"][number];
+7
View File
@@ -22,6 +22,13 @@ services:
# T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api. # T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api.
API_URL: ${API_PUBLIC_URL:-http://localhost:4101} API_URL: ${API_PUBLIC_URL:-http://localhost:4101}
WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100} WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100}
# open, or closed (new accounts wait for a moderator's approval) until an admin changes it in the console.
REGISTRATION: ${REGISTRATION:-open}
# Ways to verify age (apps/api/src/index.ts): imvu, and didit with its keys.
AGE_PROVIDERS: ${AGE_PROVIDERS:-imvu}
DIDIT_API_KEY: ${DIDIT_API_KEY:-}
DIDIT_WORKFLOW_ID: ${DIDIT_WORKFLOW_ID:-}
DIDIT_WEBHOOK_SECRET: ${DIDIT_WEBHOOK_SECRET:-}
ports: ["4101:4101"] ports: ["4101:4101"]
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
+10 -6
View File
@@ -17,10 +17,11 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| | | | | |
| --- | --- | | --- | --- |
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. | | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
| `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | | | `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | | `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
## Proxies ## Proxies
@@ -97,17 +98,20 @@ person-level providers (phone wallets, document checks) are started here.
| | | | | |
| --- | --- | | --- | --- |
| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks | | `GET /api/v1/age` | `{providers: [{id, label, vendor, scope}], checks}`: the providers in use and the person's own checks (newest first; status `pending`, `complete`, `failed` or `revoked`) |
| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) | | `POST /api/v1/age/:provider/start` | `{returnURL}` (a path on the website, or an address on it) → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API). An unfinished check is given again. 409 `refused`: already verified, or a limit reached (`message` says which); 502 `provider_unavailable` |
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | | `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature | | `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) |
## Console (moderators; roles: admins) ## Console (moderators; roles: admins)
| | | | | |
| --- | --- | | --- | --- |
| `GET /api/v1/console` | `{counts, connections, online, audit}` | | `GET /api/v1/console` | `{counts, connections, online, audit}` |
| `GET /api/v1/console/people?q=` | `{people}` | | `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) |
| `POST /api/v1/console/people/:id/approve` | `{person}` |
| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it |
| `GET /api/v1/console/settings`, `PUT` (admins) | `{registration}`: `open` or `closed` (default: the API's `REGISTRATION`, else open) |
| `GET /api/v1/console/people/:id` | `{person, links, computers}` | | `GET /api/v1/console/people/:id` | `{person, links, computers}` |
| `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) | | `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) |
| `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators | | `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators |
+22 -5
View File
@@ -100,6 +100,9 @@ age, not verified) and `tagline`.
- **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked: - **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked:
offer to link. Linked to another hub account: no community features that session. offer to link. Linked to another hub account: no community features that session.
- **Bans** apply to the person, so all linked accounts. - **Bans** apply to the person, so all linked accounts.
- **Registration** is open or closed, an admin's setting in the console (default from `REGISTRATION`; staging is
meant to be closed, for testers only). Closed: anyone can sign up, but the account can only sign in until a
moderator approves it (or declines it, deleting it); the hub treats it as signed out everywhere else.
## Age (**Decided**: free, several providers, vendor-neutral) ## Age (**Decided**: free, several providers, vendor-neutral)
@@ -112,8 +115,11 @@ No payment for verification (adult content rules out mainstream merchants of rec
2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet, 2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet,
asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or
birth date reach it. Scope: the person. birth date reach it. Scope: the person.
3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person 3. **ID document and selfie** (live, `didit`): Didit's hosted check: ID document $0.15, passive liveness $0.10 and
(the liveness step stops someone using another person's ID). face match $0.05, so $0.30 a check, paid from the free plan's $10 of credit a month (since 2026-11-01; the 500
free checks a month are gone), so about 30 a month unless topped up. Scope: the person (liveness and face match
stop someone using another person's ID). Over 18 is worked out from the age Didit reads off the document;
nothing else is kept. With so few checks, phone wallets (free) should become the main way.
### How it's built ### How it's built
@@ -128,14 +134,25 @@ No payment for verification (adult content rules out mainstream merchants of rec
webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is
configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
one new adapter; nothing else changes. one new adapter; nothing else changes.
- Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature. - Webhooks arrive at `/api/v1/age/webhook/<provider>` and each provider checks its own signature. The provider's
latest answer stands: one taken back after approval (say, by a manual review) becomes `revoked`.
- **Limits** on checks of a person, for every provider: one unfinished check at a time (starting again gives the
same one), at most 3 started in 30 days, none once verified, and a provider's monthly cap (`monthlyLimit`; Didit's
`DIDIT_MONTHLY_LIMIT`, default 30), counted from the checks started this month (UTC).
- People are sent back only to the hub's website (`returnURL` is a path on it, or an address on `WEB_URL`).
- Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room - Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room
channels for viewers who aren't adult and opted in), and the viewer's proxy. channels for viewers who aren't adult and opted in), and the viewer's proxy.
### Setting up Didit
In Didit's console (business.didit.me): an application; a KYC workflow with ID verification, passive liveness and
face match only (no IP analysis, AML or proof of address, which cost extra; no white label, $0.10 a session more);
an API key; and a webhook destination
`<API_URL>/api/v1/age/webhook/didit` with its secret. Then the API's environment: `AGE_PROVIDERS=imvu,didit`,
`DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. Use the sandbox application on staging.
### To add the planned providers ### To add the planned providers
- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning
only "over 18". Confirm its free monthly allowance covers age checks.
- Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP, - Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP,
mdoc); Apple and the EU wallet through the same browser API where supported. mdoc); Apple and the EU wallet through the same browser API where supported.
@@ -0,0 +1,7 @@
CREATE TABLE "hub_setting" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "approved" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1793372872749, "when": 1793372872749,
"tag": "0008_product_deletion", "tag": "0008_product_deletion",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1793707706030,
"tag": "0009_registration",
"breakpoints": true
} }
] ]
} }
+9
View File
@@ -43,6 +43,8 @@ export const user = pgTable(
banExpires: timestamp("ban_expires", { withTimezone: true }), banExpires: timestamp("ban_expires", { withTimezone: true }),
// How many IMVU accounts the person may link; moderators raise it on request. // How many IMVU accounts the person may link; moderators raise it on request.
linkCap: integer("link_cap").notNull().default(10), linkCap: integer("link_cap").notNull().default(10),
// False while a new account waits for a moderator's approval (registration closed); it can only sign in.
approved: boolean("approved").notNull().default(true),
}, },
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)], (t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
); );
@@ -473,3 +475,10 @@ export const roomDecoration = pgTable(
}, },
(t) => [index("room_decoration_owner").on(t.ownerImvuId)], (t) => [index("room_decoration_owner").on(t.ownerImvuId)],
); );
// The hub's settings that admins change from the console (e.g. registration: "open" or "closed").
export const hubSetting = pgTable("hub_setting", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: updatedAt(),
});