diff --git a/.changeset/profile-certified-lexicon.md b/.changeset/profile-certified-lexicon.md new file mode 100644 index 00000000..0285b42e --- /dev/null +++ b/.changeset/profile-certified-lexicon.md @@ -0,0 +1,134 @@ +--- +"@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)` + +- **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 + - ✅ `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 + - `CertifiedProfileRecord` - Record type for Certified profiles (replaces `HypercertProfile`) + - `CreateBskyProfileParams`, `UpdateBskyProfileParams` + - `CreateCertifiedProfileParams`, `UpdateCertifiedProfileParams` (replace `CreateHypercertProfileParams`, + `UpdateHypercertProfileParams`) + +**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"); +} + +// 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`):** + +**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/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/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..03abcd9f 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, @@ -97,6 +100,8 @@ export type { CreateContributionDetailsParams, ContributorIdentityParams, CreateContributorInformationParams, + CreateCertifiedProfileParams, + UpdateCertifiedProfileParams, } from "./repository/interfaces.js"; // ============================================================================ @@ -158,6 +163,7 @@ export type { BadgeDefinition, BadgeResponse, FundingReceipt, + CertifiedProfileRecord, // SDK-specific types HypercertAttachment, HypercertImage, @@ -173,9 +179,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/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..f4c1e597 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( @@ -158,10 +154,7 @@ export class BlobOperationsImpl implements BlobOperations { * @throws {@link NetworkError} if the upload fails * @internal */ - private async uploadViaSDS( - data: Uint8Array, - encoding: string, - ): Promise<{ ref: { $link: string }; mimeType: string; size: number }> { + 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", @@ -175,21 +168,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; + const result = await response.json(); - return { - ref: { $link: ref }, - mimeType: result.blob.mimeType, - size: result.blob.size, - }; + return result.blob; } /** diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index f32194e4..4f685d3b 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -8,53 +8,51 @@ * @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, type OrgHypercertsDefs, 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, } 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"; /** @@ -141,17 +139,6 @@ export class HypercertOperationsImpl extends EventEmitter imple } } - /** - * Converts a blob upload result to JsonBlobRef format. - * - * @param uploadResult - Result from BlobOperations.upload() - * @returns JsonBlobRef formatted for records - * @internal - */ - private blobToJsonRef(uploadResult: { ref: { $link: string }; mimeType: string; size: number }): JsonBlobRef { - return uploadResultToBlobRef(uploadResult); - } - /** * Uploads an image blob and returns a blob reference. * @@ -161,10 +148,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); @@ -173,7 +157,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); @@ -270,7 +254,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<{ @@ -296,7 +280,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 @@ -334,6 +321,12 @@ export class HypercertOperationsImpl extends EventEmitter imple throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`); } + // if its a blob ref guaranteed to have ref and a .toString method + let imageRef: string | undefined; + if (imageBlobRef) { + imageRef = imageBlobRef.ref.toString(); + } + // 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 +339,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, @@ -670,7 +655,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 @@ -1393,7 +1381,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; @@ -1589,7 +1577,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 { @@ -1603,7 +1591,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, @@ -1830,7 +1818,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 +2018,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..860348d5 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -1,55 +1,75 @@ /** - * 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 { isValidUri } from "../lib/url-utils.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 +81,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 +89,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 (isValidUri(result)) { + 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)) { + // ignore these since profile has 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..7d239daa 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -8,22 +8,23 @@ * @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, UpdateCollectionParams, - UpdateProjectParams, - CreateMeasurementParams, UpdateMeasurementParams, - CreateAttachmentParams, - RefUri, + UpdateProjectParams, } from "../services/hypercerts/types.js"; import type { CreateResult, @@ -35,6 +36,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 +621,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 +649,241 @@ 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); * - * // Update profile - * await repo.profile.update({ + * // Get Certified profile (with hypercerts fields) + * const certProfile = await repo.profile.getCertifiedProfile(); + * console.log(certProfile.pronouns); // "she/her" + * + * // 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 } +>; + +/** + * 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>, + { 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 } +>; + +/** + * 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>, + { 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 Bluesky profile data + * @throws {NetworkError} If profile cannot be fetched * - * @returns Promise resolving to profile data + * @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..64b5a057 100644 --- a/packages/sdk-core/src/services/hypercerts/types.ts +++ b/packages/sdk-core/src/services/hypercerts/types.ts @@ -8,11 +8,11 @@ // 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 export { + AppCertifiedActorProfile, AppCertifiedBadgeAward, AppCertifiedBadgeDefinition, AppCertifiedBadgeResponse, @@ -55,6 +55,7 @@ export type { AppCertifiedDefs, OrgHypercertsDefs } from "@hypercerts-org/lexico * when defining `type HypercertClaim = OrgHypercertsClaimActivity.Main`. */ import type { + AppCertifiedActorProfile, AppCertifiedBadgeAward, AppCertifiedBadgeDefinition, AppCertifiedBadgeResponse, @@ -72,6 +73,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 +189,12 @@ export type HypercertEvaluation = OrgHypercertsClaimEvaluation.Main; export type HypercertAttachment = OrgHypercertsClaimAttachment.Main; export type HypercertCollection = OrgHypercertsClaimCollection.Main; +/** + * Certified actor profile record (app.certified.actor.profile). + * Extended profile with additional fields beyond Bluesky profiles. + */ +export type CertifiedProfileRecord = AppCertifiedActorProfile.Main; + /** * Collection item with optional weight. * @@ -689,3 +699,5 @@ export type UpdateAttachmentParams = Partial; * - Full attachment params object to create new attachment */ export type AttachmentParams = RefUri | CreateAttachmentParams; + +export type CreateCertifiedProfileParams = SetOptional; diff --git a/packages/sdk-core/src/types.ts b/packages/sdk-core/src/types.ts index badf2e0e..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,9 +86,9 @@ export type { HypercertWithMetadata, HypercertProject, HypercertProjectWithMetadata, + CertifiedProfileRecord, CreateProjectParams, UpdateProjectParams, - JsonBlobRef, } from "./services/hypercerts/types.js"; // BlobRef is a class, not just a type 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..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 { @@ -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. @@ -110,13 +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({ - ref: { $link: "image-cid" }, - mimeType: "image/png", - size: 100, - }); + const mockBlobRef = createMockBlobRef({ mimeType: "image/png", size: 1024 }); + mockBlobs.upload.mockResolvedValue(mockBlobRef); await hypercertOps.create({ ...validParams, @@ -124,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 () => { @@ -899,13 +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({ - ref: { $link: "new-image-cid" }, - mimeType: "image/png", - size: 100, - }); + 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", @@ -914,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 () => { @@ -1031,11 +1043,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 +1057,15 @@ 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({ - $type: "org.hypercerts.defs#smallBlob", // Your code wraps it in smallBlob - blob: { - // The actual blob data is nested here - ref: { $link: "blob-cid" }, - mimeType: "application/geo+json", - size: 100, - }, - }); + + // 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(); }); it("should attach a location using a simple text string (beta.13+ format)", async () => { @@ -1670,11 +1678,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 +1700,16 @@ 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); + + // 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(); }); it("should add attachment with multiple subjects", async () => { @@ -1738,11 +1746,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 +1764,15 @@ describe("HypercertOperationsImpl", () => { $type: "org.hypercerts.defs#uri", uri: "https://example.com/report.pdf", }); - expect(call.record.content[1]).toEqual({ - $type: "org.hypercerts.defs#smallBlob", - blob: { ref: { $link: "blob-cid" }, 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(); }); it("should add attachment with contentType", async () => { @@ -2039,7 +2048,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 +2062,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 +2530,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 +2597,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 +2856,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 +3183,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 +3193,20 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.avatar).toEqual({ - $type: "org.hypercerts.defs#smallImage", - image: { ref: { $link: "avatar-cid" }, 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(); }); 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 +3216,15 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(bannerBlob); const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0]; - expect(createCall.record.banner).toEqual({ - $type: "org.hypercerts.defs#largeImage", - image: { ref: { $link: "banner-cid" }, 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(); }); it("should upload both avatar and banner when provided", async () => { @@ -3257,16 +3232,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 +3716,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 +3724,20 @@ describe("HypercertOperationsImpl", () => { expect(mockBlobs.upload).toHaveBeenCalledWith(newAvatar); const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; - expect(putCall.record.avatar).toEqual({ - $type: "org.hypercerts.defs#smallImage", - image: { ref: { $link: "new-avatar-cid" }, 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(); }); 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..9f3e90a9 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,374 @@ 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: false, + data: {}, + }); + + 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")); + + await expect(profileOps.getBskyProfile()).rejects.toThrow(NetworkError); + await expect(profileOps.getBskyProfile()).rejects.toThrow("Network error"); + }); + }); + + describe("getCertifiedProfile", () => { + it("should return Certified profile with blob URLs", async () => { + // Mock handle fetch + mockAgent.getProfile!.mockResolvedValue({ + success: true, + data: { handle: "alice.bsky.social" }, + }); + + // Mock certified profile record + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { - handle: "minimal.bsky.social", + 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, + }, + }, + }, }, }); - const result = await profileOps.get(); - - expect(result.handle).toBe("minimal.bsky.social"); - expect(result.displayName).toBeUndefined(); - expect(result.description).toBeUndefined(); + 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 returns success: false", async () => { + it("should handle URI image format", async () => { mockAgent.getProfile!.mockResolvedValue({ - success: false, + 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"); }); - it("should throw NetworkError when API throws", async () => { - mockAgent.getProfile!.mockRejectedValue(new Error("Profile not found")); + 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", + }, + }, + }, + }); - await expect(profileOps.get()).rejects.toThrow(NetworkError); + const result = await profileOps.getCertifiedProfile(); + expect(result).not.toBeNull(); + expect(result!.avatar).toBe("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + expect(result!.banner).toBe("ar://some-arweave-tx-id"); }); - }); - 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 +392,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: { + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Old Name", + pronouns: "they/them", + website: "https://old.com", + }, + }, + }); + + 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: { - displayName: "Name", - avatar: { ref: { $link: "old-avatar" } }, + $type: CERTIFIED_PROFILE_COLLECTION, + createdAt: "2024-01-01T00:00:00.000Z", + displayName: "Alice", + pronouns: "she/her", + website: "https://alice.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: "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 expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow( - "Profile not found. Use create() for new profiles.", - ); + await profileOps.upsertCertifiedProfile({ + displayName: "Alice", + avatar: avatarBlob, + }); + + 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", + }, + }); + + const result = await profileOps.upsertBskyProfile({ + displayName: "Bob", + description: "Bluesky user", }); - await expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow(NetworkError); + 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", + }, + }, + }); + + 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(); - await expect(profileOps.update({ displayName: "New Name" })).rejects.toThrow(NetworkError); + 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