Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/profile-blob-input.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 48 additions & 5 deletions packages/sdk-core/src/repository/ProfileOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
*/

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";
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,
Expand Down Expand Up @@ -117,34 +120,74 @@ 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 }`).
Comment on lines +124 to +127
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment mentions "untyped/legacy form" which is checked in the implementation, but this form is likely not a valid JsonBlobRef according to the AT Protocol specification. This documentation should be updated to either:

  1. Remove the mention of the legacy form if it's not actually supported
  2. Clearly document where this legacy form comes from and why it needs to be supported

The inconsistency between the documentation and the actual JsonBlobRef type could lead to confusion for developers.

Suggested change
* 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 }`).
* Type guard to check if a value is an AT Protocol `JsonBlobRef` (already-uploaded blob reference).
*
* Primarily supports the spec-compliant typed form (`{ $type: "blob", ref, mimeType, size }`).
* For backward compatibility, it also treats a legacy/untyped form (`{ cid: string, mimeType: string }`)
* as acceptable input. This legacy shape originates from earlier Hypercerts profile image handling
* prior to adopting the formal `JsonBlobRef` schema and is not part of the official AT Protocol spec.

Copilot uses AI. Check for mistakes.
*
* @internal
*/
private isJsonBlobRef(value: unknown): value is JsonBlobRef {
if (typeof value !== "object" || value === null) return false;
const r = value as Record<string, unknown>;
// 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;
Comment on lines +138 to +142
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "legacy form" check for JsonBlobRef appears to be incorrect. The JsonBlobRef type from @atproto/lexicon always has the typed form with $type: "blob", ref, mimeType, and size fields. The "untyped/legacy form" with just cid and mimeType is not a valid JsonBlobRef structure according to the AT Protocol specification.

If you need to support legacy blob references with a different structure, you should either:

  1. Remove this legacy check if it's not actually needed
  2. Document why this legacy form exists and where it comes from
  3. Ensure that BlobRef.fromJsonRef() can actually handle this format (it likely cannot)

The first check (lines 135-137) is sufficient for validating JsonBlobRef objects.

Copilot uses AI. Check for mistakes.
}

/**
* 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
*/
private async applyImageField(
result: Record<string, unknown>,
field: string,
value: Blob | null | undefined,
input: BlobInput | null | undefined,
collection: ProfileCollection,
): Promise<void> {
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);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to LexiconBlobRef.fromJsonRef(input) is not wrapped in error handling. If the input is malformed or doesn't match the expected structure (despite passing the isJsonBlobRef check), this could throw an error that isn't caught and wrapped appropriately.

Consider adding a try-catch block around this conversion to provide clearer error messages if the conversion fails, or ensure that isJsonBlobRef validates all fields that fromJsonRef requires. The current type guard only checks for the presence and types of fields, but doesn't validate the structure of the ref field.

Suggested change
const blobRef = LexiconBlobRef.fromJsonRef(input);
let blobRef: LexiconBlobRef;
try {
blobRef = LexiconBlobRef.fromJsonRef(input as JsonBlobRef);
} catch (err) {
// Wrap low-level conversion errors in a domain-specific validation error
throw new ValidationError(
`Invalid blob reference for field "${field}" in collection "${collection}": ${JSON.stringify(input)}`,
);
}

Copilot uses AI. Check for mistakes.
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) {
Expand Down
14 changes: 10 additions & 4 deletions packages/sdk-core/src/repository/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BlobInput type is added to interfaces.ts and documented as a public API type in the changeset, but it's not exported from the main index.ts file. SDK consumers who want to use this type in their TypeScript code for type-checking would not be able to import it.

Consider adding BlobInput to the exports in src/index.ts if it's intended to be part of the public API. This would allow consumers to write type-safe code like:

const avatar: BlobInput = existingBlobRef;
await repo.profile.updateBskyProfile({ avatar });

Copilot uses AI. Check for mistakes.

/**
* Bluesky profile type - direct from AT Protocol.
* Returned by agent.getProfile() with avatar/banner as CDN URLs.
Expand All @@ -696,7 +702,7 @@ type Nullable<T> = { [K in keyof T]?: T[K] | null };

export type CreateBskyProfileParams = OverrideProperties<
SetOptional<AppBskyActorProfile.Record, "$type" | "createdAt">,
{ avatar?: Blob; banner?: Blob }
{ avatar?: BlobInput; banner?: BlobInput }
>;

/**
Expand All @@ -706,7 +712,7 @@ export type CreateBskyProfileParams = OverrideProperties<
*/
export type UpdateBskyProfileParams = OverrideProperties<
Nullable<Except<CreateBskyProfileParams, "$type" | "createdAt">>,
{ avatar?: Blob | null; banner?: Blob | null }
{ avatar?: BlobInput | null; banner?: BlobInput | null }
>;

/**
Expand All @@ -716,7 +722,7 @@ export type UpdateBskyProfileParams = OverrideProperties<

export type CreateCertifiedProfileParams = OverrideProperties<
SetOptional<AppCertifiedActorProfile.Main, "$type" | "createdAt">,
{ avatar?: Blob; banner?: Blob }
{ avatar?: BlobInput; banner?: BlobInput }
>;

/**
Expand All @@ -726,7 +732,7 @@ export type CreateCertifiedProfileParams = OverrideProperties<
*/
export type UpdateCertifiedProfileParams = OverrideProperties<
Nullable<Except<CreateCertifiedProfileParams, "$type" | "createdAt">>,
{ avatar?: Blob | null; banner?: Blob | null }
{ avatar?: BlobInput | null; banner?: BlobInput | null }
>;

export interface ProfileOperations {
Expand Down
12 changes: 5 additions & 7 deletions packages/sdk-core/src/repository/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @packageDocumentation
*/

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

// ============================================================================
Expand Down Expand Up @@ -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();
}
Comment on lines +122 to 124
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blobRefToJsonRef helper function is added but not exported from the main index.ts file. If this helper is intended to be part of the public API for SDK consumers to convert BlobRef instances to their JSON representation, it should be exported.

If it's only for internal use, consider moving it to a different location (e.g., a utils file) or marking the file/section as internal-only. The changeset description indicates this is a public feature ("Add blobRefToJsonRef(blobRef: BlobRef): JsonBlobRef helper"), which suggests it should be exported.

Copilot uses AI. Check for mistakes.
75 changes: 75 additions & 0 deletions packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down