Badges: the proxy asks which IMVU accounts show the T5 badge
CI / test (push) Successful in 49s

POST /api/v1/badges {imvuIds} (at most 200) answers the linked accounts whose
badge is on, of people who aren't banned, in one query. It uses the existing
per-account showBadge (off by default, set from the account page).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 02:38:01 -07:00
1 parent 6668b55117
commit 0c67232425
4 files changed
+149 -2

No files matched your search

+24
View File
@@ -957,6 +957,30 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}, },
) )
// 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. // T5DE's proxy: the live connection.
.ws("/realtime", { .ws("/realtime", {
async beforeHandle({ person, set }) { async beforeHandle({ person, set }) {
+121
View File
@@ -0,0 +1,121 @@
// The T5 badge on people: who shows it, as the proxy asks.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, proxy = false) {
const auth: Record<string, string> = p ? (proxy ? { Authorization: `Bearer ${p.token}` } : { Cookie: p.cookie }) : {};
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...auth },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up and links their IMVU accounts. */
async function person(name: string, ...ids: number[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, id] of ids.entries()) {
const username = `${name}Av${i}`;
accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200);
}
return p;
}
const badges = async (p: Person | undefined, imvuIds: unknown) => call(p, "POST", "/api/v1/badges", { imvuIds }, true);
test("only linked accounts whose badge is on, of people who aren't banned, get the badge", async () => {
const admin = await person("badgeadmin");
const ann = await person("ann", 301, 302);
const bob = await person("bob", 303);
const asker = await person("asker");
// Off by default.
const account = (await call(ann, "GET", "/api/v1/account")).json;
expect(account.links.map((l: any) => l.showBadge)).toEqual([false, false]);
expect((await badges(asker, [301, 302, 303, 999])).json).toEqual({ badges: [] });
// Turned on for one account; the other stays off.
const patched = await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: true });
expect(patched.json.link.showBadge).toBe(true);
await call(bob, "PATCH", "/api/v1/account/links/303", { showBadge: true });
const got = await badges(asker, [301, 302, 303, 999, 301]);
expect(got.status).toBe(200);
expect(got.json.badges.sort()).toEqual([301, 303]);
expect(Object.keys(got.json)).toEqual(["badges"]);
// Turned off again.
await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: false });
expect((await badges(asker, [301, 303])).json).toEqual({ badges: [303] });
// A banned person's accounts don't.
const bobId = (await call(bob, "GET", "/api/v1/me")).json.person.id;
await db.update(schema.user).set({ banned: true }).where(eq(schema.user.id, bobId));
expect((await badges(asker, [303])).json).toEqual({ badges: [] });
expect((await badges(asker, [])).json).toEqual({ badges: [] });
expect((await badges(admin, [303])).status).toBe(200);
});
test("lookups are validated and need signing in", async () => {
const p = await person("carol");
expect((await badges(undefined, [1])).status).toBe(401);
expect((await badges(p, Array.from({ length: 201 }, (_, i) => i + 1))).status).toBe(400);
expect((await badges(p, Array.from({ length: 200 }, (_, i) => i + 1))).status).toBe(200);
for (const bad of [[0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], ["x"], null, "x", { id: 1 }])
expect(JSON.stringify(bad) + " " + (await badges(p, bad)).status).toBe(JSON.stringify(bad) + " 400");
expect((await call(p, "POST", "/api/v1/badges", {}, true)).status).toBe(400);
});
+2 -1
View File
@@ -29,6 +29,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. | | `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` | | `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person | | `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
| `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. |
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, and the room channel. Protocol in `apps/api/src/realtime.ts`. | | `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, and the room channel. Protocol in `apps/api/src/realtime.ts`. |
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with | | `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
@@ -44,7 +45,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) | | `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` | | `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking | | `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` | | `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` (each off by default; `showBadge`: others see the T5 badge on this account in the client) |
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) | | `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
| `DELETE /api/v1/account/links/:imvu` | unlink | | `DELETE /api/v1/account/links/:imvu` | unlink |
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
+2 -1
View File
@@ -305,7 +305,8 @@ is per person, shared across their accounts.
## Presence and badge (**Decided**: both opt-in, per IMVU account) ## Presence and badge (**Decided**: both opt-in, per IMVU account)
- **Badge**: other T5DE users see that this account uses T5DE. - **Badge**: other T5DE users see that this account uses T5DE. The proxy asks the hub which of the accounts it's
showing have it (`POST /api/v1/badges`, docs/api.md).
- **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't - **Presence**: friends see "online in T5DE". The proxy asks the hub about the user's IMVU friends; the hub doesn't
store friend lists, but it sees them when asked (stated in the privacy text). store friend lists, but it sees them when asked (stated in the privacy text).