From 44d2e8bff85a07a5a0ed58d9399fd5a0a3b566f5 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 2 Feb 2026 22:10:03 +0600 Subject: [PATCH 1/8] refactor: extract fetch record and uri parseer into reusable functions --- packages/sdk-core/src/index.ts | 1 + packages/sdk-core/src/lexicons/utils.ts | 31 ++ .../src/repository/HypercertOperationsImpl.ts | 328 +++++++----------- 3 files changed, 157 insertions(+), 203 deletions(-) diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 03abcd9f..54166417 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -38,6 +38,7 @@ export { parseAtUri, buildAtUri, extractRkeyFromUri, + AT_URI_REGEX, isValidAtUri, createStrongRef, createStrongRefFromResult, diff --git a/packages/sdk-core/src/lexicons/utils.ts b/packages/sdk-core/src/lexicons/utils.ts index 1c0f1ecd..29ddbce1 100644 --- a/packages/sdk-core/src/lexicons/utils.ts +++ b/packages/sdk-core/src/lexicons/utils.ts @@ -11,6 +11,37 @@ import type { StrongRef } from "../services/hypercerts/types.js"; import type { CreateResult, UpdateResult } from "../repository/types.js"; +/** + * Regular expression for parsing AT-URIs. + * + * AT-URIs follow the format: `at://{did}/{collection}/{rkey}` + * + * Capture groups: + * - [1] did - The DID of the repository owner (e.g., "did:plc:abc123") + * - [2] collection - The NSID of the record type (e.g., "org.hypercerts.claim.activity") + * - [3] rkey - The record key (e.g., "3km2vj4kfqp2a") + * + * @example Direct regex usage + * ```typescript + * const match = AT_URI_REGEX.exec("at://did:plc:abc/org.hypercerts.claim.activity/xyz"); + * if (match) { + * const [, did, collection, rkey] = match; + * } + * ``` + * + * @example Validation + * ```typescript + * if (AT_URI_REGEX.test(userInput)) { + * // Valid AT-URI format + * } + * ``` + * + * @remarks + * For most use cases, prefer using {@link parseAtUri} which provides + * better error messages and returns a typed object. + */ +export const AT_URI_REGEX = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/; + /** * Components of an AT-URI (AT Protocol Uniform Resource Identifier). * diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index 4f685d3b..743ffbcb 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -122,6 +122,94 @@ export class HypercertOperationsImpl extends EventEmitter imple super(); } + /** + * Parses an AT-URI and throws ValidationError if invalid. + * + * This wrapper converts the generic Error from parseAtUri() to a ValidationError + * for consistent error handling throughout the SDK. + * + * @param uri - AT-URI to parse + * @returns Parsed URI components + * @throws {@link ValidationError} if URI format is invalid + * @internal + */ + private parseUri(uri: string): { did: string; collection: string; rkey: string } { + try { + return parseAtUri(uri); + } catch (error) { + throw new ValidationError(error instanceof Error ? error.message : `Invalid URI format: ${uri}`); + } + } + + /** + * Fetches any record by AT-URI with generic typing. + * + * Returns the record along with parsed URI components needed for updates. + * Unlike the public `get()` method which is typed for HypercertClaim, + * this method can fetch any record type. + * + * @typeParam T - The expected type of the record + * @param uri - AT-URI of the record to fetch + * @returns Record data with parsed URI components + * @throws {@link ValidationError} if URI format is invalid + * @throws {@link NetworkError} if record cannot be fetched + * @internal + */ + private async fetchRecord( + uri: string, + ): Promise<{ + uri: string; + cid: string; + record: T; + collection: string; + rkey: string; + }> { + const { did, collection, rkey } = this.parseUri(uri); + + const result = await this.agent.com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + + if (!result.success) { + throw new NetworkError(`Failed to fetch record: ${uri}`); + } + + return { + uri: result.data.uri, + cid: result.data.cid ?? "", + record: result.data.value as T, + collection, + rkey, + }; + } + + /** + * Updates a record in the repository. + * + * @param collection - NSID of the collection + * @param rkey - Record key + * @param record - Updated record data + * @returns Update result with URI and CID + * @throws {@link NetworkError} if update fails + * @internal + */ + private async saveRecord(collection: string, rkey: string, record: Record): Promise { + const result = await this.agent.com.atproto.repo.putRecord({ + repo: this.repoDid, + collection, + rkey, + record, + }); + + if (!result.success) { + throw new NetworkError("Failed to save record:", `${collection}:${rkey}`); + } + + return { uri: result.data.uri, cid: result.data.cid }; + } + /** * Emits a progress event to the optional progress handler. * @@ -629,17 +717,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async update(params: { uri: string; updates: UpdateHypercertParams; image?: Blob | null }): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(params.uri); - - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - // The existing record comes from ATProto, use it directly - // TypeScript ensures type safety through the HypercertClaim interface - const existingRecord = existing.data.value as HypercertClaim; + const { record: existingRecord, collection, rkey } = await this.fetchRecord(params.uri); const recordForUpdate: Record = { ...existingRecord, @@ -670,19 +748,10 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid hypercert record: ${validation.error?.message}`); } - const result = await this.agent.com.atproto.repo.putRecord({ - repo: this.repoDid, - collection, - rkey, - record: recordForUpdate, - }); - - if (!result.success) { - throw new NetworkError("Failed to update hypercert"); - } + const result = await this.saveRecord(collection, rkey, recordForUpdate); - this.emit("recordUpdated", { uri: result.data.uri, cid: result.data.cid }); - return { uri: result.data.uri, cid: result.data.cid }; + this.emit("recordUpdated", { uri: result.uri, cid: result.cid }); + return result; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError( @@ -708,23 +777,8 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async get(uri: string): Promise<{ uri: string; cid: string; record: HypercertClaim }> { try { - const { collection, rkey } = this.parseAndValidateUri(uri); - - const result = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!result.success) { - throw new NetworkError("Failed to get hypercert"); - } - - return { - uri: result.data.uri, - cid: result.data.cid ?? "", - record: result.data.value as HypercertClaim, - }; + const { uri: resultUri, cid, record } = await this.fetchRecord(uri); + return { uri: resultUri, cid, record }; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError(`Failed to get hypercert: ${error instanceof Error ? error.message : "Unknown"}`, error); @@ -795,7 +849,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async delete(uri: string): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); + const { collection, rkey } = this.parseUri(uri); const result = await this.agent.com.atproto.repo.deleteRecord({ repo: this.repoDid, @@ -986,16 +1040,13 @@ export class HypercertOperationsImpl extends EventEmitter imple } private async resolveStrongRefFromUri(uri: string): Promise { - const { did: repo, collection, rkey } = this.parseAndValidateUri(uri); - const record = await this.agent.com.atproto.repo.getRecord({ repo, collection, rkey }); - if (!record.success) { - throw new NetworkError(`Failed to fetch record for repo=${repo}, collection=${collection}, rkey=${rkey}`); - } - if (!record.data.cid) { - throw new NetworkError(`Record missing CID for repo=${repo}, collection=${collection}, rkey=${rkey}`); + const { collection, rkey, did } = this.parseUri(uri); + const record = await this.fetchRecord(uri); + if (!record.cid) { + throw new NetworkError(`Record missing CID for repo=${did}, collection=${collection}, rkey=${rkey}`); } - return { $type: "com.atproto.repo.strongRef" as const, uri, cid: record.data.cid }; + return { $type: "com.atproto.repo.strongRef" as const, uri, cid: record.cid }; } /** @@ -1741,7 +1792,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateMeasurement(uri: string, updates: UpdateMeasurementParams): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); + const { collection, rkey } = this.parseUri(uri); if (collection !== HYPERCERT_COLLECTIONS.MEASUREMENT) { throw new ValidationError( @@ -1749,36 +1800,19 @@ export class HypercertOperationsImpl extends EventEmitter imple ); } - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Measurement not found: ${uri}`); - } + const { record: existingRecord } = await this.fetchRecord(uri); - const recordForUpdate = await this.applyMeasurementUpdates(existing.data.value as HypercertMeasurement, updates); + const recordForUpdate = await this.applyMeasurementUpdates(existingRecord, updates); const validation = validate(recordForUpdate, HYPERCERT_COLLECTIONS.MEASUREMENT, "main", false); if (!validation.success) { throw new ValidationError(`Invalid measurement record: ${validation.error?.message}`); } - const result = await this.agent.com.atproto.repo.putRecord({ - repo: this.repoDid, - collection, - rkey, - record: recordForUpdate, - }); - - if (!result.success) { - throw new NetworkError("Failed to update measurement"); - } + const result = await this.saveRecord(collection, rkey, recordForUpdate); - this.emit("measurementUpdated", { uri: result.data.uri, cid: result.data.cid }); - return { uri: result.data.uri, cid: result.data.cid }; + this.emit("measurementUpdated", { uri: result.uri, cid: result.cid }); + return result; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError( @@ -1983,29 +2017,15 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async getCollection(uri: string): Promise<{ uri: string; cid: string; record: HypercertCollection }> { try { - const { collection, rkey } = this.parseAndValidateUri(uri); - - const result = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!result.success) { - throw new NetworkError("Failed to get collection"); - } + const { uri: resultUri, cid, record } = await this.fetchRecord(uri); // Validate with lexicon registry (more lenient - doesn't require $type) - const validation = validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false); + const validation = validate(record, HYPERCERT_COLLECTIONS.COLLECTION, "main", false); if (!validation.success) { throw new ValidationError(`Invalid collection record format: ${validation.error?.message}`); } - return { - uri: result.data.uri, - cid: result.data.cid ?? "", - record: result.data.value as HypercertCollection, - }; + return { uri: resultUri, cid, record }; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError(`Failed to get collection: ${error instanceof Error ? error.message : "Unknown"}`, error); @@ -2130,37 +2150,20 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async getProject(uri: string): Promise<{ uri: string; cid: string; record: HypercertCollection }> { try { - // Parse URI - const { collection, rkey } = this.parseAndValidateUri(uri); - - // Fetch record - const result = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!result.success) { - throw new NetworkError("Failed to get project"); - } + const { uri: resultUri, cid, record } = await this.fetchRecord(uri); // Validate as collection - const validation = validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false); + const validation = validate(record, HYPERCERT_COLLECTIONS.COLLECTION, "main", false); if (!validation.success) { throw new ValidationError(`Invalid project record format: ${validation.error?.message}`); } // Verify it's actually a project (collection with type='project') - const record = result.data.value as HypercertCollection; if (record.type !== "project") { throw new ValidationError(`Record is not a project (type='${record.type}')`); } - return { - uri: result.data.uri, - cid: result.data.cid ?? "", - record, - }; + return { uri: resultUri, cid, record }; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError(`Failed to get project: ${error instanceof Error ? error.message : "Unknown"}`, error); @@ -2258,19 +2261,8 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateProject(uri: string, updates: UpdateProjectParams): Promise { // Verify it's a project before updating - const { collection, rkey } = this.parseAndValidateUri(uri); - - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Project not found: ${uri}`); - } + const { record } = await this.fetchRecord(uri); - const record = existing.data.value as HypercertCollection; if (record.type !== "project") { throw new ValidationError(`Record is not a project (type='${record.type}')`); } @@ -2297,19 +2289,8 @@ export class HypercertOperationsImpl extends EventEmitter imple * ``` */ async deleteProject(uri: string): Promise { - const { collection, rkey } = this.parseAndValidateUri(uri); + const { record } = await this.fetchRecord(uri); - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Project not found: ${uri}`); - } - - const record = existing.data.value as HypercertCollection; if (record.type !== "project") { throw new ValidationError(`Record is not a project (type='${record.type}')`); } @@ -2359,19 +2340,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateCollection(uri: string, updates: UpdateCollectionParams): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); - - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Collection not found: ${uri}`); - } - - const existingRecord = existing.data.value as HypercertCollection; + const { record: existingRecord, collection, rkey } = await this.fetchRecord(uri); const recordForUpdate: Record = { ...existingRecord, @@ -2436,19 +2405,10 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid collection record: ${validation.error?.message}`); } - const result = await this.agent.com.atproto.repo.putRecord({ - repo: this.repoDid, - collection, - rkey, - record: recordForUpdate, - }); + const result = await this.saveRecord(collection, rkey, recordForUpdate); - if (!result.success) { - throw new NetworkError("Failed to update collection"); - } - - this.emit("collectionUpdated", { uri: result.data.uri, cid: result.data.cid }); - return { uri: result.data.uri, cid: result.data.cid }; + this.emit("collectionUpdated", { uri: result.uri, cid: result.cid }); + return result; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError( @@ -2465,7 +2425,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async deleteCollection(uri: string): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); + const { collection, rkey } = this.parseUri(uri); const result = await this.agent.com.atproto.repo.deleteRecord({ repo: this.repoDid, @@ -2496,17 +2456,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async attachLocationToCollection(uri: string, location: LocationParams): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); - - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Collection not found: ${uri}`); - } + const { record, collection, rkey } = await this.fetchRecord(uri); const resolvedLocation = await this.resolveLocation(location); if (!resolvedLocation) { @@ -2514,20 +2464,11 @@ export class HypercertOperationsImpl extends EventEmitter imple } const recordForUpdate: Record = { - ...existing.data.value, + ...record, location: resolvedLocation, }; - const updateResult = await this.agent.com.atproto.repo.putRecord({ - repo: this.repoDid, - collection, - rkey, - record: recordForUpdate, - }); - - if (!updateResult.success) { - throw new NetworkError("Failed to update collection with location"); - } + await this.saveRecord(collection, rkey, recordForUpdate); this.emit("locationAttachedToCollection", { uri: resolvedLocation.uri, @@ -2548,31 +2489,12 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async removeLocationFromCollection(uri: string): Promise { try { - const { collection, rkey } = this.parseAndValidateUri(uri); - - const existing = await this.agent.com.atproto.repo.getRecord({ - repo: this.repoDid, - collection, - rkey, - }); - - if (!existing.success) { - throw new NetworkError(`Collection not found: ${uri}`); - } + const { record, collection, rkey } = await this.fetchRecord(uri); - const recordForUpdate = { ...existing.data.value }; + const recordForUpdate = { ...record } as Record; delete (recordForUpdate as { location?: unknown }).location; - const result = await this.agent.com.atproto.repo.putRecord({ - repo: this.repoDid, - collection, - rkey, - record: recordForUpdate, - }); - - if (!result.success) { - throw new NetworkError("Failed to remove location from collection"); - } + await this.saveRecord(collection, rkey, recordForUpdate); this.emit("locationRemovedFromCollection", { collectionUri: uri }); } catch (error) { From 5db01ee2baee53a0fd1c4e1ee1e32f2c7e41c30e Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 3 Feb 2026 12:32:26 +0600 Subject: [PATCH 2/8] refactor: return proper blob after upload instead of manual construction --- .changeset/refactor-uri-parsing-utilities.md | 11 +++++++++++ .../sdk-core/src/repository/BlobOperationsImpl.ts | 4 +++- .../src/repository/HypercertOperationsImpl.ts | 2 +- .../tests/repository/BlobOperationsImpl.test.ts | 4 ++-- 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .changeset/refactor-uri-parsing-utilities.md diff --git a/.changeset/refactor-uri-parsing-utilities.md b/.changeset/refactor-uri-parsing-utilities.md new file mode 100644 index 00000000..5222983f --- /dev/null +++ b/.changeset/refactor-uri-parsing-utilities.md @@ -0,0 +1,11 @@ +--- +"@hypercerts-org/sdk-core": patch +--- + +Refactor internal URI parsing and blob upload operations + +- Export `AT_URI_REGEX` from `@hypercerts-org/sdk-core` for direct regex usage +- Consolidate AT-URI parsing in HypercertOperationsImpl using `parseAtUri()` utility +- Add internal `fetchRecord()` and `saveRecord()` helpers to reduce code duplication +- Simplify `BlobOperationsImpl.upload()` to return `BlobRef` directly from the API instead of manually reconstructing it +- Update return type of `upload()` from manual `{ ref, mimeType, size }` to AT Protocol's `BlobRef` type diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index f4c1e597..32364e9e 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -168,7 +168,9 @@ export class BlobOperationsImpl implements BlobOperations { throw new NetworkError(`SDS blob upload failed: ${response.statusText}`); } - const result = await response.json(); + const result = (await response.json()) as { + blob: BlobRef; + }; return result.blob; } diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index 743ffbcb..f3c29f87 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -2602,7 +2602,7 @@ export class HypercertOperationsImpl extends EventEmitter imple const result = await this.agent.com.atproto.repo.createRecord({ repo: this.repoDid, collection: HYPERCERT_COLLECTIONS.LOCATION, - record: locationRecord as Record, + record: locationRecord, }); if (!result.success) { diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index 7e7adf14..2cd41cdb 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -134,7 +134,7 @@ describe("BlobOperationsImpl", () => { ok: true, json: async () => ({ blob: { - ref: "bafyrei-string-ref", + ref: { toString: () => "bafyrei-string-ref" }, mimeType: "text/plain", size: 4, }, @@ -143,7 +143,7 @@ describe("BlobOperationsImpl", () => { const result = await sdsBlobOps.upload(mockBlob); - expect(result.ref).toBe("bafyrei-string-ref"); + expect(result.ref.toString()).toEqual("bafyrei-string-ref"); }); it("should throw NetworkError when SDS returns non-ok response", async () => { From 111ca8ec0d6ac0ee229515d2f2cb385a4affb662 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 3 Feb 2026 17:29:16 +0600 Subject: [PATCH 3/8] fix: change scope regex for oauth --- packages/sdk-core/src/auth/permissions.ts | 2 +- .../sdk-core/tests/auth/permissions.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index 8f7fff54..06dd3bf2 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -1233,7 +1233,7 @@ export function validateScope(scope: string): { const invalidPermissions: string[] = []; // Pattern for valid permission prefixes - const validPrefixes = /^(atproto|transition:|account:|repo:|blob:?|rpc:|identity:|include:)/; + const validPrefixes = /^(atproto$|transition:|account:|repo[:?]|blob[:?]|rpc[:?]|identity:|include:)/; for (const permission of permissions) { if (!validPrefixes.test(permission)) { diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index a3f50edc..078dbb86 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -1083,6 +1083,29 @@ describe("Scope Utility Functions", () => { expect(result.isValid).toBe(true); expect(result.invalidPermissions).toEqual([]); }); + + it("should validate query-param style permissions without colon separator", () => { + const queryParamScopes = [ + "rpc?lxm=*&aud=did:web:api.example.com%23svc_appview", + "blob?accept=video/*&accept=text/html", + "repo:app.example.profile?action=create&action=update&action=delete", + "identity:*", + "identity:*?", + "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat", + ]; + + for (const scope of queryParamScopes) { + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + } + }); + + it("should not match atproto as a prefix of longer strings", () => { + const result = validateScope("atprotofoo"); + expect(result.isValid).toBe(false); + expect(result.invalidPermissions).toEqual(["atprotofoo"]); + }); }); describe("Integration: Builder with Utilities", () => { From 64f9f03ea2bd3ee0e52db1eeeb6eac2cbbd6b02f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 16:31:33 +0000 Subject: [PATCH 4/8] fix(sdk-core): address PR126 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SDS blob upload to return proper BlobRef instance using BlobRef.asBlobRef - Fix AT_URI_REGEX rkey capture group (.+ → [^/]+) to prevent over-matching - Fix fetchRecord to throw NetworkError when CID is absent - Fix saveRecord error message (two-arg → template literal) - Remove dead parseAndValidateUri method and unused imports - Eliminate double-fetch in updateProject via new updateCollectionRecord helper - Rename record → fetchResult in resolveStrongRefFromUri for clarity - Rename misleading test description in permissions.test.ts - Update changeset to minor with breaking change note for BlobRef return type --- .changeset/refactor-uri-parsing-utilities.md | 14 +++- packages/sdk-core/src/lexicons/utils.ts | 2 +- .../src/repository/BlobOperationsImpl.ts | 19 ++++- .../src/repository/HypercertOperationsImpl.ts | 74 ++++++++++--------- .../sdk-core/tests/auth/permissions.test.ts | 2 +- .../repository/BlobOperationsImpl.test.ts | 20 ++--- .../HypercertOperationsImpl.test.ts | 4 +- 7 files changed, 80 insertions(+), 55 deletions(-) diff --git a/.changeset/refactor-uri-parsing-utilities.md b/.changeset/refactor-uri-parsing-utilities.md index 5222983f..f85c483f 100644 --- a/.changeset/refactor-uri-parsing-utilities.md +++ b/.changeset/refactor-uri-parsing-utilities.md @@ -1,11 +1,19 @@ --- -"@hypercerts-org/sdk-core": patch +"@hypercerts-org/sdk-core": minor --- Refactor internal URI parsing and blob upload operations +**Breaking:** `BlobOperationsImpl.upload()` now returns AT Protocol's `BlobRef` type instead of a plain +`{ ref, mimeType, size }` object. Callers should access blob properties via `BlobRef` methods (e.g. +`result.ref.toString()` for the CID string). SDS uploads now return a proper `BlobRef` instance constructed from the +server response. + - Export `AT_URI_REGEX` from `@hypercerts-org/sdk-core` for direct regex usage - Consolidate AT-URI parsing in HypercertOperationsImpl using `parseAtUri()` utility - Add internal `fetchRecord()` and `saveRecord()` helpers to reduce code duplication -- Simplify `BlobOperationsImpl.upload()` to return `BlobRef` directly from the API instead of manually reconstructing it -- Update return type of `upload()` from manual `{ ref, mimeType, size }` to AT Protocol's `BlobRef` type +- Fix `AT_URI_REGEX` rkey capture group to use `[^/]+` instead of `.+` to prevent over-matching +- Fix `fetchRecord` to throw `NetworkError` when CID is absent instead of silently using an empty string +- Fix `saveRecord` error message formatting (was passing two arguments to `NetworkError`) +- Remove dead `parseAndValidateUri` method +- Eliminate redundant network fetch in `updateProject` by passing pre-fetched record to `updateCollectionRecord` diff --git a/packages/sdk-core/src/lexicons/utils.ts b/packages/sdk-core/src/lexicons/utils.ts index 29ddbce1..12c89a6b 100644 --- a/packages/sdk-core/src/lexicons/utils.ts +++ b/packages/sdk-core/src/lexicons/utils.ts @@ -40,7 +40,7 @@ import type { CreateResult, UpdateResult } from "../repository/types.js"; * For most use cases, prefer using {@link parseAtUri} which provides * better error messages and returns a typed object. */ -export const AT_URI_REGEX = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/; +export const AT_URI_REGEX = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/; /** * Components of an AT-URI (AT Protocol Uniform Resource Identifier). diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index 32364e9e..b89d5a77 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -7,7 +7,8 @@ * @packageDocumentation */ -import type { Agent, BlobRef } from "@atproto/api"; +import type { Agent } from "@atproto/api"; +import { BlobRef } from "@atproto/lexicon"; import { NetworkError } from "../core/errors.js"; import type { BlobOperations } from "./interfaces.js"; @@ -168,11 +169,23 @@ export class BlobOperationsImpl implements BlobOperations { throw new NetworkError(`SDS blob upload failed: ${response.statusText}`); } + // SDS returns { blob: { ref: { $link: string }, mimeType: string, size: number } } + // which is a JSON-serialized blob ref, not a BlobRef instance. + // Use BlobRef.asBlobRef with the untyped format ({ cid, mimeType }) to construct a proper BlobRef. const result = (await response.json()) as { - blob: BlobRef; + blob: { ref: { $link: string }; mimeType: string; size: number }; }; - return result.blob; + const blobRef = BlobRef.asBlobRef({ + cid: result.blob.ref.$link, + mimeType: result.blob.mimeType, + }); + + if (!blobRef) { + throw new NetworkError("SDS blob upload returned an invalid blob reference"); + } + + return blobRef; } /** diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index f3c29f87..83a3f10d 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -53,7 +53,7 @@ import type { LocationParams, } from "./interfaces.js"; import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js"; -import { parseAtUri, isValidAtUri, type AtUriComponents } from "../lexicons/utils.js"; +import { parseAtUri } from "../lexicons/utils.js"; /** * Implementation of high-level hypercert operations. @@ -176,9 +176,14 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new NetworkError(`Failed to fetch record: ${uri}`); } + const cid = result.data.cid; + if (!cid) { + throw new NetworkError(`Record at ${uri} returned no CID`); + } + return { uri: result.data.uri, - cid: result.data.cid ?? "", + cid, record: result.data.value as T, collection, rkey, @@ -204,7 +209,7 @@ export class HypercertOperationsImpl extends EventEmitter imple }); if (!result.success) { - throw new NetworkError("Failed to save record:", `${collection}:${rkey}`); + throw new NetworkError(`Failed to save record: ${collection}/${rkey}`); } return { uri: result.data.uri, cid: result.data.cid }; @@ -252,24 +257,6 @@ export class HypercertOperationsImpl extends EventEmitter imple } } - /** - * Parse and validate an AT-URI, throwing ValidationError on failure. - * - * Extracts the DID, collection NSID, and record key from an AT-URI string. - * Validates the URI format and throws a descriptive error if invalid. - * - * @param uri - The AT-URI to parse (e.g., "at://did:plc:abc/collection/rkey") - * @returns The parsed URI components (did, collection, rkey) - * @throws {@link ValidationError} If the URI format is invalid - * @internal - */ - private parseAndValidateUri(uri: string): AtUriComponents { - if (!isValidAtUri(uri)) { - throw new ValidationError(`Invalid AT-URI format: ${uri}`); - } - return parseAtUri(uri); - } - /** * Creates a rights record for a hypercert. * @@ -1040,13 +1027,9 @@ export class HypercertOperationsImpl extends EventEmitter imple } private async resolveStrongRefFromUri(uri: string): Promise { - const { collection, rkey, did } = this.parseUri(uri); - const record = await this.fetchRecord(uri); - if (!record.cid) { - throw new NetworkError(`Record missing CID for repo=${did}, collection=${collection}, rkey=${rkey}`); - } - - return { $type: "com.atproto.repo.strongRef" as const, uri, cid: record.cid }; + // fetchRecord already validates CID presence and throws NetworkError if absent + const fetchResult = await this.fetchRecord(uri); + return { $type: "com.atproto.repo.strongRef" as const, uri, cid: fetchResult.cid }; } /** @@ -2261,14 +2244,14 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateProject(uri: string, updates: UpdateProjectParams): Promise { // Verify it's a project before updating - const { record } = await this.fetchRecord(uri); + const fetchResult = await this.fetchRecord(uri); - if (record.type !== "project") { - throw new ValidationError(`Record is not a project (type='${record.type}')`); + if (fetchResult.record.type !== "project") { + throw new ValidationError(`Record is not a project (type='${fetchResult.record.type}')`); } - // Delegate to updateCollection - const result = await this.updateCollection(uri, updates); + // Pass pre-fetched record to avoid a second fetch in updateCollectionRecord + const result = await this.updateCollectionRecord(fetchResult, updates); this.emit("projectUpdated", { uri: result.uri, cid: result.cid }); return result; @@ -2339,8 +2322,29 @@ export class HypercertOperationsImpl extends EventEmitter imple * ``` */ async updateCollection(uri: string, updates: UpdateCollectionParams): Promise { + const fetchResult = await this.fetchRecord(uri); + const result = await this.updateCollectionRecord(fetchResult, updates); + this.emit("collectionUpdated", { uri: result.uri, cid: result.cid }); + return result; + } + + /** + * Core collection update logic operating on a pre-fetched record. + * + * Extracted so that callers like {@link updateProject} can fetch once, + * validate the type, and then delegate here without a redundant fetch. + * + * @param fetchResult - The pre-fetched record, collection, and rkey + * @param updates - Fields to update (partial) + * @returns Promise resolving to updated URI and CID + * @internal + */ + private async updateCollectionRecord( + fetchResult: { record: HypercertCollection; collection: string; rkey: string }, + updates: UpdateCollectionParams, + ): Promise { try { - const { record: existingRecord, collection, rkey } = await this.fetchRecord(uri); + const { record: existingRecord, collection, rkey } = fetchResult; const recordForUpdate: Record = { ...existingRecord, @@ -2406,8 +2410,6 @@ export class HypercertOperationsImpl extends EventEmitter imple } const result = await this.saveRecord(collection, rkey, recordForUpdate); - - this.emit("collectionUpdated", { uri: result.uri, cid: result.cid }); return result; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index 078dbb86..ed7cd0f6 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -1084,7 +1084,7 @@ describe("Scope Utility Functions", () => { expect(result.invalidPermissions).toEqual([]); }); - it("should validate query-param style permissions without colon separator", () => { + it("should validate mixed-format permissions including query-param style", () => { const queryParamScopes = [ "rpc?lxm=*&aud=did:web:api.example.com%23svc_appview", "blob?accept=video/*&accept=text/html", diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index 2cd41cdb..407d4bbb 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -101,13 +101,13 @@ describe("BlobOperationsImpl", () => { sdsBlobOps = new BlobOperationsImpl(mockAgent as unknown as Agent, TEST_REPO_DID, TEST_SDS_URL, true); }); - it("should upload a blob via SDS fetchHandler", async () => { + it("should upload a blob via SDS fetchHandler and return a real BlobRef", async () => { const mockBlob = new Blob(["test content"], { type: "image/png" }); mockAgent.fetchHandler.mockResolvedValue({ ok: true, json: async () => ({ blob: { - ref: { $link: "bafyrei-sds-123" }, + ref: { $link: "bafkreih2lkrpuecmhbfuqkapcuy5n3r6bniibdes65v5j2yogi7jdsecia" }, mimeType: "image/png", size: 12, }, @@ -116,9 +116,11 @@ describe("BlobOperationsImpl", () => { const result = await sdsBlobOps.upload(mockBlob); - expect(result.ref).toEqual({ $link: "bafyrei-sds-123" }); + // BlobRef.asBlobRef constructs a proper BlobRef from the untyped { cid, mimeType } format + expect(result.ref.toString()).toBe("bafkreih2lkrpuecmhbfuqkapcuy5n3r6bniibdes65v5j2yogi7jdsecia"); expect(result.mimeType).toBe("image/png"); - expect(result.size).toBe(12); + // Size is -1 because the untyped BlobRef format doesn't carry size + expect(result.size).toBe(-1); expect(mockAgent.fetchHandler).toHaveBeenCalledWith( `/xrpc/com.sds.repo.uploadBlob?repo=${encodeURIComponent(TEST_REPO_DID)}`, expect.objectContaining({ @@ -128,22 +130,20 @@ describe("BlobOperationsImpl", () => { ); }); - it("should handle string blob ref from SDS", async () => { + it("should throw NetworkError for invalid SDS blob ref", async () => { const mockBlob = new Blob(["test"], { type: "text/plain" }); mockAgent.fetchHandler.mockResolvedValue({ ok: true, json: async () => ({ blob: { - ref: { toString: () => "bafyrei-string-ref" }, + ref: { $link: "not-a-valid-cid" }, mimeType: "text/plain", size: 4, }, }), }); - const result = await sdsBlobOps.upload(mockBlob); - - expect(result.ref.toString()).toEqual("bafyrei-string-ref"); + await expect(sdsBlobOps.upload(mockBlob)).rejects.toThrow(NetworkError); }); it("should throw NetworkError when SDS returns non-ok response", async () => { @@ -169,7 +169,7 @@ describe("BlobOperationsImpl", () => { ok: true, json: async () => ({ blob: { - ref: { $link: "bafyrei-sds" }, + ref: { $link: "bafkreih2lkrpuecmhbfuqkapcuy5n3r6bniibdes65v5j2yogi7jdsecia" }, mimeType: "image/jpeg", size: 4, }, diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index 499aa3f6..1530e995 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -932,6 +932,8 @@ describe("HypercertOperationsImpl", () => { mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { + cid: "bafyreiabc123", + uri: "at://did:plc:test/org.hypercerts.claim.record/abc123", value: { title: "Title", description: "Desc", @@ -4219,7 +4221,7 @@ describe("HypercertOperationsImpl", () => { await expect( // @ts-expect-error - accessing private method for testing hypercertOps.resolveToStrongRef(uri), - ).rejects.toThrow("Record missing CID"); + ).rejects.toThrow("returned no CID"); }); it("should throw ValidationError for invalid input type", async () => { From d6097b9a298334312a32ac991d113ef366e9bf45 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 16:38:37 +0000 Subject: [PATCH 5/8] chore(beads): ignore dolt/ directory and dolt-access.lock --- .beads/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.beads/.gitignore b/.beads/.gitignore index d27a1db5..9b996819 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,3 +1,7 @@ +# Dolt database directory and lock file +dolt/ +dolt-access.lock + # SQLite databases *.db *.db?* From 23d5c2e2ce6a9d47cd1b722d46a0452fa7215df9 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 16:47:10 +0000 Subject: [PATCH 6/8] fix(sdk-core): preserve SDS blob size by constructing BlobRef with CID.parse --- .changeset/refactor-uri-parsing-utilities.md | 4 ++-- .../sdk-core/src/repository/BlobOperationsImpl.ts | 15 +++++++-------- .../tests/repository/BlobOperationsImpl.test.ts | 4 +--- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.changeset/refactor-uri-parsing-utilities.md b/.changeset/refactor-uri-parsing-utilities.md index f85c483f..b61a6e6b 100644 --- a/.changeset/refactor-uri-parsing-utilities.md +++ b/.changeset/refactor-uri-parsing-utilities.md @@ -6,8 +6,8 @@ Refactor internal URI parsing and blob upload operations **Breaking:** `BlobOperationsImpl.upload()` now returns AT Protocol's `BlobRef` type instead of a plain `{ ref, mimeType, size }` object. Callers should access blob properties via `BlobRef` methods (e.g. -`result.ref.toString()` for the CID string). SDS uploads now return a proper `BlobRef` instance constructed from the -server response. +`result.ref.toString()` for the CID string). SDS uploads now return a proper `BlobRef` instance with `ref`, `mimeType`, +and `size` correctly populated from the server response. - Export `AT_URI_REGEX` from `@hypercerts-org/sdk-core` for direct regex usage - Consolidate AT-URI parsing in HypercertOperationsImpl using `parseAtUri()` utility diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index b89d5a77..1e8aefd3 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -9,6 +9,7 @@ import type { Agent } from "@atproto/api"; import { BlobRef } from "@atproto/lexicon"; +import { CID } from "multiformats/cid"; import { NetworkError } from "../core/errors.js"; import type { BlobOperations } from "./interfaces.js"; @@ -171,21 +172,19 @@ export class BlobOperationsImpl implements BlobOperations { // SDS returns { blob: { ref: { $link: string }, mimeType: string, size: number } } // which is a JSON-serialized blob ref, not a BlobRef instance. - // Use BlobRef.asBlobRef with the untyped format ({ cid, mimeType }) to construct a proper BlobRef. + // Construct a BlobRef directly using CID.parse to preserve the size from the SDS response. const result = (await response.json()) as { blob: { ref: { $link: string }; mimeType: string; size: number }; }; - const blobRef = BlobRef.asBlobRef({ - cid: result.blob.ref.$link, - mimeType: result.blob.mimeType, - }); - - if (!blobRef) { + let cid: CID; + try { + cid = CID.parse(result.blob.ref.$link); + } catch { throw new NetworkError("SDS blob upload returned an invalid blob reference"); } - return blobRef; + return new BlobRef(cid, result.blob.mimeType, result.blob.size); } /** diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index 407d4bbb..199a50c7 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -116,11 +116,9 @@ describe("BlobOperationsImpl", () => { const result = await sdsBlobOps.upload(mockBlob); - // BlobRef.asBlobRef constructs a proper BlobRef from the untyped { cid, mimeType } format expect(result.ref.toString()).toBe("bafkreih2lkrpuecmhbfuqkapcuy5n3r6bniibdes65v5j2yogi7jdsecia"); expect(result.mimeType).toBe("image/png"); - // Size is -1 because the untyped BlobRef format doesn't carry size - expect(result.size).toBe(-1); + expect(result.size).toBe(12); expect(mockAgent.fetchHandler).toHaveBeenCalledWith( `/xrpc/com.sds.repo.uploadBlob?repo=${encodeURIComponent(TEST_REPO_DID)}`, expect.objectContaining({ From aad80109d96c6027a67cc37713377f582f80712b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 18:03:55 +0000 Subject: [PATCH 7/8] test(sdk-core): add missing tests for fetchRecord, saveRecord, AT_URI_REGEX and updateProject --- .../sdk-core/tests/lexicons/utils.test.ts | 15 +++ .../HypercertOperationsImpl.test.ts | 106 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/sdk-core/tests/lexicons/utils.test.ts b/packages/sdk-core/tests/lexicons/utils.test.ts index 7156de50..6a40f498 100644 --- a/packages/sdk-core/tests/lexicons/utils.test.ts +++ b/packages/sdk-core/tests/lexicons/utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + AT_URI_REGEX, parseAtUri, buildAtUri, extractRkeyFromUri, @@ -10,6 +11,20 @@ import { isStrongRef, } from "../../src/lexicons/utils.js"; +describe("AT_URI_REGEX", () => { + it("should match a standard AT-URI", () => { + expect(AT_URI_REGEX.test("at://did:plc:abc123/org.example.col/rkey123")).toBe(true); + }); + + it("should not match when rkey contains a slash", () => { + expect(AT_URI_REGEX.test("at://did:plc:abc123/org.example.col/rkey/extra")).toBe(false); + }); + + it("should not match a URI with only two path segments (no rkey)", () => { + expect(AT_URI_REGEX.test("at://did:plc:abc123/org.example.col")).toBe(false); + }); +}); + describe("AT-URI Utilities", () => { describe("parseAtUri", () => { it("should parse valid AT-URI", () => { diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index 1530e995..f72eb531 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -3075,6 +3075,30 @@ describe("HypercertOperationsImpl", () => { }), ).rejects.toThrow(NetworkError); }); + + it("should throw NetworkError when putRecord fails", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.collection/abc123", + cid: "collection-cid", + value: { + $type: "org.hypercerts.claim.collection", + title: "Test Collection", + items: [], + createdAt: "2024-01-01T00:00:00Z", + }, + }, + }); + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ success: false }); + + await expect( + hypercertOps.attachLocationToCollection(`at://${TEST_REPO_DID}/org.hypercerts.collection/abc123`, { + uri: `at://${TEST_REPO_DID}/app.certified.location/loc123`, + cid: "location-cid", + }), + ).rejects.toThrow(NetworkError); + }); }); describe("removeLocationFromCollection", () => { @@ -3128,6 +3152,28 @@ describe("HypercertOperationsImpl", () => { hypercertOps.removeLocationFromCollection("at://did:plc:test/org.hypercerts.collection/missing"), ).rejects.toThrow(NetworkError); }); + + it("should throw NetworkError when putRecord fails", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.collection/abc123", + cid: "collection-cid", + value: { + $type: "org.hypercerts.claim.collection", + title: "Test Collection", + items: [], + createdAt: "2024-01-01T00:00:00Z", + location: { uri: "at://location", cid: "location-cid" }, + }, + }, + }); + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ success: false }); + + await expect( + hypercertOps.removeLocationFromCollection(`at://${TEST_REPO_DID}/org.hypercerts.collection/abc123`), + ).rejects.toThrow(NetworkError); + }); }); describe("Project Operations", () => { @@ -3816,6 +3862,14 @@ describe("HypercertOperationsImpl", () => { ).rejects.toThrow(NetworkError); }); + it("should call getRecord exactly once per updateProject invocation", async () => { + await hypercertOps.updateProject("at://did:plc:test/org.hypercerts.claim.collection/abc123", { + title: "New Title", + }); + + expect(mockAgent.com.atproto.repo.getRecord).toHaveBeenCalledTimes(1); + }); + it("should throw ValidationError when record is not a project", async () => { // Mock a collection with type='favorites' instead of type='project' mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ @@ -4250,4 +4304,56 @@ describe("HypercertOperationsImpl", () => { ).rejects.toThrow("Invalid input: expected string URI or StrongRef"); }); }); + + describe("fetchRecord (private helper)", () => { + it("should throw NetworkError when getRecord returns success:false", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: false }); + + await expect( + // @ts-expect-error - accessing private method for testing + hypercertOps.fetchRecord("at://did:plc:test/org.hypercerts.claim.collection/abc123"), + ).rejects.toThrow(NetworkError); + }); + + it("should throw NetworkError when getRecord returns no CID", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.claim.collection/abc123", + cid: undefined, + value: {}, + }, + }); + + await expect( + // @ts-expect-error - accessing private method for testing + hypercertOps.fetchRecord("at://did:plc:test/org.hypercerts.claim.collection/abc123"), + ).rejects.toThrow(NetworkError); + + await expect( + // @ts-expect-error - accessing private method for testing + hypercertOps.fetchRecord("at://did:plc:test/org.hypercerts.claim.collection/abc123"), + ).rejects.toThrow("returned no CID"); + }); + }); + + describe("saveRecord (private helper)", () => { + it("should throw NetworkError when putRecord returns success:false", async () => { + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ success: false }); + + await expect( + // @ts-expect-error - accessing private method for testing + hypercertOps.saveRecord("org.hypercerts.claim.collection", "abc123", { + $type: "org.hypercerts.claim.collection", + }), + ).rejects.toThrow(NetworkError); + + await expect( + // @ts-expect-error - accessing private method for testing + hypercertOps.saveRecord("org.hypercerts.claim.collection", "abc123", { + $type: "org.hypercerts.claim.collection", + }), + ).rejects.toThrow("Failed to save record"); + }); + }); }); From 5eda6ee93f64920e3d26f7d4d92a374d712c925a Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 18:43:32 +0000 Subject: [PATCH 8/8] chore: add validateScope fix to changeset --- .changeset/refactor-uri-parsing-utilities.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/refactor-uri-parsing-utilities.md b/.changeset/refactor-uri-parsing-utilities.md index b61a6e6b..c64f56ea 100644 --- a/.changeset/refactor-uri-parsing-utilities.md +++ b/.changeset/refactor-uri-parsing-utilities.md @@ -9,6 +9,8 @@ Refactor internal URI parsing and blob upload operations `result.ref.toString()` for the CID string). SDS uploads now return a proper `BlobRef` instance with `ref`, `mimeType`, and `size` correctly populated from the server response. +- Fix `validateScope` permission prefix regex to correctly accept query-param style scopes (e.g. `repo?action=create`, + `blob?accept=video/*`, `rpc?lxm=*`) and reject bare `atproto` with a suffix - Export `AT_URI_REGEX` from `@hypercerts-org/sdk-core` for direct regex usage - Consolidate AT-URI parsing in HypercertOperationsImpl using `parseAtUri()` utility - Add internal `fetchRecord()` and `saveRecord()` helpers to reduce code duplication