Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/backend/profile-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,14 @@ Implementation and acceptance criteria: [Profile link destination names](../plan

When no authored profile image or primary-logo placement exists, a profile can use cached
destination artwork. Hidden authored media still blocks replacement. People use only custom
VRChat `userIcon` images, never profile overrides, banners, or current-avatar images. A matching primary VRChat connection
VRChat icons, never profile overrides, banners, or current-avatar images. The collector uses
the explicit legacy `userIcon` when present. For current `iconUrl` responses, it reads the
exact referenced file and requires matching file identity and user ownership, only the
`icon` purpose tag, and a complete matching file version. Unknown URL layouts or missing,
inaccessible, or ambiguous provenance supply no artwork. Temporary file-read failures
preserve cached metadata; authentication, rate-limit, and request-budget failures retain
the existing collector handling. Each file lookup consumes its own shared and local budget
reservation. A matching primary VRChat connection
is preferred among public outbound links; otherwise saved link order decides. Communities
prefer their selected VRChat group, then selected Discord server. Without a selection, the
first eligible link of each kind is used, including on unclaimed communities.
Expand All @@ -109,7 +116,8 @@ event identities, owner previews, and share cards. Both image and source-link vi
apply for the requested surface. The 512px image route rechecks the currently selected
fallback before serving bytes, including after source removal or preference changes.

VRChat user artwork requires `artworkType: user_icon`. Legacy rows without provenance,
VRChat user artwork requires `artworkType: user_icon`, including modern icons validated
through their referenced file. Legacy rows without provenance,
including the former `profile_picture` marker (which incorrectly identified profile overrides),
are suppressed immediately on new reads and old URLs fail authorization; the existing
demand-driven refresh can repopulate custom pictures. Existing browser-cached bytes may
Expand Down
7 changes: 6 additions & 1 deletion docs/planning/vrchat-profile-image-fallback-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ saved link order supplies a default before owner selection. See the
Correction (2026-09-09): the portrait-field assumption below was wrong. The requested
custom profile icon is `userIcon`, not `profilePicOverride` or its thumbnail. VRCX's
[user-image helper](https://github.com/vrcx-team/VRCX/blob/master/src/shared/utils/user.js)
handles the icon separately from the override. Current implementation uses only `userIcon`.
handles the icon separately from the override. The September correction used only `userIcon`.
Correction (2026-10-15): current user responses can instead expose `iconUrl`. The collector
accepts that value only after verifying the exact referenced file's identity, owner,
`icon` purpose, and complete matching version. The explicit legacy `userIcon` remains
supported. The current implementation is documented in
[Automatic profile images](../backend/profile-schema.md#automatic-profile-images).

The existing destination resolver already collects a usable portrait URL for a VRChat user. Its priority is `profilePicOverrideThumbnail`, `profilePicOverride`, `currentAvatarThumbnailImageUrl`, then `currentAvatarImageUrl`. This is our implemented choice, not an assertion that VRChat guarantees this precedence. It stores one selected source URL, so the original full-size alternative is not retained when a thumbnail wins. [Resolver](../../workers/group-telemetry/profile-link-destination.mjs)

Expand Down
24 changes: 24 additions & 0 deletions docs/superpowers/plans/2026-10-15-vrchat-icon-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Current VRChat icon support

## Approved outcome

Restore automatic person pictures for current VRChat `iconUrl` responses while preserving custom-picture-only selection. BASIC authorized implementation after the field-shape investigation. Authored media, visibility, source precedence, and existing cache behavior stay in force.

Use the existing resolver and request-budget path. Legacy `userIcon` retains its explicit custom-icon behavior. A modern `iconUrl` requires an exact provider file reference, matching file owner, unambiguous `icon` purpose, and a complete matching version. Missing or unsupported provenance resolves the name without artwork. Temporary file-read failures preserve cached results; account authentication, rate limits, and budget errors retain existing handling.

The tradeoff is one additional budgeted provider GET for a modern icon. Unsupported URL layouts fail closed until their file identity can be established. No public prose or visual layout changes are proposed.

## Implementation and verification

- [x] Add a failing current-response regression through `VrchatClient` and the destination-job boundary. Cover file ownership/purpose/version rejection and the second request's budget/error behavior.
- [x] Add the smallest resolver change in `workers/group-telemetry/profile-link-destination.mjs`. Reuse the existing `user_icon` result contract, bounded HTTP parser, and per-request reservation. The five-minute job lease already bounds two provider requests of at most 15 seconds each.
- [x] Run the complete collector suite and relevant existing backend/web projection coverage. Update `docs/backend/profile-schema.md`, collector operating documentation, and the historical research correction.
- [ ] Request independent code review, address valid findings, then commit, push, and open a PR. Report source/CI evidence separately from deployment and production refresh.

## Scope boundary

No production cache sweep, manual image replacement, provider credential access, or deployment is included in this implementation. The collector account's exact-target file visibility and default/migrated icon controls remain runtime verification requirements. A later bounded rollout should verify those before refreshing affected rows through the existing queue.

## Verification evidence

The modern-response regression and its second-request budget/authentication tests failed before the selector change and passed afterward. The complete collector suite passed 182 tests. Existing backend destination/fallback/projection tests passed 18 tests; artwork/import/cache tests passed 13 tests. Markdown lint passed across 213 files. Independent review found no actionable defects. These checks establish local behavior, not production deployment or a refreshed picture.
9 changes: 8 additions & 1 deletion workers/group-telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ IDs, or group short codes, without joining groups or fetching member lists. The
result contains only a public display name, stable ID, and approved artwork
source URL. Non-public group responses cannot supply display metadata. Artwork
bytes are handled separately by the web application's bounded image boundary.
Each provider request, including short-code resolution, reserves the existing
Current person `iconUrl` values require an additional exact-file lookup to verify
file identity, owner, the `icon` purpose, and the referenced complete version.
An explicit legacy `userIcon` remains eligible without that extra read. Missing or
ambiguous file evidence omits artwork while retaining the resolved name. Temporary
file-read failures preserve cached metadata. No avatar, profile override, or banner
is selected as a substitute. Unknown image URL layouts fail closed.

Each provider request, including short-code resolution and icon-file metadata, reserves the existing
shared proof budget and consumes the local account/proof allowance. Provider
throttles publish the existing account-wide cooldown before a claim is released;
an authenticated 401 stops the account through the existing recovery path.
Expand Down
54 changes: 54 additions & 0 deletions workers/group-telemetry/destination-jobs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ test("metadata reaches the control plane with its lease and only after a shared
assert.equal(run.metadataBudget.remaining(), 14);
});

const personId = "usr_71fc66f4-249b-4737-969e-47bf30a1a8ba";
const iconFileId = "file_df4eb0c6-4d36-468d-934f-26523453aa0e";
const iconUrl = `https://api.vrchat.cloud/api/1/image/${iconFileId}/1/256`;
const personJob = { key: `vrchat_user:${personId}`, kind: "vrchat_user", locator: personId, leaseToken: "person-lease" };
const personResponse = { id: personId, displayName: "DrBlue47", iconUrl };
const fileResponse = { id: iconFileId, ownerId: personId, tags: ["icon"], versions: [
{ version: 0, status: "complete" },
{ version: 1, status: "complete", file: { status: "complete", url: `https://api.vrchat.cloud/api/1/file/${iconFileId}/1/file` } },
] };

test("the observed modern person response reaches the control plane with verified icon provenance", async () => {
const run = scenario({ jobs: [personJob], providerResponse: url => {
if (url.endsWith(`/users/${personId}`)) return json(personResponse);
if (url.endsWith(`/file/${iconFileId}`)) return json(fileResponse);
assert.fail(`Unexpected provider URL: ${url}`);
} });
await run.run();
assert.deepEqual(run.requests.find(entry => entry.operation === "destination_result").result, {
status: "resolved", entityId: personId, displayName: "DrBlue47", artworkSourceUrl: iconUrl, artworkType: "user_icon",
});
const providerIndexes = run.requests.flatMap((entry, index) => entry.boundary === "provider" ? [index] : []);
assert.equal(providerIndexes.length, 2);
for (const index of providerIndexes) assert.equal(run.requests[index - 1].operation, "proof_budget");
assert.equal(run.accountBudget.remaining(), 28);
assert.equal(run.metadataBudget.remaining(), 13);
});

test("denying the icon-file reservation preserves cached artwork without making the second provider request", async () => {
let reservations = 0;
const run = scenario({ jobs: [personJob], providerResponse: () => json(personResponse),
controlResponse: body => body.operation === "proof_budget" ? json({ granted: ++reservations === 1 }) : undefined,
});
await run.run();
assert.equal(reservations, 2);
assert.equal(run.requests.filter(entry => entry.boundary === "provider").length, 1);
assert.deepEqual(run.requests.at(-1).result, { status: "transient" });
assert.equal(run.accountBudget.remaining(), 29);
});

test("icon-file authentication and rate limits use the existing collector account handling", async () => {
for (const status of [401, 429]) {
const run = scenario({ jobs: [personJob], providerResponse: url => url.endsWith(`/users/${personId}`) ? json(personResponse) : json({}, status, { "retry-after": "90" }) });
await run.run();
assert.equal(run.requests.filter(entry => entry.boundary === "provider").length, 2);
if (status === 401) {
assert.deepEqual(run.requests.slice(-2).map(entry => entry.operation), ["destination_result", "proof_auth_failure"]);
assert.deepEqual(run.requests.at(-2).result, { status: "transient" });
} else {
assert.deepEqual(run.requests.slice(-2).map(entry => entry.operation), ["proof_rate_limit", "destination_result"]);
assert.equal(run.requests.at(-2).retryAfterMs, 90_000);
}
}
});

test("a denied shared budget releases the lease without querying VRChat or consuming local allowance", async () => {
const run = scenario({ controlResponse: body => body.operation === "proof_budget" ? json({ granted: false }) : undefined });
await run.run();
Expand Down
33 changes: 31 additions & 2 deletions workers/group-telemetry/profile-link-destination.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ export function allowedDestinationArtworkUrl(value, kind) {
} catch { return undefined; }
}

function iconFileReference(value) {
const allowed = allowedDestinationArtworkUrl(value, "vrchat_user");
if (!allowed) return undefined;
const match = new URL(allowed).pathname.match(new RegExp(`^/api/1/(image|file)/(file_${UUID})/([1-9][0-9]{0,8})/([1-9][0-9]{0,4}|file)$`, "i"));
if (!match || (match[1] === "image" ? match[4] === "file" : match[4] !== "file")) return undefined;
return { endpoint: match[1], id: match[2].toLowerCase(), version: Number(match[3]) };
}

async function personArtwork(entity, requestVrchat) {
const explicitIcon = allowedDestinationArtworkUrl(entity.userIcon, "vrchat_user");
if (explicitIcon) return explicitIcon;
const source = allowedDestinationArtworkUrl(entity.iconUrl, "vrchat_user");
const reference = iconFileReference(source);
if (!reference) return undefined;
let file;
try { file = record(await requestVrchat(`/file/${reference.id}`)); }
catch (error) {
// An inaccessible image does not invalidate the user. Temporary and
// account-wide failures retain the existing cached-result/backoff policy.
if ([403, 404, 410].includes(error?.status)) return undefined;
throw error;
}
if (file.id !== reference.id || file.ownerId !== entity.id || !Array.isArray(file.tags)
|| file.tags.length !== 1 || file.tags[0] !== "icon" || !Array.isArray(file.versions)) return undefined;
const version = file.versions.find(value => record(value).version === reference.version);
if (!version || version.deleted === true || version.status !== "complete" || record(version.file).status !== "complete") return undefined;
const original = iconFileReference(version.file.url);
return original?.endpoint === "file" && original.id === reference.id && original.version === reference.version ? source : undefined;
}

function providerFailure(error) {
// Account-wide failures must reach the collector's existing session/cooldown handling.
if (error?.status === 401 || error?.status === 429 || ["authentication", "rate_limit", "metadata_budget", "control_plane"].includes(error?.category)) throw error;
Expand Down Expand Up @@ -126,8 +156,7 @@ export async function resolveProfileLinkDestination(target, { requestVrchat, fet
if (isGroup && entity.privacy !== "default") return { status: "inaccessible" };
const displayName = name(isGroup ? entity.name : entity.displayName);
if (!displayName) return { status: "transient" };
const candidates = isGroup ? [entity.iconUrl] : [entity.userIcon];
const artworkSourceUrl = candidates.map((url) => allowedDestinationArtworkUrl(url, target.kind)).find(Boolean);
const artworkSourceUrl = isGroup ? allowedDestinationArtworkUrl(entity.iconUrl, target.kind) : await personArtwork(entity, requestVrchat);
return { status: "resolved", entityId: entity.id.toLowerCase(), displayName, ...(artworkSourceUrl ? { artworkSourceUrl, artworkType: isGroup ? "group_icon" : "user_icon" } : {}) };
} catch (error) { return providerFailure(error); }
}
101 changes: 101 additions & 0 deletions workers/group-telemetry/profile-link-destination.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,104 @@ test("user icon remains eligible without a profile override", async () => {
assert.equal(result.artworkSourceUrl, portraitThumbnail);
assert.equal(result.artworkType, "user_icon");
});

const iconFileId = "file_df4eb0c6-4d36-468d-934f-26523453aa0e";
const modernIcon = `https://api.vrchat.cloud/api/1/image/${iconFileId}/1/256`;
const modernUser = { id: userId, displayName: "Person", iconUrl: modernIcon };
const iconFile = {
id: iconFileId, ownerId: userId, tags: ["icon"], mimeType: "image/png",
versions: [{ version: 0, status: "complete" }, { version: 1, status: "complete", file: {
status: "complete", url: `https://api.vrchat.cloud/api/1/file/${iconFileId}/1/file`,
} }],
};

test("current iconUrl resolves custom artwork only after verifying its file", async () => {
const paths = [];
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => {
paths.push(path);
if (path === `/users/${userId}`) return modernUser;
if (path === `/file/${iconFileId}`) return iconFile;
assert.fail(`Unexpected provider path: ${path}`);
},
});
assert.deepEqual(result, { status: "resolved", entityId: userId, displayName: "Person", artworkSourceUrl: modernIcon, artworkType: "user_icon" });
assert.deepEqual(paths, [`/users/${userId}`, `/file/${iconFileId}`]);
});

test("modern file URLs and unsafe legacy icons use the verified current icon", async () => {
for (const iconUrl of [modernIcon, iconFile.versions[1].file.url]) {
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => path === `/users/${userId}` ? { ...modernUser, iconUrl, userIcon: "https://evil.example/icon" } : iconFile,
});
assert.equal(result.artworkSourceUrl, iconUrl);
assert.equal(result.artworkType, "user_icon");
}
});

test("an explicit legacy icon takes precedence without extra metadata requests", async () => {
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => {
assert.equal(path, `/users/${userId}`);
return { ...modernUser, userIcon: portraitThumbnail };
},
});
assert.equal(result.artworkSourceUrl, portraitThumbnail);
});

test("modern display icons with missing or conflicting provenance resolve only the name", async () => {
for (const metadata of [
null, {}, { ...iconFile, id: "file_11111111-1111-4111-8111-111111111111" },
{ ...iconFile, ownerId: "usr_22222222-2222-4222-8222-222222222222" },
...[undefined, [], ["avatarimage"], ["avatargallery"], ["icon", "avatarimage"], ["icon", "unknown"]].map(tags => ({ ...iconFile, tags })),
{ ...iconFile, versions: [] },
{ ...iconFile, versions: [{ ...iconFile.versions[1], version: 2 }] },
{ ...iconFile, versions: [{ ...iconFile.versions[1], status: "waiting" }] },
{ ...iconFile, versions: [{ ...iconFile.versions[1], deleted: true }] },
{ ...iconFile, versions: [{ ...iconFile.versions[1], file: { ...iconFile.versions[1].file, status: "waiting" } }] },
{ ...iconFile, versions: [{ ...iconFile.versions[1], file: { ...iconFile.versions[1].file, url: artwork } }] },
]) {
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => path === `/users/${userId}` ? modernUser : metadata,
});
assert.deepEqual(result, { status: "resolved", entityId: userId, displayName: "Person" });
}
});

test("unsupported modern icon references do not trigger a file lookup or select avatar fields", async () => {
for (const iconUrl of [undefined, "", "https://evil.example/icon.png", "https://files.vrchat.cloud/unknown-layout.png", modernIcon.replace("/1/256", "/0/256"), modernIcon.replace("/256", "/file"), modernIcon.replace("/image/", "/file/")]) {
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => {
assert.equal(path, `/users/${userId}`);
return { ...modernUser, iconUrl, profilePicOverride: portraitThumbnail, bannerUrl: portraitThumbnail, currentAvatarImageUrl: portraitThumbnail };
},
});
assert.deepEqual(result, { status: "resolved", entityId: userId, displayName: "Person" });
}
});

test("file absence omits artwork without invalidating the resolved person", async () => {
for (const status of [403, 404, 410]) {
const result = await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => {
if (path === `/users/${userId}`) return modernUser;
throw Object.assign(new Error("file inaccessible"), { status });
},
});
assert.deepEqual(result, { status: "resolved", entityId: userId, displayName: "Person" });
}
});

test("temporary icon metadata failures preserve cached results and propagate account failures", async () => {
for (const error of [Object.assign(new Error("unavailable"), { status: 503 }), Object.assign(new Error("timeout"), { category: "timeout" })]) {
assert.deepEqual(await resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => { if (path === `/users/${userId}`) return modernUser; throw error; },
}), { status: "transient" });
}
for (const category of ["authentication", "rate_limit", "metadata_budget", "control_plane"]) {
const error = Object.assign(new Error("account failure"), { category });
await assert.rejects(resolveProfileLinkDestination({ kind: "vrchat_user", locator: userId }, {
requestVrchat: async path => { if (path === `/users/${userId}`) return modernUser; throw error; },
}), value => value === error);
}
});
Loading