Compare commits

1 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 c75a4c9afc feat: age verification with an ID document (Didit), and closed registration
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 58s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
Age: a Didit provider behind the vendor-neutral age interface. A person
verifies once (ID document, passive liveness, face match) and all their
linked IMVU accounts count as adult. Webhooks are checked against Didit's
signatures; only "over 18" is kept. A later answer replaces an earlier one
(an approval taken back is revoked). Checks are limited: one unfinished
check per person, 3 in 30 days, none once verified, and a monthly cap
(DIDIT_MONTHLY_LIMIT, default 30: Didit's free plan is $10 of credit a
month and a check costs $0.30). The Account page gets an Age Verification
card.

Registration: open or closed, an admin's setting in the console
(default from REGISTRATION). Closed: new accounts can only sign in until
a moderator approves them (or declines, deleting the account); the
website shows them a waiting page and the API treats them as signed out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:15:47 -08:00
28 changed files with 3247 additions and 76 deletions

No files matched your search

+5 -2
View File
@@ -8,11 +8,14 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
## What works
- **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
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
(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 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
+123
View File
@@ -0,0 +1,123 @@
// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in
// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether
// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest.
//
// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session
// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AgeResult, Provider } from "./index";
export type DiditConfig = {
apiKey: string;
workflowId: string;
/** The webhook destination's secret_shared_key. */
webhookSecret: string;
/** Checks started a month at most (what the month's credit pays for). */
monthlyLimit?: number;
baseURL?: string;
fetch?: typeof fetch;
};
/** How far a webhook's X-Timestamp may be from now. */
const timestampTolerance = 300;
/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */
const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]);
type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] };
/** Age in whole years on a date, from an ISO birth date. */
export function ageOn(birth: string, today: Date): number | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth);
if (!m) return undefined;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
let age = today.getUTCFullYear() - y;
if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--;
return age;
}
/** The age on the session's ID document, or undefined if it has none. */
function documentAge(decision: Decision | undefined): number | undefined {
const id = decision?.id_verifications?.[0];
if (typeof id?.age === "number") return id.age;
return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined;
}
/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */
function canonical(v: unknown): unknown {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === "object")
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
return v;
}
function hmacMatches(secret: string, input: string, signature: string | null): boolean {
if (!signature) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex"));
const given = Buffer.from(signature.trim().toLowerCase());
return expected.length === given.length && timingSafeEqual(expected, given);
}
export function diditAgeProvider(config: DiditConfig): Provider {
const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, "");
const fetcher = config.fetch ?? fetch;
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetcher(base + path, {
method,
headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`);
return res.json();
};
return {
id: "didit",
label: "ID document and selfie",
vendor: "Didit",
scope: "person",
monthlyLimit: config.monthlyLimit,
async start(subject, { returnURL }) {
const s = (await call("POST", "/v3/session/", {
workflow_id: config.workflowId,
vendor_data: subject.userId,
callback: returnURL,
})) as { session_id: string; url: string };
return { kind: "redirect", url: s.url, ref: s.session_id };
},
async webhook(request) {
const raw = await request.text();
const timestamp = Number(request.headers.get("x-timestamp"));
if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook");
const body = JSON.parse(raw) as {
webhook_type?: string;
session_id?: string;
status?: string;
decision?: Decision;
};
// The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header
// during a rotation.
const v2 = JSON.stringify(canonical(body));
const signed = ["", "-previous"].some(
(suffix) =>
hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) ||
hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)),
);
if (!signed) throw new Error("bad signature");
if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null;
if (!body.session_id || !body.status) return null;
const ref = body.session_id;
if (failedStatuses.has(body.status)) return { ref, result: "failed" };
if (body.status !== "Approved") return null;
const decision =
body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision);
const age = documentAge(decision);
if (age === undefined) return { ref, result: "failed" };
const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref };
return { ref, result };
},
};
}
+1
View File
@@ -19,6 +19,7 @@ export function imvuAgeProvider(imvu: ImvuClient): Provider {
return {
id: "imvu",
label: "IMVU's age verification or Access Pass",
vendor: "IMVU",
scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check,
+80 -17
View File
@@ -1,7 +1,7 @@
// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface,
// results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
// 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;
@@ -27,13 +27,19 @@ export type Started =
export type Provider = {
id: string;
/** Shown to people. */
/** Shown to people: the way of checking, and who does it. */
label: string;
vendor: string;
/** Whether it checks a person, or an 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>;
/** 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. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */
@@ -41,6 +47,12 @@ export type Provider = {
};
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");
@@ -56,7 +68,7 @@ export class AgeService {
/** The providers in use, for the website. */
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 {
@@ -87,13 +99,25 @@ export class AgeService {
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> {
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 });
if (started.kind === "done") {
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({
id: randomID(),
userId: subject.userId,
@@ -106,11 +130,55 @@ export class AgeService {
return started;
}
/** Finishes a pending check (from a webhook or the browser). */
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
private async pendingCheck(userId: string, provider: string) {
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 =
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",
overEighteen: result.overEighteen,
@@ -118,13 +186,8 @@ export class AgeService {
checkedAt: new Date(),
expiresAt: result.expiresAt ?? null,
};
const rows = await this.db
.update(ageCheck)
.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]!;
const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
return updated!;
}
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
+110 -11
View File
@@ -19,7 +19,7 @@ import {
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age";
import { AgeError, AgeRefused, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
@@ -40,6 +40,7 @@ import {
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -52,6 +53,10 @@ export type Deps = {
rooms?: Rooms;
/** The hub's API URL, the issuer of its tokens. */
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. */
@@ -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 settings = hubSettings ?? new Settings(db);
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, 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(),
banned: p.banned === true,
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,
@@ -449,17 +466,33 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.derive(async ({ request }) => {
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;
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({
/** 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: {
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.");
return { me: person };
},
},
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 (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
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.
.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 }) => {
const online = (await visibleOnline(person)).length;
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.");
}
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;
},
{ 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;
try {
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.");
}
if (answer) {
try {
await age.finish(p.id, answer.ref, answer.result);
await age.finish(p.id, answer.ref, answer.result, { anyStatus: true });
} 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;
},
@@ -1353,6 +1408,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const [counts] = await db
.select({
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`,
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()
.from(user)
.where(
and(
q
? or(
ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
)
: undefined,
query.waiting ? and(eq(user.approved, false), sql`coalesce(${user.banned}, false) = false`) : undefined,
),
)
.orderBy(desc(user.createdAt))
.limit(100);
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(
"/console/people/:id",
+9 -2
View File
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db";
import { Settings } from "./settings";
export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */
@@ -15,12 +16,15 @@ export type AuthConfig = {
secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
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. */
export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) {
const settings = config.settings ?? new Settings(db);
return betterAuth({
baseURL: config.apiURL,
basePath: "/api/auth",
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
user: {
additionalFields: {
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.
@@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) {
databaseHooks: {
user: {
create: {
// The first account runs the hub.
// The first account runs the hub; with registration closed, the others wait for approval.
before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
const first = (row?.n ?? 0) === 0;
const approved = first || (await settings.registration()) === "open";
return { data: { ...u, role: first ? "admin" : "user", approved } };
},
},
},
+43 -2
View File
@@ -4,9 +4,19 @@
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
// WEB_URL the website's address (default http://localhost:4100)
// 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 { AgeService, type Provider } from "./age";
import { diditAgeProvider } from "./age/didit";
import { imvuAgeProvider } from "./age/imvu";
import { createApp } from "./app";
import { createAuth } from "./auth";
import { Settings } from "./settings";
import { ImvuClient } from "./imvu";
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 { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret });
const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL });
const settings = new Settings(db, { registration: env.REGISTRATION });
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" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
+29
View File
@@ -0,0 +1,29 @@
// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its
// default from the environment (so a new staging hub can start with registration closed).
import { eq, schema, type Database } from "@t5de/db";
const { hubSetting } = schema;
/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */
export type Registration = "open" | "closed";
export const registrations: readonly Registration[] = ["open", "closed"];
export class Settings {
constructor(
private readonly db: Database,
private readonly defaults: { registration?: string } = {},
) {}
async registration(): Promise<Registration> {
const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration"));
const value = row?.value ?? this.defaults.registration;
return value === "closed" ? "closed" : "open";
}
async setRegistration(value: Registration) {
await this.db
.insert(hubSetting)
.values({ key: "registration", value })
.onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } });
}
}
+146 -7
View File
@@ -1,8 +1,10 @@
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed
// webhooks), and IMVU's answers changing.
// Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
// 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 { openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, type Provider } from "../src/age";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
import { ageOn, diditAgeProvider } from "../src/age/didit";
import { imvuAgeProvider } from "../src/age/imvu";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
@@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret";
const fakeVendor: Provider = {
id: "vendor",
label: "ID document",
vendor: "Vendor",
scope: "person",
start: async () => {
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 close: () => Promise<void>;
let base: string;
@@ -49,8 +91,8 @@ beforeAll(async () => {
({ 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 imvu = new ImvuClient("http://imvu.test", fakeIMVU);
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]);
const hub = createApp({ db, auth, imvu, age });
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
const hub = createApp({ db, auth, imvu, age, webURL });
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();
@@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?:
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 }) =>
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 () => {
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.
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 });
// A repeated webhook is harmless.
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 () => {
+104
View File
@@ -0,0 +1,104 @@
// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's
// approval); admins switch it in the console, and the environment gives the default.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient } from "../src/imvu";
import { Settings } from "../src/settings";
const webURL = "http://web.test";
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
// Closed by default (as on staging), until an admin changes it.
const settings = new Settings(db, { registration: "closed" });
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings });
const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch);
const hub = createApp({ db, auth, imvu, webURL, settings });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
async function call(cookie: { v: string }, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
async function signUp(name: string) {
const cookie = { v: "" };
expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200);
return cookie;
}
test("closed: new accounts wait for approval; the first one runs the hub", async () => {
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" });
const admin = await signUp("ada");
expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true });
expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200);
const bo = await signUp("bo_");
// Signed in, but nothing else.
expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false });
expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } });
expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403);
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
// Moderators see who's waiting, and approve them.
expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1);
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200);
expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]);
// An approved account can't be declined.
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400);
});
test("declining deletes the account", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const cy = await signUp("cyd");
const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(p.username).toBe("cyd");
expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200);
expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401);
expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401);
});
test("only admins open or close registration", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const mod = await signUp("dot");
const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0];
await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`);
await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" });
expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" });
expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403);
expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" });
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" });
// Open: new accounts can use the hub at once.
const eve = await signUp("eve");
expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200);
const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action);
expect(audit).toContain("hub.registration");
});
+13
View File
@@ -1,5 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
@@ -58,3 +59,15 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
return "Signed out everywhere.";
});
}
/** Starts an age check with a provider that checks the person, and sends them to its page. */
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
let url: string | undefined;
const state = await attempt(async () => {
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
if (started.kind === "redirect") url = started.url;
done();
});
if (url) redirect(url);
return state;
}
+68
View File
@@ -0,0 +1,68 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AgeInfo } from "@/lib/api";
import { startAgeCheck } from "./actions";
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
const ways = age.providers.filter((p) => p.scope === "person");
if (ways.length === 0) return null;
const now = Date.now();
const verified = age.checks.find(
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
);
const latest = age.checks[0];
const pending = latest?.status === "pending" ? latest : undefined;
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
return (
<Card>
<CardHeader>
<CardTitle>Age Verification</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{verified ? (
<p className="flex flex-wrap items-center gap-2">
<Tag tone="adult">Age verified</Tag>
<span className="text-muted-foreground">
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
IMVU accounts count as adult.
</span>
</p>
) : (
<>
{pending ? (
<p>
{back
? "Thanks. Your check is being processed; this page shows the result when it's done."
: "Your check isn't finished yet."}
</p>
) : latest?.status === "complete" && !latest.overEighteen ? (
<p>Your last check found you're under 18.</p>
) : latest?.status === "failed" || latest?.status === "revoked" ? (
<p>Your last check didn&apos;t pass. You can try again.</p>
) : (
<p className="text-muted-foreground">
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
</p>
)}
{ways.map((p) => (
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
<div className="space-y-2">
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
</SubmitButton>
<p className="text-xs text-muted-foreground">
Done by {p.vendor}. T5DE only keeps whether you&apos;re over 18, never your ID, photo or birth date.
</p>
</div>
</ActionForm>
))}
</>
)}
</CardContent>
</Card>
);
}
+9 -5
View File
@@ -8,17 +8,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card";
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();
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 welcome = (await searchParams).welcome !== undefined;
const params = await searchParams;
const welcome = params.welcome !== undefined;
return (
<div className="space-y-6">
@@ -78,12 +80,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
</p>
)}
<p className="text-sm text-muted-foreground">
Other people can&apos;t see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
can see adult community products; each account is checked on its own.
Other people can&apos;t see which IMVU accounts are yours. Adult IMVU accounts can see adult community
products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
</p>
</CardContent>
</Card>
<AgeCard age={age} back={params.age === "back"} />
<Card>
<CardHeader>
<CardTitle>T5DE on your computers</CardTitle>
+23
View File
@@ -1,5 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
@@ -52,3 +53,25 @@ export async function decideCatalog(_: ActionState, form: FormData): Promise<Act
revalidatePath("/catalogs");
});
}
/** An account waiting for approval: approve it, or decline it (deleting it). */
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decline = field(form, "decision") === "decline";
const state = await attempt(async () => {
if (decline) unwrap(await api.console.people({ id }).decline.post());
else unwrap(await api.console.people({ id }).approve.post());
revalidatePath("/console", "layout");
});
if (decline && !state?.error) redirect("/console/people?waiting=true");
return state;
}
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
const registration = field(form, "registration") === "closed" ? "closed" : "open";
unwrap(await api.console.settings.put({ registration }));
revalidatePath("/console");
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
});
}
+41 -4
View File
@@ -1,19 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
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 { api, unwrap, type AuditEntry } from "@/lib/api";
import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav";
export const metadata: Metadata = { title: "Console" };
export default async function Console() {
await requireModerator("/console");
const data = unwrap(await api.console.get());
const me = await requireModerator("/console");
const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
const stats = [
{ 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: "Computers", value: data.counts.computers, note: "signed in" },
];
@@ -23,18 +28,50 @@ export default async function Console() {
<PageHeader title="Console">Moderation.</PageHeader>
<ConsoleNav />
<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) => (
<Card key={s.label}>
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</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>
))}
</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>
<CardHeader>
<CardTitle>Online now</CardTitle>
+31 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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";
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 />
</span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
</span>
</PageHeader>
<ConsoleNav />
<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>
<CardHeader>
<CardTitle>IMVU accounts</CardTitle>
+25 -8
View File
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
export default async function People({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
}) {
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 (
<>
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<ConsoleNav />
<Card>
<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" />
{waiting && <input type="hidden" name="waiting" value="true" />}
<Button type="submit" variant="outline">
Search
</Button>
</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 ? (
<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>
<TableHeader>
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly />
</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>
))}
</TableBody>
+14 -2
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header";
import { NarrowCard } from "@/components/narrow-card";
import { StagingBanner } from "@/components/staging-banner";
import { getMe } from "@/lib/api";
import { cn } from "@/lib/utils";
import "./globals.css";
@@ -13,13 +15,23 @@ export const metadata: Metadata = {
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 (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col">
<StagingBanner />
<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">
<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.
+7 -2
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api";
import { api, getMe, unwrap } from "@/lib/api";
import { SignUpForm } from "./signup-form";
export const metadata: Metadata = { title: "Create account" };
export default async function SignUp() {
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 (
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
<NarrowCard title="Create account" description={description}>
<SignUpForm />
</NarrowCard>
);
+2 -2
View File
@@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr
}
/** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>;
}
+1
View File
@@ -37,6 +37,7 @@ export type Person = Data<V1["me"]["get"]>["person"];
export type Account = Data<V1["account"]["get"]>;
export type Link = Account["links"][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 ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number];
+7
View File
@@ -22,6 +22,13 @@ services:
# T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api.
API_URL: ${API_PUBLIC_URL:-http://localhost:4101}
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"]
depends_on:
postgres: { condition: service_healthy }
+10 -6
View File
@@ -17,10 +17,11 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| | |
| --- | --- |
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
| `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-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
@@ -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 |
| `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) |
| `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}` (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/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)
| | |
| --- | --- |
| `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}` |
| `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) |
| `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators |
+22 -5
View File
@@ -100,6 +100,9 @@ age, not verified) and `tagline`.
- **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked:
offer to link. Linked to another hub account: no community features that session.
- **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)
@@ -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,
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.
3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person
(the liveness step stops someone using another person's ID).
3. **ID document and selfie** (live, `didit`): Didit's hosted check: ID document $0.15, passive liveness $0.10 and
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
@@ -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
configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is
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
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
- 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,
mdoc); Apple and the EU wallet through the same browser API where supported.
@@ -0,0 +1,7 @@
CREATE TABLE "hub_setting" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "approved" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1793372872749,
"tag": "0008_product_deletion",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1793707706030,
"tag": "0009_registration",
"breakpoints": true
}
]
}
+9
View File
@@ -43,6 +43,8 @@ export const user = pgTable(
banExpires: timestamp("ban_expires", { withTimezone: true }),
// How many IMVU accounts the person may link; moderators raise it on request.
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})`)],
);
@@ -473,3 +475,10 @@ export const roomDecoration = pgTable(
},
(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(),
});