diff --git a/src/index.ts b/src/index.ts index 08088f6..cbdd2ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,8 +56,10 @@ export { isProfileUpdateMessage, isProfileSnapshotMessage, ProfileUpdateCodec, + ProfileUpdateV2Codec, ProfileSnapshotCodec, ContentTypeProfileUpdate, + ContentTypeProfileUpdateV2, ContentTypeProfileSnapshot, MemberKind, type ProfileUpdateContent, diff --git a/src/utils/client.ts b/src/utils/client.ts index 9a32223..378ba00 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -8,7 +8,11 @@ import { } from "@xmtp/content-type-remote-attachment"; import { Client, IdentifierKind, LogLevel } from "@xmtp/node-sdk"; import type { ClientOptions } from "@xmtp/node-sdk"; -import { ProfileUpdateCodec, ProfileSnapshotCodec } from "./profileMessages.js"; +import { + ProfileUpdateCodec, + ProfileUpdateV2Codec, + ProfileSnapshotCodec, +} from "./profileMessages.js"; import { JoinRequestCodec } from "./joinRequest.js"; import { TypingIndicatorCodec } from "./typingIndicator.js"; import { ExplodeSettingsCodec } from "./explodeSettings.js"; @@ -139,6 +143,7 @@ async function buildClient( new AttachmentCodec(), new RemoteAttachmentCodec(), new ProfileUpdateCodec() as any, + new ProfileUpdateV2Codec() as any, new ProfileSnapshotCodec() as any, new JoinRequestCodec() as any, new TypingIndicatorCodec() as any, diff --git a/src/utils/profileMessages.ts b/src/utils/profileMessages.ts index 2c0b796..d03c8f0 100644 --- a/src/utils/profileMessages.ts +++ b/src/utils/profileMessages.ts @@ -46,7 +46,12 @@ const ProfileUpdateType = new protobuf.Type("ProfileUpdate") .add(new protobuf.Field("name", 1, "string", "optional")) .add(new protobuf.Field("encrypted_image", 2, "EncryptedProfileImageRef", "optional")) .add(new protobuf.Field("member_kind", 3, "MemberKind", "optional")) - .add(new protobuf.MapField("metadata", 4, "string", "MetadataValue")); + .add(new protobuf.MapField("metadata", 4, "string", "MetadataValue")) + // v2. Field 2 stays declared rather than reserved: senders that have not + // upgraded still populate it, and reserving it would make their avatars + // decode as absent instead of failing loudly. + .add(new protobuf.Field("avatar_url", 5, "string", "optional")) + .add(new protobuf.Field("version", 6, "uint64", "optional")); const MemberProfileType = new protobuf.Type("MemberProfile") .add(new protobuf.Field("inbox_id", 1, "bytes")) @@ -75,6 +80,24 @@ export const ContentTypeProfileUpdate = { versionMinor: 0, }; +/** + * What the iOS client sends once profiles live on the backend: a plain avatar + * URL and a version instead of an encrypted image. Registered for reading, + * because the XMTP codec registry keys on the full version string - without + * this, a v2 message finds no codec and arrives as raw EncodedContent. + * + * The CLI keeps sending v1. The wire format is a superset, so v1 carries the + * new fields fine, and a client that predates v2 would ignore a v2-typed + * message entirely - which for an agent means its name silently stops + * updating on every un-upgraded client, in exchange for nothing. + */ +export const ContentTypeProfileUpdateV2 = { + authorityId: "convos.org", + typeId: "profile_update", + versionMajor: 2, + versionMinor: 0, +}; + export const ContentTypeProfileSnapshot = { authorityId: "convos.org", typeId: "profile_snapshot", @@ -112,9 +135,14 @@ export type ProfileMetadata = Record; export interface ProfileUpdateContent { name?: string; + /** Pre-v2 senders only. New senders carry `avatarUrl` instead. */ encryptedImage?: EncryptedProfileImageRef; memberKind?: MemberKind; metadata?: ProfileMetadata; + /** Plain CDN URL of the backend-hosted avatar. */ + avatarUrl?: string; + /** The backend's monotonic profile version, so a reader can skip a fetch. */ + version?: number; } export interface MemberProfileEntry { @@ -274,6 +302,14 @@ export function encodeProfileUpdate(update: ProfileUpdateContent): EncodedConten obj.metadata = metadataToProto(update.metadata); } + if (update.avatarUrl !== undefined) { + obj.avatar_url = update.avatarUrl; + } + + if (update.version !== undefined) { + obj.version = update.version; + } + const errMsg = ProfileUpdateType.verify(obj); if (errMsg) throw new Error(`Invalid ProfileUpdate: ${errMsg}`); @@ -344,6 +380,8 @@ interface RawProfileUpdateMsg { encrypted_image?: { url: string; salt: Uint8Array; nonce: Uint8Array } | null; member_kind?: number; metadata?: Record; + avatar_url?: string; + version?: number | { toNumber(): number }; } /** @@ -377,6 +415,21 @@ export function decodeProfileUpdate(encoded: EncodedContent): ProfileUpdateConte result.memberKind = msg.member_kind as MemberKind; } + if (msg.avatar_url) { + result.avatarUrl = msg.avatar_url; + } + + if (msg.version !== undefined && msg.version !== null) { + // protobufjs hands back a Long for uint64 unless configured otherwise, and + // decodes an unset field as 0 rather than leaving it out. Backend versions + // start at 1, so 0 means the sender did not set one. + const version = + typeof msg.version === "number" ? msg.version : msg.version.toNumber(); + if (version > 0) { + result.version = version; + } + } + const meta = metadataFromProto(msg.metadata); if (meta) { result.metadata = meta; @@ -470,6 +523,16 @@ export class ProfileUpdateCodec implements ContentCodec { } } +/** + * Reads v2 profile updates. Decoding is identical - the proto is a superset + * and only the content type differs, which is what the registry keys on. + */ +export class ProfileUpdateV2Codec extends ProfileUpdateCodec { + get contentType(): ContentTypeId { + return ContentTypeProfileUpdateV2; + } +} + /** * XMTP ContentCodec for ProfileSnapshot messages. * Register this with the XMTP client so it can decode profile messages. diff --git a/test/utils/profileMessages.test.ts b/test/utils/profileMessages.test.ts index 5c010f5..508875f 100644 --- a/test/utils/profileMessages.test.ts +++ b/test/utils/profileMessages.test.ts @@ -15,6 +15,8 @@ import { type ProfileSnapshotContent, type ProfileMetadata, type ProfileMetadataValue, + ProfileUpdateV2Codec, + ContentTypeProfileUpdateV2, } from "../../src/utils/profileMessages.js"; import { isDisplayableMessage, @@ -402,6 +404,72 @@ describe("appData corruption prevention", () => { }); }); +// ─── v2 Wire Format ─── + +describe("ProfileUpdate v2 fields", () => { + it("round-trips the avatar URL and version", () => { + const encoded = encodeProfileUpdate({ + name: "Ada", + avatarUrl: "https://cdn.convos.org/profiles/abc.jpg", + version: 7, + }); + const decoded = decodeProfileUpdate(encoded); + expect(decoded.name).toBe("Ada"); + expect(decoded.avatarUrl).toBe("https://cdn.convos.org/profiles/abc.jpg"); + expect(decoded.version).toBe(7); + }); + + it("omits the new fields when they are not set", () => { + const decoded = decodeProfileUpdate(encodeProfileUpdate({ name: "Ada" })); + expect(decoded.avatarUrl).toBeUndefined(); + expect(decoded.version).toBeUndefined(); + }); + + // The reason field 2 is still declared rather than reserved: a v1 sender + // populates it, and reserving it would decode their avatar as absent. + it("still decodes a v1 payload's encrypted image", () => { + const encoded = encodeProfileUpdate({ + name: "Grace", + encryptedImage: { + url: "https://cdn.convos.org/old.enc", + salt: new Uint8Array(32).fill(1), + nonce: new Uint8Array(12).fill(2), + }, + }); + const decoded = decodeProfileUpdate(encoded); + expect(decoded.encryptedImage?.url).toBe("https://cdn.convos.org/old.enc"); + expect(decoded.name).toBe("Grace"); + }); + + // The XMTP codec registry keys on the full version string, so reading v2 + // needs its own registered codec - not just a tolerant matcher. + it("registers a v2 codec that decodes the same payload", () => { + const v2 = new ProfileUpdateV2Codec(); + expect(v2.contentType).toEqual(ContentTypeProfileUpdateV2); + expect(v2.contentType.versionMajor).toBe(2); + const decoded = v2.decode(encodeProfileUpdate({ name: "Alan" })); + expect(decoded.name).toBe("Alan"); + }); + + // Sending stays on v1 deliberately: the proto is a superset, so the new + // fields ride along, and a client that predates v2 keeps decoding it. + it("keeps sending v1 while carrying the v2 fields", () => { + const encoded = encodeProfileUpdate({ name: "Ada", version: 3 }); + expect(encoded.type?.versionMajor).toBe(1); + expect(decodeProfileUpdate(encoded).version).toBe(3); + }); + + it("recognizes a v2 message as a profile update", () => { + const message = mockMessage({ + authorityId: "convos.org", + typeId: "profile_update", + versionMajor: 2, + versionMinor: 0, + }); + expect(isProfileUpdateMessage(message)).toBe(true); + }); +}); + // ─── Encoded Content Type ─── describe("encoded content type", () => {