Files
t5de-hub/apps/api/src/app.ts
T
dhkatz d5cd2be5d0
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m19s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 7s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 20s
feat: age verification with an ID document (Didit), and closed registration (#4)
## Age verification with Didit

A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date.

- Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit.
- A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending.
- Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30).
- People are only ever sent back to the hub's website.
- The Account page gets an Age Verification card.
- Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`.

## Closed registration

Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`.

- When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account).
- The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`.
- The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting.
- The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`.

## Testing

- New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration.
- `bun run test`: 35 pass. `bun run check-types` passes.
- Checked locally: the account, waiting, sign-up and console pages render.
- Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Age verification with Didit

A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date.

- Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit.
- A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending.
- Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30).
- People are only ever sent back to the hub's website.
- The Account page gets an Age Verification card.
- Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`.

## Closed registration

Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`.

- When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account).
- The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`.
- The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting.
- The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`.

## Testing

- New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration.
- `bun run test`: 35 pass. `bun run check-types` passes.
- Checked locally: the account, waiting, sign-up and console pages render.
- Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-04 11:37:21 +00:00

1621 lines
64 KiB
TypeScript

// The hub's API: Better Auth at /api/auth, the hub's own routes at /api/v1. The website calls it through its own
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
// token.
import { Elysia, t } from "elysia";
import {
and,
asc,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
count,
schema,
type Database,
type ParentInfo,
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeRefused, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import {
chatId,
HostedError,
HostedRooms,
instanceId,
maxImageBytes,
maxModerators,
maxPlacement,
maxSceneItems,
roomInfo,
type RoomChanges,
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
import { Settings } from "./settings";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = {
db: Database;
auth: Auth;
imvu: ImvuClient;
realtime?: Realtime;
age?: AgeService;
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. */
export class Problem extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
type Person = typeof user.$inferSelect;
type Link = typeof imvuLink.$inferSelect;
const roles = ["user", "moderator", "admin"] as const;
const isModerator = (p: Person) => p.role === "moderator" || p.role === "admin";
// Link codes: "t5de-" and eight letters and digits without look-alikes.
function linkCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = crypto.getRandomValues(new Uint8Array(8));
const s = [...bytes].map((b) => alphabet[b % alphabet.length]).join("");
return `t5de-${s.slice(0, 4)}-${s.slice(4)}`;
}
function randomID(bytes = 16): string {
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
}
const challengeLifetime = 24 * 60 * 60 * 1000;
/** Allows n attempts per key in a window (IMVU lookups, which cost IMVU a request each). */
class Limiter {
private hits = new Map<string, number[]>();
allow(key: string, n: number, windowMs: number): boolean {
const now = Date.now();
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= n) {
this.hits.set(key, recent);
return false;
}
recent.push(now);
this.hits.set(key, recent);
if (this.hits.size > 100_000) this.hits.clear();
return true;
}
}
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);
const hosted = new HostedRooms(db);
const decorations = new Decorations(db);
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
const realtime =
rt ??
new Realtime({
owner: async (imvuId) => {
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.userId;
},
adult: async (userId, imvuId) => (await age.adultAccounts(userId, [imvuId])).has(imvuId),
room: (instanceId) => {
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
hostedRoom: (n) => hosted.info(n),
hostedBanned: (n, imvuId) => hosted.banned(n, imvuId),
hostedBoot: async (b) => {
if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes);
await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), {
imvu: b.imvuId,
by: b.by,
...(b.ban ? { minutes: b.minutes } : {}),
});
},
accountName: async (imvuId) => {
const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.username ?? "";
},
hostedVisit: (userId, n, imvuId) => {
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
},
});
const limiter = new Limiter();
const personView = (p: Person) => ({
id: p.id,
username: p.displayUsername ?? p.username ?? p.name,
email: p.email,
role: (roles as readonly string[]).includes(p.role) ? (p.role as (typeof roles)[number]) : "user",
linkCap: p.linkCap,
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,
* or IMVU can't say). Kept an hour per account, so pages don't each ask IMVU. */
const pictures = new Map<number, { url: string | null; at: number }>();
const picture = async (userId: string): Promise<string | null> => {
const [first] = await db
.select({ imvuId: imvuLink.imvuId })
.from(imvuLink)
.where(eq(imvuLink.userId, userId))
.orderBy(asc(imvuLink.linkedAt))
.limit(1);
if (!first) return null;
const kept = pictures.get(first.imvuId);
if (kept && Date.now() - kept.at < 60 * 60 * 1000) return kept.url;
let url: string | null = kept?.url ?? null;
try {
url = (await imvu.byId(first.imvuId)).picture;
} catch {
// IMVU unavailable: the last known one (or none), asked again next time.
return url;
}
pictures.set(first.imvuId, { url, at: Date.now() });
return url;
};
/** Links as the website shows them: with live presence, and age by the one rule. */
const linkViews = async (userId: string, links: Link[]) => {
const ids = links.map((l) => l.imvuId);
const adult = await age.adultAccounts(userId, ids);
const imvuChecks = await age.accountChecks("imvu", ids);
const online = realtime.online();
return links.map((l) => {
const live = online.find((o) => o.imvuId === l.imvuId);
const check = imvuChecks.get(l.imvuId);
return {
imvuId: l.imvuId,
username: l.username,
linkedAt: l.linkedAt.toISOString(),
ageVerified: check?.method === "imvu_age_verified",
accessPass: check?.method === "imvu_access_pass",
adult: adult.has(l.imvuId),
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
listRooms: l.listRooms,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
});
};
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
db.insert(audit).values({ actorId, action, target, detail });
/** Who's online that viewer may see: accounts showing their presence (moderators see all). */
const visibleOnline = async (viewer: Person | undefined) => {
const online = realtime.online();
if (online.length === 0) return [];
const links = await db.select().from(imvuLink).where(inArray(imvuLink.imvuId, online.map((o) => o.imvuId)));
const mod = viewer !== undefined && isModerator(viewer);
const adult = new Set<number>();
if (mod) {
for (const l of links) if ((await age.adultAccounts(l.userId, [l.imvuId])).has(l.imvuId)) adult.add(l.imvuId);
}
return online.flatMap((o) => {
const l = links.find((l) => l.imvuId === o.imvuId);
if (!l || (!l.showPresence && !mod)) return [];
return [
{
imvuId: o.imvuId,
username: l.username,
badge: l.showBadge,
since: o.since.toISOString(),
outfit: o.outfit,
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: adult.has(o.imvuId) } : {}),
},
];
});
};
const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) });
/** Whether any of a person's IMVU accounts counts as adult (for seeing adult products and rooms). */
const personAdult = async (p: Person | undefined) => {
if (!p) return false;
const ids = (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id);
return ids.length > 0 && (await age.adultAccounts(p.id, ids)).size > 0;
};
/** Runs a product operation, turning its errors into Problems. */
const productCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
/** `latest`: the latest revision's parent and its info (the image and categories are the parent's). */
const productView = (
p: typeof schema.product.$inferSelect,
creator?: string | null,
creatorUsername?: string | null,
latest?: { parent: number | null; parentInfo: ParentInfo | null } | null,
) => ({
id: p.id,
name: p.name,
description: p.description,
rating: p.rating as "general" | "adult",
latest: p.latest,
creator: creator ?? creatorUsername ?? "",
creatorUsername: creatorUsername ?? "",
parent: latest?.parent ?? 0,
image: latest?.parentInfo?.image ?? null,
categories: latest?.parentInfo?.categories ?? [],
deleted: p.deletedAt !== null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
});
const catalogView = (c: typeof schema.catalog.$inferSelect) => ({
id: c.id,
name: c.name,
url: c.url,
contact: c.contact,
visibility: c.visibility,
membership: c.membership,
status: c.status,
createdAt: c.createdAt.toISOString(),
});
/** Runs a hosted-room operation, turning its errors into Problems. */
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof HostedError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string };
type Extras = { favorite: boolean; score: number; ratings: number };
const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({
n: r.n,
instanceId: instanceId(r.ownerImvuId, r.n),
chatId: chatId(r.n),
name: r.name,
description: r.description,
roomPid: r.roomPid,
rating: r.rating as "general" | "adult",
maxUsers: r.maxUsers,
ownerImvuId: r.ownerImvuId,
ownerName,
occupancy: realtime.occupancy(r.n),
privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private",
moderators: r.moderators.map((m) => m.imvuId),
moderatorNames: r.moderators.map((m) => m.username),
image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null,
favorite: extras.favorite,
score: extras.score,
ratings: extras.ratings,
createdAt: r.createdAt.toISOString(),
});
/** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */
const hostedViews = async (rows: HostedRow[], person: Person | undefined) => {
const ns = rows.map((r) => r.room.n);
const [scores, favorites] = await Promise.all([
hosted.scores(ns),
person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set<number>()),
]);
return rows.map((r) =>
hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }),
);
};
const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!;
/** The person and their linked IMVU accounts. */
const viewerOf = async (p: Person): Promise<Viewer> => ({
id: p.id,
imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id),
});
/** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */
const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => {
if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`);
const out: RoomModerator[] = [];
for (const e of entries) {
const text = typeof e === "string" ? e.trim() : "";
const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0;
if (id > 0) {
const name = known.find((m) => m.imvuId === id)?.username;
const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id));
out.push({ imvuId: id, username: name ?? l?.username ?? "" });
continue;
}
if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs.");
const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase());
if (k) {
out.push(k);
continue;
}
const [l] = await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username })
.from(imvuLink)
.where(sql`lower(${imvuLink.username}) = lower(${text})`);
if (l) {
out.push(l);
continue;
}
try {
const found = await imvuCall(me, () => imvu.byUsername(text));
out.push({ imvuId: found.id, username: found.username });
} catch (err) {
if (err instanceof Problem && err.slug === "no_such_imvu_account")
throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`);
throw err;
}
}
return out;
};
const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) });
const rating = t.Union([t.Literal("general"), t.Literal("adult")]);
const privacy = t.Union([t.Literal("public"), t.Literal("private")]);
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
const sceneItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ pattern: "^[a-z2-7]{10}$" })]),
t.String({ maxLength: maxPlacement }),
]);
/** Runs a decorations operation, turning its errors into Problems. */
const decorationCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof DecorationError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
const decorationItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.String({ pattern: "^[a-z2-7]{10}$" }),
t.String({ maxLength: maxDecorationPlacement }),
]);
const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const ownLink = async (p: Person, imvuId: number) => {
const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id)));
if (!l) throw new Problem(404, "not_found", "That account isn't linked to you.");
return l;
};
const imvuCall = async <T>(p: Person, call: () => Promise<T>): Promise<T> => {
if (!limiter.allow(`imvu:${p.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while.");
try {
return await call();
} catch (e) {
if (e instanceof NoSuchImvuUser) throw new Problem(404, "no_such_imvu_account", "IMVU has no account by that name.");
if (e instanceof ImvuUnavailable) throw new Problem(502, "imvu_unavailable", "IMVU didn't answer. Try again in a moment.");
throw e;
}
};
const personById = async (id: string) => {
const [p] = await db.select().from(user).where(eq(user.id, id));
if (!p) throw new Problem(404, "not_found", "No such person.");
return p;
};
const api = new Elysia({ prefix: "/api/v1" })
.error({ Problem })
.onError(({ code, error, set }) => {
if (code === "Problem") {
set.status = error.status;
return { error: error.slug, message: error.message };
}
if (code === "VALIDATION") {
set.status = 400;
return { error: "invalid", message: "Some of that isn't right. Check it and try again." };
}
if (code === "NOT_FOUND") {
set.status = 404;
return { error: "not_found", message: "Not found." };
}
})
.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;
// 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, 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, 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 };
},
},
})
// Who's signed in, and the home page.
.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 };
const [row] = await db.select({ n: count() }).from(imvuLink).where(eq(imvuLink.userId, person.id));
return { onlineCount: online, links: row?.n ?? 0 };
})
// Who's online.
.get("/online", async ({ person }) => ({ online: await visibleOnline(person) }))
.get("/online/events", ({ person, request }) => {
const encoder = new TextEncoder();
let stop = () => {};
const stream = new ReadableStream({
start(controller) {
const send = async () => {
const online = await visibleOnline(person);
try {
controller.enqueue(encoder.encode(`event: online\ndata: ${JSON.stringify({ online })}\n\n`));
} catch {}
};
const unsubscribe = realtime.subscribe(() => void send());
const keepAlive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keep-alive\n\n"));
} catch {}
}, 25_000);
stop = () => {
unsubscribe();
clearInterval(keepAlive);
};
request.signal.addEventListener("abort", () => {
stop();
try {
controller.close();
} catch {}
});
void send();
},
cancel() {
stop();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" },
});
})
// Your account.
.get(
"/account",
async ({ me }) => {
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, me.id)).orderBy(imvuLink.linkedAt);
const challenges = await db
.select()
.from(linkChallenge)
.where(and(eq(linkChallenge.userId, me.id), sql`${linkChallenge.createdAt} > now() - interval '1 day'`))
.orderBy(linkChallenge.createdAt);
const [computers] = await db
.select({ n: count() })
.from(session)
.where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return {
person: personView(me),
links: await linkViews(me.id, links),
challenges: challenges.map((c) => ({
imvuId: c.imvuId,
username: c.username,
code: c.code,
createdAt: c.createdAt.toISOString(),
expiresAt: new Date(c.createdAt.getTime() + challengeLifetime).toISOString(),
})),
computers: computers?.n ?? 0,
canLink: links.length < me.linkCap,
};
},
{ signedIn: true },
)
.post(
"/account/links",
async ({ me, body, set }) => {
const found = await imvuCall(me, () => imvu.byUsername(body.username));
const [existing] = await db.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
if (existing?.userId === me.id)
throw new Problem(409, "already_linked", `${found.username} is already linked.`);
const code = linkCode();
const [c] = await db
.insert(linkChallenge)
.values({ userId: me.id, imvuId: found.id, username: found.username, code })
.onConflictDoUpdate({
target: [linkChallenge.userId, linkChallenge.imvuId],
set: { code, username: found.username, createdAt: new Date() },
})
.returning();
set.status = 201;
return {
challenge: {
imvuId: c!.imvuId,
username: c!.username,
code: c!.code,
createdAt: c!.createdAt.toISOString(),
expiresAt: new Date(c!.createdAt.getTime() + challengeLifetime).toISOString(),
},
};
},
{ signedIn: true, body: t.Object({ username: t.String({ minLength: 1, maxLength: 64 }) }) },
)
.post(
"/account/links/:imvu/check",
async ({ me, params }) => {
const [c] = await db
.select()
.from(linkChallenge)
.where(
and(
eq(linkChallenge.userId, me.id),
eq(linkChallenge.imvuId, params.imvu),
sql`${linkChallenge.createdAt} > now() - interval '1 day'`,
),
);
if (!c) throw new Problem(410, "expired", "That code expired. Start again.");
const found = await imvuCall(me, () => imvu.byId(params.imvu));
if (!found.tagline.toLowerCase().includes(c.code.toLowerCase()))
throw new Problem(
409,
"code_missing",
`${found.username}'s tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.`,
);
const result = await db.transaction(async (tx) => {
const [{ n } = { n: 0 }] = await tx
.select({ n: count() })
.from(imvuLink)
.where(and(eq(imvuLink.userId, me.id), ne(imvuLink.imvuId, found.id)));
if (n >= me.linkCap)
throw new Problem(403, "link_cap", `You can link up to ${me.linkCap} IMVU accounts. Ask a moderator if you need more.`);
const [prev] = await tx.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
const moved = prev !== undefined && prev.userId !== me.id;
const values = { userId: me.id, username: found.username };
let link: Link;
if (!prev) {
[link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link];
} else if (moved) {
// A moved account gets a new pairwise ID and starts with nothing shown: nothing connects the new owner
// to the old one.
[link] = (await tx
.update(imvuLink)
.set({ ...values, pairwiseId: randomID(), linkedAt: new Date(), showBadge: false, showPresence: false })
.where(eq(imvuLink.imvuId, found.id))
.returning()) as [Link];
} else {
[link] = (await tx.update(imvuLink).set(values).where(eq(imvuLink.imvuId, found.id)).returning()) as [Link];
}
await tx.delete(linkChallenge).where(eq(linkChallenge.imvuId, found.id));
await tx.insert(audit).values({
actorId: me.id,
action: "link.add",
target: String(found.id),
detail: moved ? { imvu: found.username, movedFrom: prev!.userId } : { imvu: found.username },
});
return { link, moved, previousOwner: prev?.userId };
});
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
if (result.moved) await age.forgetAccount(found.id);
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
if (result.moved) {
await rooms.forgetOwner(found.id);
realtime.unlinked(result.previousOwner!, found.id);
}
await realtime.linked(me.id, found.id);
return { link: await linkView(result.link), moved: result.moved };
},
{ signedIn: true, params: imvuIdParam },
)
.delete(
"/account/challenges/:imvu",
async ({ me, params, set }) => {
await db.delete(linkChallenge).where(and(eq(linkChallenge.userId, me.id), eq(linkChallenge.imvuId, params.imvu)));
set.status = 204;
},
{ signedIn: true, params: imvuIdParam },
)
.patch(
"/account/links/:imvu",
async ({ me, params, body }) => {
await ownLink(me, params.imvu);
const [l] = await db
.update(imvuLink)
.set({
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
...(body.listRooms !== undefined ? { listRooms: body.listRooms } : {}),
})
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: await linkView(l!) };
},
{
signedIn: true,
params: imvuIdParam,
body: t.Object({
showBadge: t.Optional(t.Boolean()),
showPresence: t.Optional(t.Boolean()),
listRooms: t.Optional(t.Boolean()),
}),
},
)
.post(
"/account/links/:imvu/recheck",
async ({ me, params }) => {
await ownLink(me, params.imvu);
const found = await imvuCall(me, () => imvu.byId(params.imvu));
await age.record({ userId: me.id, imvuId: params.imvu }, "imvu", imvuResult(found));
const [l] = await db.update(imvuLink).set({ username: found.username }).where(eq(imvuLink.imvuId, params.imvu)).returning();
return { link: await linkView(l!) };
},
{ signedIn: true, params: imvuIdParam },
)
.delete(
"/account/links/:imvu",
async ({ me, params, set }) => {
const l = await ownLink(me, params.imvu);
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
await age.forgetAccount(l.imvuId);
await rooms.forgetOwner(l.imvuId);
realtime.unlinked(me.id, l.imvuId);
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
set.status = 204;
},
{ signedIn: true, params: imvuIdParam },
)
.post(
"/account/signout-computers",
async ({ me, set }) => {
await db.delete(session).where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%")));
realtime.disconnect(me.id);
set.status = 204;
},
{ signedIn: true },
)
// A proxy's sign-in code, approved by the person on the website.
.post(
"/device/approve",
async ({ me, body, request, set }) => {
const raw = body.code.toUpperCase().replace(/[\s-]/g, "");
try {
// Viewing the code claims it for this person; then they approve it.
await auth.api.deviceVerify({ query: { user_code: raw }, headers: request.headers });
await auth.api.deviceApprove({ body: { userCode: raw }, headers: request.headers });
} catch {
throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows.");
}
await audited(me.id, "proxy.signin", "");
set.status = 204;
},
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
)
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
.get("/rooms", async ({ person: me }) => {
const viewerAdult = await personAdult(me);
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
const here = new Map<string, number[]>();
for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]);
const ids = [...new Set([...here.values()].flat())];
const shown = ids.length
? await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username, badge: imvuLink.showBadge })
.from(imvuLink)
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showPresence, true)))
: [];
const out = listed.map(({ room: r, owner }) => {
const t5de = shown.filter((u) => here.get(r.instanceId)?.includes(u.imvuId));
return {
instanceId: r.instanceId,
name: r.name,
description: r.description,
imageURL: r.imageURL,
owner: { imvuId: r.ownerImvuId, username: owner },
adult: isAdultRoom(r),
occupancy: r.occupancy,
capacity: r.capacity,
url: `https://go.imvu.com/chat/room-${r.instanceId}`,
t5de,
seenAt: r.seenAt.toISOString(),
};
});
out.sort((a, b) => b.t5de.length - a.t5de.length);
return { rooms: out };
})
// T5DE-hosted rooms (docs/hosted-rooms.md).
.post(
"/hosted-rooms",
async ({ me, body, set }) => {
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
await audited(me.id, "hosted_room.create", String(r.room.n), {
name: r.room.name,
rating: r.room.rating,
privacy: r.room.privacy,
});
set.status = 201;
return { room: hostedView(r) };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 64 }),
description: t.Optional(t.String({ maxLength: 512 })),
roomPid,
rating,
maxUsers: t.Optional(maxUsers),
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
privacy: t.Optional(privacy),
}),
},
)
.get(
"/hosted-rooms",
async ({ person, query }) => {
const on = (v?: string) => v === "1" || v === "true";
const mine = on(query.mine);
const favorites = on(query.favorites);
const recent = on(query.recent);
if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({
adult: await personAdult(person),
viewer: person ? await viewerOf(person) : undefined,
mine,
favorites,
recent,
});
return { rooms: await hostedViews(rows, person) };
},
{
query: t.Object({
mine: t.Optional(t.String({ maxLength: 8 })),
favorites: t.Optional(t.String({ maxLength: 8 })),
recent: t.Optional(t.String({ maxLength: 8 })),
}),
},
)
.get(
"/hosted-rooms/:n",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return {
room: await hostedViewFor(r, person),
scene: { contents: r.room.scene, hash: r.room.sceneHash },
myRating: person ? await hosted.myRating(person.id, r.room.n) : 0,
};
},
{ params: hostedParam },
)
.patch(
"/hosted-rooms/:n",
async ({ me, params, body }) => {
const { moderators: given, ...rest } = body;
const changes: RoomChanges = given
? {
...rest,
moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators),
}
: rest;
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
realtime.hostedChanged(r.room.n, roomInfo(r));
await audited(me.id, "hosted_room.edit", String(r.room.n), {
fields: Object.keys(body),
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
});
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 512 })),
rating: t.Optional(rating),
maxUsers: t.Optional(maxUsers),
roomPid: t.Optional(roomPid),
privacy: t.Optional(privacy),
moderators: t.Optional(
t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), {
maxItems: maxModerators,
}),
),
}),
},
)
.put(
"/hosted-rooms/:n/scene",
async ({ me, params, body }) => {
if (!limiter.allow(`scene:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
const r = await hostedCall(() => hosted.setScene(me.id, params.n, body.contents, body.roomPid));
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ contents: t.Array(sceneItem, { maxItems: maxSceneItems }), roomPid: t.Optional(roomPid) }),
},
)
.delete(
"/hosted-rooms/:n",
async ({ me, params, set }) => {
const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n));
realtime.hostedClosed(r.n);
await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Phase 2: bans, images, favorites, ratings.
.get(
"/hosted-rooms/:n/participants",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { participants: realtime.participants(r.room.n) };
},
{ params: hostedParam },
)
.get(
"/hosted-rooms/:n/bans",
async ({ me, params }) => {
await hostedCall(async () => hosted.managed(await viewerOf(me), params.n));
const bans = await hosted.bans(params.n);
return {
bans: bans.map((b) => ({
imvuId: b.imvuId,
byImvuId: b.byImvuId,
until: b.until?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
})),
};
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/bans/:imvu",
async ({ me, params, set }) => {
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
throw new Problem(404, "not_found", "That account isn't banned here.");
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
set.status = 204;
},
{
signedIn: true,
params: t.Object({
n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }),
imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }),
}),
},
)
.get(
"/hosted-rooms/:n/image",
async ({ person, params, request }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
const img = r.room.imageHash ? await hosted.image(params.n) : undefined;
if (!img) throw new Problem(404, "not_found", "This room has no image.");
const etag = `"${img.hash}"`;
const headers = {
"Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`,
ETag: etag,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
};
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } });
},
{ params: hostedParam },
)
.put(
"/hosted-rooms/:n/image",
async ({ me, params, body }) => {
if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while.");
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
const data = Buffer.from(await body.image.arrayBuffer());
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ image: t.File() }),
beforeHandle({ request }) {
// Refuse big uploads before reading them (with room for the multipart framing).
if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024)
throw new Problem(400, "invalid", "An image is up to 512 KB.");
},
},
)
.delete(
"/hosted-rooms/:n/image",
async ({ me, params }) => {
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
return { room: await hostedViewFor(r, me) };
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
await hosted.favorite(me.id, params.n, true);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
await hosted.favorite(me.id, params.n, false);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/rating",
async ({ me, params, body }) => {
if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room.");
await hosted.rate(me.id, params.n, body.score);
return { room: await hostedViewFor(r, me), myRating: body.score };
},
{ signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) },
)
.delete(
"/hosted-rooms/:n/rating",
async ({ me, params, set }) => {
await hosted.rate(me.id, params.n, null);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Decorations over IMVU rooms (docs/products.md, "Decorations").
.get(
"/decorations",
async ({ person, query }) => {
const instances = [...new Set((query.instances ?? "").split(",").filter(Boolean))];
if (instances.length > maxBatch) throw new Problem(400, "invalid", `Up to ${maxBatch} rooms at a time.`);
return { rooms: await decorationCall(async () => decorations.get(instances, await personAdult(person))) };
},
{ query: t.Object({ instances: t.Optional(t.String({ maxLength: 1024 })) }) },
)
.get(
"/decorations/:instance",
async ({ person, params }) =>
(await decorationCall(async () => decorations.get([params.instance], await personAdult(person))))[0]!,
{ params: t.Object({ instance: t.String({ maxLength: 32 }) }) },
)
.put(
"/decorations/:instance",
async ({ me, params, body }) => {
if (!limiter.allow(`decor:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
await decorationCall(() => decorations.set(me.id, params.instance, body.contents));
realtime.decorated(params.instance);
return (await decorations.get([params.instance], await personAdult(me)))[0]!;
},
{
signedIn: true,
params: t.Object({ instance: t.String({ maxLength: 32 }) }),
body: t.Object({ contents: t.Array(decorationItem, { maxItems: maxDecorations }) }),
},
)
// Catalogs: registering one, and the directory.
.post(
"/catalogs",
async ({ me, body, set }) => {
const r = await productCall(() => products.registerCatalog(me.id, body));
await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url });
set.status = 201;
return { catalog: catalogView(r.catalog), key: r.key };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 64 }),
url: t.String({ maxLength: 300 }),
contact: t.String({ maxLength: 200 }),
visibility: t.String(),
membership: t.String(),
}),
},
)
.get("/catalogs", async ({ person }) => ({
catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })),
}))
// For catalogs: registering an uploaded revision.
.post(
"/catalog/revisions",
async ({ request, body, set }) => {
const c = await products.catalogFor(request.headers.get("authorization"));
if (!c) throw new Problem(401, "forbidden", "Not an approved catalog.");
const r = await productCall(() => products.registerRevision(c, body));
set.status = 201;
return r;
},
{
body: t.Object({
uploadToken: t.String({ maxLength: 4000 }),
files: t.Array(t.Object({ name: t.String(), sha256: t.String(), size: t.Number() }), { maxItems: 512 }),
rating: t.String(),
parent: t.Number(),
name: t.String({ maxLength: 200 }),
notes: t.String({ maxLength: 4000 }),
}),
},
)
// Products.
.post(
"/products/upload",
async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)),
{ signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) },
)
.get(
"/products",
async ({ person, query }) => {
const adult = await personAdult(person);
const rows = await products.list({ ...query, adult });
return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) };
},
{
query: t.Object({
q: t.Optional(t.String({ maxLength: 100 })),
creator: t.Optional(t.String({ maxLength: 64 })),
category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
}),
},
)
.get(
"/products/:id",
async ({ person, params }) => {
const adult = await personAdult(person);
const p = await productCall(() => products.get(params.id, adult));
const lib = person ? await products.inLibrary(person.id, params.id) : undefined;
const latest = p.revisions.find((r) => r.number === p.product.latest);
return {
product: productView(p.product, p.creator, p.creatorUsername, latest),
revisions: p.revisions.map((r) => ({
number: r.number,
rating: r.rating,
notes: r.notes,
retracted: r.retracted,
files: r.files,
size: r.files.reduce((n, f) => n + f.size, 0),
createdAt: r.createdAt.toISOString(),
catalogs: p.hosts.filter((h) => h.number === r.number).map((h) => h.name),
})),
inLibrary: lib !== undefined,
stayOn: lib?.stayOn ?? null,
mine: person?.id === p.product.creatorId,
};
},
{ params: productParam },
)
.patch(
"/products/:id",
async ({ me, params, body }) => {
const p = await productCall(() => products.update(me.id, params.id, body));
await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) });
return { product: productView(p, undefined, undefined, await products.latestParent(p)) };
},
{
signedIn: true,
params: productParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 20000 })),
rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
}),
},
)
// Its creator deletes a product (no longer listed or added; those who have it keep it).
.delete(
"/products/:id",
async ({ me, params, set }) => {
await productCall(() => products.delete(me.id, params.id));
await audited(me.id, "product.delete", params.id);
set.status = 204;
},
{ signedIn: true, params: productParam },
)
// Moderation removes a product: gone everywhere.
.post(
"/products/:id/remove",
async ({ me, params, body }) => {
await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() });
return { ok: true };
},
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
.post(
"/products/:id/revisions/:n/retract",
async ({ me, params }) => {
await productCall(() => products.retract(me.id, params.id, params.n));
await audited(me.id, "product.retract", params.id, { revision: params.n });
return { ok: true };
},
{ signedIn: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) },
)
// What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files.
.get(
"/products/:id/files",
async ({ person, params }) => {
const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person)));
return {
product: r.product.id,
revision: r.revision.number,
rating: r.revision.rating,
parent: r.revision.parent,
name: r.product.name,
creator: r.creator ?? "",
files: r.revision.files,
catalogs: r.catalogs,
token: r.revision.token,
};
},
{ params: productParam },
)
.post(
"/access",
async ({ me }) => ({
token: await signer.sign("t5de-access", { sub: me.id, adult: await personAdult(me) }, 60 * 60),
}),
{ signedIn: true },
)
// Libraries.
.get(
"/library",
async ({ me }) => ({
library: (await products.library(me.id)).map((l) => ({
product: productView(l.product, l.creator, undefined, l),
stayOn: l.stayOn,
addedAt: l.addedAt.toISOString(),
})),
}),
{ signedIn: true },
)
.put(
"/library/:id",
async ({ me, params }) => {
await productCall(async () => products.add(me.id, params.id, await personAdult(me)));
realtime.notify(me.id, { type: "library" });
return { ok: true };
},
{ signedIn: true, params: productParam },
)
.delete(
"/library/:id",
async ({ me, params, set }) => {
await products.remove(me.id, params.id);
realtime.notify(me.id, { type: "library" });
set.status = 204;
},
{ signedIn: true, params: productParam },
)
.patch(
"/library/:id",
async ({ me, params, body }) => {
await productCall(() => products.stayOn(me.id, params.id, body.stayOn));
realtime.notify(me.id, { type: "library" });
return { ok: true };
},
{
signedIn: true,
params: productParam,
body: t.Object({ stayOn: t.Union([t.Integer({ minimum: 1 }), t.Null()]) }),
},
)
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
.get(
"/age",
async ({ me }) => ({
providers: age.list(),
checks: (await age.personChecks(me.id)).map((c) => ({
provider: c.provider,
method: c.method,
status: c.status,
overEighteen: c.overEighteen,
createdAt: c.createdAt.toISOString(),
checkedAt: c.checkedAt?.toISOString() ?? null,
expiresAt: c.expiresAt?.toISOString() ?? null,
})),
}),
{ signedIn: true },
)
.post(
"/age/:provider/start",
async ({ me, params, body }) => {
let p;
try {
p = age.provider(params.provider);
} catch {
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.");
// 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 }) }) },
)
.post(
"/age/:provider/complete",
async ({ params, body }) => {
const p = age.provider(params.provider);
if (!p.complete) throw new Problem(404, "not_found", "Not found.");
try {
const c = await age.finish(p.id, body.ref, await p.complete(body.ref, body.response));
return { status: c.status, overEighteen: c.overEighteen };
} catch (e) {
if (e instanceof AgeError) throw new Problem(404, "not_found", "That check isn't waiting for an answer.");
throw e;
}
},
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ ref: t.String(), response: t.Unknown() }) },
)
// Providers' webhooks: each checks the request is really from it.
.post(
"/age/webhook/:provider",
async ({ params, request, set }) => {
let p;
try {
p = age.provider(params.provider);
} catch {
throw new Problem(404, "not_found", "Not found.");
}
if (!p.webhook) throw new Problem(404, "not_found", "Not found.");
let answer;
try {
answer = await p.webhook(request);
} 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, { anyStatus: true });
} catch (e) {
if (!(e instanceof AgeError)) throw e; // a check that isn't the hub's (another environment's)
}
}
set.status = 204;
},
{ params: t.Object({ provider: t.String() }), parse: "none" },
)
// The console.
.get(
"/console/catalogs",
async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }),
{ moderator: true },
)
.post(
"/console/catalogs/:id/:decision",
async ({ me, params }) => {
const c = await productCall(() =>
products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"),
);
await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name });
return { catalog: catalogView(c) };
},
{ moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) },
)
.get(
"/console",
async ({ me }) => {
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`,
})
.from(sql`(select 1) as one`);
const log = await db
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail })
.from(audit)
.leftJoin(user, eq(user.id, audit.actorId))
.orderBy(desc(audit.id))
.limit(50);
return {
counts: counts!,
connections: realtime.connections(),
online: await visibleOnline(me),
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })),
};
},
{ moderator: true },
)
.get(
"/console/people",
async ({ query }) => {
const q = (query.q ?? "").trim();
const like = `%${q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
const rows = await db
.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 })), 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",
async ({ params }) => {
const p = await personById(params.id);
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, p.id)).orderBy(imvuLink.linkedAt);
const [computers] = await db
.select({ n: count() })
.from(session)
.where(and(eq(session.userId, p.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return { person: personView(p), links: await linkViews(p.id, links), computers: computers?.n ?? 0 };
},
{ moderator: true },
)
.post(
"/console/people/:id/link-cap",
async ({ me, params, body }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
return { person: personView(updated!) };
},
{ moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) },
)
.post(
"/console/people/:id/ban",
async ({ me, params, body }) => {
const p = await personById(params.id);
if (p.id === me.id) throw new Problem(400, "invalid", "You can't ban yourself.");
if (isModerator(p) && me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can ban moderators.");
const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning();
await db.delete(session).where(eq(session.userId, p.id));
realtime.disconnect(p.id);
await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason });
return { person: personView(updated!) };
},
{ moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
.post(
"/console/people/:id/unban",
async ({ me, params }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.unban", p.username ?? p.id);
return { person: personView(updated!) };
},
{ moderator: true },
)
.post(
"/console/people/:id/role",
async ({ me, params, body }) => {
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change roles.");
const p = await personById(params.id);
if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role.");
const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
return { person: personView(updated!) };
},
{
moderator: true,
body: t.Object({ role: t.Union([t.Literal("user"), t.Literal("moderator"), t.Literal("admin")]) }),
},
)
// T5DE's proxy: which of the IMVU accounts it's showing get the T5 badge (linked, the badge turned on, the
// person not banned). Nothing else about them.
.post(
"/badges",
async ({ me, body }) => {
if (!limiter.allow(`badges:${me.id}`, 120, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while.");
const ids = [...new Set(body.imvuIds)];
if (ids.length === 0) return { badges: [] };
const rows = await db
.select({ imvuId: imvuLink.imvuId })
.from(imvuLink)
.innerJoin(user, eq(user.id, imvuLink.userId))
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showBadge, true), sql`coalesce(${user.banned}, false) = false`));
return { badges: rows.map((r) => r.imvuId) };
},
{
signedIn: true,
body: t.Object({
imvuIds: t.Array(t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), { maxItems: 200 }),
}),
},
)
// T5DE's proxy: the live connection.
.ws("/realtime", {
async beforeHandle({ person, set }) {
if (!person) {
set.status = 401;
return { error: "signed_out", message: "Sign in first." };
}
},
open(ws) {
const { person } = ws.data as unknown as { person: Person };
realtime.open(
ws.id,
person.id,
() => ws.close(4001, "signed out"),
(m) => ws.send(JSON.stringify(m)),
);
ws.send(JSON.stringify({ type: "welcome" }));
},
async message(ws, message) {
const reply = await realtime.message(ws.id, message);
if (reply) ws.send(JSON.stringify(reply));
},
close(ws) {
realtime.close(ws.id);
},
});
const app = new Elysia()
.all("/api/auth/*", ({ request }) => auth.handler(request))
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
.get("/.well-known/t5de-keys.json", async () => signer.jwks())
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime, age, rooms, products, signer, hosted, decorations };
}
export type App = ReturnType<typeof createApp>["app"];