Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s
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:
1 parent
64c552330b
commit
7a06ba49c3
59 files changed
+2640
-3523
No files matched your search
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user