feat: age verification with an ID document (Didit), and closed registration #4
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'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're over 18, never your ID, photo or birth date.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
|
Other people can'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>
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user