Skip to content

Commit 8802b3c

Browse files
aspiersclaude
andcommitted
fix(sdk-core): fix SDS blob uploads and DRY blob/profile operations
Without this patch, blob uploads always use the PDS endpoint (com.atproto.repo.uploadBlob) regardless of server type, and profile operations incorrectly route through non-existent com.sds.repo.* endpoints. This causes blob uploads to fail on SDS servers because the repository DID is never passed. This patch solves the problem by: 1. Using com.sds.repo.uploadBlob with the repo query parameter for SDS servers, and com.atproto.repo.uploadBlob for PDS servers 2. Removing incorrect SDS routing from profile operations — SDS servers override standard ATProto endpoints rather than exposing separate ones 3. Extracting a shared BlobOperations interface with dependency injection, eliminating duplicate blob upload code across HypercertOperationsImpl, ProfileOperationsImpl, and BlobOperationsImpl 4. Adding a ProfileOperationsImpl.create() method for creating new profiles without the read-modify-write pattern 5. Refactoring applyParamsToProfile with helper methods to reduce duplication Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d356fb commit 8802b3c

11 files changed

Lines changed: 597 additions & 271 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@hypercerts-org/sdk-core": patch
3+
---
4+
5+
Fix SDS blob uploads, add profile creation, and refactor blob operations
6+
7+
- Route SDS blob uploads to `com.sds.repo.uploadBlob` with repo query parameter, and PDS blob uploads to standard
8+
`com.atproto.repo.uploadBlob`
9+
- Remove incorrect SDS routing logic from profile operations (profiles use standard ATProto endpoints on both PDS and
10+
SDS)
11+
- DRY blob uploads: extract shared `BlobOperations` interface and use dependency injection in both
12+
`ProfileOperationsImpl` and `HypercertOperationsImpl`
13+
- Refactor `applyParamsToProfile` to eliminate code duplication with helper methods
14+
- Add `create()` method to `ProfileOperationsImpl` for creating new profiles
15+
- Fix collection blob upload tests to mock `BlobOperations.upload` instead of `agent.uploadBlob`

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,17 @@ export class BlobOperationsImpl implements BlobOperations {
5757
* @param agent - AT Protocol Agent for making API calls
5858
* @param repoDid - DID of the repository (used for blob retrieval)
5959
* @param _serverUrl - Server URL (reserved for future use)
60+
* @param isSDS - Whether this is a Shared Data Server
61+
* @param repo - Optional repository instance for referencing back to repository
6062
*
6163
* @internal
6264
*/
6365
constructor(
6466
private agent: Agent,
6567
private repoDid: string,
6668
private _serverUrl: string,
69+
private isSDS: boolean,
70+
private repo?: unknown,
6771
) {}
6872

6973
/**
@@ -112,9 +116,16 @@ export class BlobOperationsImpl implements BlobOperations {
112116
try {
113117
const arrayBuffer = await blob.arrayBuffer();
114118
const uint8Array = new Uint8Array(arrayBuffer);
119+
const encoding = blob.type || "application/octet-stream";
120+
121+
// Use SDS endpoint if available, otherwise use standard PDS endpoint
122+
// SDS has a dedicated uploadBlob endpoint that accepts the repo param
123+
if (this.isSDS) {
124+
return await this.uploadViaSDS(uint8Array, encoding);
125+
}
115126

116127
const result = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
117-
encoding: blob.type || "application/octet-stream",
128+
encoding,
118129
});
119130

120131
if (!result.success) {
@@ -135,6 +146,54 @@ export class BlobOperationsImpl implements BlobOperations {
135146
}
136147
}
137148

149+
/**
150+
* Uploads a blob via the SDS-specific XRPC endpoint.
151+
*
152+
* Uses the agent's fetch handler directly since `com.sds.repo.uploadBlob`
153+
* is not a registered XRPC namespace on the standard AT Protocol Agent.
154+
* This follows the same pattern used by CollaboratorOperationsImpl and
155+
* OrganizationOperationsImpl for SDS-specific endpoints.
156+
*
157+
* @param data - Binary data to upload
158+
* @param encoding - MIME type of the data
159+
* @returns Promise resolving to blob reference and metadata
160+
* @throws {@link NetworkError} if the upload fails
161+
* @internal
162+
*/
163+
private async uploadViaSDS(
164+
data: Uint8Array,
165+
encoding: string,
166+
): Promise<{ ref: { $link: string }; mimeType: string; size: number }> {
167+
const url = `/xrpc/com.sds.repo.uploadBlob?repo=${encodeURIComponent(this.repoDid)}`;
168+
const response = await this.agent.fetchHandler(url, {
169+
method: "POST",
170+
headers: {
171+
"Content-Type": encoding,
172+
},
173+
body: data as unknown as BodyInit,
174+
});
175+
176+
if (!response.ok) {
177+
throw new NetworkError(`SDS blob upload failed: ${response.statusText}`);
178+
}
179+
180+
const result = (await response.json()) as {
181+
blob: {
182+
ref: { $link: string } | string;
183+
mimeType: string;
184+
size: number;
185+
};
186+
};
187+
188+
const ref = typeof result.blob.ref === "string" ? result.blob.ref : result.blob.ref.$link;
189+
190+
return {
191+
ref: { $link: ref },
192+
mimeType: result.blob.mimeType,
193+
size: result.blob.size,
194+
};
195+
}
196+
138197
/**
139198
* Retrieves a blob by its CID.
140199
*

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

Lines changed: 30 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
type CreateAttachmentParams,
4040
} from "../services/hypercerts/types.js";
4141
import type {
42+
BlobOperations,
4243
LocationParams,
4344
CreateHypercertParams,
4445
CreateHypercertResult,
@@ -106,15 +107,15 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
106107
*
107108
* @param agent - AT Protocol Agent for making API calls
108109
* @param repoDid - DID of the repository to operate on
109-
* @param _serverUrl - Server URL (reserved for future use)
110+
* @param blobs - Blob operations for uploading images and files
110111
* @param logger - Optional logger for debugging
111112
*
112113
* @internal
113114
*/
114115
constructor(
115116
private agent: Agent,
116117
private repoDid: string,
117-
private _serverUrl: string,
118+
private blobs: BlobOperations,
118119
private logger?: LoggerInterface,
119120
) {
120121
super();
@@ -138,25 +139,19 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
138139
}
139140

140141
/**
141-
* Helper function to upload a blob to the repository, returns a blob reference
142+
* Converts a blob upload result to JsonBlobRef format.
142143
*
143-
* @param content - Blob to upload
144-
* @param fallbackContentType | if content.type is empty,we use this
145-
* @returns BlobRef
146-
* @throws {@link NetworkError} if upload fails
144+
* @param uploadResult - Result from BlobOperations.upload()
145+
* @returns JsonBlobRef formatted for records
147146
* @internal
148147
*/
149-
150-
private async handleBlobUpload(content: Blob, fallbackContentType: string) {
151-
const arrayBuffer = await content.arrayBuffer();
152-
const uint8Array = new Uint8Array(arrayBuffer);
153-
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
154-
encoding: content.type || fallbackContentType,
155-
});
156-
if (!uploadResult.success) {
157-
throw new NetworkError("Failed to upload blob");
158-
}
159-
return uploadResult.data.blob;
148+
private blobToJsonRef(uploadResult: { ref: { $link: string }; mimeType: string; size: number }): JsonBlobRef {
149+
return {
150+
$type: "blob",
151+
ref: uploadResult.ref,
152+
mimeType: uploadResult.mimeType,
153+
size: uploadResult.size,
154+
};
160155
}
161156

162157
/**
@@ -174,26 +169,13 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
174169
): Promise<JsonBlobRef | undefined> {
175170
this.emitProgress(onProgress, { name: "uploadImage", status: "start" });
176171
try {
177-
const arrayBuffer = await image.arrayBuffer();
178-
const uint8Array = new Uint8Array(arrayBuffer);
179-
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
180-
encoding: image.type || "image/jpeg",
172+
const uploadResult = await this.blobs.upload(image);
173+
this.emitProgress(onProgress, {
174+
name: "uploadImage",
175+
status: "success",
176+
data: { size: image.size },
181177
});
182-
if (uploadResult.success) {
183-
const blobRef: JsonBlobRef = {
184-
$type: "blob",
185-
ref: { $link: uploadResult.data.blob.ref.toString() },
186-
mimeType: uploadResult.data.blob.mimeType,
187-
size: uploadResult.data.blob.size,
188-
};
189-
this.emitProgress(onProgress, {
190-
name: "uploadImage",
191-
status: "success",
192-
data: { size: image.size },
193-
});
194-
return blobRef;
195-
}
196-
throw new NetworkError("Image upload succeeded but returned no blob reference");
178+
return this.blobToJsonRef(uploadResult);
197179
} catch (error) {
198180
this.emitProgress(onProgress, { name: "uploadImage", status: "error", error: error as Error });
199181
throw new NetworkError(`Failed to upload image: ${error instanceof Error ? error.message : "Unknown"}`, error);
@@ -723,19 +705,8 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
723705
if (params.image === null) {
724706
// Remove image
725707
} else {
726-
const arrayBuffer = await params.image.arrayBuffer();
727-
const uint8Array = new Uint8Array(arrayBuffer);
728-
const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
729-
encoding: params.image.type || "image/jpeg",
730-
});
731-
if (uploadResult.success) {
732-
recordForUpdate.image = {
733-
$type: "blob",
734-
ref: uploadResult.data.blob.ref,
735-
mimeType: uploadResult.data.blob.mimeType,
736-
size: uploadResult.data.blob.size,
737-
};
738-
}
708+
const uploadResult = await this.blobs.upload(params.image);
709+
recordForUpdate.image = this.blobToJsonRef(uploadResult);
739710
}
740711
} else if (existingRecord.image) {
741712
// Preserve existing image
@@ -980,7 +951,7 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
980951
* @returns Promise resolving to either a URI ref or blob ref
981952
* @internal
982953
*/
983-
private async resolveUriOrBlob(content: string | Blob, fallbackMimeType: string) {
954+
private async resolveUriOrBlob(content: string | Blob, _fallbackMimeType: string) {
984955
if (typeof content === "string") {
985956
const uriRef = {
986957
$type: "org.hypercerts.defs#uri",
@@ -989,12 +960,11 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
989960
return uriRef;
990961
}
991962

992-
const uploadedBlob = await this.handleBlobUpload(content, fallbackMimeType);
993-
const blobRef = {
994-
$type: "org.hypercerts.defs#smallBlob",
995-
blob: uploadedBlob,
996-
} satisfies $Typed<OrgHypercertsDefs.SmallBlob>;
997-
return blobRef;
963+
const uploadResult = await this.blobs.upload(content);
964+
return {
965+
$type: "org.hypercerts.defs#smallBlob" as const,
966+
blob: uploadResult,
967+
};
998968
}
999969

1000970
private async resolveCollectionImageInput(input: string | Blob): Promise<NonNullable<HypercertCollection["avatar"]>>;
@@ -1007,12 +977,12 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
1007977
return { $type: "org.hypercerts.defs#uri" as const, uri: input };
1008978
}
1009979

1010-
const blob = await this.handleBlobUpload(input, "image/jpeg");
980+
const uploadResult = await this.blobs.upload(input);
1011981
if (isBanner) {
1012-
return { $type: "org.hypercerts.defs#largeImage" as const, image: blob };
982+
return { $type: "org.hypercerts.defs#largeImage" as const, image: uploadResult };
1013983
}
1014984

1015-
return { $type: "org.hypercerts.defs#smallImage" as const, image: blob };
985+
return { $type: "org.hypercerts.defs#smallImage" as const, image: uploadResult };
1016986
}
1017987

1018988
private async resolveLocationValue(location: string | Blob | HypercertLocation["location"]) {

0 commit comments

Comments
 (0)