From 196fde295e1a43c72f1acb61c08f7b34b3dfbf91 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 19:46:41 +0000 Subject: [PATCH] feat(repository): support existing blob refs for profile avatar/banner - Add BlobInput = Blob | JsonBlobRef type; profile param types updated accordingly - ProfileOperationsImpl.applyImageField now accepts BlobInput: existing JsonBlobRef is converted to BlobRef via fromJsonRef() and stored without re-uploading; new Blob is uploaded as before - Add blobRefToJsonRef() helper in types.ts (BlobRef.ipld()) - Remove stale BlobUploadResult interface (was @deprecated; actual return type of blobs.upload() is BlobRef from @atproto/lexicon) --- .changeset/profile-blob-input.md | 15 ++++ .../src/repository/ProfileOperationsImpl.ts | 53 +++++++++++-- .../sdk-core/src/repository/interfaces.ts | 14 +++- packages/sdk-core/src/repository/types.ts | 12 ++- .../repository/ProfileOperationsImpl.test.ts | 75 +++++++++++++++++++ 5 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 .changeset/profile-blob-input.md diff --git a/.changeset/profile-blob-input.md b/.changeset/profile-blob-input.md new file mode 100644 index 00000000..8380ddac --- /dev/null +++ b/.changeset/profile-blob-input.md @@ -0,0 +1,15 @@ +--- +"@hypercerts-org/sdk-core": minor +--- + +Support existing blob references for profile avatar and banner fields + +- Add `BlobInput = Blob | JsonBlobRef` type to `interfaces.ts`; avatar/banner fields on all profile param types now + accept either a new `Blob` (uploaded automatically) or an existing `JsonBlobRef` (used directly without re-uploading) +- Add `blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef` helper to `types.ts` for converting AT Protocol blob refs to + their JSON representation +- Remove stale `BlobUploadResult` interface (was already marked `@deprecated`; `blobs.upload()` returns `BlobRef` from + `@atproto/lexicon`, not this shape) + +**Potentially breaking:** any code that imported `BlobUploadResult` from `@hypercerts-org/sdk-core` will need to switch +to `BlobRef` from `@atproto/lexicon` directly. diff --git a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts index 860348d5..99bc5932 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -9,6 +9,8 @@ */ import type { Agent, AppBskyActorDefs } from "@atproto/api"; +import { BlobRef as LexiconBlobRef } from "@atproto/lexicon"; +import type { JsonBlobRef } from "@atproto/lexicon"; import { NetworkError, ValidationError } from "../core/errors.js"; import { HYPERCERT_COLLECTIONS } from "../lexicons.js"; import { extractCidFromImage, getBlobUrl } from "../lib/blob-url.js"; @@ -16,6 +18,7 @@ import { isValidUri } from "../lib/url-utils.js"; import { AppCertifiedActorProfile, type HypercertImageRecord } from "../services/hypercerts/types.js"; import { validate } from "@hypercerts-org/lexicon"; import type { + BlobInput, BlobOperations, BskyProfile, CertifiedProfile, @@ -117,16 +120,39 @@ export class ProfileOperationsImpl implements ProfileOperations { return getBlobUrl(this.pdsUrl, this.repoDid, result); } + /** + * Type guard to check if a value is a JsonBlobRef (already-uploaded blob reference). + * + * Supports both typed form (`{ $type: "blob", ref, mimeType, size }`) and + * untyped/legacy form (`{ cid: string, mimeType: string }`). + * + * @internal + */ + private isJsonBlobRef(value: unknown): value is JsonBlobRef { + if (typeof value !== "object" || value === null) return false; + const r = value as Record; + // Typed form: { $type: "blob", ref: object, mimeType: string, size: number } + if (r.$type === "blob" && "ref" in r && typeof r.mimeType === "string" && typeof r.size === "number") { + return true; + } + // Untyped/legacy form: { cid: string, mimeType: string } + if (typeof r.cid === "string" && typeof r.mimeType === "string") { + return true; + } + return false; + } + /** * Applies an image field (avatar/banner) with format-specific wrapping. * * - null: removes the field * - undefined: no change + * - JsonBlobRef: uses the existing blob ref directly (no re-upload) * - Blob: uploads and wraps according to collection format * * @param result - The profile record being built * @param field - Field name ("avatar" or "banner") - * @param value - Blob to upload, null to remove, or undefined to skip + * @param input - BlobInput (Blob or JsonBlobRef) to use, null to remove, or undefined to skip * @param collection - Profile collection NSID (determines image wrapping format) * * @internal @@ -134,17 +160,34 @@ export class ProfileOperationsImpl implements ProfileOperations { private async applyImageField( result: Record, field: string, - value: Blob | null | undefined, + input: BlobInput | null | undefined, collection: ProfileCollection, ): Promise { - if (value === undefined) return; + if (input === undefined) return; - if (value === null) { + if (input === null) { delete result[field]; return; } - const blobRef = await this.blobs.upload(value); + // If the input is already a JSON blob ref, convert to BlobRef instance for validation + // and store without re-uploading + if (this.isJsonBlobRef(input)) { + const blobRef = LexiconBlobRef.fromJsonRef(input); + if (collection === BSKY_PROFILE_NSID) { + result[field] = blobRef; + } else { + const isLargeImage = field === "banner"; + result[field] = { + $type: isLargeImage ? "org.hypercerts.defs#largeImage" : "org.hypercerts.defs#smallImage", + image: blobRef, + }; + } + return; + } + + // Otherwise it's a Blob — upload and store as BlobRef (validators require instanceof BlobRef) + const blobRef = await this.blobs.upload(input as Blob); // Bsky profiles use simple blob refs, Certified profiles wrap in smallImage/largeImage if (collection === BSKY_PROFILE_NSID) { diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index 7d239daa..0b305f2b 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -9,6 +9,7 @@ */ import type { AppBskyActorDefs, AppBskyActorProfile, AppBskyRichtextFacet, BlobRef } from "@atproto/api"; +import type { JsonBlobRef } from "@atproto/lexicon"; import type { EventEmitter } from "eventemitter3"; import type { AppCertifiedActorProfile, @@ -671,6 +672,11 @@ export interface BlobOperations { * }); * ``` */ +/** + * Input type for blob fields — either a new Blob to upload or an existing JsonBlobRef to reuse. + */ +export type BlobInput = Blob | JsonBlobRef; + /** * Bluesky profile type - direct from AT Protocol. * Returned by agent.getProfile() with avatar/banner as CDN URLs. @@ -696,7 +702,7 @@ type Nullable = { [K in keyof T]?: T[K] | null }; export type CreateBskyProfileParams = OverrideProperties< SetOptional, - { avatar?: Blob; banner?: Blob } + { avatar?: BlobInput; banner?: BlobInput } >; /** @@ -706,7 +712,7 @@ export type CreateBskyProfileParams = OverrideProperties< */ export type UpdateBskyProfileParams = OverrideProperties< Nullable>, - { avatar?: Blob | null; banner?: Blob | null } + { avatar?: BlobInput | null; banner?: BlobInput | null } >; /** @@ -716,7 +722,7 @@ export type UpdateBskyProfileParams = OverrideProperties< export type CreateCertifiedProfileParams = OverrideProperties< SetOptional, - { avatar?: Blob; banner?: Blob } + { avatar?: BlobInput; banner?: BlobInput } >; /** @@ -726,7 +732,7 @@ export type CreateCertifiedProfileParams = OverrideProperties< */ export type UpdateCertifiedProfileParams = OverrideProperties< Nullable>, - { avatar?: Blob | null; banner?: Blob | null } + { avatar?: BlobInput | null; banner?: BlobInput | null } >; export interface ProfileOperations { diff --git a/packages/sdk-core/src/repository/types.ts b/packages/sdk-core/src/repository/types.ts index 685bee9e..87c573bb 100644 --- a/packages/sdk-core/src/repository/types.ts +++ b/packages/sdk-core/src/repository/types.ts @@ -3,6 +3,7 @@ * @packageDocumentation */ +import type { BlobRef, JsonBlobRef } from "@atproto/lexicon"; import type { CollaboratorPermissions } from "../core/types.js"; // ============================================================================ @@ -115,12 +116,9 @@ export interface ProgressStep { // ============================================================================ /** - * Result from BlobOperations.upload() - * - * @deprecated Use BlobRef from @atproto/api directly + * Converts a BlobRef from @atproto/lexicon to its JSON representation + * suitable for storing in AT Protocol records. */ -export interface BlobUploadResult { - ref: { $link: string }; - mimeType: string; - size: number; +export function blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef { + return blobRef.ipld(); } diff --git a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts index 9f3e90a9..4731db63 100644 --- a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Agent } from "@atproto/api"; +import { BlobRef as LexiconBlobRef, type JsonBlobRef } from "@atproto/lexicon"; import { ProfileOperationsImpl } from "../../src/repository/ProfileOperationsImpl.js"; import { NetworkError } from "../../src/core/errors.js"; import type { BlobOperations } from "../../src/repository/interfaces.js"; @@ -453,6 +454,41 @@ describe("ProfileOperationsImpl", () => { await expect(profileOps.updateBskyProfile({ displayName: "Alice" })).rejects.toThrow(NetworkError); }); + + it("should use existing JsonBlobRef as avatar without re-uploading", async () => { + const existingBlobRef: JsonBlobRef = { + $type: "blob", + ref: createMockBlobRef().ref, + mimeType: "image/png", + size: 1000, + }; + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: BSKY_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + }, + }, + }); + + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy999", + }, + }); + + await profileOps.updateBskyProfile({ avatar: existingBlobRef }); + + // Should NOT upload - use the ref directly (converted to BlobRef for validation) + expect(mockBlobs.upload).not.toHaveBeenCalled(); + const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(putCall.record.avatar).toBeInstanceOf(LexiconBlobRef); + }); }); describe("createCertifiedProfile", () => { @@ -605,6 +641,45 @@ describe("ProfileOperationsImpl", () => { expect(putCall.record).not.toHaveProperty("website"); expect(putCall.record).toHaveProperty("displayName", "Alice"); }); + + it("should use existing JsonBlobRef as avatar without re-uploading", async () => { + const existingBlobRef: JsonBlobRef = { + $type: "blob", + ref: createMockBlobRef().ref, + mimeType: "image/png", + size: 1000, + }; + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + }, + }, + }); + + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy999", + }, + }); + + await profileOps.updateCertifiedProfile({ avatar: existingBlobRef }); + + // Should NOT upload - use the ref directly (converted to BlobRef for validation) + expect(mockBlobs.upload).not.toHaveBeenCalled(); + const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + // Certified profile wraps in smallImage format + expect(putCall.record.avatar).toMatchObject({ + $type: "org.hypercerts.defs#smallImage", + image: expect.any(LexiconBlobRef), + }); + }); }); describe("upsertCertifiedProfile", () => {