From a493f3b05174eeef104c83f7be14749f1c0185a2 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 29 Jan 2026 21:53:36 +0600 Subject: [PATCH 1/5] feat: create and update certified and bsky profile; --- .changeset/profile-certified-lexicon.md | 109 +++ packages/sdk-core/README.md | 199 +++-- packages/sdk-core/package.json | 10 +- packages/sdk-core/src/core/SDK.ts | 2 +- packages/sdk-core/src/index.ts | 3 + packages/sdk-core/src/lexicons.ts | 13 + packages/sdk-core/src/lib/blob-url.ts | 133 +++ .../src/repository/BlobOperationsImpl.ts | 30 +- .../src/repository/HypercertOperationsImpl.ts | 70 +- .../src/repository/ProfileOperationsImpl.ts | 691 +++++++++++----- .../sdk-core/src/repository/Repository.ts | 21 +- .../sdk-core/src/repository/interfaces.ts | 297 ++++--- packages/sdk-core/src/repository/types.ts | 21 +- .../sdk-core/src/services/hypercerts/types.ts | 92 +++ packages/sdk-core/src/types.ts | 4 + packages/sdk-core/tests/lib/blob-url.test.ts | 114 +++ .../repository/BlobOperationsImpl.test.ts | 4 +- .../HypercertOperationsImpl.test.ts | 158 +--- .../repository/ProfileOperationsImpl.test.ts | 779 +++++++++++++----- .../tests/repository/Repository.test.ts | 8 +- packages/sdk-core/tests/utils/mocks.ts | 46 ++ packages/sdk-react/README.md | 39 +- packages/sdk-react/src/hooks/useProfile.ts | 35 +- packages/sdk-react/src/testing/factory.tsx | 4 +- packages/sdk-react/src/testing/mocks.ts | 18 +- packages/sdk-react/src/types.ts | 10 +- packages/sdk-react/src/utils/ssr.ts | 8 +- .../sdk-react/tests/hooks/useProfile.test.tsx | 82 +- .../sdk-react/tests/testing/mocks.test.ts | 5 +- packages/sdk-react/tests/utils/fixtures.ts | 17 +- pnpm-lock.yaml | 124 ++- specs/02-repository-and-lexicons.md | 72 +- specs/04-react-and-clients.md | 42 +- specs/06-sdk-api-refinement.md | 5 +- 34 files changed, 2311 insertions(+), 954 deletions(-) create mode 100644 .changeset/profile-certified-lexicon.md create mode 100644 packages/sdk-core/src/lib/blob-url.ts create mode 100644 packages/sdk-core/tests/lib/blob-url.test.ts diff --git a/.changeset/profile-certified-lexicon.md b/.changeset/profile-certified-lexicon.md new file mode 100644 index 00000000..ff2c8913 --- /dev/null +++ b/.changeset/profile-certified-lexicon.md @@ -0,0 +1,109 @@ +--- +"@hypercerts-org/sdk-core": minor +"@hypercerts-org/sdk-react": minor +--- + +Add dual profile system with separate Bluesky and Certified profile operations, plus upsert methods + +**Core SDK (`@hypercerts-org/sdk-core`):** + +**Breaking Changes:** + +The profile API has been completely redesigned to support two profile types: + +- **Removed generic profile methods:** + - ❌ `profile.get()` + - ❌ `profile.create(params)` + - ❌ `profile.update(params)` + +- **Added profile-specific methods:** + - ✅ `profile.getBskyProfile()` - Get Bluesky profile (app.bsky.actor.profile) + - ✅ `profile.createBskyProfile(params)` - Create Bluesky profile + - ✅ `profile.updateBskyProfile(params)` - Update Bluesky profile + - ✅ `profile.getCertifiedProfile()` - Get Certified profile (app.certified.actor.profile) **[Returns `null` if + profile doesn't exist]** + - ✅ `profile.createCertifiedProfile(params)` - Create Certified profile + - ✅ `profile.updateCertifiedProfile(params)` - Update Certified profile + - ✅ `profile.upsertBskyProfile(params)` - Create or update Bluesky profile **[New]** + - ✅ `profile.upsertCertifiedProfile(params)` - Create or update Certified profile **[New]** + +**Features:** + +- **Bluesky profiles** (`app.bsky.actor.profile`): + - Standard AT Protocol profiles + - Avatar/banner returned as CDN URLs (`https://cdn.bsky.app/...`) + - Includes Bluesky-specific fields (labels, pinnedPost, etc.) + +- **Certified profiles** (`app.certified.actor.profile`): + - Hypercerts-specific profiles with additional fields + - Avatar/banner returned as PDS blob URLs (`https://pds.../xrpc/...`) + - **`getCertifiedProfile()` returns `null` if profile doesn't exist** (not an error - common for new users) + - Supports `pronouns` field (max 20 graphemes) + - Supports `website` field + - Images stored using `HypercertImageRecord` format internally (smallImage/largeImage wrappers) + +- **Upsert methods** (Recommended for most use cases): + - `upsertBskyProfile(params)` - Automatically creates or updates Bluesky profile + - `upsertCertifiedProfile(params)` - Automatically creates or updates Certified profile + - Simpler DX - no need to check if profile exists first + - Perfect for "save profile" operations + +- **New types:** + - `BskyProfile` - Type for Bluesky profiles (alias for `AppBskyActorDefs.ProfileViewDetailed`) + - `CertifiedProfile` - Type for Certified profiles + - `CreateBskyProfileParams`, `UpdateBskyProfileParams` + - `CreateCertifiedProfileParams`, `UpdateCertifiedProfileParams` + +**Migration Guide:** + +```typescript +// BEFORE (old API - removed) +const profile = await repo.profile.get(); +await repo.profile.create({ displayName: "Alice" }); +await repo.profile.update({ displayName: "New Name" }); + +// AFTER - Recommended: Use upsert (works for both create and update) +await repo.profile.upsertCertifiedProfile({ + displayName: "Alice", + pronouns: "she/her", + website: "https://alice.com", +}); + +// AFTER - Advanced: Explicit create/update for fine control +const certProfile = await repo.profile.getCertifiedProfile(); +if (!certProfile) { + await repo.profile.createCertifiedProfile({ + displayName: "Alice", + pronouns: "she/her", + }); +} else { + await repo.profile.updateCertifiedProfile({ + displayName: "New Name", + }); +} + +// Getting profiles - handle null case +const profile = await repo.profile.getCertifiedProfile(); +if (profile) { + console.log(profile.displayName); +} else { + console.log("User hasn't created a profile yet"); +} +``` + +**React SDK (`@hypercerts-org/sdk-react`):** + +**Breaking Changes:** + +- `useProfile` hook renamed `update` to `save` and `isUpdating` to `isSaving` +- `save()` now uses upsert internally - works for first-time profile creation too + +```typescript +// BEFORE +const { update, isUpdating } = useProfile(); +await update({ displayName: "Alice" }); + +// AFTER +const { save, isSaving } = useProfile(); +await save({ displayName: "Alice" }); // Works even if profile doesn't exist! +``` diff --git a/packages/sdk-core/README.md b/packages/sdk-core/README.md index 5bc5be79..9e44e3e8 100644 --- a/packages/sdk-core/README.md +++ b/packages/sdk-core/README.md @@ -700,74 +700,153 @@ const { records, cursor } = await repo.records.list({ ### 10. Profile Management (PDS only) +The SDK supports two profile types: + +- **Bluesky Profile** (`app.bsky.actor.profile`) - Standard AT Protocol profiles with CDN URLs +- **Certified Profile** (`app.certified.actor.profile`) - Hypercerts profiles with additional fields (pronouns, website) + +#### Bluesky Profile + ```typescript -// Get user profile -const profile = await repo.profile.get(); -console.log(`${profile.displayName} (@${profile.handle})`); - -// Update profile -await repo.profile.update({ - displayName: "Jane Researcher", - description: "Climate scientist and hypercert enthusiast", - avatar: avatarBlob, // optional - banner: bannerBlob, // optional +// Get Bluesky profile +const bskyProfile = await repo.profile.getBskyProfile(); +console.log(`${bskyProfile.displayName} (@${bskyProfile.handle})`); +console.log(bskyProfile.avatar); // CDN URL: "https://cdn.bsky.app/..." + +// Create Bluesky profile +await repo.profile.createBskyProfile({ + displayName: "Alice", + description: "Climate researcher", + avatar: avatarBlob, // Upload blob + banner: bannerBlob, +}); + +// Update Bluesky profile +await repo.profile.updateBskyProfile({ + displayName: "Alice Smith", + description: null, // Remove description +}); +``` + +#### Certified Profile + +```typescript +// Get Certified profile (returns null if it doesn't exist) +const certProfile = await repo.profile.getCertifiedProfile(); +if (certProfile) { + console.log(certProfile.displayName); + console.log(certProfile.pronouns); // "she/her" + console.log(certProfile.website); // "https://alice.com" + console.log(certProfile.avatar); // Blob URL: "https://pds.../xrpc/..." +} else { + console.log("User hasn't created a Certified profile yet"); +} + +// Create Certified profile +await repo.profile.createCertifiedProfile({ + displayName: "Alice", + description: "Climate scientist and impact certificate advocate", + pronouns: "she/her", + website: "https://alice.com", + avatar: avatarBlob, // Upload blob + banner: bannerBlob, +}); + +// Update Certified profile +await repo.profile.updateCertifiedProfile({ + pronouns: "they/them", + website: null, // Remove website +}); + +// Upsert Certified profile (create if missing, update if exists) +await repo.profile.upsertCertifiedProfile({ + displayName: "Alice", + description: "Climate scientist", + pronouns: "she/her", + website: "https://alice.com", + avatar: avatarBlob, +}); + +// Upsert Bluesky profile +await repo.profile.upsertBskyProfile({ + displayName: "Alice", + description: "Impact researcher", + avatar: avatarBlob, }); ``` +**Key Differences:** + +- **Bluesky**: Returns avatar/banner as CDN URLs (`https://cdn.bsky.app/...`), throws error if profile doesn't exist +- **Certified**: Returns avatar/banner as PDS blob URLs (`https://pds.../xrpc/...`), includes `pronouns` (max 20 + graphemes) and `website` fields, returns `null` if profile doesn't exist + +**When to use upsert vs create/update:** + +- Use `upsert*()` for convenience when you don't know if a profile exists (e.g., first-time setup flows) +- Use `create*()` when you know the profile doesn't exist (e.g., after checking with `get*()`) +- Use `update*()` when you know the profile exists and only want to modify specific fields + ## API Reference ### Repository Operations -| Operation | Method | PDS | SDS | Returns | -| ------------------ | ------------------------------------------------ | --- | --- | ---------------------------- | -| **Records** | | | | | -| Create record | `repo.records.create()` | ✅ | ✅ | `{ uri, cid }` | -| Get record | `repo.records.get()` | ✅ | ✅ | Record data | -| Update record | `repo.records.update()` | ✅ | ✅ | `{ uri, cid }` | -| Delete record | `repo.records.delete()` | ✅ | ✅ | void | -| List records | `repo.records.list()` | ✅ | ✅ | `{ records, cursor? }` | -| **Hypercerts** | | | | | -| Create hypercert | `repo.hypercerts.create()` | ✅ | ✅ | `{ uri, cid, value }` | -| Get hypercert | `repo.hypercerts.get()` | ✅ | ✅ | Full hypercert | -| Update hypercert | `repo.hypercerts.update()` | ✅ | ✅ | `{ uri, cid }` | -| Delete hypercert | `repo.hypercerts.delete()` | ✅ | ✅ | void | -| List hypercerts | `repo.hypercerts.list()` | ✅ | ✅ | `{ records, cursor? }` | -| Add contribution | `repo.hypercerts.addContribution()` | ✅ | ✅ | Contribution | -| Add measurement | `repo.hypercerts.addMeasurement()` | ✅ | ✅ | Measurement | -| **Collections** | | | | | -| Create collection | `repo.hypercerts.createCollection()` | ✅ | ✅ | `{ uri, cid, record }` | -| Get collection | `repo.hypercerts.getCollection()` | ✅ | ✅ | Collection data | -| List collections | `repo.hypercerts.listCollections()` | ✅ | ✅ | `{ records, cursor? }` | -| Update collection | `repo.hypercerts.updateCollection()` | ✅ | ✅ | `{ uri, cid }` | -| Delete collection | `repo.hypercerts.deleteCollection()` | ✅ | ✅ | void | -| Attach location | `repo.hypercerts.attachLocationToCollection()` | ✅ | ✅ | `{ uri, cid }` | -| Remove location | `repo.hypercerts.removeLocationFromCollection()` | ✅ | ✅ | void | -| **Projects** | | | | | -| Create project | `repo.hypercerts.createProject()` | ✅ | ✅ | `{ uri, cid, record }` | -| Get project | `repo.hypercerts.getProject()` | ✅ | ✅ | Project data | -| List projects | `repo.hypercerts.listProjects()` | ✅ | ✅ | `{ records, cursor? }` | -| Update project | `repo.hypercerts.updateProject()` | ✅ | ✅ | `{ uri, cid }` | -| Delete project | `repo.hypercerts.deleteProject()` | ✅ | ✅ | void | -| Attach location | `repo.hypercerts.attachLocationToProject()` | ✅ | ✅ | `{ uri, cid }` | -| Remove location | `repo.hypercerts.removeLocationFromProject()` | ✅ | ✅ | void | -| **Blobs** | | | | | -| Upload blob | `repo.blobs.upload()` | ✅ | ✅ | `{ ref, mimeType, size }` | -| Get blob | `repo.blobs.get()` | ✅ | ✅ | Blob data | -| **Profile** | | | | | -| Get profile | `repo.profile.get()` | ✅ | ❌ | Profile data | -| Update profile | `repo.profile.update()` | ✅ | ❌ | void | -| **Organizations** | | | | | -| Create org | `repo.organizations.create()` | ❌ | ✅ | `{ did, name, ... }` | -| Get org | `repo.organizations.get()` | ❌ | ✅ | Organization | -| List orgs | `repo.organizations.list()` | ❌ | ✅ | `{ organizations, cursor? }` | -| **Collaborators** | | | | | -| Grant access | `repo.collaborators.grant()` | ❌ | ✅ | void | -| Revoke access | `repo.collaborators.revoke()` | ❌ | ✅ | void | -| List collaborators | `repo.collaborators.list()` | ❌ | ✅ | `{ collaborators, cursor? }` | -| Check access | `repo.collaborators.hasAccess()` | ❌ | ✅ | boolean | -| Get role | `repo.collaborators.getRole()` | ❌ | ✅ | Role string | -| Get permissions | `repo.collaborators.getPermissions()` | ❌ | ✅ | Permissions | -| Transfer ownership | `repo.collaborators.transferOwnership()` | ❌ | ✅ | void | +| Operation | Method | PDS | SDS | Returns | +| ------------------------ | ------------------------------------------------ | --- | --- | ------------------------------------ | +| **Records** | | | | | +| Create record | `repo.records.create()` | ✅ | ✅ | `{ uri, cid }` | +| Get record | `repo.records.get()` | ✅ | ✅ | Record data | +| Update record | `repo.records.update()` | ✅ | ✅ | `{ uri, cid }` | +| Delete record | `repo.records.delete()` | ✅ | ✅ | void | +| List records | `repo.records.list()` | ✅ | ✅ | `{ records, cursor? }` | +| **Hypercerts** | | | | | +| Create hypercert | `repo.hypercerts.create()` | ✅ | ✅ | `{ uri, cid, value }` | +| Get hypercert | `repo.hypercerts.get()` | ✅ | ✅ | Full hypercert | +| Update hypercert | `repo.hypercerts.update()` | ✅ | ✅ | `{ uri, cid }` | +| Delete hypercert | `repo.hypercerts.delete()` | ✅ | ✅ | void | +| List hypercerts | `repo.hypercerts.list()` | ✅ | ✅ | `{ records, cursor? }` | +| Add contribution | `repo.hypercerts.addContribution()` | ✅ | ✅ | Contribution | +| Add measurement | `repo.hypercerts.addMeasurement()` | ✅ | ✅ | Measurement | +| **Collections** | | | | | +| Create collection | `repo.hypercerts.createCollection()` | ✅ | ✅ | `{ uri, cid, record }` | +| Get collection | `repo.hypercerts.getCollection()` | ✅ | ✅ | Collection data | +| List collections | `repo.hypercerts.listCollections()` | ✅ | ✅ | `{ records, cursor? }` | +| Update collection | `repo.hypercerts.updateCollection()` | ✅ | ✅ | `{ uri, cid }` | +| Delete collection | `repo.hypercerts.deleteCollection()` | ✅ | ✅ | void | +| Attach location | `repo.hypercerts.attachLocationToCollection()` | ✅ | ✅ | `{ uri, cid }` | +| Remove location | `repo.hypercerts.removeLocationFromCollection()` | ✅ | ✅ | void | +| **Projects** | | | | | +| Create project | `repo.hypercerts.createProject()` | ✅ | ✅ | `{ uri, cid, record }` | +| Get project | `repo.hypercerts.getProject()` | ✅ | ✅ | Project data | +| List projects | `repo.hypercerts.listProjects()` | ✅ | ✅ | `{ records, cursor? }` | +| Update project | `repo.hypercerts.updateProject()` | ✅ | ✅ | `{ uri, cid }` | +| Delete project | `repo.hypercerts.deleteProject()` | ✅ | ✅ | void | +| Attach location | `repo.hypercerts.attachLocationToProject()` | ✅ | ✅ | `{ uri, cid }` | +| Remove location | `repo.hypercerts.removeLocationFromProject()` | ✅ | ✅ | void | +| **Blobs** | | | | | +| Upload blob | `repo.blobs.upload()` | ✅ | ✅ | `{ ref, mimeType, size }` | +| Get blob | `repo.blobs.get()` | ✅ | ✅ | Blob data | +| **Profile** | | | | | +| Get Bluesky profile | `repo.profile.getBskyProfile()` | ✅ | ❌ | BskyProfile (CDN URLs) | +| Create Bluesky profile | `repo.profile.createBskyProfile()` | ✅ | ❌ | `{ uri, cid }` | +| Update Bluesky profile | `repo.profile.updateBskyProfile()` | ✅ | ❌ | `{ uri, cid }` | +| Upsert Bluesky profile | `repo.profile.upsertBskyProfile()` | ✅ | ❌ | `{ uri, cid }` | +| Get Certified profile | `repo.profile.getCertifiedProfile()` | ✅ | ❌ | CertifiedProfile \| null (blob URLs) | +| Create Certified profile | `repo.profile.createCertifiedProfile()` | ✅ | ❌ | `{ uri, cid }` | +| Update Certified profile | `repo.profile.updateCertifiedProfile()` | ✅ | ❌ | `{ uri, cid }` | +| Upsert Certified profile | `repo.profile.upsertCertifiedProfile()` | ✅ | ❌ | `{ uri, cid }` | +| **Organizations** | | | | | +| Create org | `repo.organizations.create()` | ❌ | ✅ | `{ did, name, ... }` | +| Get org | `repo.organizations.get()` | ❌ | ✅ | Organization | +| List orgs | `repo.organizations.list()` | ❌ | ✅ | `{ organizations, cursor? }` | +| **Collaborators** | | | | | +| Grant access | `repo.collaborators.grant()` | ❌ | ✅ | void | +| Revoke access | `repo.collaborators.revoke()` | ❌ | ✅ | void | +| List collaborators | `repo.collaborators.list()` | ❌ | ✅ | `{ collaborators, cursor? }` | +| Check access | `repo.collaborators.hasAccess()` | ❌ | ✅ | boolean | +| Get role | `repo.collaborators.getRole()` | ❌ | ✅ | Role string | +| Get permissions | `repo.collaborators.getPermissions()` | ❌ | ✅ | Permissions | +| Transfer ownership | `repo.collaborators.transferOwnership()` | ❌ | ✅ | void | ## Type System diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index e1e6a9b2..abf10e00 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -42,8 +42,12 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-typescript": "^12.3.0", + "@types/chai": "^5.2.3", + "@types/deep-eql": "^4.0.2", + "@types/json-schema": "^7.0.15", "@types/node": ">=20.10.0", "jose": "^6.1.3", + "multiformats": "^13.4.2", "rollup": "^4.53.3", "rollup-plugin-dts": "^6.2.3" }, @@ -84,11 +88,11 @@ }, "sideEffects": false, "dependencies": { - "@atproto/api": "^0.17.5", - "@atproto/lexicon": "^0.5.1", + "@atproto/api": "0.18.10", + "@atproto/lexicon": "0.6.0", "@atproto/oauth-client": "^0.5.10", "@atproto/oauth-client-node": "^0.3.12", - "@hypercerts-org/lexicon": "0.10.0-beta.13", + "@hypercerts-org/lexicon": "0.10.0-beta.14", "eventemitter3": "^5.0.1", "type-fest": "^5.4.1", "zod": "^3.24.4" diff --git a/packages/sdk-core/src/core/SDK.ts b/packages/sdk-core/src/core/SDK.ts index 8781a7a6..a03d7dfa 100644 --- a/packages/sdk-core/src/core/SDK.ts +++ b/packages/sdk-core/src/core/SDK.ts @@ -447,7 +447,7 @@ export class ATProtoSDK { * @example Using default PDS * ```typescript * const repo = sdk.repository(session); - * const profile = await repo.profile.get(); + * const profile = await repo.profile.getBskyProfile(); * ``` * * @example Using configured SDS diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index cf1ad90d..4658f8ab 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -30,6 +30,9 @@ export type { ProgressStep, } from "./repository/types.js"; +// Blob URL Utilities +export { getBlobUrl, extractCidFromImage } from "./lib/blob-url.js"; + // Lexicon Development Utilities export { parseAtUri, diff --git a/packages/sdk-core/src/lexicons.ts b/packages/sdk-core/src/lexicons.ts index da48ca98..5ccfac24 100644 --- a/packages/sdk-core/src/lexicons.ts +++ b/packages/sdk-core/src/lexicons.ts @@ -73,6 +73,7 @@ import { ATTACHMENT_LEXICON_JSON, MEASUREMENT_LEXICON_JSON, RIGHTS_LEXICON_JSON, + ACTOR_PROFILE_LEXICON_JSON, BADGE_AWARD_LEXICON_JSON, BADGE_DEFINITION_LEXICON_JSON, BADGE_RESPONSE_LEXICON_JSON, @@ -93,6 +94,7 @@ import { BADGE_RESPONSE_NSID, FUNDING_RECEIPT_NSID, WORK_SCOPE_TAG_NSID, + ACTOR_PROFILE_NSID, } from "@hypercerts-org/lexicon"; // Export LexiconRegistry for custom lexicon management @@ -116,6 +118,7 @@ export const HYPERCERT_LEXICONS: LexiconDoc[] = [ ATTACHMENT_LEXICON_JSON as LexiconDoc, MEASUREMENT_LEXICON_JSON as LexiconDoc, RIGHTS_LEXICON_JSON as LexiconDoc, + ACTOR_PROFILE_LEXICON_JSON as LexiconDoc, BADGE_AWARD_LEXICON_JSON as LexiconDoc, BADGE_DEFINITION_LEXICON_JSON as LexiconDoc, BADGE_RESPONSE_LEXICON_JSON as LexiconDoc, @@ -203,4 +206,14 @@ export const HYPERCERT_COLLECTIONS = { * For defining reusable work scope atoms. */ WORK_SCOPE_TAG: WORK_SCOPE_TAG_NSID, + + /** + * Bluesky profile collection (app.bsky.actor.profile). + */ + BSKY_PROFILE: "app.bsky.actor.profile", + + /** + * Certified profile collection (app.certified.actor.profile). + */ + CERTIFIED_PROFILE: ACTOR_PROFILE_NSID, } as const; diff --git a/packages/sdk-core/src/lib/blob-url.ts b/packages/sdk-core/src/lib/blob-url.ts new file mode 100644 index 00000000..b0de87fb --- /dev/null +++ b/packages/sdk-core/src/lib/blob-url.ts @@ -0,0 +1,133 @@ +/** + * Blob URL Utilities + * + * Utilities for constructing AT Protocol blob URLs and extracting image references + * from Hypercert image records. + * + * @remarks + * + * ## Why these utilities exist + * + * AT Protocol stores blobs (images, videos, etc.) on PDS servers and returns blob objects + * containing a CID (Content Identifier) and mimetype. To make it easier for SDK users to + * consume these blobs, we provide utilities to: + * + * 1. **Convert blob references to URLs**: Transform CID + DID + PDS into a direct URL + * 2. **Extract image references**: Handle both blob-based images (CID) and direct URIs + * + * This allows users to consume images directly without manual URL construction: + * This can and will be reused across any response that has to return a blob. + * + * + * @packageDocumentation + */ + +import type { HypercertImageRecord } from "../services/hypercerts/types.js"; + +/** + * Constructs a URL for retrieving a blob from an AT Protocol server. + * + * @param pdsUrl - The PDS/server base URL (e.g., "https://pds1.certified.app") + * @param did - The DID of the repository owner + * @param cid - The Content Identifier (CID) of the blob + * @returns Full blob URL + * + * @throws {Error} If any parameter is empty or invalid + * + * @example + * ```typescript + * const url = getBlobUrl( + * "https://pds1.certified.app", + * "did:plc:r5p2aletd4fegsklphgiog3s", + * "bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q" + * ); + * // Returns: "https://pds1.certified.app/xrpc/com.atproto.sync.getBlob?did=did:plc:r5p2aletd4fegsklphgiog3s&cid=bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q" + * ``` + * + * @public + */ +export function getBlobUrl(pdsUrl: string, did: string, cid: string): string { + if (!pdsUrl || typeof pdsUrl !== "string") { + throw new Error("pdsUrl must be a non-empty string"); + } + if (!did || typeof did !== "string") { + throw new Error("did must be a non-empty string"); + } + if (!cid || typeof cid !== "string") { + throw new Error("cid must be a non-empty string"); + } + + const normalizedPdsUrl = pdsUrl.replace(/\/$/, ""); + return `${normalizedPdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; +} + +/** + * Extracts the CID or URI from a Hypercert image record. + * + * Handles all Hypercert image formats: + * - `smallImage`: Avatar/thumbnail format with blob reference - returns CID + * - `largeImage`: Banner/cover format with blob reference - returns CID + * - `uri`: Direct URL - returns the URI string + * + * @param image - Hypercert image record + * @returns CID string if image contains a blob reference, URI string if uri format, undefined otherwise + * + * @example Blob format + * ```typescript + * const smallImage = { + * $type: "org.hypercerts.defs#smallImage", + * image: { + * $type: "blob", + * ref: { $link: "bafyrei123" }, + * mimeType: "image/png", + * size: 1000 + * } + * }; + * + * const cid = extractCidFromImage(smallImage); + * // Returns: "bafyrei123" + * ``` + * + * @example URI format + * ```typescript + * const uriImage = { + * $type: "org.hypercerts.defs#uri", + * uri: "https://example.com/image.jpg" + * }; + * + * const uri = extractCidFromImage(uriImage); + * // Returns: "https://example.com/image.jpg" + * ``` + * + * @public + */ +export function extractCidFromImage(image: HypercertImageRecord): string | undefined { + if (!image || typeof image !== "object") { + return undefined; + } + + // Check $type to determine format + const imageType = image.$type; + + // If it's a URI format, return the URI string directly + if (imageType === "org.hypercerts.defs#uri") { + const uri = image.uri; + if (uri && typeof uri === "string") { + return uri; + } + return undefined; + } + + if (imageType === "org.hypercerts.defs#smallImage" || imageType === "org.hypercerts.defs#largeImage") { + // Blob format: image.image.ref.$link + const imageData = image.image; + if (imageData && typeof imageData === "object") { + const ref = imageData.ref; + if (ref) { + return ref.toString(); + } + } + } + + return undefined; +} diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index 04c9b038..b51e8a2c 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -7,7 +7,7 @@ * @packageDocumentation */ -import type { Agent } from "@atproto/api"; +import type { Agent, BlobRef } from "@atproto/api"; import { NetworkError } from "../core/errors.js"; import type { BlobOperations } from "./interfaces.js"; @@ -110,7 +110,7 @@ export class BlobOperationsImpl implements BlobOperations { * }); * ``` */ - async upload(blob: Blob): Promise<{ ref: { $link: string }; mimeType: string; size: number }> { + async upload(blob: Blob): Promise { try { const arrayBuffer = await blob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); @@ -130,11 +130,7 @@ export class BlobOperationsImpl implements BlobOperations { throw new NetworkError("Failed to upload blob"); } - return { - ref: { $link: result.data.blob.ref.toString() }, - mimeType: result.data.blob.mimeType, - size: result.data.blob.size, - }; + return result.data.blob; } catch (error) { if (error instanceof NetworkError) throw error; throw new NetworkError( @@ -161,7 +157,7 @@ export class BlobOperationsImpl implements BlobOperations { private async uploadViaSDS( data: Uint8Array, encoding: string, - ): Promise<{ ref: { $link: string }; mimeType: string; size: number }> { + ): Promise { const url = `/xrpc/com.sds.repo.uploadBlob?repo=${encodeURIComponent(this.repoDid)}`; const response = await this.agent.fetchHandler(url, { method: "POST", @@ -175,21 +171,9 @@ export class BlobOperationsImpl implements BlobOperations { throw new NetworkError(`SDS blob upload failed: ${response.statusText}`); } - const result = (await response.json()) as { - blob: { - ref: { $link: string } | string; - mimeType: string; - size: number; - }; - }; - - const ref = typeof result.blob.ref === "string" ? result.blob.ref : result.blob.ref.$link; - - return { - ref: { $link: ref }, - mimeType: result.blob.mimeType, - size: result.blob.size, - }; + const result = (await response.json()) + + return result.blob } /** diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index f32194e4..ee91cdd3 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -8,25 +8,30 @@ * @packageDocumentation */ -import type { Agent } from "@atproto/api"; +import type { Agent, BlobRef } from "@atproto/api"; +import { $Typed } from "@atproto/api"; import { validate } from "@hypercerts-org/lexicon"; import { EventEmitter } from "eventemitter3"; -import { NetworkError, ValidationError } from "../errors.js"; import type { LoggerInterface } from "../core/interfaces.js"; +import { NetworkError, ValidationError } from "../errors.js"; +import { sha256Hash } from "../lib/crypto.js"; +import { isValidUri } from "../lib/url-utils.js"; import { HYPERCERT_COLLECTIONS, + type CreateAttachmentParams, type CreateCollectionParams, type CreateCollectionResult, + type CreateLocationParams, + type CreateMeasurementParams, type CreateProjectParams, type CreateProjectResult, + type HypercertAttachment, type HypercertClaim, type HypercertCollection, type HypercertContributionDetails, type HypercertContributorInformation, type HypercertEvaluation, - type HypercertAttachment, type HypercertLocation, - type CreateLocationParams, type HypercertMeasurement, type HypercertRights, type JsonBlobRef, @@ -34,27 +39,24 @@ import { type RefUri, type StrongRef, type UpdateCollectionParams, - type UpdateProjectParams, - type CreateMeasurementParams, type UpdateMeasurementParams, - type CreateAttachmentParams, + type UpdateProjectParams, } from "../services/hypercerts/types.js"; import type { BlobOperations, - LocationParams, + ContributionDetailsParams, + ContributorIdentityParams, CreateHypercertParams, UpdateHypercertParams, CreateHypercertResult, HypercertEvents, HypercertOperations, - ContributionDetailsParams, - ContributorIdentityParams, + LocationParams, + ResolvedContributionDetails, + ResolvedContributorIdentity, } from "./interfaces.js"; import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js"; import { uploadResultToBlobRef } from "./types.js"; -import { $Typed } from "@atproto/api"; -import { sha256Hash } from "../lib/crypto.js"; -import { isValidUri } from "../lib/url-utils.js"; import { parseAtUri, isValidAtUri, type AtUriComponents } from "../lexicons/utils.js"; /** @@ -142,14 +144,14 @@ export class HypercertOperationsImpl extends EventEmitter imple } /** - * Converts a blob upload result to JsonBlobRef format. + * Converts BlobRef to JsonBlobRef format. * - * @param uploadResult - Result from BlobOperations.upload() + * @param blobRef - BlobRef from blob upload * @returns JsonBlobRef formatted for records * @internal */ - private blobToJsonRef(uploadResult: { ref: { $link: string }; mimeType: string; size: number }): JsonBlobRef { - return uploadResultToBlobRef(uploadResult); + private blobToJsonRef(blobRef: BlobRef): JsonBlobRef { + return blobRef.ipld(); } /** @@ -334,6 +336,17 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`); } + // JsonBlobRef can have ref (CID object) or cid (string in existing record) + // Image: extract CID string from blob ref (stable content hash) + let imageRef: string | undefined; + if (imageBlobRef) { + if ("ref" in imageBlobRef && imageBlobRef.ref) { + imageRef = typeof imageBlobRef.ref === "string" ? imageBlobRef.ref : imageBlobRef.ref.toString(); + } else if ("cid" in imageBlobRef) { + imageRef = imageBlobRef.cid; + } + } + // Generate rKey from stable content hash (idempotency) // Use NORMALIZED values (already resolved StrongRefs and processed data) // to ensure JSON-serializability and deterministic hashing. @@ -346,15 +359,7 @@ export class HypercertOperationsImpl extends EventEmitter imple workScope: params.workScope, startDate: params.startDate, endDate: params.endDate, - // Image: extract CID string from blob ref (stable content hash) - // JsonBlobRef can have ref.$link (upload result) or cid (existing record) - imageRef: imageBlobRef - ? "ref" in imageBlobRef && imageBlobRef.ref - ? imageBlobRef.ref.$link - : "cid" in imageBlobRef - ? imageBlobRef.cid - : undefined - : undefined, + imageRef, // Rights: canonical object with only known fields rights: { name: params.rights.name, @@ -917,9 +922,10 @@ export class HypercertOperationsImpl extends EventEmitter imple } const uploadResult = await this.blobs.upload(content); + const jsonBlobRef = this.blobToJsonRef(uploadResult); return { $type: "org.hypercerts.defs#smallBlob" as const, - blob: uploadResult, + blob: jsonBlobRef, }; } @@ -934,11 +940,12 @@ export class HypercertOperationsImpl extends EventEmitter imple } const uploadResult = await this.blobs.upload(input); + const jsonBlobRef = this.blobToJsonRef(uploadResult); if (isBanner) { - return { $type: "org.hypercerts.defs#largeImage" as const, image: uploadResult }; + return { $type: "org.hypercerts.defs#largeImage" as const, image: jsonBlobRef }; } - return { $type: "org.hypercerts.defs#smallImage" as const, image: uploadResult }; + return { $type: "org.hypercerts.defs#smallImage" as const, image: jsonBlobRef }; } /** @@ -1830,7 +1837,7 @@ export class HypercertOperationsImpl extends EventEmitter imple const evaluationRecord: HypercertEvaluation = { $type: HYPERCERT_COLLECTIONS.EVALUATION, subject: { uri: subject.uri, cid: subject.cid }, - evaluators: params.evaluators, + evaluators: params.evaluators.map((evaluator) => ({ did: evaluator })), summary: params.summary, createdAt, }; @@ -2030,8 +2037,7 @@ export class HypercertOperationsImpl extends EventEmitter imple * @param params - Optional pagination parameters * @returns Promise resolving to paginated list of collections * @throws {@link NetworkError} if the list operation fails - * - * @example + * * @example * ```typescript * const { records } = await repo.hypercerts.listCollections(); * for (const { record } of records) { diff --git a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts index 1b9ee9db..0aa94677 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -1,55 +1,74 @@ /** - * ProfileOperationsImpl - User profile operations. + * ProfileOperationsImpl - User profile operations supporting dual profiles. * - * This module provides the implementation for AT Protocol profile - * management, including fetching and updating user profiles. + * This module provides operations for managing AT Protocol profiles: + * - Bluesky profiles (app.bsky.actor.profile) - Simple profiles with CDN images + * - Certified profiles (app.certified.actor.profile) - Hypercerts profiles with pronouns/website * * @packageDocumentation */ -import type { Agent } from "@atproto/api"; -import { NetworkError } from "../core/errors.js"; -import type { BlobOperations, ProfileOperations, ProfileParams } from "./interfaces.js"; -import type { CreateResult, UpdateResult } from "./types.js"; -import { uploadResultToBlobRef } from "./types.js"; +import type { Agent, AppBskyActorDefs } from "@atproto/api"; +import { NetworkError, ValidationError } from "../core/errors.js"; +import { HYPERCERT_COLLECTIONS } from "../lexicons.js"; +import { extractCidFromImage, getBlobUrl } from "../lib/blob-url.js"; +import { AppCertifiedActorProfile, type HypercertImageRecord } from "../services/hypercerts/types.js"; +import { validate } from "@hypercerts-org/lexicon"; +import type { + BlobOperations, + BskyProfile, + CertifiedProfile, + CreateBskyProfileParams, + CreateCertifiedProfileParams, + ProfileOperations, + UpdateBskyProfileParams, + UpdateCertifiedProfileParams, +} from "./interfaces.js"; +import { type CreateResult, type UpdateResult } from "./types.js"; +import { AppBskyActorProfile } from "@atproto/api"; + +type ProfileCollection = typeof BSKY_PROFILE_NSID | typeof CERTIFIED_PROFILE_NSID; +const BSKY_PROFILE_NSID = HYPERCERT_COLLECTIONS.BSKY_PROFILE; +const CERTIFIED_PROFILE_NSID = HYPERCERT_COLLECTIONS.CERTIFIED_PROFILE; + +/** Profile record key (always "self" for the user's own profile) */ +const PROFILE_RKEY = "self"; /** - * Implementation of profile operations for user profile management. + * Implementation of profile operations supporting dual profiles. * - * Profiles in AT Protocol are stored as records in the `app.bsky.actor.profile` - * collection with the special rkey "self". This class provides a convenient - * API for reading and updating profile data. + * This class provides operations for both Bluesky and Certified profiles: + * - Bluesky profiles: Simple AT Protocol profiles with avatar/banner as CDN URLs + * - Certified profiles: Hypercerts profiles hypercerts image format * * @remarks * This class is typically not instantiated directly. Access it through * {@link Repository.profile}. * - * **Profile Fields**: - * - `handle`: Read-only, managed by the PDS - * - `displayName`: User's display name (max 64 chars typically) - * - `description`: Profile bio (max 256 chars typically) - * - `avatar`: Profile picture blob reference - * - `banner`: Banner image blob reference - * - `website`: User's website URL (may not be available on all servers) + * **Profile Types**: + * - `app.bsky.actor.profile`: Standard Bluesky profile, images are simple blob refs + * - `app.certified.actor.profile`: Hypercerts profile, images wrapped in smallImage/largeImage. Omits some bsky profile properties like pinnedPost labels etc * * @example * ```typescript - * // Get profile - * const profile = await repo.profile.get(); - * console.log(`${profile.displayName} (@${profile.handle})`); + * // Get Bluesky profile + * const bskyProfile = await repo.profile.getBskyProfile(); + * console.log(bskyProfile.displayName); + * console.log(bskyProfile.avatar); // CDN URL * - * // Update profile - * await repo.profile.update({ - * displayName: "New Name", - * description: "Updated bio", - * }); + * // Get Certified profile + * const certifiedProfile = await repo.profile.getCertifiedProfile(); + * console.log(certifiedProfile.pronouns); // "she/her" + * console.log(certifiedProfile.avatar); // Blob URL * - * // Update with new avatar - * const avatarBlob = new Blob([imageData], { type: "image/png" }); - * await repo.profile.update({ avatar: avatarBlob }); + * // Create/update profiles + * await repo.profile.createBskyProfile({ displayName: "Alice" }); + * await repo.profile.updateBskyProfile({ description: "New bio" }); * - * // Remove a field - * await repo.profile.update({ website: null }); + * await repo.profile.createCertifiedProfile({ + * displayName: "Alice", + * pronouns: "she/her", + * }); * ``` * * @internal @@ -61,6 +80,7 @@ export class ProfileOperationsImpl implements ProfileOperations { * @param agent - AT Protocol Agent for making API calls * @param repoDid - DID of the repository/user * @param blobs - Blob operations for uploading images + * @param pdsUrl - PDS URL for constructing blob URLs * * @internal */ @@ -68,289 +88,516 @@ export class ProfileOperationsImpl implements ProfileOperations { private agent: Agent, private repoDid: string, private blobs: BlobOperations, + private pdsUrl: string, ) {} /** - * Applies a simple field (string or null) to the profile. + * Converts a Hypercert image record to a URL string. + * + * - URI format: returns the URI string directly + * - Blob format (smallImage/largeImage): constructs blob URL using PDS + * + * @param image - Hypercert image record + * @returns URL string + * @throws {Error} If image format is invalid or blob CID cannot be extracted * * @internal */ - private applySimpleField(result: Record, field: string, value: string | null | undefined): void { - if (value === undefined) return; - - if (value === null) { - delete result[field]; - } else { - result[field] = value; + private imageToUrl(image: HypercertImageRecord): string { + const result = extractCidFromImage(image); + if (!result) { + throw new Error("Unable to extract CID or URI from image record"); + } + if (result.startsWith("http://") || result.startsWith("https://")) { + return result; } + + // Otherwise, it's a CID - construct blob URL + return getBlobUrl(this.pdsUrl, this.repoDid, result); } /** - * Applies a blob field to the profile, uploading if needed. + * Applies an image field (avatar/banner) with format-specific wrapping. + * + * - null: removes the field + * - undefined: no change + * - 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 collection - Profile collection NSID (determines image wrapping format) * * @internal */ - private async applyBlobField( + private async applyImageField( result: Record, field: string, - blob: Blob | null | undefined, + value: Blob | null | undefined, + collection: ProfileCollection, ): Promise { - if (blob === undefined) return; + if (value === undefined) return; - if (blob === null) { + if (value === null) { delete result[field]; + return; + } + + const blobRef = await this.blobs.upload(value); + + // Bsky profiles use simple blob refs, Certified profiles wrap in smallImage/largeImage + if (collection === BSKY_PROFILE_NSID) { + result[field] = blobRef; } else { - const uploadResult = await this.blobs.upload(blob); - result[field] = uploadResultToBlobRef(uploadResult); + const isLargeImage = field === "banner"; + result[field] = { + $type: isLargeImage ? "org.hypercerts.defs#largeImage" : "org.hypercerts.defs#smallImage", + image: blobRef, + }; } } /** - * Applies profile params to a profile record, handling null values for deletion. + * Validates a profile record against the appropriate lexicon schema. * - * Ensures $type and createdAt are always present on the record, using - * nullish coalescing to allow callers to override defaults. + * @param profile - The profile record to validate + * @param collection - Profile collection NSID (determines validation schema) + * @throws {ValidationError} If validation fails + * @internal + */ + private validateProfileRecord(profile: Record, collection: ProfileCollection): void { + if (collection === CERTIFIED_PROFILE_NSID) { + const validation = validate(profile, CERTIFIED_PROFILE_NSID, "main", false); + if (!validation.success) { + throw new ValidationError(`Invalid profile record: ${validation.error?.message}`); + } + } + if (collection === BSKY_PROFILE_NSID) { + const validation = AppBskyActorProfile.validateMain(profile); + if (!validation.success) { + throw new ValidationError(`Invalid profile record: ${validation.error?.message}`); + } + } + } + + /** + * Creates a profile record with lexicon validation. * + * @param collection - NSID of the collection (Bsky or Certified profile) + * @param params - Profile creation parameters + * @returns Promise resolving to create result with URI and CID + * @throws {ValidationError} if validation fails + * @throws {NetworkError} if creation fails * @internal */ - private async mergeParamsIntoProfile( - profile: Record, - params: ProfileParams, - ): Promise> { - const result = { ...profile }; - - // Ensure $type and createdAt are always present - result.$type = params.$type ?? (result.$type as string | undefined) ?? "app.bsky.actor.profile"; - result.createdAt = params.createdAt ?? (result.createdAt as string | undefined) ?? new Date().toISOString(); - - this.applySimpleField(result, "displayName", params.displayName); - this.applySimpleField(result, "description", params.description); - this.applySimpleField(result, "website", params.website); - await this.applyBlobField(result, "avatar", params.avatar); - await this.applyBlobField(result, "banner", params.banner); - - return result; + private async createProfileRecord( + collection: ProfileCollection, + params: CreateBskyProfileParams | CreateCertifiedProfileParams, + ): Promise { + try { + const { avatar, banner, $type, createdAt, ...otherFields } = params; + + const profile: Record = { + $type: $type ?? collection, + createdAt: createdAt ?? new Date().toISOString(), + ...otherFields, + }; + + await this.applyImageField(profile, "avatar", avatar, collection); + await this.applyImageField(profile, "banner", banner, collection); + + // Validate profile record against lexicon schema + this.validateProfileRecord(profile, collection); + + const result = await this.agent.com.atproto.repo.createRecord({ + repo: this.repoDid, + collection, + rkey: PROFILE_RKEY, + record: profile, + }); + + if (!result.success) { + throw new NetworkError("Failed to create profile"); + } + + return { uri: result.data.uri, cid: result.data.cid }; + } catch (error) { + if (error instanceof NetworkError || error instanceof ValidationError) throw error; + throw new NetworkError( + `Failed to create profile: ${error instanceof Error ? error.message : "Unknown error"}`, + error, + ); + } } /** - * Gets the repository's profile. + * Updates a profile record with proper null handling and validation. * - * @returns Promise resolving to profile data - * @throws {@link NetworkError} if the profile cannot be fetched + * @param collection - NSID of the collection (Bsky or Certified profile) + * @param params - Profile update parameters (partial, with null for deletions) + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} if profile not found or update fails + * @throws {ValidationError} if validation fails + * @internal + */ + private async updateProfileRecord( + collection: ProfileCollection, + params: UpdateBskyProfileParams | UpdateCertifiedProfileParams, + ): Promise { + try { + const existing = await this.agent.com.atproto.repo.getRecord({ + repo: this.repoDid, + collection, + rkey: PROFILE_RKEY, + }); + + if (!existing.success) { + throw new NetworkError("Profile not found"); + } + const { avatar, banner, ...otherFields } = params; + const updatedProfile: Record = { + ...existing.data.value, + }; + // Apply non-image field updates, handling null deletions + for (const [key, value] of Object.entries(otherFields)) { + if (value === null) { + delete updatedProfile[key]; + } else if (value !== undefined) { + updatedProfile[key] = value; + } + } + + await this.applyImageField(updatedProfile, "avatar", avatar, collection); + await this.applyImageField(updatedProfile, "banner", banner, collection); + + // Validate updated record against lexicon schema + this.validateProfileRecord(updatedProfile, collection); + + const result = await this.agent.com.atproto.repo.putRecord({ + repo: this.repoDid, + collection, + rkey: PROFILE_RKEY, + record: updatedProfile, + }); + + if (!result.success) { + throw new NetworkError("Failed to update profile"); + } + + return { uri: result.data.uri, cid: result.data.cid }; + } catch (error) { + if (error instanceof NetworkError || error instanceof ValidationError) throw error; + throw new NetworkError( + `Failed to update profile: ${error instanceof Error ? error.message : "Unknown error"}`, + error, + ); + } + } + + /** + * Upserts a profile record (creates if missing, updates if exists). * - * @remarks - * This method fetches the full profile using the `getProfile` API, - * which includes resolved information like follower counts on some - * servers. For hypercerts SDK usage, the basic profile fields are - * returned. + * @param collection - NSID of the collection (Bsky or Certified profile) + * @param params - Profile fields to set + * @returns Promise resolving to update result with URI and CID + * @throws {ValidationError} if validation fails + * @throws {NetworkError} if operation fails + * @internal + */ + private async upsertProfileRecord( + collection: ProfileCollection, + params: CreateBskyProfileParams | CreateCertifiedProfileParams, + ): Promise { + try { + // Check if profile exists + const existing = await this.agent.com.atproto.repo.getRecord({ + repo: this.repoDid, + collection, + rkey: PROFILE_RKEY, + }); + + if (!existing.success) { + return this.createProfileRecord(collection, params); + } + + const { avatar, banner, ...otherFields } = params; + + const updatedProfile: Record = { + ...existing.data.value, + }; + + for (const [key, value] of Object.entries(otherFields)) { + // ignotre these since profile already created + if (["$type", "createdAt"].includes(key)) continue; + if (value === null) { + delete updatedProfile[key]; + } else if (value !== undefined) { + updatedProfile[key] = value; + } + } + + await this.applyImageField(updatedProfile, "avatar", avatar, collection); + await this.applyImageField(updatedProfile, "banner", banner, collection); + + this.validateProfileRecord(updatedProfile, collection); + + const result = await this.agent.com.atproto.repo.putRecord({ + repo: this.repoDid, + collection, + rkey: PROFILE_RKEY, + record: updatedProfile, + }); + + if (!result.success) { + throw new NetworkError("Failed to upsert profile"); + } + + return { uri: result.data.uri, cid: result.data.cid }; + } catch (error) { + if (error instanceof NetworkError || error instanceof ValidationError) throw error; + throw new NetworkError( + `Failed to upsert profile: ${error instanceof Error ? error.message : "Unknown error"}`, + error, + ); + } + } + + /** + * Gets Bluesky profile (app.bsky.actor.profile). * - * **Note**: The `website` field may not be available on all AT Protocol - * servers. Standard Bluesky profiles don't include this field. + * @returns Promise resolving to Bluesky profile data + * @throws {NetworkError} If profile cannot be fetched * * @example * ```typescript - * const profile = await repo.profile.get(); - * - * console.log(`Handle: @${profile.handle}`); - * console.log(`Name: ${profile.displayName || "(not set)"}`); - * console.log(`Bio: ${profile.description || "(no bio)"}`); - * - * if (profile.avatar) { - * // Avatar is a URL or blob reference - * console.log(`Avatar: ${profile.avatar}`); - * } + * const bskyProfile = await repo.profile.getBskyProfile(); + * console.log(bskyProfile.displayName); // "Alice" + * console.log(bskyProfile.avatar); // "https://cdn.bsky.app/..." * ``` */ - async get(): Promise<{ - handle: string; - displayName?: string; - description?: string; - avatar?: string; - banner?: string; - website?: string; - }> { + async getBskyProfile(): Promise { try { - const result = await this.agent.getProfile({ actor: this.repoDid }); + const profileResult = await this.agent.getProfile({ actor: this.repoDid }); - if (!result.success) { - throw new NetworkError("Failed to get profile"); + if (!profileResult.success) { + throw new NetworkError("Failed to get Bluesky profile"); } - return { - handle: result.data.handle, - displayName: result.data.displayName, - description: result.data.description, - avatar: result.data.avatar, - banner: result.data.banner, - // Note: website may not be available in standard profile - }; + return profileResult.data as AppBskyActorDefs.ProfileViewDetailed; } catch (error) { if (error instanceof NetworkError) throw error; throw new NetworkError( - `Failed to get profile: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to get Bluesky profile: ${error instanceof Error ? error.message : "Unknown error"}`, error, ); } } /** - * Creates a new profile for the repository. - * - * @param params - Profile fields to set - * @returns Promise resolving to create result with URI and CID - * @throws {@link NetworkError} if the creation fails + * Gets Certified profile (app.certified.actor.profile). * - * @remarks - * Use this method when no profile exists yet. If a profile already exists, - * use {@link update} instead. + * Returns the profile record with avatar and banner converted to blob URLs. + * Includes the user's handle fetched from getProfile(). If getProfile() fails, + * handle is set to empty string. * - * **Image Handling**: When providing `avatar` or `banner` as a Blob, - * the image is automatically uploaded and the blob reference is stored - * in the profile. + * @returns Promise resolving to Certified profile data, or null if no profile exists + * @throws {NetworkError} If profile fetch fails due to network/server issues * - * @example Create a basic profile - * ```typescript - * await repo.profile.create({ - * displayName: "Alice", - * description: "Building impact certificates", - * }); - * ``` - * - * @example Create a profile with avatar + * @example * ```typescript - * const avatarBlob = new Blob([avatarData], { type: "image/png" }); - * await repo.profile.create({ - * displayName: "Alice", - * description: "Building impact certificates", - * avatar: avatarBlob, - * }); + * const certifiedProfile = await repo.profile.getCertifiedProfile(); + * if (certifiedProfile) { + * console.log(certifiedProfile.displayName); // "Alice" + * console.log(certifiedProfile.pronouns); // "she/her" + * console.log(certifiedProfile.avatar); // "https://pds.../xrpc/..." + * } else { + * console.log("User hasn't created a certified profile yet"); + * } * ``` */ - async create(params: ProfileParams): Promise { + async getCertifiedProfile(): Promise { try { - const profile = await this.mergeParamsIntoProfile({}, params); + // Fetch handle from Bluesky profile (non-blocking) + let handle = ""; + try { + const profileResult = await this.agent.getProfile({ actor: this.repoDid }); + if (profileResult.success) { + handle = (profileResult.data as { handle: string }).handle; + } + } catch { + // Ignore error, use empty string for handle + handle = ""; + } - const createParams = { + // Fetch certified profile record + const recordResult = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, - collection: "app.bsky.actor.profile", - rkey: "self", - record: profile, - }; + collection: CERTIFIED_PROFILE_NSID, + rkey: PROFILE_RKEY, + }); - const result = await this.agent.com.atproto.repo.createRecord(createParams); + if (!recordResult.success) { + return null; + } - if (!result.success) { - throw new NetworkError("Failed to create profile"); + const profileRecord = recordResult.data.value as AppCertifiedActorProfile.Main; + + let avatar: string | undefined; + let banner: string | undefined; + + if (profileRecord.avatar) { + avatar = this.imageToUrl(profileRecord.avatar as HypercertImageRecord); } - return { uri: result.data.uri, cid: result.data.cid }; + if (profileRecord.banner) { + banner = this.imageToUrl(profileRecord.banner as HypercertImageRecord); + } + + return { + ...profileRecord, + handle, + avatar, + banner, + }; } catch (error) { + // Check for RecordNotFoundError from AT Protocol SDK + if (error && typeof error === "object" && "error" in error && error.error === "RecordNotFound") { + return null; + } + + // Actual network/server errors still throw if (error instanceof NetworkError) throw error; throw new NetworkError( - `Failed to create profile: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to get Certified profile: ${error instanceof Error ? error.message : "Unknown error"}`, error, ); } } /** - * Updates the repository's profile. - * - * @param params - Fields to update. Pass `null` to remove a field. - * Omitted fields are preserved from the existing profile. - * @returns Promise resolving to update result with new URI and CID - * @throws {@link NetworkError} if the update fails - * - * @remarks - * This method performs a read-modify-write operation: - * 1. Fetches the existing profile record - * 2. Merges in the provided updates - * 3. Writes the updated profile back - * - * **Image Handling**: When providing `avatar` or `banner` as a Blob, - * the image is automatically uploaded and the blob reference is stored - * in the profile. + * Creates Bluesky profile (app.bsky.actor.profile). * - * **Field Removal**: Pass `null` to explicitly remove a field. Omitting - * a field (not including it in params) preserves the existing value. + * @param params - Profile fields to set + * @returns Promise resolving to create result with URI and CID + * @throws {NetworkError} If creation fails * - * @example Update display name and bio + * @example * ```typescript - * await repo.profile.update({ + * await repo.profile.createBskyProfile({ * displayName: "Alice", * description: "Building impact certificates", * }); * ``` + */ + async createBskyProfile(params: CreateBskyProfileParams): Promise { + return this.createProfileRecord(BSKY_PROFILE_NSID, params); + } + + /** + * Updates Bluesky profile (app.bsky.actor.profile). * - * @example Update avatar image - * ```typescript - * // From a file input - * const file = document.getElementById("avatar").files[0]; - * await repo.profile.update({ avatar: file }); + * @param params - Fields to update (pass null to remove) + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If update fails * - * // From raw data - * const response = await fetch("https://example.com/my-avatar.png"); - * const blob = await response.blob(); - * await repo.profile.update({ avatar: blob }); + * @example + * ```typescript + * await repo.profile.updateBskyProfile({ + * displayName: "New Name", + * description: null, // Remove description + * }); * ``` + */ + async updateBskyProfile(params: UpdateBskyProfileParams): Promise { + return this.updateProfileRecord(BSKY_PROFILE_NSID, params); + } + + /** + * Creates Certified profile (app.certified.actor.profile). * - * @example Remove description + * @param params - Profile fields to set + * @returns Promise resolving to create result with URI and CID + * @throws {NetworkError} If creation fails + * + * @example * ```typescript - * // Removes the description field entirely - * await repo.profile.update({ description: null }); + * await repo.profile.createCertifiedProfile({ + * displayName: "Alice", + * description: "Building impact certificates", + * pronouns: "she/her", + * website: "https://alice.com", + * }); * ``` + */ + async createCertifiedProfile(params: CreateCertifiedProfileParams): Promise { + return this.createProfileRecord(CERTIFIED_PROFILE_NSID, params); + } + + /** + * Updates Certified profile (app.certified.actor.profile). * - * @example Multiple updates at once - * ```typescript - * const newAvatar = new Blob([avatarData], { type: "image/png" }); - * const newBanner = new Blob([bannerData], { type: "image/jpeg" }); + * @param params - Fields to update (pass null to remove) + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If update fails * - * await repo.profile.update({ + * @example + * ```typescript + * await repo.profile.updateCertifiedProfile({ * displayName: "New Name", - * description: "New bio", - * avatar: newAvatar, - * banner: newBanner, + * pronouns: null, // Remove pronouns * }); * ``` */ - async update(params: ProfileParams): Promise { - try { - // Get existing profile record - const getParams = { - repo: this.repoDid, - collection: "app.bsky.actor.profile", - rkey: "self", - }; - - const existing = await this.agent.com.atproto.repo.getRecord(getParams); - - if (!existing.success) { - throw new NetworkError("Profile not found. Use create() for new profiles."); - } - - const existingProfile = (existing.data.value as Record) || {}; - const updatedProfile = await this.mergeParamsIntoProfile(existingProfile, params); - - const putParams = { - repo: this.repoDid, - collection: "app.bsky.actor.profile", - rkey: "self", - record: updatedProfile, - }; - - const result = await this.agent.com.atproto.repo.putRecord(putParams); + async updateCertifiedProfile(params: UpdateCertifiedProfileParams): Promise { + return this.updateProfileRecord(CERTIFIED_PROFILE_NSID, params); + } - if (!result.success) { - throw new NetworkError("Failed to update profile"); - } + /** + * Upserts Bluesky profile (creates if missing, updates if exists). + * + * Automatically detects whether the profile exists and creates or updates accordingly. + * This is the recommended method for most use cases. + * + * @param params - Profile fields to set + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If operation fails + * @throws {ValidationError} If validation fails + * + * @example + * ```typescript + * // Works whether profile exists or not + * await repo.profile.upsertBskyProfile({ + * displayName: "Alice", + * description: "Building on AT Protocol", + * }); + * ``` + */ + async upsertBskyProfile(params: CreateBskyProfileParams): Promise { + return this.upsertProfileRecord(BSKY_PROFILE_NSID, params); + } - return { uri: result.data.uri, cid: result.data.cid }; - } catch (error) { - if (error instanceof NetworkError) throw error; - throw new NetworkError( - `Failed to update profile: ${error instanceof Error ? error.message : "Unknown error"}`, - error, - ); - } + /** + * Upserts Certified profile (creates if missing, updates if exists). + * + * Automatically detects whether the profile exists and creates or updates accordingly. + * This is the recommended method for most use cases. + * + * @param params - Profile fields to set + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If operation fails + * @throws {ValidationError} If validation fails + * + * @example + * ```typescript + * // Works whether profile exists or not + * await repo.profile.upsertCertifiedProfile({ + * displayName: "Alice", + * pronouns: "she/her", + * website: "https://alice.com", + * }); + * ``` + */ + async upsertCertifiedProfile(params: CreateCertifiedProfileParams): Promise { + return this.upsertProfileRecord(CERTIFIED_PROFILE_NSID, params); } } diff --git a/packages/sdk-core/src/repository/Repository.ts b/packages/sdk-core/src/repository/Repository.ts index fdb14d8d..0d76219e 100644 --- a/packages/sdk-core/src/repository/Repository.ts +++ b/packages/sdk-core/src/repository/Repository.ts @@ -87,7 +87,7 @@ import type { * const repo = sdk.repository(session); * * // Access user profile - * const profile = await repo.profile.get(); + * const bskyProfile = await repo.profile.getBskyProfile(); * * // Create a hypercert * const result = await repo.hypercerts.create({ @@ -111,7 +111,7 @@ import type { * * // Get another user's repo (read-only for most operations) * const otherRepo = myRepo.repo("did:plc:other-user-did"); - * const theirProfile = await otherRepo.profile.get(); + * const theirProfile = await otherRepo.profile.getCertifiedProfile(); * ``` * * @example SDS operations @@ -250,7 +250,7 @@ export class Repository { * ```typescript * // Read another user's profile * const otherRepo = repo.repo("did:plc:other-user"); - * const profile = await otherRepo.profile.get(); + * const profile = await otherRepo.profile.getBskyProfile(); * * // List their public hypercerts * const hypercerts = await otherRepo.hypercerts.list(); @@ -378,21 +378,22 @@ export class Repository { * * @example * ```typescript - * // Get current profile - * const profile = await repo.profile.get(); - * console.log(profile.displayName); + * // Get Certified profile (with hypercerts fields) + * const certProfile = await repo.profile.getCertifiedProfile(); + * console.log(certProfile.displayName); + * console.log(certProfile.pronouns); * - * // Update profile - * await repo.profile.update({ + * // Update Certified profile + * await repo.profile.updateCertifiedProfile({ * displayName: "New Name", - * description: "Updated bio", + * pronouns: "they/them", * avatar: avatarBlob, // Optional: update avatar image * }); * ``` */ get profile(): ProfileOperations { if (!this._profile) { - this._profile = new ProfileOperationsImpl(this.agent, this.repoDid, this.blobs); + this._profile = new ProfileOperationsImpl(this.agent, this.repoDid, this.blobs, this.serverUrl); } return this._profile; } diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index c69d6b83..08bf6faa 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -8,22 +8,24 @@ * @packageDocumentation */ -import type { AppBskyRichtextFacet } from "@atproto/api"; +import type { AppBskyActorDefs, AppBskyActorProfile, AppBskyRichtextFacet, BlobRef } from "@atproto/api"; import type { EventEmitter } from "eventemitter3"; import type { - LocationParams, + AppCertifiedActorProfile, + CreateAttachmentParams, CreateCollectionParams, CreateCollectionResult, + CreateMeasurementParams, CreateProjectParams, CreateProjectResult, - HypercertCollection, HypercertClaim, + HypercertCollection, + LocationParams, + RefUri, + StrongRef, UpdateCollectionParams, - UpdateProjectParams, - CreateMeasurementParams, UpdateMeasurementParams, - CreateAttachmentParams, - RefUri, + UpdateProjectParams, } from "../services/hypercerts/types.js"; import type { CreateResult, @@ -35,6 +37,7 @@ import type { RepositoryRole, UpdateResult, } from "./types.js"; +import { Except, OverrideProperties, SetOptional } from "type-fest"; // Re-export AttachLocationParams for convenience export type { LocationParams }; @@ -619,26 +622,9 @@ export interface BlobOperations { * Uploads a blob to the server. * * @param blob - The blob to upload - * @returns Promise resolving to blob reference and metadata + * @returns Promise resolving to blob reference */ - upload(blob: Blob): Promise<{ - /** - * Blob reference to use in records. - * - * Contains `$link` property with the CID. - */ - ref: { $link: string }; - - /** - * MIME type of the uploaded blob. - */ - mimeType: string; - - /** - * Size of the blob in bytes. - */ - size: number; - }>; + upload(blob: Blob): Promise; /** * Retrieves a blob by its CID. @@ -664,104 +650,231 @@ export interface BlobOperations { * * @example * ```typescript - * // Get profile - * const profile = await repo.profile.get(); - * console.log(profile.displayName); + * // Get Bluesky profile (standard AT Protocol) + * const bskyProfile = await repo.profile.getBskyProfile(); + * console.log(bskyProfile.displayName); + * + * // Get Certified profile (with hypercerts fields) + * const certProfile = await repo.profile.getCertifiedProfile(); + * console.log(certProfile.pronouns); // "she/her" * - * // Update profile - * await repo.profile.update({ + * // Update Bluesky profile + * await repo.profile.updateBskyProfile({ * displayName: "New Name", * description: "Updated bio", * }); * - * // Update avatar - * await repo.profile.update({ - * avatar: new Blob([avatarData], { type: "image/png" }), - * }); - * - * // Clear a field by passing null - * await repo.profile.update({ + * // Update Certified profile with pronouns + * await repo.profile.updateCertifiedProfile({ + * displayName: "New Name", + * pronouns: "they/them", * website: null, // Removes website * }); * ``` */ /** - * Parameters for creating or updating a profile. - * - * Follows the established pattern used in other record creation params - * (CreateAttachmentParams, CreateLocationParams, etc.) where `$type` and - * `createdAt` are optional and auto-populated if not provided. + * Bluesky profile type - direct from AT Protocol. + * Returned by agent.getProfile() with avatar/banner as CDN URLs. */ -export interface ProfileParams { - /** Record type identifier. Defaults to "app.bsky.actor.profile". */ - $type?: string; - /** ISO timestamp of when the profile was created. Auto-populated if not provided. */ - createdAt?: string; - displayName?: string | null; - description?: string | null; - avatar?: Blob | null; - banner?: Blob | null; - website?: string | null; -} +export type BskyProfile = AppBskyActorDefs.ProfileViewDetailed; + +/** + * Certified profile type - AT Protocol record with converted image URLs. + * Images are converted from HypercertImageRecord format to blob URL strings. + */ +export type CertifiedProfile = OverrideProperties< + AppCertifiedActorProfile.Main, + { avatar?: string; banner?: string } +> & { handle?: string }; + +// Helper to allow setting optional fields to null +type Nullable = { [K in keyof T]?: T[K] | null }; + +/** + * Parameters for creating/updating Bluesky profile (app.bsky.actor.profile). + * Images are uploaded as simple blob refs (not wrapped in hypercerts format). + */ + +export type CreateBskyProfileParams = OverrideProperties< + SetOptional, + { avatar?: Blob; banner?: Blob } +>; + +export type UpdateBskyProfileParams = OverrideProperties< + Nullable>, + { avatar?: Blob | null; banner?: Blob | null } +>; + +/** + * Parameters for creating/updating Certified profile (app.certified.actor.profile). + * Images are uploaded and wrapped in hypercerts image format (smallImage/largeImage). + */ + +export type CreateCertifiedProfileParams = OverrideProperties< + SetOptional, + { avatar?: Blob; banner?: Blob } +>; + +export type UpdateCertifiedProfileParams = OverrideProperties< + Nullable>, + { avatar?: Blob | null; banner?: Blob | null } +>; export interface ProfileOperations { /** - * Gets the repository's profile. + * Gets Bluesky profile (app.bsky.actor.profile). + * + * Returns the profile as fetched from agent.getProfile(), which includes + * avatar and banner as CDN URLs. * - * @returns Promise resolving to profile data + * @returns Promise resolving to Bluesky profile data + * @throws {NetworkError} If profile cannot be fetched + * + * @example + * ```typescript + * const bskyProfile = await repo.profile.getBskyProfile(); + * console.log(bskyProfile.displayName); // "Alice" + * console.log(bskyProfile.avatar); // "https://cdn.bsky.app/..." + * ``` */ - get(): Promise<{ - /** - * User's handle (e.g., "alice.bsky.social"). - */ - handle: string; + getBskyProfile(): Promise; - /** - * Display name. - */ - displayName?: string; + /** + * Gets Certified profile (app.certified.actor.profile). + * + * Returns the profile record with avatar and banner converted to blob URLs. + * Includes the user's handle fetched from getProfile(). + * + * @returns Promise resolving to Certified profile data, or null if no profile exists + * @throws {NetworkError} If profile fetch fails due to network/server issues + * + * @example + * ```typescript + * const certifiedProfile = await repo.profile.getCertifiedProfile(); + * if (certifiedProfile) { + * console.log(certifiedProfile.displayName); // "Alice" + * console.log(certifiedProfile.pronouns); // "she/her" + * console.log(certifiedProfile.avatar); // "https://pds.../xrpc/..." + * } else { + * console.log("User hasn't created a certified profile yet"); + * } + * ``` + */ + getCertifiedProfile(): Promise; - /** - * Profile description/bio. - */ - description?: string; + /** + * Creates Bluesky profile (app.bsky.actor.profile). + * + * @param params - Profile fields to set + * @returns Promise resolving to create result with URI and CID + * @throws {NetworkError} If creation fails + * + * @example + * ```typescript + * await repo.profile.createBskyProfile({ + * displayName: "Alice", + * description: "Building impact certificates", + * }); + * ``` + */ + createBskyProfile(params: CreateBskyProfileParams): Promise; - /** - * Avatar image URL or blob reference. - */ - avatar?: string; + /** + * Updates Bluesky profile (app.bsky.actor.profile). + * + * @param params - Fields to update (pass null to remove) + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If update fails + * + * @example + * ```typescript + * await repo.profile.updateBskyProfile({ + * displayName: "New Name", + * description: null, // Remove description + * }); + * ``` + */ + updateBskyProfile(params: UpdateBskyProfileParams): Promise; - /** - * Banner image URL or blob reference. - */ - banner?: string; + /** + * Creates Certified profile (app.certified.actor.profile). + * + * @param params - Profile fields to set + * @returns Promise resolving to create result with URI and CID + * @throws {NetworkError} If creation fails + * + * @example + * ```typescript + * await repo.profile.createCertifiedProfile({ + * displayName: "Alice", + * description: "Building impact certificates", + * pronouns: "she/her", + * website: "https://alice.com", + * }); + * ``` + */ + createCertifiedProfile(params: CreateCertifiedProfileParams): Promise; - /** - * Website URL. - */ - website?: string; - }>; + /** + * Updates Certified profile (app.certified.actor.profile). + * + * @param params - Fields to update (pass null to remove) + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If update fails + * + * @example + * ```typescript + * await repo.profile.updateCertifiedProfile({ + * displayName: "New Name", + * pronouns: null, // Remove pronouns + * }); + * ``` + */ + updateCertifiedProfile(params: UpdateCertifiedProfileParams): Promise; /** - * Creates a new profile for the repository. + * Upserts Bluesky profile (creates if missing, updates if exists). * - * Use this when no profile exists yet. If a profile already exists, - * use {@link update} instead. + * Automatically detects whether the profile exists and creates or updates accordingly. + * This is the recommended method for most use cases. * * @param params - Profile fields to set - * @returns Promise resolving to create result + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If operation fails + * @throws {ValidationError} If validation fails + * + * @example + * ```typescript + * // Works whether profile exists or not + * await repo.profile.upsertBskyProfile({ + * displayName: "Alice", + * description: "Building on AT Protocol", + * }); + * ``` */ - create(params: ProfileParams): Promise; + upsertBskyProfile(params: CreateBskyProfileParams): Promise; /** - * Updates the repository's profile. + * Upserts Certified profile (creates if missing, updates if exists). * - * Pass `null` to clear a field. Omitted fields are unchanged. + * Automatically detects whether the profile exists and creates or updates accordingly. + * This is the recommended method for most use cases. * - * @param params - Fields to update - * @returns Promise resolving to update result + * @param params - Profile fields to set + * @returns Promise resolving to update result with URI and CID + * @throws {NetworkError} If operation fails + * @throws {ValidationError} If validation fails + * + * @example + * ```typescript + * // Works whether profile exists or not + * await repo.profile.upsertCertifiedProfile({ + * displayName: "Alice", + * pronouns: "she/her", + * }); + * ``` */ - update(params: ProfileParams): Promise; + upsertCertifiedProfile(params: CreateCertifiedProfileParams): Promise; } /** diff --git a/packages/sdk-core/src/repository/types.ts b/packages/sdk-core/src/repository/types.ts index 01d5c4f6..685bee9e 100644 --- a/packages/sdk-core/src/repository/types.ts +++ b/packages/sdk-core/src/repository/types.ts @@ -3,7 +3,6 @@ * @packageDocumentation */ -import type { JsonBlobRef } from "@atproto/lexicon"; import type { CollaboratorPermissions } from "../core/types.js"; // ============================================================================ @@ -117,27 +116,11 @@ export interface ProgressStep { /** * Result from BlobOperations.upload() + * + * @deprecated Use BlobRef from @atproto/api directly */ export interface BlobUploadResult { ref: { $link: string }; mimeType: string; size: number; } - -/** - * Converts a blob upload result to JsonBlobRef format. - * - * AT Protocol requires blob fields to include the full structure: - * `{ $type: "blob", ref: { $link }, mimeType, size }` - * - * @param uploadResult - Result from BlobOperations.upload() - * @returns JsonBlobRef formatted for records - */ -export function uploadResultToBlobRef(uploadResult: BlobUploadResult): JsonBlobRef { - return { - $type: "blob", - ref: uploadResult.ref, - mimeType: uploadResult.mimeType, - size: uploadResult.size, - }; -} diff --git a/packages/sdk-core/src/services/hypercerts/types.ts b/packages/sdk-core/src/services/hypercerts/types.ts index be39aac6..8f45c633 100644 --- a/packages/sdk-core/src/services/hypercerts/types.ts +++ b/packages/sdk-core/src/services/hypercerts/types.ts @@ -13,6 +13,7 @@ import type { Except, OverrideProperties, SetOptional } from "type-fest"; // Re-export everything from lexicon package export { + AppCertifiedActorProfile, AppCertifiedBadgeAward, AppCertifiedBadgeDefinition, AppCertifiedBadgeResponse, @@ -55,6 +56,7 @@ export type { AppCertifiedDefs, OrgHypercertsDefs } from "@hypercerts-org/lexico * when defining `type HypercertClaim = OrgHypercertsClaimActivity.Main`. */ import type { + AppCertifiedActorProfile, AppCertifiedBadgeAward, AppCertifiedBadgeDefinition, AppCertifiedBadgeResponse, @@ -72,6 +74,9 @@ import type { OrgHypercertsFundingReceipt, OrgHypercertsHelperWorkScopeTag, } from "@hypercerts-org/lexicon"; + +// Re-export Bluesky profile types +export type { AppBskyActorProfile } from "@atproto/api"; // Re-export AppBskyRichtextFacet for rich text annotations export type { AppBskyRichtextFacet } from "@atproto/api"; @@ -185,6 +190,86 @@ export type HypercertEvaluation = OrgHypercertsClaimEvaluation.Main; export type HypercertAttachment = OrgHypercertsClaimAttachment.Main; export type HypercertCollection = OrgHypercertsClaimCollection.Main; +/** + * @deprecated Use CertifiedProfileRecord instead. This type alias will be removed in a future version. + */ +export type HypercertProfile = AppCertifiedActorProfile.Main; + +/** + * Certified actor profile record (app.certified.actor.profile). + * Extended profile with additional fields beyond Bluesky profiles. + */ +export type CertifiedProfileRecord = AppCertifiedActorProfile.Main; + +// ============================================================================ +// Profile SDK Input Types +// ============================================================================ + +/** + * SDK input parameters for creating a hypercert profile. + * + * Derived from lexicon type with $type and createdAt optional. + * avatar/banner accept HypercertImage (string URI or Blob) for user convenience. + * + * @example Basic profile creation + * ```typescript + * const params: CreateHypercertProfileParams = { + * displayName: "Alice", + * description: "Building impact certificates", + * pronouns: "she/her", + * website: "https://example.com", + * }; + * ``` + * + * @example Profile with avatar + * ```typescript + * const avatarBlob = new Blob([avatarData], { type: "image/png" }); + * const params: CreateHypercertProfileParams = { + * displayName: "Alice", + * avatar: avatarBlob, + * }; + * ``` + */ +export type CreateHypercertProfileParams = OverrideProperties< + SetOptional, + { + avatar?: HypercertImage; + banner?: HypercertImage; + } +>; + +/** + * SDK input parameters for updating a hypercert profile. + * All fields optional. Pass null to remove a field. + * + * @example Update display name + * ```typescript + * const params: UpdateHypercertProfileParams = { + * displayName: "New Name", + * }; + * ``` + * + * @example Remove a field + * ```typescript + * const params: UpdateHypercertProfileParams = { + * description: null, // Removes description + * }; + * ``` + */ +export type UpdateHypercertProfileParams = OverrideProperties< + Partial, + { + avatar?: HypercertImage | null; + banner?: HypercertImage | null; + } +>; + +/** + * Union type for all profile parameter variants. + * Used when a function can accept either create or update params. + */ +export type HypercertProfileParams = CreateHypercertProfileParams | UpdateHypercertProfileParams; + /** * Collection item with optional weight. * @@ -689,3 +774,10 @@ export type UpdateAttachmentParams = Partial; * - Full attachment params object to create new attachment */ export type AttachmentParams = RefUri | CreateAttachmentParams; + +/** + * @deprecated Use CreateCertifiedProfileParams instead. This type will be removed in a future version. + */ +export type CreateProfileParams = SetOptional; + +export type CreateCertifiedProfileParams = SetOptional; diff --git a/packages/sdk-core/src/types.ts b/packages/sdk-core/src/types.ts index badf2e0e..60f54cef 100644 --- a/packages/sdk-core/src/types.ts +++ b/packages/sdk-core/src/types.ts @@ -84,6 +84,10 @@ export type { HypercertWithMetadata, HypercertProject, HypercertProjectWithMetadata, + HypercertProfile, + CreateHypercertProfileParams, + UpdateHypercertProfileParams, + HypercertProfileParams, CreateProjectParams, UpdateProjectParams, JsonBlobRef, diff --git a/packages/sdk-core/tests/lib/blob-url.test.ts b/packages/sdk-core/tests/lib/blob-url.test.ts new file mode 100644 index 00000000..c01f3e29 --- /dev/null +++ b/packages/sdk-core/tests/lib/blob-url.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { HypercertImageRecord, OrgHypercertsDefs } from "../../src/index.js"; +import { extractCidFromImage, getBlobUrl } from "../../src/lib/blob-url.js"; + +describe("blob-url utilities", () => { + describe("getBlobUrl", () => { + it("should construct correct blob URL", () => { + const url = getBlobUrl( + "https://climateai.org", + "did:plc:r5p2aletd4fegsklphgiog3s", + "bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q", + ); + + expect(url).toBe( + "https://climateai.org/xrpc/com.atproto.sync.getBlob?did=did:plc:r5p2aletd4fegsklphgiog3s&cid=bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q", + ); + }); + + it("should normalize PDS URL by removing trailing slash", () => { + const url = getBlobUrl("https://test.example.com/", "did:plc:test", "bafyrei123"); + + expect(url).toBe("https://test.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyrei123"); + }); + + it("should throw on empty pdsUrl", () => { + expect(() => getBlobUrl("", "did:plc:test", "bafyrei123")).toThrow("pdsUrl must be a non-empty string"); + }); + + it("should throw on empty did", () => { + expect(() => getBlobUrl("https://test.com", "", "bafyrei123")).toThrow("did must be a non-empty string"); + }); + + it("should throw on empty cid", () => { + expect(() => getBlobUrl("https://test.com", "did:plc:test", "")).toThrow("cid must be a non-empty string"); + }); + + it("should throw on non-string inputs", () => { + // @ts-expect-error null is not a valid input + expect(() => getBlobUrl(null, "did:plc:test", "bafyrei123")).toThrow(); + // @ts-expect-error undefined is not a valid input + expect(() => getBlobUrl("https://test.com", undefined, "bafyrei123")).toThrow(); + // @ts-expect-error number is not a valid input + expect(() => getBlobUrl("https://test.com", "did:plc:test", 123)).toThrow(); + }); + }); + + describe("extractCidFromImage", () => { + it("should extract CID from smallImage format", () => { + // We use 'as unknown as' because our mock doesn't implement the full BlobRef class + // (which has methods like original, ipld, toJSON), but extractCidFromImage only + // needs ref.toString() at runtime + const image = { + $type: "org.hypercerts.defs#smallImage", + image: { + $type: "blob", + ref: { $link: "bafyrei-small-123", toString: () => "bafyrei-small-123" }, + mimeType: "image/png", + size: 1000, + }, + } as unknown as HypercertImageRecord; + + expect(extractCidFromImage(image)).toBe("bafyrei-small-123"); + }); + + it("should extract CID from largeImage format", () => { + const image = { + $type: "org.hypercerts.defs#largeImage", + image: { + $type: "blob", + ref: { $link: "bafyrei-large-456", toString: () => "bafyrei-large-456" }, + mimeType: "image/jpeg", + size: 5000, + }, + } as unknown as HypercertImageRecord; + + expect(extractCidFromImage(image)).toBe("bafyrei-large-456"); + }); + + it("should return URI string for uri format", () => { + const image = { + $type: "org.hypercerts.defs#uri", + uri: "https://example.com/image.jpg", + } as OrgHypercertsDefs.Uri; + + expect(extractCidFromImage(image)).toBe("https://example.com/image.jpg"); + }); + + it("should return undefined for malformed smallImage", () => { + const image = { + $type: "org.hypercerts.defs#smallImage", + image: { + // missing ref + mimeType: "image/png", + }, + } as OrgHypercertsDefs.SmallImage; + + expect(extractCidFromImage(image)).toBeUndefined(); + }); + + it("should return undefined for null/undefined input", () => { + // @ts-expect-error null/undefined are not valid inputs + expect(extractCidFromImage(null)).toBeUndefined(); + // @ts-expect-error null/undefined are not valid inputs + expect(extractCidFromImage(undefined)).toBeUndefined(); + }); + + it("should return undefined for non-object input", () => { + // @ts-expect-error string is not a valid input + expect(extractCidFromImage("not an object")).toBeUndefined(); + // @ts-expect-error number is not a valid input + expect(extractCidFromImage(123)).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index af90d6c2..7e7adf14 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -32,7 +32,7 @@ describe("BlobOperationsImpl", () => { const result = await blobOps.upload(mockBlob); - expect(result.ref).toEqual({ $link: "bafyrei123" }); + expect(result.ref.toString()).toBe("bafyrei123"); expect(result.mimeType).toBe("text/plain"); expect(result.size).toBe(12); }); @@ -143,7 +143,7 @@ describe("BlobOperationsImpl", () => { const result = await sdsBlobOps.upload(mockBlob); - expect(result.ref).toEqual({ $link: "bafyrei-string-ref" }); + expect(result.ref).toBe("bafyrei-string-ref"); }); it("should throw NetworkError when SDS returns non-ok response", async () => { diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index a5d398ae..6d9c9870 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -9,7 +9,7 @@ import type { } from "../../src/repository/interfaces.js"; import type { RefUri } from "../../src/services/hypercerts/types.js"; import type { ProgressStep, UpdateResult } from "../../src/repository/types.js"; -import { createMockAgent, createMockBlobOperations, TEST_REPO_DID } from "../utils/mocks.js"; +import { createMockAgent, createMockBlobOperations, createMockBlobRef, TEST_REPO_DID } from "../utils/mocks.js"; /** * Create a simple string work scope for testing. @@ -112,11 +112,7 @@ describe("HypercertOperationsImpl", () => { it("should upload image and include in hypercert", async () => { const imageBlob = new Blob(["image data"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "image-cid" }, - mimeType: "image/png", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef()); await hypercertOps.create({ ...validParams, @@ -901,11 +897,7 @@ describe("HypercertOperationsImpl", () => { it("should upload new image", async () => { const imageBlob = new Blob(["new image"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "new-image-cid" }, - mimeType: "image/png", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef()); await hypercertOps.update({ uri: "at://did:plc:test/org.hypercerts.claim.record/abc123", @@ -1031,11 +1023,7 @@ describe("HypercertOperationsImpl", () => { const blob = new Blob([JSON.stringify({ type: "Point", coordinates: [0, 0] })], { type: "application/geo+json", }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "blob-cid" }, - mimeType: "application/geo+json", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "application/geo+json" })); const result = await hypercertOps.attachLocation(hypercertUri, { lpVersion: "1.0.0", @@ -1049,15 +1037,17 @@ describe("HypercertOperationsImpl", () => { // Check the location record that was created const call = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(call.record.location).toEqual({ + expect(call.record.location).toMatchObject({ $type: "org.hypercerts.defs#smallBlob", // Your code wraps it in smallBlob blob: { // The actual blob data is nested here - ref: { $link: "blob-cid" }, + $type: "blob", mimeType: "application/geo+json", size: 100, }, }); + // Check that ref exists and has the right structure + expect(call.record.location.blob.ref).toBeDefined(); }); it("should attach a location using a simple text string (beta.13+ format)", async () => { @@ -1670,11 +1660,7 @@ describe("HypercertOperationsImpl", () => { it("should add attachment with single subject (StrongRef) and single Blob content", async () => { const blob = new Blob(["evidence data"], { type: "application/pdf" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "blob-cid" }, - mimeType: "application/pdf", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "application/pdf" })); const result = await hypercertOps.addAttachment({ subjects: { uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", cid: "hypercert-cid" }, @@ -1696,12 +1682,12 @@ describe("HypercertOperationsImpl", () => { ]); // Verify content array with blob - expect(call.record.content).toEqual([ - { - $type: "org.hypercerts.defs#smallBlob", - blob: { ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, - }, - ]); + expect(call.record.content).toHaveLength(1); + expect(call.record.content[0]).toMatchObject({ + $type: "org.hypercerts.defs#smallBlob", + blob: { $type: "blob", mimeType: "application/pdf", size: 100 }, + }); + expect(call.record.content[0].blob.ref).toBeDefined(); }); it("should add attachment with multiple subjects", async () => { @@ -1738,11 +1724,7 @@ describe("HypercertOperationsImpl", () => { it("should add attachment with multiple content items (mixed URIs and Blobs)", async () => { const blob = new Blob(["evidence data"], { type: "application/pdf" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "blob-cid" }, - mimeType: "application/pdf", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "application/pdf" })); const result = await hypercertOps.addAttachment({ subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc", @@ -1760,10 +1742,11 @@ describe("HypercertOperationsImpl", () => { $type: "org.hypercerts.defs#uri", uri: "https://example.com/report.pdf", }); - expect(call.record.content[1]).toEqual({ + expect(call.record.content[1]).toMatchObject({ $type: "org.hypercerts.defs#smallBlob", - blob: { ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, + blob: { $type: "blob", mimeType: "application/pdf", size: 100 }, }); + expect(call.record.content[1].blob.ref).toBeDefined(); }); it("should add attachment with contentType", async () => { @@ -2039,7 +2022,7 @@ describe("HypercertOperationsImpl", () => { metric: "CO2 Reduced", unit: "tons", value: "100", - measurers: ["did:plc:measurer1"], + measurers: [{ did: "did:plc:measurer1" }], }); expect(result.uri).toContain("measurement"); @@ -2053,7 +2036,7 @@ describe("HypercertOperationsImpl", () => { value: "500", startDate: "2024-01-01T00:00:00Z", endDate: "2024-12-31T23:59:59Z", - measurers: ["did:plc:auditor1"], + measurers: [{ did: "did:plc:auditor1" }], methodType: "satellite-imagery", methodURI: "https://example.com/methodology", evidenceURI: ["https://example.com/report"], @@ -2521,17 +2504,9 @@ describe("HypercertOperationsImpl", () => { const bannerBlob = new Blob(["banner"], { type: "image/jpeg" }); // Mock blob upload via BlobOperations (not agent.uploadBlob) - mockBlobs.upload.mockResolvedValueOnce({ - ref: { $link: "bafyrei-avatar" }, - mimeType: "image/png", - size: 100, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef()); - mockBlobs.upload.mockResolvedValueOnce({ - ref: { $link: "bafyrei-banner" }, - mimeType: "image/jpeg", - size: 200, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef({ mimeType: "image/jpeg", size: 200 })); mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ success: true, @@ -2596,29 +2571,9 @@ describe("HypercertOperationsImpl", () => { const logoBlob = new Blob(["logo"], { type: "image/png" }); const headerBlob = new Blob(["header"], { type: "image/jpeg" }); - mockAgent.com.atproto.repo.uploadBlob.mockResolvedValueOnce({ - success: true, - data: { - blob: { - $type: "blob", - ref: { $link: "bafyrei-logo" }, - mimeType: "image/png", - size: 150, - }, - }, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef({ size: 150 })); - mockAgent.com.atproto.repo.uploadBlob.mockResolvedValueOnce({ - success: true, - data: { - blob: { - $type: "blob", - ref: { $link: "bafyrei-header" }, - mimeType: "image/jpeg", - size: 250, - }, - }, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef({ mimeType: "image/jpeg", size: 250 })); mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ success: true, @@ -2875,17 +2830,9 @@ describe("HypercertOperationsImpl", () => { const newBanner = new Blob(["new-banner"], { type: "image/jpeg" }); // Mock blob upload via BlobOperations (not agent.uploadBlob) - mockBlobs.upload.mockResolvedValueOnce({ - ref: { $link: "bafyrei-new-avatar" }, - mimeType: "image/png", - size: 100, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef()); - mockBlobs.upload.mockResolvedValueOnce({ - ref: { $link: "bafyrei-new-banner" }, - mimeType: "image/jpeg", - size: 200, - }); + mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef({ mimeType: "image/jpeg", size: 200 })); const result = await hypercertOps.updateCollection("at://did:plc:test/org.hypercerts.collection/abc123", { avatar: newAvatar, @@ -3210,11 +3157,7 @@ describe("HypercertOperationsImpl", () => { it("should upload avatar blob when provided", async () => { const avatarBlob = new Blob(["avatar data"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "avatar-cid" }, - mimeType: "image/png", - size: 100, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef()); await hypercertOps.createProject({ title: "Project with Avatar", @@ -3224,19 +3167,16 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.avatar).toEqual({ + expect(createCall.record.avatar).toMatchObject({ $type: "org.hypercerts.defs#smallImage", - image: { ref: { $link: "avatar-cid" }, mimeType: "image/png", size: 100 }, + image: { $type: "blob", mimeType: "image/png", size: 100 }, }); + expect(createCall.record.avatar.image.ref).toBeDefined(); }); it("should upload banner blob when provided", async () => { const bannerBlob = new Blob(["banner data"], { type: "image/jpeg" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "banner-cid" }, - mimeType: "image/jpeg", - size: 200, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "image/jpeg", size: 200 })); await hypercertOps.createProject({ title: "Project with Banner", @@ -3246,10 +3186,11 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(bannerBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.banner).toEqual({ + expect(createCall.record.banner).toMatchObject({ $type: "org.hypercerts.defs#largeImage", - image: { ref: { $link: "banner-cid" }, mimeType: "image/jpeg", size: 200 }, + image: { $type: "blob", mimeType: "image/jpeg", size: 200 }, }); + expect(createCall.record.banner.image.ref).toBeDefined(); }); it("should upload both avatar and banner when provided", async () => { @@ -3257,16 +3198,8 @@ describe("HypercertOperationsImpl", () => { const bannerBlob = new Blob(["banner"], { type: "image/jpeg" }); mockBlobs.upload - .mockResolvedValueOnce({ - ref: { $link: "avatar-cid" }, - mimeType: "image/png", - size: 50, - }) - .mockResolvedValueOnce({ - ref: { $link: "banner-cid" }, - mimeType: "image/jpeg", - size: 100, - }); + .mockResolvedValueOnce(createMockBlobRef({ size: 50 })) + .mockResolvedValueOnce(createMockBlobRef({ mimeType: "image/jpeg", size: 100 })); await hypercertOps.createProject({ title: "Full Project", @@ -3749,11 +3682,7 @@ describe("HypercertOperationsImpl", () => { it("should upload and update avatar", async () => { const newAvatar = new Blob(["new avatar"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "new-avatar-cid" }, - mimeType: "image/png", - size: 150, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ size: 150 })); await hypercertOps.updateProject("at://did:plc:test/org.hypercerts.claim.collection/abc123", { avatar: newAvatar, @@ -3761,19 +3690,16 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(newAvatar); const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.avatar).toEqual({ + expect(putCall.record.avatar).toMatchObject({ $type: "org.hypercerts.defs#smallImage", - image: { ref: { $link: "new-avatar-cid" }, mimeType: "image/png", size: 150 }, + image: { $type: "blob", mimeType: "image/png", size: 150 }, }); + expect(putCall.record.avatar.image.ref).toBeDefined(); }); it("should upload and update banner", async () => { const newBanner = new Blob(["new banner"], { type: "image/jpeg" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "new-banner-cid" }, - mimeType: "image/jpeg", - size: 250, - }); + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "image/jpeg", size: 250 })); await hypercertOps.updateProject("at://did:plc:test/org.hypercerts.claim.collection/abc123", { banner: newBanner, diff --git a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts index fd498cdc..b7843071 100644 --- a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Agent } from "@atproto/api"; +import { Agent } from "@atproto/api"; import { ProfileOperationsImpl } from "../../src/repository/ProfileOperationsImpl.js"; import { NetworkError } from "../../src/core/errors.js"; import type { BlobOperations } from "../../src/repository/interfaces.js"; -import { createMockAgent, createMockBlobOperations, TEST_REPO_DID } from "../utils/mocks.js"; +import { createMockAgent, createMockBlobOperations, createMockBlobRef, TEST_REPO_DID } from "../utils/mocks.js"; + +const BSKY_PROFILE_COLLECTION = "app.bsky.actor.profile"; +const CERTIFIED_PROFILE_COLLECTION = "app.certified.actor.profile"; +const TEST_PDS_URL = "https://test.pds.example"; describe("ProfileOperationsImpl", () => { let mockAgent: ReturnType; @@ -13,195 +17,343 @@ describe("ProfileOperationsImpl", () => { beforeEach(() => { mockAgent = createMockAgent(vi); mockBlobs = createMockBlobOperations(vi); - profileOps = new ProfileOperationsImpl(mockAgent as unknown as Agent, TEST_REPO_DID, mockBlobs as BlobOperations); + profileOps = new ProfileOperationsImpl( + mockAgent as unknown as Agent, + TEST_REPO_DID, + mockBlobs as BlobOperations, + TEST_PDS_URL, + ); }); - describe("get", () => { - it("should get profile successfully", async () => { + describe("getBskyProfile", () => { + it("should return Bsky profile successfully", async () => { mockAgent.getProfile!.mockResolvedValue({ success: true, data: { - handle: "test.bsky.social", - displayName: "Test User", - description: "A test user", - avatar: "https://example.com/avatar.jpg", - banner: "https://example.com/banner.jpg", + handle: "alice.bsky.social", + displayName: "Alice", + description: "Building on AT Protocol", + avatar: "https://cdn.bsky.app/avatar.jpg", + banner: "https://cdn.bsky.app/banner.jpg", }, }); - const result = await profileOps.get(); + const result = await profileOps.getBskyProfile(); - expect(result.handle).toBe("test.bsky.social"); - expect(result.displayName).toBe("Test User"); - expect(result.description).toBe("A test user"); - expect(result.avatar).toBe("https://example.com/avatar.jpg"); - expect(result.banner).toBe("https://example.com/banner.jpg"); + expect(result.handle).toBe("alice.bsky.social"); + expect(result.displayName).toBe("Alice"); + expect(result.description).toBe("Building on AT Protocol"); + expect(result.avatar).toBe("https://cdn.bsky.app/avatar.jpg"); + expect(result.banner).toBe("https://cdn.bsky.app/banner.jpg"); expect(mockAgent.getProfile).toHaveBeenCalledWith({ actor: TEST_REPO_DID }); }); - it("should handle profile without optional fields", async () => { + it("should throw NetworkError if Bsky profile fetch fails", async () => { mockAgent.getProfile!.mockResolvedValue({ - success: true, - data: { - handle: "minimal.bsky.social", - }, + success: false, + data: {}, }); - const result = await profileOps.get(); + await expect(profileOps.getBskyProfile()).rejects.toThrow(NetworkError); + await expect(profileOps.getBskyProfile()).rejects.toThrow("Failed to get Bluesky profile"); + }); + + it("should throw NetworkError if agent throws error", async () => { + mockAgent.getProfile!.mockRejectedValue(new Error("Network error")); - expect(result.handle).toBe("minimal.bsky.social"); - expect(result.displayName).toBeUndefined(); - expect(result.description).toBeUndefined(); + await expect(profileOps.getBskyProfile()).rejects.toThrow(NetworkError); + await expect(profileOps.getBskyProfile()).rejects.toThrow("Network error"); }); + }); - it("should throw NetworkError when API returns success: false", async () => { + describe("getCertifiedProfile", () => { + it("should return Certified profile with blob URLs", async () => { + // Mock handle fetch mockAgent.getProfile!.mockResolvedValue({ - success: false, + success: true, + data: { handle: "alice.bsky.social" }, + }); + + // Mock certified profile record + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + description: "Certified bio", + pronouns: "she/her", + website: "https://alice.com", + avatar: { + $type: "org.hypercerts.defs#smallImage", + image: { + $type: "blob", + ref: { $link: "bafyabc", toString: () => "bafyabc" }, + mimeType: "image/png", + size: 1000, + }, + }, + banner: { + $type: "org.hypercerts.defs#largeImage", + image: { + $type: "blob", + ref: { $link: "bafydef", toString: () => "bafydef" }, + mimeType: "image/jpeg", + size: 5000, + }, + }, + }, + }, }); - await expect(profileOps.get()).rejects.toThrow(NetworkError); + const result = await profileOps.getCertifiedProfile(); + + expect(result).not.toBeNull(); + expect(result!.handle).toBe("alice.bsky.social"); + expect(result!.displayName).toBe("Alice"); + expect(result!.description).toBe("Certified bio"); + expect(result!.pronouns).toBe("she/her"); + expect(result!.website).toBe("https://alice.com"); + expect(result!.avatar).toBe(`${TEST_PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${TEST_REPO_DID}&cid=bafyabc`); + expect(result!.banner).toBe(`${TEST_PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${TEST_REPO_DID}&cid=bafydef`); + expect(mockAgent.com.atproto.repo.getRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: CERTIFIED_PROFILE_COLLECTION, + rkey: "self", + }); }); - it("should throw NetworkError when API throws", async () => { - mockAgent.getProfile!.mockRejectedValue(new Error("Profile not found")); + it("should handle URI image format", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, + }); + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Test", + avatar: { + $type: "org.hypercerts.defs#uri", + uri: "https://example.com/avatar.jpg", + }, + }, + }, + }); - await expect(profileOps.get()).rejects.toThrow(NetworkError); + const result = await profileOps.getCertifiedProfile(); + expect(result).not.toBeNull(); + expect(result!.avatar).toBe("https://example.com/avatar.jpg"); + expect(result!.displayName).toBe("Test"); }); - }); - describe("create", () => { - beforeEach(() => { - mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + it("should return empty string for handle if getProfile fails", async () => { + mockAgent.getProfile!.mockRejectedValue(new Error("Network error")); + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { - uri: "at://did:plc:test/app.bsky.actor.profile/self", - cid: "bafyrei123", + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + pronouns: "she/her", + }, }, }); + + const result = await profileOps.getCertifiedProfile(); + expect(result).not.toBeNull(); + expect(result!.handle).toBe(""); + expect(result!.displayName).toBe("Alice"); + expect(result!.pronouns).toBe("she/her"); }); - it("should create profile with displayName and description", async () => { - const result = await profileOps.create({ - displayName: "New User", - description: "A new profile", - }); - - expect(result.uri).toBe("at://did:plc:test/app.bsky.actor.profile/self"); - expect(result.cid).toBe("bafyrei123"); - expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( - expect.objectContaining({ - repo: TEST_REPO_DID, - collection: "app.bsky.actor.profile", - rkey: "self", - record: expect.objectContaining({ - $type: "app.bsky.actor.profile", - createdAt: expect.any(String), - displayName: "New User", - description: "A new profile", - }), - }), - ); + it("should return null if certified profile record does not exist", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, + }); + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: false, + data: {}, + }); + + const result = await profileOps.getCertifiedProfile(); + expect(result).toBeNull(); }); - it("should create profile with avatar", async () => { - const avatarBlob = new Blob(["avatar data"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "avatar-cid" }, - mimeType: "image/png", - size: 100, + it("should return null if RecordNotFound error is thrown", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, }); - await profileOps.create({ - displayName: "User with Avatar", - avatar: avatarBlob, + const recordNotFoundError = { + error: "RecordNotFound", + message: "Record not found", + status: 400, + }; + + mockAgent.com.atproto.repo.getRecord.mockRejectedValue(recordNotFoundError); + + const result = await profileOps.getCertifiedProfile(); + expect(result).toBeNull(); + }); + + it("should throw NetworkError for genuine network failures", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, }); - expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); - expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - displayName: "User with Avatar", - avatar: { $type: "blob", ref: { $link: "avatar-cid" }, mimeType: "image/png", size: 100 }, - }), - }), - ); + mockAgent.com.atproto.repo.getRecord.mockRejectedValue(new Error("Connection timeout")); + + await expect(profileOps.getCertifiedProfile()).rejects.toThrow(NetworkError); + await expect(profileOps.getCertifiedProfile()).rejects.toThrow("Connection timeout"); }); - it("should create profile with banner", async () => { - const bannerBlob = new Blob(["banner data"], { type: "image/jpeg" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "banner-cid" }, - mimeType: "image/jpeg", - size: 200, + it("should throw error if image conversion fails", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, }); - await profileOps.create({ - displayName: "User with Banner", - banner: bannerBlob, + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + avatar: { + $type: "org.hypercerts.defs#smallImage", + image: { + // Invalid - missing ref + $type: "blob", + mimeType: "image/png", + }, + }, + }, + }, }); - expect(mockBlobs.upload).toHaveBeenCalledWith(bannerBlob); - expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - displayName: "User with Banner", - banner: { $type: "blob", ref: { $link: "banner-cid" }, mimeType: "image/jpeg", size: 200 }, - }), - }), - ); + await expect(profileOps.getCertifiedProfile()).rejects.toThrow("Unable to extract CID or URI from image record"); }); - it("should create profile with website", async () => { - await profileOps.create({ - displayName: "User", - website: "https://example.com", + it("should handle profile without images", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, }); - expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - displayName: "User", - website: "https://example.com", - }), - }), - ); + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Test User", + }, + }, + }); + + const result = await profileOps.getCertifiedProfile(); + expect(result).not.toBeNull(); + expect(result!.displayName).toBe("Test User"); + expect(result!.avatar).toBeUndefined(); + expect(result!.banner).toBeUndefined(); }); + }); - it("should ignore null values when creating", async () => { - await profileOps.create({ - displayName: "User", - description: null, + describe("createBskyProfile", () => { + it("should create Bsky profile with basic fields", async () => { + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy123", + }, }); - const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.displayName).toBe("User"); - expect(createCall.record.description).toBeUndefined(); + const result = await profileOps.createBskyProfile({ + displayName: "Alice", + description: "Test user", + }); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy123"); + + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: BSKY_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + $type: BSKY_PROFILE_COLLECTION, + displayName: "Alice", + description: "Test user", + createdAt: expect.any(String), + }), + }); }); - it("should throw NetworkError when createRecord returns success: false", async () => { + it("should create Bsky profile with avatar", async () => { + const avatarBlob = new Blob(["avatar"], { type: "image/png" }); + + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ size: 1000 })); + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ - success: false, + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy123", + }, + }); + + await profileOps.createBskyProfile({ + displayName: "Alice", + avatar: avatarBlob, }); - await expect(profileOps.create({ displayName: "New User" })).rejects.toThrow(NetworkError); + expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: BSKY_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + $type: BSKY_PROFILE_COLLECTION, + avatar: expect.any(Object), + createdAt: expect.any(String), + displayName: "Alice", + }), + }); }); - it("should throw NetworkError when API throws", async () => { - mockAgent.com.atproto.repo.createRecord.mockRejectedValue(new Error("Create failed")); + it("should throw NetworkError on failure", async () => { + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: false, + data: {}, + }); - await expect(profileOps.create({ displayName: "New User" })).rejects.toThrow(NetworkError); + await expect(profileOps.createBskyProfile({ displayName: "Alice" })).rejects.toThrow(NetworkError); }); }); - describe("update", () => { - beforeEach(() => { - // Default mock for getting existing profile + describe("updateBskyProfile", () => { + it("should update Bsky profile fields", async () => { + // Mock existing profile mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { value: { + $type: BSKY_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", displayName: "Old Name", - description: "Old description", + description: "Old bio", }, }, }); @@ -209,165 +361,376 @@ describe("ProfileOperationsImpl", () => { mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ success: true, data: { - uri: "at://did:plc:test/app.bsky.actor.profile/self", - cid: "bafyrei123", + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy456", }, }); - }); - it("should update displayName", async () => { - const result = await profileOps.update({ + const result = await profileOps.updateBskyProfile({ displayName: "New Name", }); - expect(result.uri).toBe("at://did:plc:test/app.bsky.actor.profile/self"); - expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - displayName: "New Name", - description: "Old description", - }), + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy456"); + + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: BSKY_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + displayName: "New Name", + description: "Old bio", // Preserved }), - ); + }); }); - it("should update description", async () => { - await profileOps.update({ - description: "New description", + it("should remove fields when passed null", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: BSKY_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + description: "Bio to remove", + }, + }, }); - expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - displayName: "Old Name", - description: "New description", - }), - }), - ); - }); + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy789", + }, + }); - it("should remove displayName when set to null", async () => { - await profileOps.update({ - displayName: null, + await profileOps.updateBskyProfile({ + description: null, }); const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.displayName).toBeUndefined(); + expect(putCall.record).not.toHaveProperty("description"); + expect(putCall.record).toHaveProperty("displayName", "Alice"); }); - it("should remove description when set to null", async () => { - await profileOps.update({ - description: null, + it("should throw error if profile not found", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: false, + data: {}, }); - const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.description).toBeUndefined(); + await expect(profileOps.updateBskyProfile({ displayName: "Alice" })).rejects.toThrow(NetworkError); }); + }); - it("should upload and set avatar", async () => { - const avatarBlob = new Blob(["avatar data"], { type: "image/jpeg" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "avatar-cid" }, - mimeType: "image/jpeg", - size: 100, + describe("createCertifiedProfile", () => { + it("should create Certified profile with all fields", async () => { + const avatarBlob = new Blob(["avatar"], { type: "image/png" }); + + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ size: 1000 })); + + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy123", + }, }); - await profileOps.update({ + const result = await profileOps.createCertifiedProfile({ + displayName: "Alice", + description: "Certified profile bio", + pronouns: "she/her", + website: "https://alice.com", avatar: avatarBlob, }); - expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); - expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - avatar: { $type: "blob", ref: { $link: "avatar-cid" }, mimeType: "image/jpeg", size: 100 }, + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy123"); + + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: CERTIFIED_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + $type: CERTIFIED_PROFILE_COLLECTION, + displayName: "Alice", + description: "Certified profile bio", + pronouns: "she/her", + website: "https://alice.com", + avatar: expect.objectContaining({ + $type: "org.hypercerts.defs#smallImage", + image: expect.any(Object), }), + createdAt: expect.any(String), }), - ); + }); }); - it("should remove avatar when set to null", async () => { + it("should create Certified profile with banner in largeImage format", async () => { + const bannerBlob = new Blob(["banner"], { type: "image/jpeg" }); + + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ mimeType: "image/jpeg", size: 5000 })); + + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy123", + }, + }); + + await profileOps.createCertifiedProfile({ + displayName: "Alice", + banner: bannerBlob, + }); + + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: CERTIFIED_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + banner: expect.objectContaining({ + $type: "org.hypercerts.defs#largeImage", + image: expect.any(Object), + }), + }), + }); + }); + }); + + describe("updateCertifiedProfile", () => { + it("should update Certified profile preserving existing fields", async () => { mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { value: { - displayName: "Name", - avatar: { ref: { $link: "old-avatar" } }, + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Old Name", + pronouns: "they/them", + website: "https://old.com", }, }, }); - await profileOps.update({ - avatar: null, + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy456", + }, + }); + + const result = await profileOps.updateCertifiedProfile({ + displayName: "New Name", + website: "https://new.com", + }); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`); + + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith({ + repo: TEST_REPO_DID, + collection: CERTIFIED_PROFILE_COLLECTION, + rkey: "self", + record: expect.objectContaining({ + displayName: "New Name", + pronouns: "they/them", // Preserved + website: "https://new.com", + }), + }); + }); + + it("should remove fields when passed null", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + pronouns: "she/her", + website: "https://alice.com", + }, + }, + }); + + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy789", + }, + }); + + await profileOps.updateCertifiedProfile({ + pronouns: null, + website: null, }); const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.avatar).toBeUndefined(); + expect(putCall.record).not.toHaveProperty("pronouns"); + expect(putCall.record).not.toHaveProperty("website"); + expect(putCall.record).toHaveProperty("displayName", "Alice"); }); + }); - it("should upload and set banner", async () => { - const bannerBlob = new Blob(["banner data"], { type: "image/jpeg" }); - mockBlobs.upload.mockResolvedValue({ - ref: { $link: "banner-cid" }, - mimeType: "image/jpeg", - size: 200, + describe("upsertCertifiedProfile", () => { + it("should create profile when it doesn't exist", async () => { + // Mock: profile doesn't exist + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: false, + data: {}, }); - await profileOps.update({ - banner: bannerBlob, + // Mock: creation succeeds + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy123", + }, }); - expect(mockBlobs.upload).toHaveBeenCalledWith(bannerBlob); - expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( - expect.objectContaining({ - record: expect.objectContaining({ - banner: { $type: "blob", ref: { $link: "banner-cid" }, mimeType: "image/jpeg", size: 200 }, - }), - }), - ); + const result = await profileOps.upsertCertifiedProfile({ + displayName: "Alice", + pronouns: "she/her", + }); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy123"); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); + + const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; + expect(createCall.record).toHaveProperty("displayName", "Alice"); + expect(createCall.record).toHaveProperty("pronouns", "she/her"); }); - it("should remove banner when set to null", async () => { + it("should update profile when it exists", async () => { + // Mock: profile exists mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { value: { - displayName: "Name", - banner: { ref: { $link: "old-banner" } }, + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Old Name", + pronouns: "they/them", }, }, }); - await profileOps.update({ - banner: null, + // Mock: update succeeds + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy456", + }, + }); + + const result = await profileOps.upsertCertifiedProfile({ + displayName: "New Name", }); + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy456"); + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); + + // Verify merge happened - old pronouns preserved, displayName updated const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.banner).toBeUndefined(); + expect(putCall.record).toHaveProperty("displayName", "New Name"); + expect(putCall.record).toHaveProperty("pronouns", "they/them"); }); - it("should throw NetworkError when getRecord returns success: false", async () => { + it("should handle image uploads during create", async () => { + const avatarBlob = new Blob(["avatar"], { type: "image/png" }); + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: false, + data: {}, + }); + + mockBlobs.upload.mockResolvedValue(createMockBlobRef({ size: 1000 })); + + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${CERTIFIED_PROFILE_COLLECTION}/self`, + cid: "bafy789", + }, + }); + + await profileOps.upsertCertifiedProfile({ + displayName: "Alice", + avatar: avatarBlob, }); - await expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow( - "Profile not found. Use create() for new profiles.", - ); + expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalled(); }); + }); - it("should throw NetworkError when putRecord returns success: false", async () => { - mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + describe("upsertBskyProfile", () => { + it("should create profile when it doesn't exist", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: false, + data: {}, + }); + + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy321", + }, }); - await expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow(NetworkError); + const result = await profileOps.upsertBskyProfile({ + displayName: "Bob", + description: "Bluesky user", + }); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy321"); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); }); - it("should throw NetworkError when API throws", async () => { - mockAgent.com.atproto.repo.putRecord.mockRejectedValue(new Error("Update failed")); + it("should update profile when it exists", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: BSKY_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Old Bob", + }, + }, + }); - await expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow(NetworkError); + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`, + cid: "bafy654", + }, + }); + + const result = await profileOps.upsertBskyProfile({ + displayName: "New Bob", + }); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/${BSKY_PROFILE_COLLECTION}/self`); + expect(result.cid).toBe("bafy654"); + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); + + const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(putCall.record).toHaveProperty("displayName", "New Bob"); }); }); }); diff --git a/packages/sdk-core/tests/repository/Repository.test.ts b/packages/sdk-core/tests/repository/Repository.test.ts index f4c67540..108c4804 100644 --- a/packages/sdk-core/tests/repository/Repository.test.ts +++ b/packages/sdk-core/tests/repository/Repository.test.ts @@ -79,8 +79,12 @@ describe("Repository", () => { it("should return a ProfileOperations instance", () => { const profile = repository.profile; expect(profile).toBeDefined(); - expect(typeof profile.get).toBe("function"); - expect(typeof profile.update).toBe("function"); + expect(typeof profile.getBskyProfile).toBe("function"); + expect(typeof profile.getCertifiedProfile).toBe("function"); + expect(typeof profile.createBskyProfile).toBe("function"); + expect(typeof profile.updateBskyProfile).toBe("function"); + expect(typeof profile.createCertifiedProfile).toBe("function"); + expect(typeof profile.updateCertifiedProfile).toBe("function"); }); }); diff --git a/packages/sdk-core/tests/utils/mocks.ts b/packages/sdk-core/tests/utils/mocks.ts index b4da0655..651ec005 100644 --- a/packages/sdk-core/tests/utils/mocks.ts +++ b/packages/sdk-core/tests/utils/mocks.ts @@ -1,6 +1,8 @@ import type { SessionStore, StateStore, CacheInterface, LoggerInterface } from "../../src/core/interfaces.js"; import type { NodeSavedSession, NodeSavedState } from "@atproto/oauth-client-node"; import type { Mock } from "vitest"; +import { BlobRef } from "@atproto/lexicon"; +import { CID } from "multiformats/cid"; /** * In-memory session store for testing @@ -216,3 +218,47 @@ export function createMockBlobOperations(vi: typeof import("vitest").vi): Mocked get: vi.fn(), }; } + +/** + * Creates a mock BlobRef for testing blob upload operations. + * Returns a BlobRef instance with a proper ipld() method, compatible + * with @atproto/api expectations. + * + * @param options - Optional configuration for customizing the BlobRef + * @param options.cid - Custom CID string (defaults to a valid test CID) + * @param options.mimeType - MIME type of the blob (defaults to "image/png") + * @param options.size - Size of the blob in bytes (defaults to 100) + * @returns BlobRef instance that can be used with mockBlobs.upload + * + * @example + * ```typescript + * // Use defaults (image/png, size 100) + * mockBlobs.upload.mockResolvedValue(createMockBlobRef()); + * + * // Custom MIME type for GeoJSON + * mockBlobs.upload.mockResolvedValue( + * createMockBlobRef({ mimeType: "application/geo+json" }) + * ); + * + * // Custom size for banner + * mockBlobs.upload.mockResolvedValue( + * createMockBlobRef({ mimeType: "image/jpeg", size: 200 }) + * ); + * + * // Sequential uploads + * mockBlobs.upload.mockResolvedValueOnce(createMockBlobRef()); + * mockBlobs.upload.mockResolvedValueOnce( + * createMockBlobRef({ mimeType: "image/jpeg", size: 200 }) + * ); + * ``` + */ +export function createMockBlobRef(options?: { cid?: string; mimeType?: string; size?: number }): BlobRef { + const { + cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + mimeType = "image/png", + size = 100, + } = options || {}; + + const mockCid = CID.parse(cid); + return new BlobRef(mockCid, mimeType, size); +} diff --git a/packages/sdk-react/README.md b/packages/sdk-react/README.md index 6f9824ed..533e63fa 100644 --- a/packages/sdk-react/README.md +++ b/packages/sdk-react/README.md @@ -96,7 +96,7 @@ const atproto = createATProtoReact({ config, queryClient }); ``` useAuth() → session, status, login, logout, refresh -useProfile(did?) → profile, update, isLoading +useProfile(did?) → profile, save, isSaving, isLoading useRepository(opts?) → repository, isSDS, serverUrl useOrganizations() → organizations, create (SDS only) useOrganization(did) → organization (SDS only) @@ -105,6 +105,43 @@ useHypercerts(did?) → hypercerts, create, fetchNextPage useHypercert(uri) → hypercert, update, remove ``` +### useProfile + +The `useProfile` hook manages Certified profile data using an upsert pattern: + +```typescript +function ProfileEditor() { + const { profile, save, isSaving, isLoading } = useProfile(); + + // Profile may be null if user hasn't created one yet + if (isLoading) return
Loading...
; + + return ( +
{ + e.preventDefault(); + save({ + displayName: "Alice", + description: "Climate researcher", + pronouns: "she/her", + website: "https://alice.com", + }); + }}> + + +
+ ); +} +``` + +**Key features:** + +- Returns `null` when profile doesn't exist (not an error) +- `save()` uses upsert pattern (creates if missing, updates if exists) +- Automatically invalidates profile cache on save +- Handles blob uploads for avatar/banner + ## Query Keys For manual cache management: diff --git a/packages/sdk-react/src/hooks/useProfile.ts b/packages/sdk-react/src/hooks/useProfile.ts index 7e99a87b..05f0ca9a 100644 --- a/packages/sdk-react/src/hooks/useProfile.ts +++ b/packages/sdk-react/src/hooks/useProfile.ts @@ -80,10 +80,13 @@ export function useProfile(did?: string): UseProfileResult { queryFn: async (): Promise => { if (!repository) return null; - const profileData = await repository.profile.get(); + const profileData = await repository.profile.getCertifiedProfile(); + + // Handle null case - user hasn't created certified profile + if (!profileData) return null; return { - handle: profileData.handle, + handle: profileData.handle ?? "", displayName: profileData.displayName, description: profileData.description, avatar: profileData.avatar, @@ -95,19 +98,21 @@ export function useProfile(did?: string): UseProfileResult { staleTime: 5 * 60 * 1000, // 5 minutes }); - // Update mutation - const updateMutation = useMutation({ + // Save mutation (uses upsert - works for both create and update) + const saveMutation = useMutation({ mutationFn: async (params: ProfileUpdate) => { if (!repository) { throw new Error("Repository not available"); } - await repository.profile.update({ - displayName: params.displayName, - description: params.description, - avatar: params.avatar, - banner: params.banner, - website: params.website, + // Use upsert instead of update - works for first-time profile creation too + // Filter out null values for upsert (upsert uses create params, update uses null for deletion) + await repository.profile.upsertCertifiedProfile({ + displayName: params.displayName ?? undefined, + description: params.description ?? undefined, + avatar: params.avatar ?? undefined, + banner: params.banner ?? undefined, + website: params.website ?? undefined, }); }, onSuccess: () => { @@ -119,11 +124,11 @@ export function useProfile(did?: string): UseProfileResult { }); // Callbacks - const update = useCallback( + const save = useCallback( async (params: ProfileUpdate) => { - await updateMutation.mutateAsync(params); + await saveMutation.mutateAsync(params); }, - [updateMutation], + [saveMutation], ); const refetch = useCallback(async () => { @@ -134,8 +139,8 @@ export function useProfile(did?: string): UseProfileResult { profile: profileQuery.data ?? null, isLoading: profileQuery.isLoading || repoStatus === "loading", error: profileQuery.error ?? null, - update, - isUpdating: updateMutation.isPending, + save, + isSaving: saveMutation.isPending, refetch, }; } diff --git a/packages/sdk-react/src/testing/factory.tsx b/packages/sdk-react/src/testing/factory.tsx index c784c600..e2c85f9c 100644 --- a/packages/sdk-react/src/testing/factory.tsx +++ b/packages/sdk-react/src/testing/factory.tsx @@ -145,8 +145,8 @@ export function createMockATProtoReact(options: MockATProtoReactOptions = {}): A profile: createMockProfile(), isLoading: false, error: null, - update: async () => {}, - isUpdating: false, + save: async () => {}, + isSaving: false, refetch: async () => {}, ...options.mockHooks?.useProfile, }; diff --git a/packages/sdk-react/src/testing/mocks.ts b/packages/sdk-react/src/testing/mocks.ts index 81d9e86f..354a18d6 100644 --- a/packages/sdk-react/src/testing/mocks.ts +++ b/packages/sdk-react/src/testing/mocks.ts @@ -4,7 +4,13 @@ * @packageDocumentation */ -import type { Session, Collaborator, CollaboratorPermissions, OrganizationInfo } from "@hypercerts-org/sdk-core"; +import type { + Session, + Collaborator, + CollaboratorPermissions, + OrganizationInfo, + OrgHypercertsClaimActivity, +} from "@hypercerts-org/sdk-core"; import type { Profile, Hypercert } from "../types.js"; /** @@ -199,19 +205,23 @@ export function createMockHypercert(overrides: Partial = {}): Hyperce const now = new Date(); const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); - return { + const base: Hypercert = { uri: "at://did:plc:mock123/org.hypercerts.claim.activity/mock-rkey", cid: "bafyreimockhypercertcid123456789", $type: "org.hypercerts.claim.activity", title: "Mock Hypercert", shortDescription: "A mock hypercert", description: "A mock hypercert for testing purposes", - workScope: "Testing, Environment" as Hypercert["workScope"], + workScope: { + $type: "org.hypercerts.claim.activity#workScopeString", + scope: "Testing, Environment", + } as unknown as OrgHypercertsClaimActivity.Main["workScope"], startDate: yearAgo.toISOString(), endDate: now.toISOString(), workTimeFrameFrom: yearAgo.toISOString().split("T")[0], workTimeFrameTo: now.toISOString().split("T")[0], createdAt: now.toISOString(), - ...overrides, }; + + return { ...base, ...overrides }; } diff --git a/packages/sdk-react/src/types.ts b/packages/sdk-react/src/types.ts index b1860fc0..884dc7d9 100644 --- a/packages/sdk-react/src/types.ts +++ b/packages/sdk-react/src/types.ts @@ -160,7 +160,9 @@ export interface Profile { handle: string; displayName?: string; description?: string; + /** Avatar image URL. */ avatar?: string; + /** Banner image URL. */ banner?: string; website?: string; followersCount?: number; @@ -192,11 +194,11 @@ export interface UseProfileResult { /** Query error */ error: Error | null; - /** Update profile */ - update: (params: ProfileUpdate) => Promise; + /** Save profile (creates if doesn't exist, updates if exists) */ + save: (params: ProfileUpdate) => Promise; - /** Update loading state */ - isUpdating: boolean; + /** Save operation loading state */ + isSaving: boolean; /** Refetch profile */ refetch: () => Promise; diff --git a/packages/sdk-react/src/utils/ssr.ts b/packages/sdk-react/src/utils/ssr.ts index 90aaebf5..67aa79e6 100644 --- a/packages/sdk-react/src/utils/ssr.ts +++ b/packages/sdk-react/src/utils/ssr.ts @@ -84,10 +84,13 @@ export function createSSRHelpers(sdk: ATProtoSDK, queryClient: QueryClient): SSR if (!session) return null; const repo = await sdk.repository(session); - const profile = await repo.profile.get(); + const profile = await repo.profile.getCertifiedProfile(); + + // Handle null case - user hasn't created certified profile + if (!profile) return null; return { - handle: profile.handle, + handle: profile.handle ?? "", displayName: profile.displayName, description: profile.description, avatar: profile.avatar, @@ -95,6 +98,7 @@ export function createSSRHelpers(sdk: ATProtoSDK, queryClient: QueryClient): SSR website: profile.website, }; } catch { + // Only catch unexpected errors (session/network issues) return null; } }, diff --git a/packages/sdk-react/tests/hooks/useProfile.test.tsx b/packages/sdk-react/tests/hooks/useProfile.test.tsx index 7f13b96f..6eee2c4d 100644 --- a/packages/sdk-react/tests/hooks/useProfile.test.tsx +++ b/packages/sdk-react/tests/hooks/useProfile.test.tsx @@ -24,8 +24,8 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockProfile = createMockProfile(); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -54,8 +54,8 @@ describe("useProfile", () => { handle: "test.bsky.social", displayName: "Test User", }); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -89,8 +89,8 @@ describe("useProfile", () => { handle: "other.bsky.social", displayName: "Other User", }); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile("did:plc:otheruser"), { @@ -132,8 +132,8 @@ describe("useProfile", () => { }); }); - describe("updating profile", () => { - it("should provide update function", async () => { + describe("saving profile", () => { + it("should provide save function", async () => { const session = createMockSession(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, @@ -141,9 +141,11 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockProfile = createMockProfile(); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockUpdate = vi.fn().mockResolvedValue(undefined); - const mockRepo = { profile: { get: mockGet, update: mockUpdate } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockUpsertCertifiedProfile = vi.fn().mockResolvedValue(undefined); + const mockRepo = { + profile: { getCertifiedProfile: mockGetCertifiedProfile, upsertCertifiedProfile: mockUpsertCertifiedProfile }, + }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -157,11 +159,11 @@ describe("useProfile", () => { }), }); - expect(result.current.update).toBeDefined(); - expect(typeof result.current.update).toBe("function"); + expect(result.current.save).toBeDefined(); + expect(typeof result.current.save).toBe("function"); }); - it("should call repository.profile.update with params", async () => { + it("should call repository.profile.upsert with params", async () => { const session = createMockSession(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, @@ -169,9 +171,11 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockProfile = createMockProfile(); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockUpdate = vi.fn().mockResolvedValue(undefined); - const mockRepo = { profile: { get: mockGet, update: mockUpdate } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockUpsertCertifiedProfile = vi.fn().mockResolvedValue(undefined); + const mockRepo = { + profile: { getCertifiedProfile: mockGetCertifiedProfile, upsertCertifiedProfile: mockUpsertCertifiedProfile }, + }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -196,10 +200,10 @@ describe("useProfile", () => { }; await act(async () => { - await result.current.update(updateParams); + await result.current.save(updateParams); }); - expect(mockUpdate).toHaveBeenCalledWith( + expect(mockUpsertCertifiedProfile).toHaveBeenCalledWith( expect.objectContaining({ displayName: "New Name", description: "New bio", @@ -207,7 +211,7 @@ describe("useProfile", () => { ); }); - it("should set isUpdating while update is in progress", async () => { + it("should set isSaving while save is in progress", async () => { const session = createMockSession(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, @@ -216,14 +220,16 @@ describe("useProfile", () => { let resolveUpdate: () => void; const mockProfile = createMockProfile(); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockUpdate = vi.fn().mockImplementation( + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockUpsertCertifiedProfile = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveUpdate = resolve; }), ); - const mockRepo = { profile: { get: mockGet, update: mockUpdate } }; + const mockRepo = { + profile: { getCertifiedProfile: mockGetCertifiedProfile, upsertCertifiedProfile: mockUpsertCertifiedProfile }, + }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -242,22 +248,22 @@ describe("useProfile", () => { expect(result.current.profile).not.toBeNull(); }); - // Start update without awaiting + // Start save without awaiting act(() => { - result.current.update({ displayName: "New Name" }); + result.current.save({ displayName: "New Name" }); }); await waitFor(() => { - expect(result.current.isUpdating).toBe(true); + expect(result.current.isSaving).toBe(true); }); - // Complete the update + // Complete the save await act(async () => { resolveUpdate!(); }); await waitFor(() => { - expect(result.current.isUpdating).toBe(false); + expect(result.current.isSaving).toBe(false); }); }); }); @@ -271,8 +277,8 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockProfile = createMockProfile(); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -298,8 +304,8 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockProfile = createMockProfile({ displayName: "Initial Name" }); - const mockGet = vi.fn().mockResolvedValue(mockProfile); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockResolvedValue(mockProfile); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { @@ -319,16 +325,16 @@ describe("useProfile", () => { }); // Clear mock and set new response - mockGet.mockClear(); - mockGet.mockResolvedValue({ ...mockProfile, displayName: "Updated Name" }); + mockGetCertifiedProfile.mockClear(); + mockGetCertifiedProfile.mockResolvedValue({ ...mockProfile, displayName: "Updated Name" }); // Refetch await act(async () => { await result.current.refetch(); }); - // Verify get was called again - expect(mockGet).toHaveBeenCalled(); + // Verify getCertifiedProfile was called again + expect(mockGetCertifiedProfile).toHaveBeenCalled(); }); }); @@ -341,8 +347,8 @@ describe("useProfile", () => { queryClient.setQueryData(atprotoKeys.session(), session); const mockError = new Error("Profile fetch failed"); - const mockGet = vi.fn().mockRejectedValue(mockError); - const mockRepo = { profile: { get: mockGet, update: vi.fn() } }; + const mockGetCertifiedProfile = vi.fn().mockRejectedValue(mockError); + const mockRepo = { profile: { getCertifiedProfile: mockGetCertifiedProfile, updateCertifiedProfile: vi.fn() } }; const mockRepository = vi.fn().mockReturnValue(mockRepo); const { result } = renderHook(() => useProfile(), { diff --git a/packages/sdk-react/tests/testing/mocks.test.ts b/packages/sdk-react/tests/testing/mocks.test.ts index c71a394a..c6ca83e9 100644 --- a/packages/sdk-react/tests/testing/mocks.test.ts +++ b/packages/sdk-react/tests/testing/mocks.test.ts @@ -165,7 +165,10 @@ describe("createMockHypercert", () => { const hc = createMockHypercert(); expect(hc.title).toBe("Mock Hypercert"); - expect(hc.workScope).toBe("Testing, Environment"); + expect(hc.workScope).toEqual({ + $type: "org.hypercerts.claim.activity#workScopeString", + scope: "Testing, Environment", + }); expect(hc.$type).toBe("org.hypercerts.claim.activity"); expect(hc.uri).toContain("at://"); expect(hc.cid).toBeTruthy(); diff --git a/packages/sdk-react/tests/utils/fixtures.ts b/packages/sdk-react/tests/utils/fixtures.ts index 0fb4fee5..b76a6bbd 100644 --- a/packages/sdk-react/tests/utils/fixtures.ts +++ b/packages/sdk-react/tests/utils/fixtures.ts @@ -4,7 +4,12 @@ * Re-exports fixtures from sdk-core and adds React-specific fixtures. */ -import type { Session, CollaboratorPermissions, OrganizationInfo } from "@hypercerts-org/sdk-core"; +import type { + Session, + CollaboratorPermissions, + OrganizationInfo, + OrgHypercertsClaimActivity, +} from "@hypercerts-org/sdk-core"; import type { Profile, Hypercert } from "../../src/types.js"; /** @@ -80,21 +85,25 @@ export function createMockHypercert(overrides: Partial = {}): Hyperce const now = new Date(); const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); - return { + const base: Hypercert = { uri: "at://did:plc:test123/org.hypercerts.claim.activity/test-rkey", cid: "bafyreitesthypercertcid123456789", $type: "org.hypercerts.claim.activity", title: "Test Hypercert", shortDescription: "A test hypercert", description: "A test hypercert for testing purposes", - workScope: "Testing, Environment" as Hypercert["workScope"], + workScope: { + $type: "org.hypercerts.claim.activity#workScopeString", + scope: "Testing, Environment", + } as unknown as OrgHypercertsClaimActivity.Main["workScope"], startDate: yearAgo.toISOString(), endDate: now.toISOString(), workTimeFrameFrom: yearAgo.toISOString().split("T")[0], workTimeFrameTo: now.toISOString().split("T")[0], createdAt: now.toISOString(), - ...overrides, }; + + return { ...base, ...overrides }; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d079c500..a11523d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,11 +51,11 @@ importers: packages/sdk-core: dependencies: '@atproto/api': - specifier: ^0.17.5 - version: 0.17.7 + specifier: 0.18.10 + version: 0.18.10 '@atproto/lexicon': - specifier: ^0.5.1 - version: 0.5.2 + specifier: 0.6.0 + version: 0.6.0 '@atproto/oauth-client': specifier: ^0.5.10 version: 0.5.10 @@ -63,8 +63,8 @@ importers: specifier: ^0.3.12 version: 0.3.12 '@hypercerts-org/lexicon': - specifier: 0.10.0-beta.13 - version: 0.10.0-beta.13(@atproto/xrpc@0.7.6) + specifier: 0.10.0-beta.14 + version: 0.10.0-beta.14(@atproto/xrpc@0.7.7) eventemitter3: specifier: ^5.0.1 version: 5.0.1 @@ -87,12 +87,24 @@ importers: '@rollup/plugin-typescript': specifier: ^12.3.0 version: 12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@5.9.3) + '@types/chai': + specifier: ^5.2.3 + version: 5.2.3 + '@types/deep-eql': + specifier: ^4.0.2 + version: 4.0.2 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/node': specifier: '>=20.10.0' version: 24.10.1 jose: specifier: ^6.1.3 version: 6.1.3 + multiformats: + specifier: ^13.4.2 + version: 13.4.2 rollup: specifier: ^4.53.3 version: 4.53.3 @@ -157,11 +169,11 @@ packages: '@atcute/bluesky@3.2.17': resolution: {integrity: sha512-Li+RsPkcRNC6AnNlqOGnlmAcjSwBdXIKFubJL1nwACDngKNXG4ooGL5cvzeekdDEfHmtFhS/tyZNaUx9QXYEUw==} - '@atcute/leaflet@1.0.17': - resolution: {integrity: sha512-aCykf/vCRY19B8S42VEoyIvUR2CuOsH/RQcDF8u8rHZvmGfc4qgcAAxriHxiVrlinLNhxzhzuliq2avVxHiv8g==} + '@atcute/leaflet@1.0.18': + resolution: {integrity: sha512-6EKNy8Pdv99BqoRloeY3Vw+JZ1NtsmgxugUjeu1n6lwk0oPXwWO+D/0JDLmqDxYgoH//E+z1vH2OqJ+TD0mOZA==} - '@atcute/lexicons@1.2.7': - resolution: {integrity: sha512-gCvkSMI1F1zx7xXa59iPiSKMH3L5Hga6iurGqQjaQbE2V/np/2QuDqQzt96TNbWfaFAXE9f9oY+0z3ljf/bweA==} + '@atcute/lexicons@1.2.8': + resolution: {integrity: sha512-3WPyU5droiovgVyWYuhsNibFAknna7uuVqvcnCd++oVp7ZPvXtXPFVx1MuAjJV7FspYwkuMh/GlTPsdfIOetBQ==} '@atcute/uint8array@1.1.0': resolution: {integrity: sha512-JtHXIVW6LPU9FMWp7SgE4HbUs3uV2WdfkK/2RWdEGjr4EgMV50P3FdU6fPeGlTfDNBJVYMIsuD2wwaKRPV/Aqg==} @@ -198,15 +210,12 @@ packages: '@atproto-labs/simple-store@0.3.0': resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} - '@atproto/api@0.17.7': - resolution: {integrity: sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw==} + '@atproto/api@0.18.10': + resolution: {integrity: sha512-q23wreAGhktrMLepulvljZWHsUOrTIDwhU3gr/uSX3R1TZIZ3i4SxQZVlMqaQHpNJ/5Xj8J1hozkwVpaOX37eA==} '@atproto/common-web@0.4.16': resolution: {integrity: sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==} - '@atproto/common-web@0.4.5': - resolution: {integrity: sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA==} - '@atproto/did@0.2.3': resolution: {integrity: sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg==} @@ -219,23 +228,17 @@ packages: '@atproto/jwk@0.6.0': resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} - '@atproto/lex-data@0.0.1': - resolution: {integrity: sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA==} - '@atproto/lex-data@0.0.11': resolution: {integrity: sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==} - '@atproto/lex-json@0.0.1': - resolution: {integrity: sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg==} - '@atproto/lex-json@0.0.11': resolution: {integrity: sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==} '@atproto/lexicon@0.5.2': resolution: {integrity: sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ==} - '@atproto/lexicon@0.6.1': - resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} + '@atproto/lexicon@0.6.0': + resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} '@atproto/oauth-client-node@0.3.12': resolution: {integrity: sha512-9ejfO1H8qo3EbiAJgxKcdcR5Ay/9hgaC5OdxtTN63bcOrkIhvBN0xpVPGZYLL1iJQyNeK1T5m/LDrv4gUS1B+g==} @@ -247,15 +250,15 @@ packages: '@atproto/oauth-types@0.5.2': resolution: {integrity: sha512-9DCDvtvCanTwAaU5UakYDO0hzcOITS3RutK5zfLytE5Y9unj0REmTDdN8Xd8YCfUJl7T/9pYpf04Uyq7bFTASg==} - '@atproto/syntax@0.4.1': - resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} - '@atproto/syntax@0.4.3': resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} '@atproto/xrpc@0.7.6': resolution: {integrity: sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA==} + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -592,8 +595,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@hypercerts-org/lexicon@0.10.0-beta.13': - resolution: {integrity: sha512-QXgHZjn6ngbcmTh3QV/T7yu2sxgeCKURUcOGcXjRoY+M9Gnld40KVsiJ+KFR5iH3sIXOCd5uuo5KAvB1sLpkSg==} + '@hypercerts-org/lexicon@0.10.0-beta.14': + resolution: {integrity: sha512-/IsVvqVScjuwRMtu/4S4FarcjHd8BkEchbMOKWOmCH1DNp0XSwh+O9h2HSnBcaMr5lMtUtghLyWSd70YsQ0N4Q==} peerDependencies: '@atproto/xrpc': '*' @@ -2048,9 +2051,6 @@ packages: resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} engines: {node: '>=18.17'} - unicode-segmenter@0.14.0: - resolution: {integrity: sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg==} - unicode-segmenter@0.14.5: resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} @@ -2225,19 +2225,19 @@ snapshots: '@atcute/atproto@3.1.10': dependencies: - '@atcute/lexicons': 1.2.7 + '@atcute/lexicons': 1.2.8 '@atcute/bluesky@3.2.17': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.7 + '@atcute/lexicons': 1.2.8 - '@atcute/leaflet@1.0.17': + '@atcute/leaflet@1.0.18': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.7 + '@atcute/lexicons': 1.2.8 - '@atcute/lexicons@1.2.7': + '@atcute/lexicons@1.2.8': dependencies: '@atcute/uint8array': 1.1.0 '@atcute/util-text': 1.1.0 @@ -2297,12 +2297,12 @@ snapshots: '@atproto-labs/simple-store@0.3.0': {} - '@atproto/api@0.17.7': + '@atproto/api@0.18.10': dependencies: - '@atproto/common-web': 0.4.5 - '@atproto/lexicon': 0.5.2 - '@atproto/syntax': 0.4.1 - '@atproto/xrpc': 0.7.6 + '@atproto/common-web': 0.4.16 + '@atproto/lexicon': 0.6.0 + '@atproto/syntax': 0.4.3 + '@atproto/xrpc': 0.7.7 await-lock: 2.2.2 multiformats: 9.9.0 tlds: 1.261.0 @@ -2315,12 +2315,6 @@ snapshots: '@atproto/syntax': 0.4.3 zod: 3.25.76 - '@atproto/common-web@0.4.5': - dependencies: - '@atproto/lex-data': 0.0.1 - '@atproto/lex-json': 0.0.1 - zod: 3.25.76 - '@atproto/did@0.2.3': dependencies: zod: 3.25.76 @@ -2341,14 +2335,6 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 - '@atproto/lex-data@0.0.1': - dependencies: - '@atproto/syntax': 0.4.1 - multiformats: 9.9.0 - tslib: 2.8.1 - uint8arrays: 3.0.0 - unicode-segmenter: 0.14.0 - '@atproto/lex-data@0.0.11': dependencies: multiformats: 9.9.0 @@ -2356,11 +2342,6 @@ snapshots: uint8arrays: 3.0.0 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.0.1': - dependencies: - '@atproto/lex-data': 0.0.1 - tslib: 2.8.1 - '@atproto/lex-json@0.0.11': dependencies: '@atproto/lex-data': 0.0.11 @@ -2368,13 +2349,13 @@ snapshots: '@atproto/lexicon@0.5.2': dependencies: - '@atproto/common-web': 0.4.5 - '@atproto/syntax': 0.4.1 + '@atproto/common-web': 0.4.16 + '@atproto/syntax': 0.4.3 iso-datestring-validator: 2.2.2 multiformats: 9.9.0 zod: 3.25.76 - '@atproto/lexicon@0.6.1': + '@atproto/lexicon@0.6.0': dependencies: '@atproto/common-web': 0.4.16 '@atproto/syntax': 0.4.3 @@ -2416,8 +2397,6 @@ snapshots: '@atproto/jwk': 0.6.0 zod: 3.25.76 - '@atproto/syntax@0.4.1': {} - '@atproto/syntax@0.4.3': dependencies: tslib: 2.8.1 @@ -2427,6 +2406,11 @@ snapshots: '@atproto/lexicon': 0.5.2 zod: 3.25.76 + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.0 + zod: 3.25.76 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2773,12 +2757,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@hypercerts-org/lexicon@0.10.0-beta.13(@atproto/xrpc@0.7.6)': + '@hypercerts-org/lexicon@0.10.0-beta.14(@atproto/xrpc@0.7.7)': dependencies: '@atcute/bluesky': 3.2.17 - '@atcute/leaflet': 1.0.17 - '@atproto/lexicon': 0.6.1 - '@atproto/xrpc': 0.7.6 + '@atcute/leaflet': 1.0.18 + '@atproto/lexicon': 0.6.0 + '@atproto/xrpc': 0.7.7 multiformats: 13.4.2 '@inquirer/external-editor@1.0.3(@types/node@24.10.1)': @@ -4191,8 +4175,6 @@ snapshots: undici@6.22.0: {} - unicode-segmenter@0.14.0: {} - unicode-segmenter@0.14.5: {} universalify@0.1.2: {} diff --git a/specs/02-repository-and-lexicons.md b/specs/02-repository-and-lexicons.md index b84c4b1e..0687ba5e 100644 --- a/specs/02-repository-and-lexicons.md +++ b/specs/02-repository-and-lexicons.md @@ -80,25 +80,65 @@ export class BlobManager { ### ProfileManager +The ProfileManager supports dual profile types: Bluesky (standard AT Protocol) and Certified (hypercerts-specific with +additional fields). + ```typescript export class ProfileManager { - async get(repo: string): Promise<{ - handle: string; - displayName?: string; - description?: string; - avatar?: string; - banner?: string; - website?: string; - }>; + // Bluesky profiles (app.bsky.actor.profile) + async getBskyProfile(): Promise; + async createBskyProfile(params: CreateBskyProfileParams): Promise; + async updateBskyProfile(params: UpdateBskyProfileParams): Promise; + + // Certified profiles (app.certified.actor.profile) + async getCertifiedProfile(): Promise; + async createCertifiedProfile(params: CreateCertifiedProfileParams): Promise; + async updateCertifiedProfile(params: UpdateCertifiedProfileParams): Promise; +} - async update(params: { - repo: string; - displayName?: string | null; - description?: string | null; - avatar?: Blob | null; - banner?: Blob | null; - website?: string | null; - }): Promise<{ uri: string; cid: string }>; +// Types +export type BskyProfile = AppBskyActorDefs.ProfileViewDetailed; + +export interface CertifiedProfile { + handle?: string; + displayName?: string; + description?: string; + avatar?: string; // Blob URL + banner?: string; // Blob URL + pronouns?: string; // Max 20 graphemes + website?: string; +} + +export interface CreateBskyProfileParams { + displayName?: string; + description?: string; + avatar?: Blob; + banner?: Blob; +} + +export interface UpdateBskyProfileParams { + displayName?: string | null; + description?: string | null; + avatar?: Blob | null; + banner?: Blob | null; +} + +export interface CreateCertifiedProfileParams { + displayName?: string; + description?: string; + avatar?: Blob; + banner?: Blob; + pronouns?: string; + website?: string; +} + +export interface UpdateCertifiedProfileParams { + displayName?: string | null; + description?: string | null; + avatar?: Blob | null; + banner?: Blob | null; + pronouns?: string | null; + website?: string | null; } ``` diff --git a/specs/04-react-and-clients.md b/specs/04-react-and-clients.md index 0854a4c6..271f3666 100644 --- a/specs/04-react-and-clients.md +++ b/specs/04-react-and-clients.md @@ -95,19 +95,39 @@ interface UseRepositoryResult { ### `useProfile` -Profile read + write combined in a single hook. +Profile read + write combined in a single hook. Returns Certified profiles by default, which include hypercerts-specific +fields like `pronouns` and `website`. ```typescript interface UseProfileResult { - profile: Profile | null; + profile: CertifiedProfile | null; // Returns Certified profile isLoading: boolean; error: Error | null; - update(params: ProfileUpdate): Promise; + update(params: UpdateCertifiedProfileParams): Promise; isUpdating: boolean; refetch(): Promise; } + +interface CertifiedProfile { + handle?: string; + displayName?: string; + description?: string; + avatar?: string; // Blob URL + banner?: string; // Blob URL + pronouns?: string; // Max 20 graphemes + website?: string; +} + +interface UpdateCertifiedProfileParams { + displayName?: string | null; + description?: string | null; + avatar?: Blob | null; + banner?: Blob | null; + pronouns?: string | null; + website?: string | null; +} ``` **Usage**: @@ -117,14 +137,28 @@ function ProfileEditor() { const { profile, update, isUpdating } = useProfile(); return ( -
update({ displayName: newName })}> + update({ + displayName: newName, + pronouns: newPronouns, + website: newWebsite + })}> + +
); } ``` +**Note**: The `useProfile` hook returns Certified profiles (`app.certified.actor.profile`) which include +hypercerts-specific fields. For Bluesky profiles, use the core SDK directly: + +```typescript +const repo = useRepository(); +const bskyProfile = await repo.profile.getBskyProfile(); +``` + ### `useOrganizations` / `useOrganization` SDS organization management with both list and singular variants. diff --git a/specs/06-sdk-api-refinement.md b/specs/06-sdk-api-refinement.md index e34cee90..ba052b6e 100644 --- a/specs/06-sdk-api-refinement.md +++ b/specs/06-sdk-api-refinement.md @@ -319,13 +319,14 @@ await collaborators.grant({ repo, userDid, ... }); // Object await records.create({ repo, collection, ... }); // Object with redundant repo // AFTER: Consistent (repo implicit) -await repo.profile.get(); // No params needed +await repo.profile.getBskyProfile(); // No params needed +await repo.profile.getCertifiedProfile(); // Hypercerts-specific await repo.collaborators.list(); // No params needed await repo.collaborators.grant({ userDid, role }); // Object, no repo await repo.records.create({ collection, record }); // Object, no repo // For operations on different DID -await repo.repo(otherDid).profile.get(); +await repo.repo(otherDid).profile.getBskyProfile(); ``` ## 5. Smart Defaults From eef71f53701b7ac86eea7809ee798b9e095d1a1e Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 13 Feb 2026 12:17:56 +0600 Subject: [PATCH 2/5] fix: remove use of blobToJsonRef and directly use blobRef instead; This has been done because blobToJsonRef is very flaky. And is an unnecessary step since we can directly use the blobs returned from AtProto instead. This makes the code easier to maintain and also we dont have to manually construct json of blobs which are not only inaccurate but are also wrong in some cases; --- .../src/repository/HypercertOperationsImpl.ts | 40 +++---- .../src/repository/ProfileOperationsImpl.ts | 3 +- .../sdk-core/src/repository/interfaces.ts | 14 ++- .../HypercertOperationsImpl.test.ts | 108 ++++++++++++------ .../repository/ProfileOperationsImpl.test.ts | 31 +++++ 5 files changed, 136 insertions(+), 60 deletions(-) diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index ee91cdd3..da4e2f32 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -163,10 +163,7 @@ export class HypercertOperationsImpl extends EventEmitter imple * @throws {@link NetworkError} if upload fails * @internal */ - private async uploadImageBlob( - image: Blob, - onProgress?: (step: ProgressStep) => void, - ): Promise { + private async uploadImageBlob(image: Blob, onProgress?: (step: ProgressStep) => void) { this.emitProgress(onProgress, { name: "uploadImage", status: "start" }); try { const uploadResult = await this.blobs.upload(image); @@ -175,7 +172,7 @@ export class HypercertOperationsImpl extends EventEmitter imple status: "success", data: { size: image.size }, }); - return this.blobToJsonRef(uploadResult); + return uploadResult; } catch (error) { this.emitProgress(onProgress, { name: "uploadImage", status: "error", error: error as Error }); throw new NetworkError(`Failed to upload image: ${error instanceof Error ? error.message : "Unknown"}`, error); @@ -272,7 +269,7 @@ export class HypercertOperationsImpl extends EventEmitter imple params: CreateHypercertParams, rightsUri: string, rightsCid: string, - imageBlobRef: JsonBlobRef | undefined, + imageBlobRef: BlobRef | undefined, locationRefs: Array<{ uri: string; cid: string }> | undefined, contributorsData: | Array<{ @@ -298,7 +295,10 @@ export class HypercertOperationsImpl extends EventEmitter imple }; if (imageBlobRef) { - hypercertRecord.image = imageBlobRef; + hypercertRecord.image = { + $type: "org.hypercerts.defs#smallImage", + image: imageBlobRef, + }; } // Add locations as embedded StrongRefs if provided @@ -336,15 +336,10 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`); } - // JsonBlobRef can have ref (CID object) or cid (string in existing record) - // Image: extract CID string from blob ref (stable content hash) + // if its a blob ref guarateed to have ref and a .toString method let imageRef: string | undefined; if (imageBlobRef) { - if ("ref" in imageBlobRef && imageBlobRef.ref) { - imageRef = typeof imageBlobRef.ref === "string" ? imageBlobRef.ref : imageBlobRef.ref.toString(); - } else if ("cid" in imageBlobRef) { - imageRef = imageBlobRef.cid; - } + imageRef = imageBlobRef.ref.toString(); } // Generate rKey from stable content hash (idempotency) @@ -675,7 +670,10 @@ export class HypercertOperationsImpl extends EventEmitter imple // Remove image } else { const uploadResult = await this.blobs.upload(params.image); - recordForUpdate.image = this.blobToJsonRef(uploadResult); + recordForUpdate.image = { + $type: "org.hypercerts.defs#smallImage", + image: uploadResult, + }; } } else if (existingRecord.image) { // Preserve existing image @@ -922,10 +920,9 @@ export class HypercertOperationsImpl extends EventEmitter imple } const uploadResult = await this.blobs.upload(content); - const jsonBlobRef = this.blobToJsonRef(uploadResult); return { $type: "org.hypercerts.defs#smallBlob" as const, - blob: jsonBlobRef, + blob: uploadResult, }; } @@ -940,12 +937,11 @@ export class HypercertOperationsImpl extends EventEmitter imple } const uploadResult = await this.blobs.upload(input); - const jsonBlobRef = this.blobToJsonRef(uploadResult); if (isBanner) { - return { $type: "org.hypercerts.defs#largeImage" as const, image: jsonBlobRef }; + return { $type: "org.hypercerts.defs#largeImage" as const, image: uploadResult }; } - return { $type: "org.hypercerts.defs#smallImage" as const, image: jsonBlobRef }; + return { $type: "org.hypercerts.defs#smallImage" as const, image: uploadResult }; } /** @@ -1400,7 +1396,7 @@ export class HypercertOperationsImpl extends EventEmitter imple }; // Handle image upload if it's a Blob - let imageRef: JsonBlobRef | string | undefined; + let imageRef: BlobRef | string | undefined; if (image instanceof Blob) { const uploadResult = await this.uploadImageBlob(image, onProgress); imageRef = uploadResult; @@ -1596,7 +1592,7 @@ export class HypercertOperationsImpl extends EventEmitter imple async addContributorInformation(params: { identifier: string; displayName?: string; - image?: JsonBlobRef | string; + image?: BlobRef | string; [key: string]: unknown; }): Promise { try { diff --git a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts index 0aa94677..9d429237 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -12,6 +12,7 @@ import type { Agent, AppBskyActorDefs } from "@atproto/api"; 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 { @@ -108,7 +109,7 @@ export class ProfileOperationsImpl implements ProfileOperations { if (!result) { throw new Error("Unable to extract CID or URI from image record"); } - if (result.startsWith("http://") || result.startsWith("https://")) { + if (isValidUri(result)) { return result; } diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index 08bf6faa..e6ec86d3 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -700,8 +700,13 @@ export type CreateBskyProfileParams = OverrideProperties< { avatar?: Blob; banner?: Blob } >; +/** + * Parameters for updating Bluesky profile (app.bsky.actor.profile). + * All fields are optional and nullable - pass null to delete a field. + * System fields ($type, createdAt) cannot be modified. + */ export type UpdateBskyProfileParams = OverrideProperties< - Nullable>, + Nullable>, { avatar?: Blob | null; banner?: Blob | null } >; @@ -715,8 +720,13 @@ export type CreateCertifiedProfileParams = OverrideProperties< { avatar?: Blob; banner?: Blob } >; +/** + * Parameters for updating Certified profile (app.certified.actor.profile). + * All fields are optional and nullable - pass null to delete a field. + * System fields ($type, createdAt) cannot be modified. + */ export type UpdateCertifiedProfileParams = OverrideProperties< - Nullable>, + Nullable>, { avatar?: Blob | null; banner?: Blob | null } >; diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index 6d9c9870..499aa3f6 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Agent } from "@atproto/api"; +import { BlobRef, type Agent } from "@atproto/api"; import { HypercertOperationsImpl } from "../../src/repository/HypercertOperationsImpl.js"; import { NetworkError, ValidationError } from "../../src/core/errors.js"; import type { @@ -110,9 +110,10 @@ describe("HypercertOperationsImpl", () => { expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(2); }); - it("should upload image and include in hypercert", async () => { + it("should upload image and include in hypercert with correct wrapper", async () => { const imageBlob = new Blob(["image data"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue(createMockBlobRef()); + const mockBlobRef = createMockBlobRef({ mimeType: "image/png", size: 1024 }); + mockBlobs.upload.mockResolvedValue(mockBlobRef); await hypercertOps.create({ ...validParams, @@ -120,6 +121,15 @@ describe("HypercertOperationsImpl", () => { }); expect(mockBlobs.upload).toHaveBeenCalledWith(imageBlob); + + // Verify the hypercert record (second createRecord call) + const hypercertCall = mockAgent.com.atproto.repo.createRecord.mock.calls[1][0]; + expect(hypercertCall.record.image).toEqual({ + $type: "org.hypercerts.defs#smallImage", + image: mockBlobRef, + }); + expect(hypercertCall.record.image.$type).toBe("org.hypercerts.defs#smallImage"); + expect(hypercertCall.record.image.image).toBeInstanceOf(BlobRef); }); it("should include shortDescription when provided", async () => { @@ -895,9 +905,10 @@ describe("HypercertOperationsImpl", () => { expect(putCall.record.rights).toEqual({ uri: "at://rights", cid: "rights-cid" }); }); - it("should upload new image", async () => { + it("should upload new image with correct wrapper", async () => { const imageBlob = new Blob(["new image"], { type: "image/png" }); - mockBlobs.upload.mockResolvedValue(createMockBlobRef()); + const mockBlobRef = createMockBlobRef({ mimeType: "image/png", size: 2048 }); + mockBlobs.upload.mockResolvedValue(mockBlobRef); await hypercertOps.update({ uri: "at://did:plc:test/org.hypercerts.claim.record/abc123", @@ -906,6 +917,15 @@ describe("HypercertOperationsImpl", () => { }); expect(mockBlobs.upload).toHaveBeenCalledWith(imageBlob); + + // Verify the putRecord call + const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(putCall.record.image).toEqual({ + $type: "org.hypercerts.defs#smallImage", + image: mockBlobRef, + }); + expect(putCall.record.image.$type).toBe("org.hypercerts.defs#smallImage"); + expect(putCall.record.image.image).toBeInstanceOf(BlobRef); }); it("should remove image when set to null", async () => { @@ -1037,16 +1057,14 @@ describe("HypercertOperationsImpl", () => { // Check the location record that was created const call = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(call.record.location).toMatchObject({ - $type: "org.hypercerts.defs#smallBlob", // Your code wraps it in smallBlob - blob: { - // The actual blob data is nested here - $type: "blob", - mimeType: "application/geo+json", - size: 100, - }, - }); - // Check that ref exists and has the right structure + + // Verify the location wrapper type + expect(call.record.location.$type).toBe("org.hypercerts.defs#smallBlob"); + + // Verify blob is a BlobRef instance with correct properties + expect(call.record.location.blob).toBeInstanceOf(BlobRef); + expect(call.record.location.blob.mimeType).toBe("application/geo+json"); + expect(call.record.location.blob.size).toBe(100); expect(call.record.location.blob.ref).toBeDefined(); }); @@ -1683,10 +1701,14 @@ describe("HypercertOperationsImpl", () => { // Verify content array with blob expect(call.record.content).toHaveLength(1); - expect(call.record.content[0]).toMatchObject({ - $type: "org.hypercerts.defs#smallBlob", - blob: { $type: "blob", mimeType: "application/pdf", size: 100 }, - }); + + // Verify content wrapper type + expect(call.record.content[0].$type).toBe("org.hypercerts.defs#smallBlob"); + + // Verify blob is a BlobRef instance with correct properties + expect(call.record.content[0].blob).toBeInstanceOf(BlobRef); + expect(call.record.content[0].blob.mimeType).toBe("application/pdf"); + expect(call.record.content[0].blob.size).toBe(100); expect(call.record.content[0].blob.ref).toBeDefined(); }); @@ -1742,10 +1764,14 @@ describe("HypercertOperationsImpl", () => { $type: "org.hypercerts.defs#uri", uri: "https://example.com/report.pdf", }); - expect(call.record.content[1]).toMatchObject({ - $type: "org.hypercerts.defs#smallBlob", - blob: { $type: "blob", mimeType: "application/pdf", size: 100 }, - }); + + // Verify blob content wrapper type + expect(call.record.content[1].$type).toBe("org.hypercerts.defs#smallBlob"); + + // Verify blob is a BlobRef instance with correct properties + expect(call.record.content[1].blob).toBeInstanceOf(BlobRef); + expect(call.record.content[1].blob.mimeType).toBe("application/pdf"); + expect(call.record.content[1].blob.size).toBe(100); expect(call.record.content[1].blob.ref).toBeDefined(); }); @@ -3167,10 +3193,14 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.avatar).toMatchObject({ - $type: "org.hypercerts.defs#smallImage", - image: { $type: "blob", mimeType: "image/png", size: 100 }, - }); + + // Verify wrapper type + expect(createCall.record.avatar.$type).toBe("org.hypercerts.defs#smallImage"); + + // Verify image is a BlobRef instance with correct properties + expect(createCall.record.avatar.image).toBeInstanceOf(BlobRef); + expect(createCall.record.avatar.image.mimeType).toBe("image/png"); + expect(createCall.record.avatar.image.size).toBe(100); expect(createCall.record.avatar.image.ref).toBeDefined(); }); @@ -3186,10 +3216,14 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(bannerBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.banner).toMatchObject({ - $type: "org.hypercerts.defs#largeImage", - image: { $type: "blob", mimeType: "image/jpeg", size: 200 }, - }); + + // Verify wrapper type + expect(createCall.record.banner.$type).toBe("org.hypercerts.defs#largeImage"); + + // Verify image is a BlobRef instance with correct properties + expect(createCall.record.banner.image).toBeInstanceOf(BlobRef); + expect(createCall.record.banner.image.mimeType).toBe("image/jpeg"); + expect(createCall.record.banner.image.size).toBe(200); expect(createCall.record.banner.image.ref).toBeDefined(); }); @@ -3690,10 +3724,14 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(newAvatar); const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.avatar).toMatchObject({ - $type: "org.hypercerts.defs#smallImage", - image: { $type: "blob", mimeType: "image/png", size: 150 }, - }); + + // Verify wrapper type + expect(putCall.record.avatar.$type).toBe("org.hypercerts.defs#smallImage"); + + // Verify image is a BlobRef instance with correct properties + expect(putCall.record.avatar.image).toBeInstanceOf(BlobRef); + expect(putCall.record.avatar.image.mimeType).toBe("image/png"); + expect(putCall.record.avatar.image.size).toBe(150); expect(putCall.record.avatar.image.ref).toBeDefined(); }); diff --git a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts index b7843071..9f3e90a9 100644 --- a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts @@ -151,6 +151,37 @@ describe("ProfileOperationsImpl", () => { expect(result!.displayName).toBe("Test"); }); + it("should pass through non-http URI schemes unchanged (e.g., ipfs://)", async () => { + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "test.bsky.social" }, + }); + + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + value: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Test", + avatar: { + $type: "org.hypercerts.defs#uri", + uri: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + }, + banner: { + $type: "org.hypercerts.defs#uri", + uri: "ar://some-arweave-tx-id", + }, + }, + }, + }); + + const result = await profileOps.getCertifiedProfile(); + expect(result).not.toBeNull(); + expect(result!.avatar).toBe("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + expect(result!.banner).toBe("ar://some-arweave-tx-id"); + }); + it("should return empty string for handle if getProfile fails", async () => { mockAgent.getProfile!.mockRejectedValue(new Error("Network error")); From aeb1bbcc3f5b0fa9ae955f7efd24f79ea8f33135 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 16 Feb 2026 15:33:07 +0600 Subject: [PATCH 3/5] refactor(sdk-core): remove deprecated profile types and JsonBlobRef export Remove deprecated Hypercert*Profile* type aliases in favor of Certified*Profile* types: - Remove HypercertProfile (use CertifiedProfileRecord) - Remove CreateHypercertProfileParams (use CreateCertifiedProfileParams) - Remove UpdateHypercertProfileParams (use UpdateCertifiedProfileParams) - Remove HypercertProfileParams union type - Remove CreateProfileParams alias Remove JsonBlobRef type export: - JsonBlobRef was too "snowflaky" - required unnecessary BlobRef to JSON conversion - BlobRef instances work perfectly fine for all use cases - Removed flaky test patterns with mock JSON objects - Users can import from @atproto/lexicon if needed Additional changes: - Update exports in src/index.ts and src/types.ts - Remove blobToJsonRef() method from HypercertOperationsImpl - Fix typo in ProfileOperationsImpl comment - Update changeset with migration guide and rationale BREAKING CHANGE: Users must update imports to use new type names --- .changeset/profile-certified-lexicon.md | 27 ++++++- packages/sdk-core/src/index.ts | 7 +- .../src/repository/BlobOperationsImpl.ts | 11 +-- .../src/repository/HypercertOperationsImpl.ts | 16 +--- .../src/repository/ProfileOperationsImpl.ts | 2 +- .../sdk-core/src/services/hypercerts/types.ts | 80 ------------------- packages/sdk-core/src/types.ts | 8 +- 7 files changed, 40 insertions(+), 111 deletions(-) diff --git a/.changeset/profile-certified-lexicon.md b/.changeset/profile-certified-lexicon.md index ff2c8913..0285b42e 100644 --- a/.changeset/profile-certified-lexicon.md +++ b/.changeset/profile-certified-lexicon.md @@ -16,6 +16,22 @@ The profile API has been completely redesigned to support two profile types: - ❌ `profile.create(params)` - ❌ `profile.update(params)` +- **Removed deprecated profile types:** + - ❌ `HypercertProfile` - Use `CertifiedProfileRecord` instead + - ❌ `CreateHypercertProfileParams` - Use `CreateCertifiedProfileParams` instead + - ❌ `UpdateHypercertProfileParams` - Use `UpdateCertifiedProfileParams` instead + - ❌ `HypercertProfileParams` - Use specific create/update types instead + - ❌ `CreateProfileParams` - Use `CreateCertifiedProfileParams` instead + +- **Removed unused type export:** + - ❌ `JsonBlobRef` - No longer used in SDK. This type was removed because: + - It was too "snowflaky" - required converting `BlobRef` instances to JSON format unnecessarily + - The actual `BlobRef` object works perfectly fine for all use cases + - It created flaky tests where we manually created mock JSON objects that weren't representative of actual + `JsonBlobRef` values + - Internal implementation now uses `BlobRef` instances directly throughout + - Users who need this type for advanced use cases can import it directly from `@atproto/lexicon` + - **Added profile-specific methods:** - ✅ `profile.getBskyProfile()` - Get Bluesky profile (app.bsky.actor.profile) - ✅ `profile.createBskyProfile(params)` - Create Bluesky profile @@ -51,8 +67,10 @@ The profile API has been completely redesigned to support two profile types: - **New types:** - `BskyProfile` - Type for Bluesky profiles (alias for `AppBskyActorDefs.ProfileViewDetailed`) - `CertifiedProfile` - Type for Certified profiles + - `CertifiedProfileRecord` - Record type for Certified profiles (replaces `HypercertProfile`) - `CreateBskyProfileParams`, `UpdateBskyProfileParams` - - `CreateCertifiedProfileParams`, `UpdateCertifiedProfileParams` + - `CreateCertifiedProfileParams`, `UpdateCertifiedProfileParams` (replace `CreateHypercertProfileParams`, + `UpdateHypercertProfileParams`) **Migration Guide:** @@ -89,6 +107,13 @@ if (profile) { } else { console.log("User hasn't created a profile yet"); } + +// Type migrations +import type { + CertifiedProfileRecord, // was: HypercertProfile + CreateCertifiedProfileParams, // was: CreateHypercertProfileParams + UpdateCertifiedProfileParams, // was: UpdateHypercertProfileParams +} from "@hypercerts-org/sdk-core/types"; ``` **React SDK (`@hypercerts-org/sdk-react`):** diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 4658f8ab..e6f25af6 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -100,6 +100,9 @@ export type { CreateContributionDetailsParams, ContributorIdentityParams, CreateContributorInformationParams, + ResolvedContributorIdentity, + CreateCertifiedProfileParams, + UpdateCertifiedProfileParams, } from "./repository/interfaces.js"; // ============================================================================ @@ -161,6 +164,7 @@ export type { BadgeDefinition, BadgeResponse, FundingReceipt, + CertifiedProfileRecord, // SDK-specific types HypercertAttachment, HypercertImage, @@ -176,9 +180,6 @@ export type { AttachmentParams, } from "./services/hypercerts/types.js"; -// Re-export ATProto lexicon types -export type { JsonBlobRef } from "./services/hypercerts/types.js"; - // Errors export { ATProtoSDKError, diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index b51e8a2c..f4c1e597 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -154,10 +154,7 @@ export class BlobOperationsImpl implements BlobOperations { * @throws {@link NetworkError} if the upload fails * @internal */ - private async uploadViaSDS( - data: Uint8Array, - encoding: string, - ): Promise { + private async uploadViaSDS(data: Uint8Array, encoding: string): Promise { const url = `/xrpc/com.sds.repo.uploadBlob?repo=${encodeURIComponent(this.repoDid)}`; const response = await this.agent.fetchHandler(url, { method: "POST", @@ -171,9 +168,9 @@ export class BlobOperationsImpl implements BlobOperations { throw new NetworkError(`SDS blob upload failed: ${response.statusText}`); } - const result = (await response.json()) - - return result.blob + const result = await response.json(); + + return result.blob; } /** diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index da4e2f32..ce86c1aa 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -34,7 +34,6 @@ import { type HypercertLocation, type HypercertMeasurement, type HypercertRights, - type JsonBlobRef, type OrgHypercertsDefs, type RefUri, type StrongRef, @@ -143,17 +142,6 @@ export class HypercertOperationsImpl extends EventEmitter imple } } - /** - * Converts BlobRef to JsonBlobRef format. - * - * @param blobRef - BlobRef from blob upload - * @returns JsonBlobRef formatted for records - * @internal - */ - private blobToJsonRef(blobRef: BlobRef): JsonBlobRef { - return blobRef.ipld(); - } - /** * Uploads an image blob and returns a blob reference. * @@ -336,7 +324,7 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`); } - // if its a blob ref guarateed to have ref and a .toString method + // if its a blob ref guaranteed to have ref and a .toString method let imageRef: string | undefined; if (imageBlobRef) { imageRef = imageBlobRef.ref.toString(); @@ -1606,7 +1594,7 @@ export class HypercertOperationsImpl extends EventEmitter imple // URI string - wrap in typed object resolvedImage = { $type: "org.hypercerts.defs#uri", uri: image } as HypercertContributorInformation["image"]; } else { - // JsonBlobRef from upload - wrap in smallImage + // BlobRef from upload - wrap in smallImage resolvedImage = { $type: "org.hypercerts.defs#smallImage", image: image, diff --git a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts index 9d429237..860348d5 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -328,7 +328,7 @@ export class ProfileOperationsImpl implements ProfileOperations { }; for (const [key, value] of Object.entries(otherFields)) { - // ignotre these since profile already created + // ignore these since profile has already created if (["$type", "createdAt"].includes(key)) continue; if (value === null) { delete updatedProfile[key]; diff --git a/packages/sdk-core/src/services/hypercerts/types.ts b/packages/sdk-core/src/services/hypercerts/types.ts index 8f45c633..64b5a057 100644 --- a/packages/sdk-core/src/services/hypercerts/types.ts +++ b/packages/sdk-core/src/services/hypercerts/types.ts @@ -8,7 +8,6 @@ // Re-export BlobRef from ATProto lexicon export { BlobRef } from "@atproto/lexicon"; -export type { JsonBlobRef } from "@atproto/lexicon"; import type { Except, OverrideProperties, SetOptional } from "type-fest"; // Re-export everything from lexicon package @@ -190,86 +189,12 @@ export type HypercertEvaluation = OrgHypercertsClaimEvaluation.Main; export type HypercertAttachment = OrgHypercertsClaimAttachment.Main; export type HypercertCollection = OrgHypercertsClaimCollection.Main; -/** - * @deprecated Use CertifiedProfileRecord instead. This type alias will be removed in a future version. - */ -export type HypercertProfile = AppCertifiedActorProfile.Main; - /** * Certified actor profile record (app.certified.actor.profile). * Extended profile with additional fields beyond Bluesky profiles. */ export type CertifiedProfileRecord = AppCertifiedActorProfile.Main; -// ============================================================================ -// Profile SDK Input Types -// ============================================================================ - -/** - * SDK input parameters for creating a hypercert profile. - * - * Derived from lexicon type with $type and createdAt optional. - * avatar/banner accept HypercertImage (string URI or Blob) for user convenience. - * - * @example Basic profile creation - * ```typescript - * const params: CreateHypercertProfileParams = { - * displayName: "Alice", - * description: "Building impact certificates", - * pronouns: "she/her", - * website: "https://example.com", - * }; - * ``` - * - * @example Profile with avatar - * ```typescript - * const avatarBlob = new Blob([avatarData], { type: "image/png" }); - * const params: CreateHypercertProfileParams = { - * displayName: "Alice", - * avatar: avatarBlob, - * }; - * ``` - */ -export type CreateHypercertProfileParams = OverrideProperties< - SetOptional, - { - avatar?: HypercertImage; - banner?: HypercertImage; - } ->; - -/** - * SDK input parameters for updating a hypercert profile. - * All fields optional. Pass null to remove a field. - * - * @example Update display name - * ```typescript - * const params: UpdateHypercertProfileParams = { - * displayName: "New Name", - * }; - * ``` - * - * @example Remove a field - * ```typescript - * const params: UpdateHypercertProfileParams = { - * description: null, // Removes description - * }; - * ``` - */ -export type UpdateHypercertProfileParams = OverrideProperties< - Partial, - { - avatar?: HypercertImage | null; - banner?: HypercertImage | null; - } ->; - -/** - * Union type for all profile parameter variants. - * Used when a function can accept either create or update params. - */ -export type HypercertProfileParams = CreateHypercertProfileParams | UpdateHypercertProfileParams; - /** * Collection item with optional weight. * @@ -775,9 +700,4 @@ export type UpdateAttachmentParams = Partial; */ export type AttachmentParams = RefUri | CreateAttachmentParams; -/** - * @deprecated Use CreateCertifiedProfileParams instead. This type will be removed in a future version. - */ -export type CreateProfileParams = SetOptional; - export type CreateCertifiedProfileParams = SetOptional; diff --git a/packages/sdk-core/src/types.ts b/packages/sdk-core/src/types.ts index 60f54cef..48161f5b 100644 --- a/packages/sdk-core/src/types.ts +++ b/packages/sdk-core/src/types.ts @@ -64,6 +64,8 @@ export type { OrganizationOperations, CreateHypercertParams, CreateHypercertResult, + CreateCertifiedProfileParams, + UpdateCertifiedProfileParams, } from "./repository/interfaces.js"; // Hypercert types @@ -84,13 +86,9 @@ export type { HypercertWithMetadata, HypercertProject, HypercertProjectWithMetadata, - HypercertProfile, - CreateHypercertProfileParams, - UpdateHypercertProfileParams, - HypercertProfileParams, + CertifiedProfileRecord, CreateProjectParams, UpdateProjectParams, - JsonBlobRef, } from "./services/hypercerts/types.js"; // BlobRef is a class, not just a type From 19a2ebb18433bb8fee0f5cfc3bb8b08b9a19f254 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 00:56:25 +0000 Subject: [PATCH 4/5] fix: remove references to non-existent ResolvedContributor* types and uploadResultToBlobRef --- packages/sdk-core/src/index.ts | 1 - packages/sdk-core/src/repository/HypercertOperationsImpl.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index e6f25af6..03abcd9f 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -100,7 +100,6 @@ export type { CreateContributionDetailsParams, ContributorIdentityParams, CreateContributorInformationParams, - ResolvedContributorIdentity, CreateCertifiedProfileParams, UpdateCertifiedProfileParams, } from "./repository/interfaces.js"; diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index ce86c1aa..4f685d3b 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -51,11 +51,8 @@ import type { HypercertEvents, HypercertOperations, LocationParams, - ResolvedContributionDetails, - ResolvedContributorIdentity, } from "./interfaces.js"; import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js"; -import { uploadResultToBlobRef } from "./types.js"; import { parseAtUri, isValidAtUri, type AtUriComponents } from "../lexicons/utils.js"; /** From aaddcb6bbc4738279eb9d93d5894b4c380908bbc Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 18 Feb 2026 00:59:13 +0000 Subject: [PATCH 5/5] fix(sdk-core): remove unused StrongRef import; document lint-before-push requirement --- AGENTS.md | 88 ++++++++++++++++++- .../sdk-core/src/repository/interfaces.ts | 1 - 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f229df0d..682c8c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,14 +398,17 @@ The repository has Git hooks that ensure code quality: cd packages/sdk-core pnpm test -# 3. Verify the build works (from repo root) +# 3. Run linting (from repo root) cd ../.. +pnpm lint + +# 4. Verify the build works (from repo root) pnpm build -# 4. Stage changes +# 5. Stage changes git add -# 5. Commit (hooks will run automatically) +# 6. Commit (hooks will run automatically) git commit -m "feat(hypercerts): add project CRUD operations" # The pre-commit hook will: @@ -413,12 +416,25 @@ git commit -m "feat(hypercerts): add project CRUD operations" # - Warn if changeset is needed # - Block commit if build fails -# 6. If prompted, add changeset for user-facing changes +# 7. If prompted, add changeset for user-facing changes # See "Release Process" section - use the writing-changesets skill git add .changeset/*.md git commit -m "chore: add changeset for project operations" ``` +### Pre-push Verification Checklist + +**IMPORTANT: Before pushing, always run all three quality gates to match CI:** + +```bash +pnpm test # All tests pass +pnpm lint # No lint errors (unused imports, etc.) +pnpm build # Build succeeds +``` + +Skipping `pnpm lint` is a common mistake that leads to CI failures, since `pnpm build` and `pnpm test` do not catch +ESLint errors like unused imports or variables. + ## Key Files Reference ### sdk-core @@ -503,3 +519,67 @@ git commit -m "chore: add changeset for project operations" - NEVER stop before pushing - that leaves work stranded locally - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + + + +--- + +## Beads Workflow Integration + +This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are +stored in `.beads/` and tracked in git. + +### Essential Commands + +```bash +# View issues (launches TUI - avoid in automated sessions) +bv + +# CLI commands for agents (use these instead) +bd ready # Show issues ready to work (no blockers) +bd list --status=open # All open issues +bd show # Full issue details with dependencies +bd create --title="..." --type=task --priority=2 +bd update --status=in_progress +bd close --reason="Completed" +bd close # Close multiple issues at once +bd sync # Commit and push changes +``` + +### Workflow Pattern + +1. **Start**: Run `bd ready` to find actionable work +2. **Claim**: Use `bd update --status=in_progress` +3. **Work**: Implement the task +4. **Complete**: Use `bd close ` +5. **Sync**: Always run `bd sync` at session end + +### Key Concepts + +- **Dependencies**: Issues can block other issues. `bd ready` shows only unblocked work. +- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words) +- **Types**: task, bug, feature, epic, question, docs +- **Blocking**: `bd dep add ` to add dependencies + +### Session Protocol + +**Before ending any session, run this checklist:** + +```bash +git status # Check what changed +git add # Stage code changes +bd sync # Commit beads changes +git commit -m "..." # Commit code +bd sync # Commit any new beads changes +git push # Push to remote +``` + +### Best Practices + +- Check `bd ready` at session start to find available work +- Update status as you work (in_progress → closed) +- Create new issues with `bd create` when you discover tasks +- Use descriptive titles and set appropriate priority/type +- Always `bd sync` before ending session + + diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index e6ec86d3..7d239daa 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -22,7 +22,6 @@ import type { HypercertCollection, LocationParams, RefUri, - StrongRef, UpdateCollectionParams, UpdateMeasurementParams, UpdateProjectParams,