Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s

The hub becomes a Bun monorepo: apps/api (Elysia, Better Auth for accounts, roles, bans, proxy device
sign-in and JWT keys; tagline linking, presence and the console as before), packages/db (Drizzle; Postgres in
production, PGlite in development and tests), and the Next.js website from apps/web. Self-hostable with
docker compose; the website deploys to Vercel-style hosts, the API to any that runs Bun or containers. The
first version, in Go, is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 05:03:54 -07:00
1 parent 64c552330b
commit 7a06ba49c3
59 files changed
+2640 -3523

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# The hub's API. Build from the repository root: docker build -f apps/api/Dockerfile .
FROM oven/bun:1.4.4-slim AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile --production --filter @t5de/api
FROM oven/bun:1.4.4-slim
WORKDIR /app
ENV NODE_ENV=production PORT=4101
COPY --from=deps /app/node_modules ./node_modules
COPY packages/db ./packages/db
COPY apps/api ./apps/api
USER bun
EXPOSE 4101
CMD ["bun", "apps/api/src/index.ts"]
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@t5de/api",
"private": true,
"type": "module",
"exports": {
".": "./src/app.ts"
},
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@t5de/db": "workspace:*",
"better-auth": "1.7.14",
"elysia": "1.4.30"
},
"devDependencies": {
"@types/bun": "^1.4.4",
"typescript": "^7.0.2"
}
}
+581
View File
@@ -0,0 +1,581 @@
// 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, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db";
import type { Auth } from "./auth";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime };
/** 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 }: Deps) {
const realtime =
rt ??
new Realtime(async (imvuId) => {
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.userId;
});
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 ?? "",
});
const linkView = (l: Link) => {
const live = realtime.online().find((o) => o.imvuId === l.imvuId);
return {
imvuId: l.imvuId,
username: l.username,
linkedAt: l.linkedAt.toISOString(),
ageVerified: l.ageVerified,
accessPass: l.accessPass,
adult: l.ageVerified || l.accessPass,
checkedAt: l.checkedAt.toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
};
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);
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: l.ageVerified || l.accessPass } : {}),
},
];
});
};
const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) });
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;
return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id };
})
.macro({
signedIn: {
resolve: ({ person }) => {
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person };
},
},
moderator: {
resolve: ({ person }) => {
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", ({ me }) => ({ person: personView(me) }), { signedIn: true })
.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: links.map(linkView),
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,
ageVerified: found.ageVerified,
accessPass: found.accessPass,
checkedAt: new Date(),
};
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 };
});
return { link: 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 } : {}),
})
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: linkView(l!) };
},
{
signedIn: true,
params: imvuIdParam,
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: 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));
const [l] = await db
.update(imvuLink)
.set({ username: found.username, ageVerified: found.ageVerified, accessPass: found.accessPass, checkedAt: new Date() })
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: 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 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 }) }) },
)
// The console.
.get(
"/console",
async ({ me }) => {
const [counts] = await db
.select({
people: sql<number>`(select count(*) from ${user})::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(
q
? or(
ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
)
: undefined,
)
.orderBy(desc(user.createdAt))
.limit(100);
return { people: rows.map(personView) };
},
{ moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) },
)
.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: links.map(linkView), 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: 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"));
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("/health", () => ({ ok: true }))
.use(api);
return { app, realtime };
}
export type App = ReturnType<typeof createApp>["app"];
+70
View File
@@ -0,0 +1,70 @@
// Accounts, sessions and proxy sign-in, by Better Auth: email and password with usernames, roles and bans (admin),
// device sign-in for T5DE's proxy (RFC 8628), bearer tokens for the proxy's requests, and JWTs with published keys
// (for catalogs later).
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";
export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */
apiURL: string;
/** The website's address, e.g. https://hub.t5de.dev: trusted origin, and where /device is. */
webURL: string;
/** At least 32 random characters; signs sessions. */
secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean;
};
/** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) {
return betterAuth({
baseURL: config.apiURL,
basePath: "/api/auth",
secret: config.secret,
trustedOrigins: [config.webURL],
database: drizzleAdapter(db, { provider: "pg", schema }),
emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 256 },
user: {
additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false },
},
},
// Proxies stay signed in for months; a session is extended when it's used.
session: { expiresIn: 60 * 60 * 24 * 60, updateAge: 60 * 60 * 24 },
rateLimit: { enabled: config.rateLimit !== false, window: 60, max: 100 },
advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] } },
databaseHooks: {
user: {
create: {
// The first account runs the hub.
before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
},
},
},
},
plugins: [
username({
minUsernameLength: 3,
maxUsernameLength: 32,
usernameValidator: (name) => /^[A-Za-z0-9_]+$/.test(name),
}),
admin({ defaultRole: "user", adminRoles: ["admin"] }),
deviceAuthorization({
verificationUri: `${config.webURL}/device`,
expiresIn: "10m",
interval: "3s",
validateClient: (clientId) => clientId === proxyClientID,
}),
bearer(),
jwt({ jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }),
],
});
}
export type Auth = ReturnType<typeof createAuth>;
+81
View File
@@ -0,0 +1,81 @@
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
export type ImvuUser = {
id: number;
username: string;
tagline: string;
ageVerified: boolean;
accessPass: boolean;
};
export class NoSuchImvuUser extends Error {
constructor() {
super("no such IMVU account");
}
}
export class ImvuUnavailable extends Error {}
// Calls are spaced out so the hub never floods IMVU.
const minInterval = 200;
export class ImvuClient {
private next = 0;
constructor(
readonly base = "https://api.imvu.com",
private readonly fetchFn: typeof fetch = fetch,
) {}
byUsername(username: string): Promise<ImvuUser> {
const name = username.trim();
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
return this.get(`/user?username=${encodeURIComponent(name)}`);
}
byId(id: number): Promise<ImvuUser> {
return this.get(`/user/user-${id}`);
}
private async wait() {
const now = Date.now();
const at = Math.max(now, this.next);
this.next = at + minInterval;
if (at > now) await Bun.sleep(at - now);
}
private async get(path: string): Promise<ImvuUser> {
await this.wait();
let response: Response;
try {
response = await this.fetchFn(this.base + path, {
headers: { "User-Agent": "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)" },
signal: AbortSignal.timeout(15_000),
});
} catch (e) {
throw new ImvuUnavailable(String(e));
}
if (response.status === 404) throw new NoSuchImvuUser();
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
return parse(await response.json());
}
}
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
export function parse(answer: unknown): ImvuUser {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const users = Object.entries(den).filter(([key]) => key.includes("/user/user-"));
if (users.length !== 1) throw new NoSuchImvuUser();
const data = users[0]![1].data ?? {};
const id = Number(data.legacy_cid);
const username = typeof data.username === "string" ? data.username : "";
if (!Number.isSafeInteger(id) || id <= 0 || !username) throw new NoSuchImvuUser();
return {
id,
username,
tagline: typeof data.tagline === "string" ? data.tagline : "",
ageVerified: data.is_ageverified === true,
accessPass: data.is_ap === true,
};
}
+27
View File
@@ -0,0 +1,27 @@
// Runs the hub's API. Configuration (environment):
// DATABASE_URL postgres://... (default: PGlite files in ./data/db, for development)
// AUTH_SECRET at least 32 random characters (required unless NODE_ENV is development)
// 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
import { openDatabase } from "@t5de/db";
import { createApp } from "./app";
import { createAuth } from "./auth";
import { ImvuClient } from "./imvu";
const env = process.env;
const dev = env.NODE_ENV !== "production";
const port = Number(env.PORT ?? 4101);
const secret = env.AUTH_SECRET ?? (dev ? "development-secret-not-for-production-use" : "");
if (secret.length < 32) {
console.error("AUTH_SECRET must be at least 32 characters");
process.exit(1);
}
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 } = createApp({ db, auth, imvu: new ImvuClient() });
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+106
View File
@@ -0,0 +1,106 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
// community products it wears. Later, room channels.
//
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token):
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
export type Item = { id: string; name: string };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void };
export type Owners = (imvuId: number) => Promise<string | undefined>;
export class Realtime {
private sessions = new Map<string, Session>();
private listeners = new Set<() => void>();
constructor(private readonly owner: Owners) {}
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private changed() {
for (const fn of this.listeners) fn();
}
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
online(): Online[] {
const byId = new Map<number, Online>();
for (const s of this.sessions.values()) {
if (!s.imvuId) continue;
const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue;
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
}
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
}
connections(): number {
return this.sessions.size;
}
open(id: string, userId: string, close: () => void) {
this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close });
}
close(id: string) {
const s = this.sessions.get(id);
this.sessions.delete(id);
if (s?.imvuId) this.changed();
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
}
/** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id);
if (!s) return;
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
const imvuId = Number(m.imvu_id);
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it.
s.imvuId = 0;
s.outfit = [];
this.changed();
return { type: "error", message: "not_linked" };
}
s.imvuId = imvuId;
s.since = new Date();
s.outfit = [];
this.changed();
return;
}
if (m.type === "outfit") {
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" };
s.outfit = m.items
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
.map((it) => ({ id: it.id, name: it.name }));
this.changed();
return;
}
return { type: "error", message: "unknown message type" };
}
}
function safeJSON(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return undefined;
}
}
+249
View File
@@ -0,0 +1,249 @@
// The whole API against an in-process Postgres (PGlite) and a fake IMVU.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth, proxyClientID } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
// IMVU's public user API, answering from accounts.
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const found = [...accounts.values()].find(
(u) =>
url.pathname === `/user/user-${u.id}` ||
(url.pathname === "/user" && url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase()),
);
const denormalized = found
? {
[`https://api.imvu.com/user/user-${found.id}`]: {
data: {
legacy_cid: found.id,
username: found.username,
tagline: found.tagline,
is_ageverified: found.ageVerified,
is_ap: found.accessPass,
},
},
}
: {};
return Response.json({ status: "success", denormalized });
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let hub: ReturnType<typeof createApp>;
let server: { port: number; stop: () => void } | undefined;
let base: string;
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 });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
server = hub.app.server as unknown as { port: number; stop: () => void };
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
server?.stop();
await close();
});
/** A person using the website: keeps the session cookie. */
class Browser {
cookie = "";
async call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: {
Origin: webURL,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(this.cookie ? { Cookie: this.cookie } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) this.cookie = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async signup(username: string) {
const r = await this.call("POST", "/api/auth/sign-up/email", {
email: `${username.toLowerCase()}@example.test`,
password: "a long password",
name: username,
username,
});
expect(r.status).toBe(200);
return r;
}
}
/** Links an IMVU account through the API, putting the code in its fake tagline. */
async function link(b: Browser, u: Omit<ImvuUser, "tagline">) {
accounts.set(u.id, { ...u, tagline: "" });
const started = await b.call("POST", "/api/v1/account/links", { username: u.username });
expect(started.status).toBe(201);
accounts.set(u.id, { ...u, tagline: `hi ${started.json.challenge.code.toLowerCase()}` });
return b.call("POST", `/api/v1/account/links/${u.id}/check`);
}
describe("accounts", () => {
test("the first account is the admin; usernames sign in", async () => {
const alice = new Browser();
await alice.signup("alice");
expect((await alice.call("GET", "/api/v1/me")).json.person.role).toBe("admin");
const bob = new Browser();
await bob.signup("bob");
expect((await bob.call("GET", "/api/v1/me")).json.person.role).toBe("user");
expect((await bob.call("GET", "/api/v1/console")).status).toBe(403);
const again = new Browser();
const wrong = await again.call("POST", "/api/auth/sign-in/username", { username: "bob", password: "nope nope nope" });
expect(wrong.status).toBe(401);
const right = await again.call("POST", "/api/auth/sign-in/username", { username: "BOB", password: "a long password" });
expect(right.status).toBe(200);
expect((await again.call("GET", "/api/v1/me")).json.person.username).toBe("bob");
const taken = await new Browser().call("POST", "/api/auth/sign-up/email", {
email: "[email protected]",
password: "a long password",
name: "Alice",
username: "ALICE",
});
expect(taken.status).toBeGreaterThanOrEqual(400);
expect((await new Browser().call("GET", "/api/v1/me")).status).toBe(401);
});
});
describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser();
await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false });
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false });
expect(linked.status).toBe(200);
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
"no_such_imvu_account",
);
// Dave proves the same account is his: it moves, with a new pairwise ID.
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser();
await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false });
expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId);
expect(after.pairwiseId).not.toBe(before.pairwiseId);
expect((await carol.call("GET", "/api/v1/account")).json.links).toHaveLength(0);
// A moderator lowers Dave's link cap to 1: a second account is refused.
const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false });
expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap");
});
});
describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser();
await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false });
// The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: proxyClientID }),
});
const device = await codeRes.json();
expect(device.verification_uri).toBe(`${webURL}/device`);
const poll = async () =>
(
await fetch(`${base}/api/auth/device/token`, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "t5de-proxy/test" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device.device_code,
client_id: proxyClientID,
}),
})
).json();
expect((await poll()).error).toBe("authorization_pending");
const userCode = device.user_code as string;
const approved = await erin.call("POST", "/api/v1/device/approve", {
code: userCode.toLowerCase().slice(0, 4) + "-" + userCode.toLowerCase().slice(4),
});
expect(approved.status).toBe(204);
await Bun.sleep(3100); // the proxy keeps to the polling interval
const tokens = await poll();
expect(tokens.access_token).toBeString();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const me = await fetch(`${base}/api/v1/me`, { headers: bearer });
expect((await me.json()).person.username).toBe("erin");
// The live connection: another person's account is refused; Erin's shows online with her outfit, to moderators,
// and to everyone once she opts in.
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: bearer } as any);
const messages: any[] = [];
ws.onmessage = (e) => messages.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
await waitFor(() => messages.some((m) => m.message === "not_linked"));
ws.send(JSON.stringify({ type: "session", imvu_id: 77 }));
ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] }));
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1));
const visitor = new Browser();
expect((await visitor.call("GET", "/api/v1/online")).json.online).toHaveLength(0);
const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const consoleView = (await admin.call("GET", "/api/v1/console")).json;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
expect(online).toEqual([
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] },
]);
// A ban closes the connection and ends the proxy's session.
const erinId = (await erin.call("GET", "/api/v1/me")).json.person.id;
const closed = new Promise((r) => (ws.onclose = r));
expect((await admin.call("POST", `/api/v1/console/people/${erinId}/ban`, { reason: "testing" })).status).toBe(200);
await closed;
expect((await fetch(`${base}/api/v1/me`, { headers: bearer })).status).toBe(401);
expect((await admin.call("GET", "/api/v1/console")).json.audit.map((e: any) => e.action)).toContain("person.ban");
}, 20_000);
});
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200; i++) {
if (ok()) return;
await Bun.sleep(10);
}
throw new Error("timed out");
}
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true,
"noEmit": true, "skipLibCheck": true, "types": ["bun"], "noUncheckedIndexedAccess": true
},
"include": ["src", "test"]
}