Skip to content

Commit 196fde2

Browse files
committed
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)
1 parent 935769e commit 196fde2

5 files changed

Lines changed: 153 additions & 16 deletions

File tree

.changeset/profile-blob-input.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
Support existing blob references for profile avatar and banner fields
6+
7+
- Add `BlobInput = Blob | JsonBlobRef` type to `interfaces.ts`; avatar/banner fields on all profile param types now
8+
accept either a new `Blob` (uploaded automatically) or an existing `JsonBlobRef` (used directly without re-uploading)
9+
- Add `blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef` helper to `types.ts` for converting AT Protocol blob refs to
10+
their JSON representation
11+
- Remove stale `BlobUploadResult` interface (was already marked `@deprecated`; `blobs.upload()` returns `BlobRef` from
12+
`@atproto/lexicon`, not this shape)
13+
14+
**Potentially breaking:** any code that imported `BlobUploadResult` from `@hypercerts-org/sdk-core` will need to switch
15+
to `BlobRef` from `@atproto/lexicon` directly.

packages/sdk-core/src/repository/ProfileOperationsImpl.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
*/
1010

1111
import type { Agent, AppBskyActorDefs } from "@atproto/api";
12+
import { BlobRef as LexiconBlobRef } from "@atproto/lexicon";
13+
import type { JsonBlobRef } from "@atproto/lexicon";
1214
import { NetworkError, ValidationError } from "../core/errors.js";
1315
import { HYPERCERT_COLLECTIONS } from "../lexicons.js";
1416
import { extractCidFromImage, getBlobUrl } from "../lib/blob-url.js";
1517
import { isValidUri } from "../lib/url-utils.js";
1618
import { AppCertifiedActorProfile, type HypercertImageRecord } from "../services/hypercerts/types.js";
1719
import { validate } from "@hypercerts-org/lexicon";
1820
import type {
21+
BlobInput,
1922
BlobOperations,
2023
BskyProfile,
2124
CertifiedProfile,
@@ -117,34 +120,74 @@ export class ProfileOperationsImpl implements ProfileOperations {
117120
return getBlobUrl(this.pdsUrl, this.repoDid, result);
118121
}
119122

123+
/**
124+
* Type guard to check if a value is a JsonBlobRef (already-uploaded blob reference).
125+
*
126+
* Supports both typed form (`{ $type: "blob", ref, mimeType, size }`) and
127+
* untyped/legacy form (`{ cid: string, mimeType: string }`).
128+
*
129+
* @internal
130+
*/
131+
private isJsonBlobRef(value: unknown): value is JsonBlobRef {
132+
if (typeof value !== "object" || value === null) return false;
133+
const r = value as Record<string, unknown>;
134+
// Typed form: { $type: "blob", ref: object, mimeType: string, size: number }
135+
if (r.$type === "blob" && "ref" in r && typeof r.mimeType === "string" && typeof r.size === "number") {
136+
return true;
137+
}
138+
// Untyped/legacy form: { cid: string, mimeType: string }
139+
if (typeof r.cid === "string" && typeof r.mimeType === "string") {
140+
return true;
141+
}
142+
return false;
143+
}
144+
120145
/**
121146
* Applies an image field (avatar/banner) with format-specific wrapping.
122147
*
123148
* - null: removes the field
124149
* - undefined: no change
150+
* - JsonBlobRef: uses the existing blob ref directly (no re-upload)
125151
* - Blob: uploads and wraps according to collection format
126152
*
127153
* @param result - The profile record being built
128154
* @param field - Field name ("avatar" or "banner")
129-
* @param value - Blob to upload, null to remove, or undefined to skip
155+
* @param input - BlobInput (Blob or JsonBlobRef) to use, null to remove, or undefined to skip
130156
* @param collection - Profile collection NSID (determines image wrapping format)
131157
*
132158
* @internal
133159
*/
134160
private async applyImageField(
135161
result: Record<string, unknown>,
136162
field: string,
137-
value: Blob | null | undefined,
163+
input: BlobInput | null | undefined,
138164
collection: ProfileCollection,
139165
): Promise<void> {
140-
if (value === undefined) return;
166+
if (input === undefined) return;
141167

142-
if (value === null) {
168+
if (input === null) {
143169
delete result[field];
144170
return;
145171
}
146172

147-
const blobRef = await this.blobs.upload(value);
173+
// If the input is already a JSON blob ref, convert to BlobRef instance for validation
174+
// and store without re-uploading
175+
if (this.isJsonBlobRef(input)) {
176+
const blobRef = LexiconBlobRef.fromJsonRef(input);
177+
if (collection === BSKY_PROFILE_NSID) {
178+
result[field] = blobRef;
179+
} else {
180+
const isLargeImage = field === "banner";
181+
result[field] = {
182+
$type: isLargeImage ? "org.hypercerts.defs#largeImage" : "org.hypercerts.defs#smallImage",
183+
image: blobRef,
184+
};
185+
}
186+
return;
187+
}
188+
189+
// Otherwise it's a Blob — upload and store as BlobRef (validators require instanceof BlobRef)
190+
const blobRef = await this.blobs.upload(input as Blob);
148191

149192
// Bsky profiles use simple blob refs, Certified profiles wrap in smallImage/largeImage
150193
if (collection === BSKY_PROFILE_NSID) {

packages/sdk-core/src/repository/interfaces.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import type { AppBskyActorDefs, AppBskyActorProfile, AppBskyRichtextFacet, BlobRef } from "@atproto/api";
12+
import type { JsonBlobRef } from "@atproto/lexicon";
1213
import type { EventEmitter } from "eventemitter3";
1314
import type {
1415
AppCertifiedActorProfile,
@@ -671,6 +672,11 @@ export interface BlobOperations {
671672
* });
672673
* ```
673674
*/
675+
/**
676+
* Input type for blob fields — either a new Blob to upload or an existing JsonBlobRef to reuse.
677+
*/
678+
export type BlobInput = Blob | JsonBlobRef;
679+
674680
/**
675681
* Bluesky profile type - direct from AT Protocol.
676682
* Returned by agent.getProfile() with avatar/banner as CDN URLs.
@@ -696,7 +702,7 @@ type Nullable<T> = { [K in keyof T]?: T[K] | null };
696702

697703
export type CreateBskyProfileParams = OverrideProperties<
698704
SetOptional<AppBskyActorProfile.Record, "$type" | "createdAt">,
699-
{ avatar?: Blob; banner?: Blob }
705+
{ avatar?: BlobInput; banner?: BlobInput }
700706
>;
701707

702708
/**
@@ -706,7 +712,7 @@ export type CreateBskyProfileParams = OverrideProperties<
706712
*/
707713
export type UpdateBskyProfileParams = OverrideProperties<
708714
Nullable<Except<CreateBskyProfileParams, "$type" | "createdAt">>,
709-
{ avatar?: Blob | null; banner?: Blob | null }
715+
{ avatar?: BlobInput | null; banner?: BlobInput | null }
710716
>;
711717

712718
/**
@@ -716,7 +722,7 @@ export type UpdateBskyProfileParams = OverrideProperties<
716722

717723
export type CreateCertifiedProfileParams = OverrideProperties<
718724
SetOptional<AppCertifiedActorProfile.Main, "$type" | "createdAt">,
719-
{ avatar?: Blob; banner?: Blob }
725+
{ avatar?: BlobInput; banner?: BlobInput }
720726
>;
721727

722728
/**
@@ -726,7 +732,7 @@ export type CreateCertifiedProfileParams = OverrideProperties<
726732
*/
727733
export type UpdateCertifiedProfileParams = OverrideProperties<
728734
Nullable<Except<CreateCertifiedProfileParams, "$type" | "createdAt">>,
729-
{ avatar?: Blob | null; banner?: Blob | null }
735+
{ avatar?: BlobInput | null; banner?: BlobInput | null }
730736
>;
731737

732738
export interface ProfileOperations {

packages/sdk-core/src/repository/types.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* @packageDocumentation
44
*/
55

6+
import type { BlobRef, JsonBlobRef } from "@atproto/lexicon";
67
import type { CollaboratorPermissions } from "../core/types.js";
78

89
// ============================================================================
@@ -115,12 +116,9 @@ export interface ProgressStep {
115116
// ============================================================================
116117

117118
/**
118-
* Result from BlobOperations.upload()
119-
*
120-
* @deprecated Use BlobRef from @atproto/api directly
119+
* Converts a BlobRef from @atproto/lexicon to its JSON representation
120+
* suitable for storing in AT Protocol records.
121121
*/
122-
export interface BlobUploadResult {
123-
ref: { $link: string };
124-
mimeType: string;
125-
size: number;
122+
export function blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef {
123+
return blobRef.ipld();
126124
}

packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { Agent } from "@atproto/api";
3+
import { BlobRef as LexiconBlobRef, type JsonBlobRef } from "@atproto/lexicon";
34
import { ProfileOperationsImpl } from "../../src/repository/ProfileOperationsImpl.js";
45
import { NetworkError } from "../../src/core/errors.js";
56
import type { BlobOperations } from "../../src/repository/interfaces.js";
@@ -453,6 +454,41 @@ describe("ProfileOperationsImpl", () => {
453454

454455
await expect(profileOps.updateBskyProfile({ displayName: "Alice" })).rejects.toThrow(NetworkError);
455456
});
457+
458+
it("should use existing JsonBlobRef as avatar without re-uploading", async () => {
459+
const existingBlobRef: JsonBlobRef = {
460+
$type: "blob",
461+
ref: createMockBlobRef().ref,
462+
mimeType: "image/png",
463+
size: 1000,
464+
};
465+
466+
mockAgent.com.atproto.repo.getRecord.mockResolvedValue({
467+
success: true,
468+
data: {
469+
value: {
470+
$type: BSKY_PROFILE_COLLECTION,
471+
createdAt: "2024-01-01T00:00:00.000Z",
472+
displayName: "Alice",
473+
},
474+
},
475+
});
476+
477+
mockAgent.com.atproto.repo.putRecord.mockResolvedValue({
478+
success: true,
479+
data: {
480+
uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`,
481+
cid: "bafy999",
482+
},
483+
});
484+
485+
await profileOps.updateBskyProfile({ avatar: existingBlobRef });
486+
487+
// Should NOT upload - use the ref directly (converted to BlobRef for validation)
488+
expect(mockBlobs.upload).not.toHaveBeenCalled();
489+
const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0];
490+
expect(putCall.record.avatar).toBeInstanceOf(LexiconBlobRef);
491+
});
456492
});
457493

458494
describe("createCertifiedProfile", () => {
@@ -605,6 +641,45 @@ describe("ProfileOperationsImpl", () => {
605641
expect(putCall.record).not.toHaveProperty("website");
606642
expect(putCall.record).toHaveProperty("displayName", "Alice");
607643
});
644+
645+
it("should use existing JsonBlobRef as avatar without re-uploading", async () => {
646+
const existingBlobRef: JsonBlobRef = {
647+
$type: "blob",
648+
ref: createMockBlobRef().ref,
649+
mimeType: "image/png",
650+
size: 1000,
651+
};
652+
653+
mockAgent.com.atproto.repo.getRecord.mockResolvedValue({
654+
success: true,
655+
data: {
656+
value: {
657+
$type: CERTIFIED_PROFILE_COLLECTION,
658+
createdAt: "2024-01-01T00:00:00.000Z",
659+
displayName: "Alice",
660+
},
661+
},
662+
});
663+
664+
mockAgent.com.atproto.repo.putRecord.mockResolvedValue({
665+
success: true,
666+
data: {
667+
uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`,
668+
cid: "bafy999",
669+
},
670+
});
671+
672+
await profileOps.updateCertifiedProfile({ avatar: existingBlobRef });
673+
674+
// Should NOT upload - use the ref directly (converted to BlobRef for validation)
675+
expect(mockBlobs.upload).not.toHaveBeenCalled();
676+
const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0];
677+
// Certified profile wraps in smallImage format
678+
expect(putCall.record.avatar).toMatchObject({
679+
$type: "org.hypercerts.defs#smallImage",
680+
image: expect.any(LexiconBlobRef),
681+
});
682+
});
608683
});
609684

610685
describe("upsertCertifiedProfile", () => {

0 commit comments

Comments
 (0)