From 0c67232425564747341f84b20e06d9f16385e85f Mon Sep 17 00:00:00 2001 From: dhkatz Date: Fri, 30 Oct 2026 02:38:01 -0700 Subject: [PATCH] Badges: the proxy asks which IMVU accounts show the T5 badge 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 --- apps/api/src/app.ts | 24 +++++++ apps/api/test/badges.test.ts | 121 +++++++++++++++++++++++++++++++++++ docs/api.md | 3 +- docs/design.md | 3 +- 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/badges.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a10abcd..0db2def 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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. .ws("/realtime", { async beforeHandle({ person, set }) { diff --git a/apps/api/test/badges.test.ts b/apps/api/test/badges.test.ts new file mode 100644 index 0000000..924f738 --- /dev/null +++ b/apps/api/test/badges.test.ts @@ -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(); + +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; +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 = 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 { + 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); +}); diff --git a/docs/api.md b/docs/api.md index 77206b5..7a32244 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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/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/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 /.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/:imvu/check` | `{link, moved}` | | `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) | | `DELETE /api/v1/account/links/:imvu` | unlink | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | diff --git a/docs/design.md b/docs/design.md index 7f7426e..9568274 100644 --- a/docs/design.md +++ b/docs/design.md @@ -305,7 +305,8 @@ is per person, shared across their accounts. ## 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 store friend lists, but it sees them when asked (stated in the privacy text).