From 339ba8e2d65965171d750d3742b2208e7dea62c6 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 05:07:28 -0700 Subject: [PATCH] Presence: count an account as soon as it's linked; accept an empty outfit Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 5 ++++- apps/api/src/realtime.ts | 38 ++++++++++++++++++++++++++++++++++---- apps/api/test/hub.test.ts | 7 +++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9c764bb..39fa594 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -350,8 +350,10 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) { target: String(found.id), detail: moved ? { imvu: found.username, movedFrom: prev!.userId } : { imvu: found.username }, }); - return { link, moved }; + return { link, moved, previousOwner: prev?.userId }; }); + if (result.moved) realtime.unlinked(result.previousOwner!, found.id); + realtime.linked(me.id, found.id); return { link: linkView(result.link), moved: result.moved }; }, { signedIn: true, params: imvuIdParam }, @@ -403,6 +405,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) { async ({ me, params, set }) => { const l = await ownLink(me, params.imvu); await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId)); + realtime.unlinked(me.id, l.imvuId); await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username }); set.status = 204; }, diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index 764316a..7d5fde7 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -11,7 +11,8 @@ 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 }; +// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is). +type Session = { userId: string; imvuId: number; requested: number; since: Date; outfit: Item[]; close: () => void }; export type Owners = (imvuId: number) => Promise; @@ -48,7 +49,7 @@ export class Realtime { } open(id: string, userId: string, close: () => void) { - this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close }); + this.sessions.set(id, { userId, imvuId: 0, requested: 0, since: new Date(), outfit: [], close }); } close(id: string) { @@ -57,6 +58,32 @@ export class Realtime { if (s?.imvuId) this.changed(); } + /** A person just linked an IMVU account: their clients already using it count now. */ + linked(userId: string, imvuId: number) { + let changed = false; + for (const s of this.sessions.values()) { + if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) { + s.imvuId = imvuId; + s.since = new Date(); + changed = true; + } + } + if (changed) this.changed(); + } + + /** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */ + unlinked(userId: string, imvuId: number) { + let changed = false; + for (const s of this.sessions.values()) { + if (s.userId === userId && s.imvuId === imvuId) { + s.imvuId = 0; + s.outfit = []; + changed = true; + } + } + if (changed) 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(); @@ -70,6 +97,7 @@ export class Realtime { if (!m || typeof m !== "object") return { type: "error", message: "not a message" }; if (m.type === "session") { const imvuId = Number(m.imvu_id); + s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0; 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. @@ -85,8 +113,10 @@ export class Realtime { 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 + const items = m.items ?? []; + if (!Array.isArray(items)) return { type: "error", message: "items must be a list" }; + if (items.length > 64) return { type: "error", message: "too many items" }; + s.outfit = 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 })); diff --git a/apps/api/test/hub.test.ts b/apps/api/test/hub.test.ts index 2d992c8..07f3be0 100644 --- a/apps/api/test/hub.test.ts +++ b/apps/api/test/hub.test.ts @@ -213,6 +213,13 @@ describe("T5DE's proxy", () => { 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")); + // An account linked while the client runs counts at once. + ws.send(JSON.stringify({ type: "session", imvu_id: 78 })); + await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2); + await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false }); + await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78)); + await erin.call("DELETE", "/api/v1/account/links/78"); + await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78)); 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));