diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 69bd8dcb..9371e1f8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,16 +1,22 @@ {"id":"hypercerts-sdk-08g","title":"Refactor measurement schema (beta.12)","description":"Update CreateMeasurementParams: unit now required, measurers now optional, location renamed to locations array. Add startDate, endDate, comment, commentFacets fields. Update UpdateMeasurementParams, createMeasurement, updateMeasurement methods. Add JSDoc, examples, and tests.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.11-v0.10.0-beta.13.md (Change 2)\nCHANGELOG: PR #120 - Refactor measurement lexicon schema: add unit field, date ranges, and locations array\n\n## What Changed\n- Changed required fields: removed measurers from required, added unit as required\n- Added unit field (required, string, maxLength: 50): The unit of the measured value (e.g. kg CO₂e, hectares, %, index score)\n- Added startDate field (optional, datetime): The start date and time when the measurement began\n- Added endDate field (optional, datetime): The end date and time when the measurement ended\n- Changed location (single strongRef) to locations (array of strongRefs, maxLength: 100)\n- Moved measurers from required to optional field\n- Added comment field (optional, string): Short comment suitable for previews and list views\n- Added commentFacets field (optional, array): Rich text annotations for comment (mentions, URLs, hashtags, etc.)\n- Updated field descriptions for metric and value with more detailed examples\n\n## Tasks\n- [x] Update CreateMeasurementParams type to reflect:\n - unit is now required\n - measurers is now optional\n - location renamed to locations (array of StrongRef/LocationParams)\n - Add startDate (optional datetime)\n - Add endDate (optional datetime)\n - Add comment (optional string)\n - Add commentFacets (optional array of RichTextFacet)\n- [x] Update UpdateMeasurementParams accordingly (N/A - no Update function exists)\n- [x] Update createMeasurement() method to handle new required unit field\n- [x] Update createMeasurement() and updateMeasurement() methods to handle locations array\n- [x] Add JSDoc documentation about the new fields (unit, startDate, endDate, comment, commentFacets)\n- [x] Add usage examples showing:\n - Creating measurements with unit (required)\n - Adding date ranges (startDate/endDate)\n - Using locations array\n - Adding comments with facets\n- [x] Add/update tests for measurements with new fields\n- [x] Build and test\n- [ ] Create changeset (major - breaking change, required field added, location → locations)\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] Build passes (pnpm build)\n- [ ] Tests pass (pnpm test)\n- [ ] Types export correctly\n\n## Changeset\n- Type: major (breaking change - required field added, location → locations)\n\n## Remaining\nOnly changeset creation remains for this task.\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":1,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:49:04.244268512+13:00","created_by":"Adam Spiers","updated_at":"2026-01-28T10:58:42.488794068+13:00","closed_at":"2026-01-28T10:58:42.488794068+13:00","close_reason":"All tasks complete including changeset (update-measurement-api.md). Validated: tests pass, typecheck/lint/build pass.","dependencies":[{"issue_id":"hypercerts-sdk-08g","depends_on_id":"hypercerts-sdk-kvb","type":"blocks","created_at":"2026-01-27T16:52:38.515466941+13:00","created_by":"Adam Spiers"}]} +{"id":"hypercerts-sdk-0pb","title":"Fix createFacetsFromText JSDoc about unresolved mentions","description":"The async createFacetsFromText function's JSDoc (line 42) incorrectly states that mentions without an agent will have 'empty DIDs'. The actual behavior (confirmed by tests) is that unresolved mentions receive the handle string as the DID (e.g., 'alice.bsky.social'), not an empty value.\n\nThe sync function's documentation (lines 110-112) correctly describes this behavior.\n\nSource: PR #122 CodeRabbit review comment on rich-text.ts line 43","notes":"File: packages/sdk-core/src/lib/rich-text.ts\n\nUpdate the JSDoc for createFacetsFromText to match:\n1. The sync variant's documentation\n2. The test expectations (test line 45)\n\nState that unresolved mentions will use the handle string as the DID.","status":"open","priority":3,"issue_type":"bug","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:39:31.017963444+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:39:31.017963444+13:00"} {"id":"hypercerts-sdk-38n","title":"Add docs for location string format","description":"Add JSDoc documentation showing both location formats: simple string ('New York, NY, USA') and structured object ({ country: 'USA', city: 'New York', ... }).","notes":"Follow-up from hypercerts-sdk-m7h (beta.13 location string format support).\n\nThe implementation already works - this is just documentation enhancement.\n\nLocation in CreateLocationParams:\n- resolveLocationValue() wraps strings in URI ref: { $type: 'org.hypercerts.defs#uri', uri: '...' }\n\nAdd examples to JSDoc in:\n- packages/sdk-core/src/services/hypercerts/types.ts (CreateLocationParams)\n- packages/sdk-core/src/repository/HypercertOperationsImpl.ts (createLocationRecord, attachLocation)","status":"closed","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:38:08.820020891+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:09:36.503254833+13:00","closed_at":"2026-01-29T15:09:36.503254833+13:00","close_reason":"Added JSDoc documentation showing location string format support in CreateLocationParams, LocationParams, resolveLocationValue, and createLocationRecord"} {"id":"hypercerts-sdk-3us","title":"Add success check after getRecord in ProfileOperationsImpl.update","description":"The update() method doesn't check existing.success before accessing existing.data.value. If the record doesn't exist (new account), this could cause unexpected errors.","notes":"Source: PR #122 CodeRabbit review comment (line 329)\n\nFix in packages/sdk-core/src/repository/ProfileOperationsImpl.ts update() method\n\nAdd check:\nif (!existing.success) {\n throw new NetworkError('Profile not found. Use create() for new profiles.');\n}","status":"closed","priority":2,"issue_type":"bug","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:24:08.231648416+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:57:56.073677312+13:00","closed_at":"2026-01-29T14:57:56.073677312+13:00","close_reason":"Added success check after getRecord in update() method"} {"id":"hypercerts-sdk-479","title":"Fix blob reference format in ProfileOperationsImpl","description":"applyBlobField() stores only uploadResult.ref but AT Protocol requires full blob structure { $type: 'blob', ref: { $link }, mimeType, size }. Profile updates with avatar/banner will fail PDS validation.","notes":"Source: PR #122 CodeRabbit review comment (line 105)\n\nFix needed in packages/sdk-core/src/repository/ProfileOperationsImpl.ts\n\napplyBlobField currently assigns only uploadResult.ref to profile blob fields. Must construct full blob reference structure before assignment. Reuse HypercertOperationsImpl.blobToJsonRef() or extract shared helper.","status":"closed","priority":1,"issue_type":"bug","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:24:02.980404221+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:33:04.319629501+13:00","closed_at":"2026-01-29T14:33:04.319629501+13:00","close_reason":"Fixed by extracting uploadResultToBlobRef to shared types.ts and using it in both ProfileOperationsImpl and HypercertOperationsImpl. Updated tests to expect full AT Protocol blob structure."} +{"id":"hypercerts-sdk-4ni","title":"Add DID format validation before SDS blob upload","description":"The SDS blob upload endpoint in BlobOperationsImpl constructs a URL with repoDid using encodeURIComponent(), which prevents URL injection. However, there's no validation that repoDid is actually a valid DID format before making the request.\n\nWhile DID validation likely happens elsewhere in the flow, adding a defensive check would prevent cryptic errors from the SDS endpoint if an invalid DID is somehow passed.\n\nSource: PR #122 Copilot review comment on BlobOperationsImpl.ts line 165","notes":"File: packages/sdk-core/src/repository/BlobOperationsImpl.ts\n\nConsider adding validation like:\n- Check that repoDid starts with 'did:'\n- Or use a more complete DID validation regex\n\nThis is a defensive programming enhancement, not critical.","status":"closed","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:39:25.475119814+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:43:11.140986165+13:00","closed_at":"2026-01-29T15:43:11.140986165+13:00","close_reason":"Added isValidDid utility function to core/types.ts and validation in BlobOperationsImpl constructor"} {"id":"hypercerts-sdk-64k","title":"Add $type and createdAt to ProfileParams","description":"ProfileParams should include optional $type and createdAt fields to follow the established pattern used in other record creation params (CreateAttachmentParams, CreateLocationParams, etc.).","notes":"Source: PR #122 CodeRabbit review comment (line 239)\n\nUpdate ProfileParams interface to include:\n- $type?: string\n- createdAt?: string\n\nUpdate mergeParamsIntoProfile() to apply defaults:\nresult.$type = params.$type ?? result.$type ?? 'app.bsky.actor.profile';\nresult.createdAt = params.createdAt ?? result.createdAt ?? new Date().toISOString();","status":"closed","priority":2,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:24:19.423381558+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:47:38.120056094+13:00","closed_at":"2026-01-29T14:47:38.120056094+13:00","close_reason":"Added $type and createdAt fields to ProfileParams interface. Updated mergeParamsIntoProfile() to apply defaults with nullish coalescing. Updated test to expect new fields."} +{"id":"hypercerts-sdk-6jc","title":"Fix turbo.json check task dependencies","description":"The 'check' task in turbo.json has no dependencies (empty object), but it should depend on the tasks it orchestrates (lint, typecheck, build, test). The empty dependencies array means 'check' won't wait for or trigger any other tasks.\n\nAlso, the 'install' task was removed which may break CI/CD environments or new developer onboarding.\n\nSource: PR #122 Copilot review comments on turbo.json lines 7 and 18","notes":"Review the turbo.json configuration to ensure:\n1. 'check' task properly depends on lint, typecheck, build, test\n2. Verify if 'install' task removal is intentional or should be restored\n3. Ensure task dependencies are consistent across the configuration","status":"open","priority":3,"issue_type":"bug","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:39:17.643467188+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:39:17.643467188+13:00"} {"id":"hypercerts-sdk-6mv","title":"Update lexicon dependency to 0.10.0-beta.13","description":"Update @hypercerts-org/lexicon dependency from 0.10.0-beta.11 to 0.10.0-beta.13 in packages/sdk-core/package.json. Run pnpm install. Review all changesets from beta.12 and beta.13 changes. Final build and test of entire SDK. Prepare summary.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.11-v0.10.0-beta.13.md (Change 5)\n\n## Tasks\n- [ ] Update @hypercerts-org/lexicon dependency from 0.10.0-beta.11 to 0.10.0-beta.13 in packages/sdk-core/package.json\n- [ ] Run pnpm install to update the dependency\n- [ ] Review all individual changesets created in steps 1-4\n- [ ] Consider whether they should be combined into a single changeset or kept separate\n- [ ] Update any top-level documentation if needed\n- [ ] Final build and test of entire SDK\n- [ ] Prepare summary of all changes for user review\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] All builds pass (pnpm build - sdk-core and sdk-react)\n- [ ] All tests pass (pnpm test)\n- [ ] Changesets are properly formatted\n- [ ] Documentation is complete\n\n## Notes\nBreaking changes in this sync:\n1. WorkScope simplified to union of string | StrongRef (removes complex expression types)\n2. Measurement unit field now required, location → locations array\n3. Evidence lexicon renamed to Attachment with schema changes\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":1,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:49:13.353977984+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:41:25.244668754+13:00","closed_at":"2026-01-29T14:41:25.244668754+13:00","close_reason":"Lexicon already at 0.10.0-beta.13. All checks pass (build, lint, typecheck, 836 tests). Changesets exist for all beta.12/13 changes: evidence-to-attachment-rename.md, update-measurement-api.md, support-multiple-locations.md.","dependencies":[{"issue_id":"hypercerts-sdk-6mv","depends_on_id":"hypercerts-sdk-rcw","type":"blocks","created_at":"2026-01-27T16:52:40.413151947+13:00","created_by":"Adam Spiers"},{"issue_id":"hypercerts-sdk-6mv","depends_on_id":"hypercerts-sdk-08g","type":"blocks","created_at":"2026-01-27T16:52:40.986057765+13:00","created_by":"Adam Spiers"},{"issue_id":"hypercerts-sdk-6mv","depends_on_id":"hypercerts-sdk-xfj","type":"blocks","created_at":"2026-01-27T16:52:41.722908491+13:00","created_by":"Adam Spiers"},{"issue_id":"hypercerts-sdk-6mv","depends_on_id":"hypercerts-sdk-m7h","type":"blocks","created_at":"2026-01-27T16:52:42.865268559+13:00","created_by":"Adam Spiers"}]} {"id":"hypercerts-sdk-787","title":"Remove unused repo parameter from BlobOperationsImpl","description":"The repo parameter is declared but never used in BlobOperationsImpl class. Either remove it or add JSDoc explaining its intended future use.","notes":"Source: PR #122 Copilot review comment (line 70)\n\nFile: packages/sdk-core/src/repository/BlobOperationsImpl.ts","status":"closed","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:24:23.89905318+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:03:26.168282025+13:00","closed_at":"2026-01-29T15:03:26.168282025+13:00","close_reason":"Removed unused repo parameter from BlobOperationsImpl constructor"} {"id":"hypercerts-sdk-c13","title":"Add JSDoc documentation for collection avatar/banner","description":"Add JSDoc documentation to createCollection() and updateCollection() methods about avatar field. Add usage examples in method documentation showing avatar in collections and projects.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.4-v0.10.0-beta.11.md (Change 3)\nCHANGELOG: PR #106 - Add avatar and banner fields to collection lexicon\n\n## What Changed\n- Collections now have avatar and banner fields for visual representation\n- These are image fields (Uri, SmallImage, or LargeImage)\n\n## Implementation Status\n- [x] Verify CreateCollectionParams supports avatar/banner\n- [x] Verify createCollection/updateCollection handle fields\n- [ ] Add JSDoc documentation about avatar (THIS TASK)\n- [ ] Add usage examples for avatar in collections and projects\n- [x] Tests complete\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] Build passes (pnpm build)\n- [ ] Tests pass (pnpm test)\n\n## Changeset\n- Type: minor (new feature available)\n- Already created\n\nNote: Method documentation mentions banner but not avatar\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":2,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:47:16.053509722+13:00","created_by":"Adam Spiers","updated_at":"2026-01-28T10:52:01.615872728+13:00","closed_at":"2026-01-28T10:52:01.615872728+13:00","close_reason":"Added comprehensive JSDoc documentation with avatar/banner examples to createCollection, updateCollection, createProject, and updateProject methods"} {"id":"hypercerts-sdk-d2e","title":"Add tests for location string format","description":"Add explicit tests verifying location string format works (e.g., 'New York, NY, USA' as location value).","notes":"Follow-up from hypercerts-sdk-m7h (beta.13 location string format support).\n\nThe implementation already works - this adds explicit test coverage.\n\nTest cases to add in packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts:\n- createLocationRecord with string location value\n- attachLocation with inline string location\n- Verify string gets wrapped in URI ref format","status":"closed","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:38:14.006311332+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:18:11.946123637+13:00","closed_at":"2026-01-29T15:18:11.946123637+13:00","close_reason":"Added tests for simple text string location format in attachLocation and createCollection"} +{"id":"hypercerts-sdk-del","title":"Fix location examples in HypercertOperationsImpl JSDoc","description":"The JSDoc examples for createCollection in HypercertOperationsImpl use the old location shape ({ value: ... }) which will error at runtime. They need to be updated to the new CreateLocationParams shape with required fields like srs, lpVersion, locationType.\n\nAffected locations:\n- Line 1885 (createCollection example)\n- Lines 2088-2094\n- Lines 2355-2359\n\nSource: PR #122 CodeRabbit review comment on HypercertOperationsImpl.ts line 1885","notes":"File: packages/sdk-core/src/repository/HypercertOperationsImpl.ts\n\nExample fix:\n```diff\n- location: { value: \"Amazon Rainforest, Brazil\", name: \"Amazon Basin\" },\n+ location: {\n+ lpVersion: \"1.0.0\",\n+ srs: \"EPSG:4326\",\n+ locationType: \"coordinate-decimal\",\n+ location: \"Amazon Rainforest, Brazil\",\n+ name: \"Amazon Basin\",\n+ },\n```","status":"open","priority":2,"issue_type":"bug","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:39:43.168024043+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:39:43.168024043+13:00"} {"id":"hypercerts-sdk-fi6","title":"Add RichText utility for creating facets from text","description":"Create a utility function that uses @atproto/api's RichText class to automatically detect and create facets (mentions, links, hashtags) from plain text. This allows users to write natural text like 'Led by @alice with support from https://example.org #sustainability' and have facets auto-generated. Export this as a helper function from the SDK.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.4-v0.10.0-beta.11.md (Change 2)\nCHANGELOG: PR #91 - Add rich text facet support to activity claim descriptions\n\n## What Changed\n- Added shortDescriptionFacets and descriptionFacets fields to activity lexicon\n- Supports mentions, URLs, hashtags, etc. in activity descriptions\n\n## ✅ COMPLETED\n\n### Implementation\nCreated RichText utility in `src/lib/rich-text.ts`:\n- `createFacetsFromText(text, agent?)` - async, resolves mentions if agent provided\n- `createFacetsFromTextSync(text)` - sync, fast detection without mention resolution\n- Re-exports `RichText` class from @atproto/api for advanced usage\n- Exports `RichTextResult` and `AppBskyRichtextFacet` types\n\n### Exports added to index.ts\n- `createFacetsFromText`\n- `createFacetsFromTextSync`\n- `RichText`\n- `RichTextResult` (type)\n- `AppBskyRichtextFacet` (type)\n\n### Tests\nAdded 11 tests in `tests/lib/rich-text.test.ts`:\n- URL detection\n- Hashtag detection\n- Mention detection (without resolution)\n- Multiple facet types\n- Plain text handling\n- Empty text handling\n- Byte index calculation\n- Async version\n- Hypercert description example\n- RichText class export\n- Segment iteration\n\n## Validation\n- [x] Format check passes\n- [x] Lint passes\n- [x] Typecheck passes\n- [x] Build passes (with unrelated attachment warnings)\n- [x] Tests pass (11/11)\n- [x] RichText utility is exported correctly\n\n## Changeset\n- Type: minor (new feature available)\n- TODO: Create changeset file\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":2,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:47:14.717746859+13:00","created_by":"Adam Spiers","updated_at":"2026-01-28T10:48:19.158564312+13:00","closed_at":"2026-01-28T10:48:19.158574762+13:00"} {"id":"hypercerts-sdk-h0l","title":"Update changeset to mark BlobOperations constructor change as breaking","description":"Constructor signature change (HypercertOperationsImpl/ProfileOperationsImpl now require BlobOperations) should be marked as breaking with minor bump for 0.x semantics.","notes":"Source: PR #122 CodeRabbit review comment on .changeset/fix-sds-endpoint-routing.md\n\nChange from patch to minor and add BREAKING note:\n- @hypercerts-org/sdk-core: minor (not patch)\n- Add: **BREAKING:** HypercertOperationsImpl/ProfileOperationsImpl constructors now require a BlobOperations instance instead of a server URL.","status":"closed","priority":1,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T14:24:14.324265145+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:43:09.26272051+13:00","closed_at":"2026-01-29T14:43:09.26272051+13:00","close_reason":"Updated changeset from patch to minor and added BREAKING note about constructor signature change."} {"id":"hypercerts-sdk-kvb","title":"Review changesets for beta.4-beta.11 sync","description":"Review all individual changesets created for beta.4-beta.11 sync. Consider whether they should be combined or kept separate. Update top-level documentation. Final build and test. Prepare summary.","notes":"## Review Status\n\n### Changesets for beta.4-beta.11 Sync (Changes 1-3, 5)\n\nThe following changesets exist and are well-formed:\n\n1. `add-collection-item-weight-docs.md` - Change 1 (patch)\n2. `add-rich-text-facets.md` - Change 2 (minor)\n3. `add-collection-avatar-banner-docs.md` - Change 3 (minor)\n4. `support-multiple-locations.md` - Change 5 (minor - breaking)\n\n### Blocking Issue: Lexicon Version Mismatch\n\n**Critical:** The SDK codebase has a lexicon version mismatch:\n- `package.json` specifies `@hypercerts-org/lexicon@0.10.0-beta.13`\n- `node_modules` contains `0.10.0-beta.11`\n- Code references `ATTACHMENT_LEXICON_JSON` and `ATTACHMENT_NSID` which don't exist in any released version\n\nCommit `2e03d8d` (Jan 27) introduced this mismatch by:\n1. Updating lexicon dep from beta.11 to beta.13\n2. Adding ATTACHMENT_* references to lexicons.ts\n\nBut lexicon beta.13 either:\n- Hasn't been released yet, OR\n- Doesn't include the evidence→attachment rename\n\n**Tests are failing** because of this mismatch.\n\n### Recommendation\n\n1. Either revert lexicon dependency to beta.11 and remove ATTACHMENT references\n2. Or wait for lexicon beta.13 to be released with the attachment changes\n\nCannot complete this review until the version mismatch is resolved.\n\n---\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":2,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:48:59.309122978+13:00","created_by":"Adam Spiers","updated_at":"2026-01-28T10:58:23.892814062+13:00","closed_at":"2026-01-28T10:58:23.892814062+13:00","close_reason":"All changesets reviewed and validated. Tests pass (679), typecheck/lint/build all pass. Changesets are well-formed and appropriately scoped."} {"id":"hypercerts-sdk-m7h","title":"Add location string format support (beta.13)","description":"Verify CreateLocationParams supports both string and structured object format. Update createLocation() to handle string format. Add documentation showing both formats: simple string ('New York, NY, USA') and structured object. Add usage examples and tests for location string format.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.11-v0.10.0-beta.13.md (Change 4)\nCHANGELOG: PR #131 - Add inline string format to app.certified.location schema with documentation and examples\n\n## What Changed\n- app.certified.location now supports inline string format in addition to the structured object format\n- This allows locations to be specified as simple strings for convenience\n\n## Implementation Status\n**ALREADY IMPLEMENTED** - The SDK already supports this:\n- `CreateLocationParams.location` accepts `string | Blob | HypercertLocation['location']`\n- `resolveLocationValue()` wraps strings in URI ref format: `{ $type: 'org.hypercerts.defs#uri', uri: '...' }`\n- Example: `location: 'New York, NY, USA'` works and becomes `{ $type: 'org.hypercerts.defs#uri', uri: 'New York, NY, USA' }`\n\n## Remaining Tasks (Optional Enhancements)\n- [x] Verify CreateLocationParams supports both string and structured object format - DONE\n- [x] Update createLocation() method to handle string format - DONE (resolveLocationValue handles it)\n- [ ] Update documentation to show both formats (optional enhancement)\n- [ ] Add usage examples for both formats (optional enhancement)\n- [ ] Add explicit tests for location string format (optional enhancement)\n- [ ] Create changeset (minor - new feature, backward compatible) - May not be needed since already working\n\n## Validation Checklist\n- [x] Implementation works\n- [ ] Documentation could be improved\n- [ ] Tests could be added for explicit coverage\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":2,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:49:10.840368113+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:38:18.399443949+13:00","closed_at":"2026-01-29T14:38:18.399443949+13:00","close_reason":"Core implementation complete. Created follow-up issues for enhancements: hypercerts-sdk-38n (docs), hypercerts-sdk-d2e (tests).","dependencies":[{"issue_id":"hypercerts-sdk-m7h","depends_on_id":"hypercerts-sdk-kvb","type":"blocks","created_at":"2026-01-27T16:52:39.749831691+13:00","created_by":"Adam Spiers"}]} +{"id":"hypercerts-sdk-ojm","title":"Update changeset to note 0.x minor-bump posture","description":"The changeset for add-richtext-utility.md should explicitly state the 0.x minor-bump posture. For pre-1.0 libraries (0.x), minor version bumps can introduce breaking changes.\n\nThe changeset should document:\n- Whether this minor bump is intended to be non-breaking\n- Any affected APIs (createFacetsFromText, createFacetsFromTextSync, RichText re-export)\n- Any behavioral differences (e.g., async mention resolution requiring an agent)\n- Upgrade guidance for consumers\n\nSource: PR #122 CodeRabbit review comment on .changeset/add-richtext-utility.md line 12","notes":"File: .changeset/add-richtext-utility.md\n\nAdd a paragraph clarifying the 0.x versioning posture and any compatibility notes.","status":"in_progress","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:39:37.092331635+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:46:35.282896277+13:00"} {"id":"hypercerts-sdk-rcw","title":"Remove work scope expression types (beta.12)","description":"Remove type exports added for work scope expression types: HypercertWorkScopeAll, HypercertWorkScopeAny, HypercertWorkScopeNot, HypercertWorkScopeAtom, HypercertWorkScopeExpression. Remove work scope tag types: CreateWorkScopeTagParams, UpdateWorkScopeTagParams, WorkScopeTagParams. Verify CreateHypercertParams.workScope supports new union type (string | StrongRef). Update docs and examples.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.11-v0.10.0-beta.13.md (Change 1)\nCHANGELOG: PR #125 - Simplify workScope to union of strongRef and string\n\n## What Changed\n- The workScope field in org.hypercerts.claim.activity is now a union of:\n - com.atproto.repo.strongRef: A reference to a work-scope logic record for structured, nested work scope definitions\n - org.hypercerts.claim.activity#workScopeString: A free-form string for simple or legacy scopes\n- REMOVED from org.hypercerts.defs:\n - workScopeAll (logical AND operator)\n - workScopeAny (logical OR operator)\n - workScopeNot (logical NOT operator)\n - workScopeAtom (atomic scope reference)\n- This simplification allows work scope complexity to be managed via referenced records while still supporting simple string-based scopes\n\n## Tasks\n- [ ] Remove type exports that were added for work scope expression types:\n - Remove HypercertWorkScopeAll type (if it exists)\n - Remove HypercertWorkScopeAny type (if it exists)\n - Remove HypercertWorkScopeNot type (if it exists)\n - Remove HypercertWorkScopeAtom type (if it exists)\n - Remove HypercertWorkScopeExpression union type (if it exists)\n- [ ] Remove type exports for work scope tag (if they exist):\n - Remove CreateWorkScopeTagParams (if it exists)\n - Remove UpdateWorkScopeTagParams (if it exists)\n - Remove WorkScopeTagParams (if it exists)\n- [ ] Verify CreateHypercertParams.workScope supports the new union type (string | StrongRef)\n- [ ] Update documentation about work scope to reflect the simplified approach\n- [ ] Update usage examples to show:\n - Simple string-based work scope\n - StrongRef to a work scope logic record\n- [ ] Update/remove tests for work scope expressions (they should now reference external records)\n- [ ] Build and test\n- [ ] Create changeset (major - breaking change, removed types)\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] Build passes (pnpm build)\n- [ ] Tests pass (pnpm test)\n- [ ] Types export correctly\n- [ ] Work scope types are correct (string | StrongRef)\n\n## Changeset\n- Type: major (breaking change - removed types)\n\nNote: This reverses the complexity added in beta.8, so we may need to remove code that was added in the previous sync\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":1,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:49:01.530387235+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:34:53.286105756+13:00","closed_at":"2026-01-29T14:34:53.286105756+13:00","close_reason":"Already done - No HypercertWorkScopeAll, HypercertWorkScopeAny, workScopeAtom types found in codebase (they were never added before beta.12 removed them)","dependencies":[{"issue_id":"hypercerts-sdk-rcw","depends_on_id":"hypercerts-sdk-sq2","type":"blocks","created_at":"2026-01-27T16:52:37.281818165+13:00","created_by":"Adam Spiers"},{"issue_id":"hypercerts-sdk-rcw","depends_on_id":"hypercerts-sdk-kvb","type":"blocks","created_at":"2026-01-27T16:52:37.828991145+13:00","created_by":"Adam Spiers"}]} {"id":"hypercerts-sdk-sq2","title":"Add work scope expression types (beta.8)","description":"Add type exports for work scope expression types: HypercertWorkScopeAll, HypercertWorkScopeAny, HypercertWorkScopeNot, HypercertWorkScopeAtom, HypercertWorkScopeExpression. Add type exports for work scope tag: CreateWorkScopeTagParams, UpdateWorkScopeTagParams, WorkScopeTagParams. Verify CreateHypercertParams.workScope supports the union type. Add comprehensive documentation and usage examples.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.4-v0.10.0-beta.11.md (Change 4)\nCHANGELOG: PR #107 - Add work scope logic expression system with boolean operators\n\n## ⏳ IN PROGRESS - PR #113\nhttps://github.com/hypercerts-org/hypercerts-sdk/pull/113 (OPEN - not merged)\n\n## What Changed\n- New work scope logic AST with boolean operators (workScopeAll, workScopeAny, workScopeNot, workScopeAtom)\n- Work scope tag lexicon for reusable scope atoms\n- Activity workScope field now uses union type for logic expressions\n\n## Tasks\n- [x] Add type exports for work scope expression types:\n - HypercertWorkScopeAll = OrgHypercertsDefs.WorkScopeAll\n - HypercertWorkScopeAny = OrgHypercertsDefs.WorkScopeAny\n - HypercertWorkScopeNot = OrgHypercertsDefs.WorkScopeNot\n - HypercertWorkScopeAtom = OrgHypercertsDefs.WorkScopeAtom\n - HypercertWorkScopeExpression (union of above four)\n- [x] Add type exports for work scope tag:\n - CreateWorkScopeTagParams\n - UpdateWorkScopeTagParams\n - WorkScopeTagParams\n- [x] Add comprehensive JSDoc documentation about work scope expressions\n- [x] Add usage examples showing how to build AND/OR/NOT expressions in JSDoc\n- [ ] Verify CreateHypercertParams.workScope already supports the union type\n- [ ] Add examples showing how to create and reference work scope tags\n- [ ] Add/update tests for work scope expressions and tags\n- [ ] Build and test\n- [ ] Create changeset (minor - new feature available) - DONE in PR\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] Build passes (pnpm build)\n- [ ] Tests pass (pnpm test)\n- [ ] Types export correctly\n- [ ] Work scope types are usable\n\n## Changeset\n- Type: minor (new feature available)\n- File: .changeset/work-scope-expressions.md (created in PR #113)\n\n## Files Modified in PR #113\n- packages/sdk-core/src/services/hypercerts/types.ts (+240 lines of types and JSDoc)\n- packages/sdk-core/src/types.ts (+8 lines - re-exports)\n- .changeset/work-scope-expressions.md (created)\n- specs/lexicon-sync/v0.10.0-beta.4-v0.10.0-beta.11.md (updated checkboxes)\n\n## Still TODO\n1. Verify CreateHypercertParams.workScope supports union type\n2. Add examples for creating/referencing work scope tags \n3. Add/update tests for work scope expressions\n4. Run full validation suite\n5. Merge PR #113\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":3,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:48:58.255395738+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:34:49.269958498+13:00","closed_at":"2026-01-29T14:34:49.269958498+13:00","close_reason":"Superseded by beta.12 which removed these types - they were never added to the main codebase"} {"id":"hypercerts-sdk-xfj","title":"Rename evidence to attachment (beta.13)","description":"Rename all SDK references from evidence to attachment: types (HypercertEvidence, CreateEvidenceParams, etc.) and methods (createEvidence, updateEvidence, etc.). Update schema: subject to subjects array, content to array, add contentType and location fields, remove relationType/contributors/locations. Add HypercertWeightedContributor and HypercertContributorIdentity types. Update docs, examples, tests.","notes":"## Source\nFrom: specs/lexicon-sync/v0.10.0-beta.11-v0.10.0-beta.13.md (Change 3)\nCHANGELOG: PR #118 - Rename evidence lexicon to attachment and refactor schema structure\n\n## What Changed\nLexicon ID change: org.hypercerts.claim.evidence → org.hypercerts.claim.attachment\n\nSchema structure changes (org.hypercerts.claim.attachment):\n- Changed subject (single strongRef) to subjects (array of strongRefs, maxLength: 100)\n- Changed content from single union (uri/blob) to array of unions (maxLength: 100)\n- Added contentType field (string, maxLength: 64) to specify attachment type\n- Added location field (optional strongRef) to associate location metadata\n- Removed relationType field (previously used to indicate supports/challenges/clarifies)\n- Removed contributors field\n- Removed locations field\n- Added rich text support: shortDescriptionFacets and descriptionFacets (arrays of app.bsky.richtext.facet)\n- Updated required fields: [title, content, createdAt] (content is now required)\n\nCommon definitions (org.hypercerts.defs):\n- Added weightedContributor def for contributor references with optional weights\n- Added contributorIdentity def for string-based contributor identification\n\n## Tasks\n- [ ] Rename all SDK references from evidence to attachment:\n - Type exports: HypercertEvidence → HypercertAttachment\n - Type exports: CreateEvidenceParams → CreateAttachmentParams\n - Type exports: UpdateEvidenceParams → UpdateAttachmentParams\n - Type exports: EvidenceParams → AttachmentParams\n - Interface methods (if they exist): createEvidence → createAttachment\n - Interface methods (if they exist): updateEvidence → updateAttachment\n - Interface methods (if they exist): deleteEvidence → deleteAttachment\n - Interface methods (if they exist): listEvidence → listAttachments\n- [ ] Update CreateAttachmentParams type to reflect:\n - subject → subjects (array of StrongRef/ActivityParams/CollectionParams)\n - content is now array of unions (uri/blob) and required\n - Add contentType (optional string)\n - Add location (optional StrongRef/LocationParams)\n - Remove relationType\n - Remove contributors\n - Remove locations (note: theres a new singular location field)\n - Add shortDescriptionFacets (optional array)\n - Add descriptionFacets (optional array)\n- [ ] Update UpdateAttachmentParams accordingly\n- [ ] Add type exports for new common definitions:\n - HypercertWeightedContributor = OrgHypercertsDefs.WeightedContributor\n - HypercertContributorIdentity = OrgHypercertsDefs.ContributorIdentity\n- [ ] Update CRUD methods to handle schema changes\n- [ ] Add JSDoc documentation about attachment fields and migration from evidence\n- [ ] Add usage examples showing:\n - Creating attachments with subjects array\n - Using content array with contentType\n - Adding location to attachments\n - Using weighted contributors\n- [ ] Add/update tests for attachments\n- [ ] Build and test\n- [ ] Create changeset (major - breaking change, renamed lexicon and schema changes)\n\n## Validation Checklist\n- [ ] Format check passes (pnpm format:check)\n- [ ] Lint passes (pnpm lint)\n- [ ] Typecheck passes (pnpm typecheck)\n- [ ] Build passes (pnpm build)\n- [ ] Tests pass (pnpm test)\n- [ ] Types export correctly\n\n## Changeset\n- Type: major (breaking change - renamed lexicon and schema changes)\n\nIMPORTANT: Keep this issue in sync with the source .md file.","status":"closed","priority":1,"issue_type":"task","owner":"adam@hypercerts.org","created_at":"2026-01-27T16:49:07.726657855+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T14:34:31.297096347+13:00","closed_at":"2026-01-29T14:34:31.297096347+13:00","close_reason":"Already implemented - HypercertAttachment and CreateAttachmentParams types exist, no evidence types found in codebase","dependencies":[{"issue_id":"hypercerts-sdk-xfj","depends_on_id":"hypercerts-sdk-kvb","type":"blocks","created_at":"2026-01-27T16:52:39.135229154+13:00","created_by":"Adam Spiers"}]} +{"id":"hypercerts-sdk-yai","title":"Support existing blob refs for avatar/banner in ProfileOperationsImpl","description":"ProfileOperationsImpl.create() and update() currently only accept Blob for avatar/banner fields. They should also accept existing blob references (JsonBlobRef) to reuse already-uploaded images.\n\nCurrent behavior:\n- avatar/banner only accept Blob | null | undefined\n- Blob is always uploaded via this.blobs.upload()\n\nDesired behavior:\n- Accept Blob | JsonBlobRef | null | undefined for avatar/banner\n- If Blob: upload and convert to blob ref (current behavior)\n- If JsonBlobRef: use directly without re-uploading\n\nThis follows the pattern used in HypercertOperationsImpl for collection avatar/banner (HypercertImage type = string | Blob).\n\nImplementation notes:\n- Update ProfileParams interface in interfaces.ts to accept JsonBlobRef\n- Update applyBlobField() in ProfileOperationsImpl.ts to detect JsonBlobRef vs Blob\n- Consider creating a shared type like ProfileImage = Blob | JsonBlobRef\n- Reuse uploadResultToBlobRef() from types.ts (already imported)\n- Add helper function to check if input is JsonBlobRef (has $type: 'blob' or ref.$link)\n\nRelated code patterns:\n- HypercertOperationsImpl.resolveCollectionImageInput() handles string | Blob\n- types.ts has HypercertImage = string | Blob for collections\n- uploadResultToBlobRef() converts upload result to proper blob format\n\nFiles to modify:\n- packages/sdk-core/src/repository/interfaces.ts (ProfileParams type)\n- packages/sdk-core/src/repository/ProfileOperationsImpl.ts (applyBlobField method)\n- packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts (add tests)","notes":"Source: User request to support both blob upload and existing blob ref for profile images.\n\nExample usage after implementation:\n```typescript\n// Upload new blob (current behavior)\nawait repo.profile.update({ avatar: new Blob([data], { type: 'image/png' }) });\n\n// Reuse existing blob ref (new capability)\nconst existingRef = { $type: 'blob', ref: { $link: 'bafyrei...' }, mimeType: 'image/png', size: 1234 };\nawait repo.profile.update({ avatar: existingRef });\n```","status":"closed","priority":2,"issue_type":"feature","owner":"adam@hypercerts.org","created_at":"2026-01-29T15:26:45.539819574+13:00","created_by":"Adam Spiers","updated_at":"2026-01-29T15:35:07.906520192+13:00","closed_at":"2026-01-29T15:35:07.906520192+13:00","close_reason":"Added BlobInput type to accept both Blob and JsonBlobRef for avatar/banner fields in ProfileParams"} diff --git a/.changeset/add-richtext-utility.md b/.changeset/add-richtext-utility.md index 3628600d..26990b71 100644 --- a/.changeset/add-richtext-utility.md +++ b/.changeset/add-richtext-utility.md @@ -4,9 +4,24 @@ Add RichText utility functions for auto-detecting facets from text +**0.x Versioning Note:** This is a non-breaking addition. No existing APIs are modified or removed. + New utility functions to simplify creating rich text facets: - `createFacetsFromText(text, agent?)` - async function that auto-detects URLs, hashtags, and @mentions. If an agent is - provided, resolves mentions to DIDs. -- `createFacetsFromTextSync(text)` - sync function for fast detection without mention resolution + provided, resolves mentions to DIDs; otherwise mentions use the handle string as the DID. +- `createFacetsFromTextSync(text)` - sync function for fast detection without mention resolution (mentions use handle as + DID) - Re-exports `RichText` class from `@atproto/api` for advanced use cases + +**Usage:** + +```typescript +import { createFacetsFromText, createFacetsFromTextSync } from "@hypercerts-org/sdk-core"; + +// Sync (no DID resolution) +const facets = createFacetsFromTextSync("Check out #hypercerts by @alice"); + +// Async with DID resolution (requires authenticated agent) +const facets = await createFacetsFromText("Check out #hypercerts by @alice", agent); +``` diff --git a/.gitignore b/.gitignore index f381003b..f3bd65a7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ coverage.json npm-debug.log* yarn-debug.log* yarn-error.log* +package-lock.json .DS_Store /.idea stats.html diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index e1e6a9b2..470f8918 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -91,6 +91,6 @@ "@hypercerts-org/lexicon": "0.10.0-beta.13", "eventemitter3": "^5.0.1", "type-fest": "^5.4.1", - "zod": "^3.24.4" + "zod": "^3.25.76" } } diff --git a/packages/sdk-core/src/core/types.ts b/packages/sdk-core/src/core/types.ts index 1a5d688e..7fde3f63 100644 --- a/packages/sdk-core/src/core/types.ts +++ b/packages/sdk-core/src/core/types.ts @@ -22,6 +22,33 @@ import { z } from "zod"; */ export type DID = string; +/** + * Validates that a string is a valid DID format. + * + * DIDs must follow the format: `did::` + * where method is lowercase letters and digits, and the identifier contains + * alphanumeric characters plus `.`, `_`, `:`, `%`, and `-`. + * + * @param did - The string to validate + * @returns true if the string is a valid DID format + * + * @example + * ```typescript + * isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); // true + * isValidDid("did:web:example.com"); // true + * isValidDid("not-a-did"); // false + * isValidDid("did:"); // false + * ``` + * + * @see https://www.w3.org/TR/did-core/#did-syntax for DID syntax specification + */ +export function isValidDid(did: string): boolean { + // DID format: did:: + // Method: lowercase letters and digits (per W3C DID Core spec) + // Identifier: alphanumeric plus . _ : % - + return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/.test(did); +} + /** * OAuth session with DPoP (Demonstrating Proof of Possession) support. * diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 0fbce0a5..5f5889e4 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -99,6 +99,7 @@ export type { ContributorIdentityParams, CreateContributorInformationParams, ResolvedContributorIdentity, + BlobInput, } from "./repository/interfaces.js"; // ============================================================================ @@ -195,7 +196,7 @@ export { InMemoryStateStore } from "./storage/InMemoryStateStore.js"; // Core types and schemas export type { DID, Organization, Collaborator, CollaboratorPermissions } from "./core/types.js"; -export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema } from "./core/types.js"; +export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema, isValidDid } from "./core/types.js"; export { ATProtoSDKConfigSchema, OAuthConfigSchema, ServerConfigSchema, TimeoutConfigSchema } from "./core/config.js"; // OAuth Permissions System diff --git a/packages/sdk-core/src/repository/BlobOperationsImpl.ts b/packages/sdk-core/src/repository/BlobOperationsImpl.ts index 04c9b038..e4b08a92 100644 --- a/packages/sdk-core/src/repository/BlobOperationsImpl.ts +++ b/packages/sdk-core/src/repository/BlobOperationsImpl.ts @@ -8,7 +8,8 @@ */ import type { Agent } from "@atproto/api"; -import { NetworkError } from "../core/errors.js"; +import { NetworkError, ValidationError } from "../core/errors.js"; +import { isValidDid } from "../core/types.js"; import type { BlobOperations } from "./interfaces.js"; /** @@ -66,7 +67,13 @@ export class BlobOperationsImpl implements BlobOperations { private repoDid: string, private _serverUrl: string, private isSDS: boolean, - ) {} + ) { + if (!isValidDid(repoDid)) { + throw new ValidationError( + `Invalid DID format: "${repoDid}". DIDs must start with "did:" (e.g., "did:plc:abc123")`, + ); + } + } /** * Uploads a blob to the server. diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index ebf2ec27..9008a6a1 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -959,7 +959,7 @@ export class HypercertOperationsImpl extends EventEmitter imple const uploadResult = await this.blobs.upload(content); return { $type: "org.hypercerts.defs#smallBlob" as const, - blob: uploadResult, + blob: this.blobToJsonRef(uploadResult), }; } @@ -974,11 +974,12 @@ export class HypercertOperationsImpl extends EventEmitter imple } const uploadResult = await this.blobs.upload(input); + const blobRef = this.blobToJsonRef(uploadResult); if (isBanner) { - return { $type: "org.hypercerts.defs#largeImage" as const, image: uploadResult }; + return { $type: "org.hypercerts.defs#largeImage" as const, image: blobRef }; } - return { $type: "org.hypercerts.defs#smallImage" as const, image: uploadResult }; + return { $type: "org.hypercerts.defs#smallImage" as const, image: blobRef }; } /** diff --git a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts index 1b9ee9db..8a8f4583 100644 --- a/packages/sdk-core/src/repository/ProfileOperationsImpl.ts +++ b/packages/sdk-core/src/repository/ProfileOperationsImpl.ts @@ -8,8 +8,9 @@ */ import type { Agent } from "@atproto/api"; +import type { JsonBlobRef } from "@atproto/lexicon"; import { NetworkError } from "../core/errors.js"; -import type { BlobOperations, ProfileOperations, ProfileParams } from "./interfaces.js"; +import type { BlobInput, BlobOperations, ProfileOperations, ProfileParams } from "./interfaces.js"; import type { CreateResult, UpdateResult } from "./types.js"; import { uploadResultToBlobRef } from "./types.js"; @@ -44,10 +45,14 @@ import { uploadResultToBlobRef } from "./types.js"; * description: "Updated bio", * }); * - * // Update with new avatar + * // Update with new avatar (Blob - will be uploaded) * const avatarBlob = new Blob([imageData], { type: "image/png" }); * await repo.profile.update({ avatar: avatarBlob }); * + * // Update with existing blob reference (no re-upload) + * const existingRef = { $type: "blob", ref: { $link: "bafyrei..." }, mimeType: "image/png", size: 1234 }; + * await repo.profile.update({ avatar: existingRef }); + * * // Remove a field * await repo.profile.update({ website: null }); * ``` @@ -85,22 +90,59 @@ export class ProfileOperationsImpl implements ProfileOperations { } } + /** + * Checks if a value is an existing JsonBlobRef. + * + * JsonBlobRef has the structure: { $type: "blob", ref: { $link: string }, mimeType, size } + * + * @internal + */ + private isJsonBlobRef(value: unknown): value is JsonBlobRef { + if (typeof value !== "object" || value === null) { + return false; + } + + const record = value as Record; + + if (record.$type !== "blob" || !("ref" in record) || !("mimeType" in record) || !("size" in record)) { + return false; + } + + const ref = record.ref; + if (typeof ref !== "object" || ref === null) { + return false; + } + + const refRecord = ref as Record; + return typeof refRecord.$link === "string"; + } + /** * Applies a blob field to the profile, uploading if needed. * + * Handles three input types: + * - undefined: Field is not modified + * - null: Field is removed from the profile + * - Blob: Uploaded and converted to JsonBlobRef + * - JsonBlobRef: Used directly without re-uploading + * * @internal */ private async applyBlobField( result: Record, field: string, - blob: Blob | null | undefined, + input: BlobInput | null | undefined, ): Promise { - if (blob === undefined) return; + if (input === undefined) return; - if (blob === null) { + if (input === null) { delete result[field]; + } else if (this.isJsonBlobRef(input)) { + // Use existing blob ref directly + result[field] = input; } else { - const uploadResult = await this.blobs.upload(blob); + // Upload new blob + const uploadResult = await this.blobs.upload(input); result[field] = uploadResultToBlobRef(uploadResult); } } diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index 72eab32e..17975678 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -9,6 +9,7 @@ */ import type { AppBskyRichtextFacet } from "@atproto/api"; +import type { JsonBlobRef } from "@atproto/lexicon"; import type { EventEmitter } from "eventemitter3"; import type { LocationParams, @@ -656,6 +657,29 @@ export interface BlobOperations { * }); * ``` */ +/** + * Input type for blob fields that accepts either new data or existing references. + * + * Accepts either: + * - A Blob to be uploaded (will be converted to JsonBlobRef) + * - An existing JsonBlobRef (used directly without re-uploading) + * + * This allows reusing previously uploaded blobs without re-uploading them. + * + * @example Upload new data + * ```typescript + * const imageBlob = new Blob([imageData], { type: "image/png" }); + * await repo.profile.update({ avatar: imageBlob }); + * ``` + * + * @example Reuse an existing blob reference + * ```typescript + * const existingRef = { $type: "blob", ref: { $link: "bafyrei..." }, mimeType: "image/png", size: 1234 }; + * await repo.profile.update({ avatar: existingRef }); + * ``` + */ +export type BlobInput = Blob | JsonBlobRef; + /** * Parameters for creating or updating a profile. * @@ -670,8 +694,18 @@ export interface ProfileParams { createdAt?: string; displayName?: string | null; description?: string | null; - avatar?: Blob | null; - banner?: Blob | null; + /** + * Profile avatar image. + * Can be a Blob (will be uploaded) or an existing JsonBlobRef (used directly). + * Pass null to remove the avatar. + */ + avatar?: BlobInput | null; + /** + * Profile banner image. + * Can be a Blob (will be uploaded) or an existing JsonBlobRef (used directly). + * Pass null to remove the banner. + */ + banner?: BlobInput | null; website?: string | null; } diff --git a/packages/sdk-core/tests/core/types.test.ts b/packages/sdk-core/tests/core/types.test.ts new file mode 100644 index 00000000..7c132a16 --- /dev/null +++ b/packages/sdk-core/tests/core/types.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { isValidDid } from "../../src/core/types.js"; + +describe("isValidDid", () => { + describe("valid DIDs", () => { + it("should accept did:plc format", () => { + expect(isValidDid("did:plc:abc123")).toBe(true); + }); + + it("should accept did:web format", () => { + expect(isValidDid("did:web:example.com")).toBe(true); + }); + + it("should accept DID with alphanumeric identifier", () => { + expect(isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz")).toBe(true); + }); + + it("should accept DID with dots in identifier", () => { + expect(isValidDid("did:web:sub.example.com")).toBe(true); + }); + + it("should accept DID with colons in identifier", () => { + expect(isValidDid("did:web:example.com:user:123")).toBe(true); + }); + + it("should accept DID with percent-encoded characters", () => { + expect(isValidDid("did:example:abc%20def")).toBe(true); + }); + + it("should accept DID with hyphens and underscores", () => { + expect(isValidDid("did:example:my-test_id")).toBe(true); + }); + + it("should accept DID with method containing digits", () => { + expect(isValidDid("did:key2:abc123")).toBe(true); + }); + + it("should accept DID with method containing multiple digits", () => { + expect(isValidDid("did:btc1:xyz789")).toBe(true); + }); + + it("should accept DID with method that is all digits", () => { + expect(isValidDid("did:123:identifier")).toBe(true); + }); + }); + + describe("invalid DIDs", () => { + it("should reject empty string", () => { + expect(isValidDid("")).toBe(false); + }); + + it("should reject string not starting with did:", () => { + expect(isValidDid("not-a-did")).toBe(false); + }); + + it("should reject did: without method", () => { + expect(isValidDid("did:")).toBe(false); + }); + + it("should reject did:method without identifier", () => { + expect(isValidDid("did:plc:")).toBe(false); + }); + + it("should reject did:method: with empty identifier", () => { + expect(isValidDid("did:plc:")).toBe(false); + }); + + it("should reject method with uppercase letters", () => { + expect(isValidDid("did:PLC:abc123")).toBe(false); + }); + + it("should reject random URL", () => { + expect(isValidDid("https://example.com")).toBe(false); + }); + + it("should reject AT-URI", () => { + expect(isValidDid("at://did:plc:abc123/collection/rkey")).toBe(false); + }); + }); +}); diff --git a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts index af90d6c2..27b75ba1 100644 --- a/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/BlobOperationsImpl.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Agent } from "@atproto/api"; import { BlobOperationsImpl } from "../../src/repository/BlobOperationsImpl.js"; -import { NetworkError } from "../../src/core/errors.js"; +import { NetworkError, ValidationError } from "../../src/core/errors.js"; import { createMockAgent, TEST_REPO_DID, TEST_PDS_URL, TEST_SDS_URL } from "../utils/mocks.js"; describe("BlobOperationsImpl", () => { @@ -13,6 +13,26 @@ describe("BlobOperationsImpl", () => { blobOps = new BlobOperationsImpl(mockAgent as unknown as Agent, TEST_REPO_DID, TEST_PDS_URL, false); }); + describe("constructor", () => { + it("should accept valid DID", () => { + expect( + () => new BlobOperationsImpl(mockAgent as unknown as Agent, "did:plc:abc123", TEST_PDS_URL, false), + ).not.toThrow(); + }); + + it("should throw ValidationError for invalid DID", () => { + expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "not-a-did", TEST_PDS_URL, false)).toThrow( + ValidationError, + ); + }); + + it("should include helpful error message with the invalid DID", () => { + expect(() => new BlobOperationsImpl(mockAgent as unknown as Agent, "invalid", TEST_PDS_URL, false)).toThrow( + /Invalid DID format: "invalid"/, + ); + }); + }); + describe("upload", () => { it("should upload a blob successfully", async () => { const mockBlob = new Blob(["test content"], { type: "text/plain" }); diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index 0ed679d5..c5ea2014 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -994,7 +994,8 @@ describe("HypercertOperationsImpl", () => { expect(call.record.location).toEqual({ $type: "org.hypercerts.defs#smallBlob", // Your code wraps it in smallBlob blob: { - // The actual blob data is nested here + // The actual blob data is nested here with $type: "blob" for AT Protocol compliance + $type: "blob", ref: { $link: "blob-cid" }, mimeType: "application/geo+json", size: 100, @@ -1203,7 +1204,7 @@ describe("HypercertOperationsImpl", () => { expect(call.record.content).toEqual([ { $type: "org.hypercerts.defs#smallBlob", - blob: { ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, + blob: { $type: "blob", ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, }, ]); }); @@ -1266,7 +1267,7 @@ describe("HypercertOperationsImpl", () => { }); expect(call.record.content[1]).toEqual({ $type: "org.hypercerts.defs#smallBlob", - blob: { ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, + blob: { $type: "blob", ref: { $link: "blob-cid" }, mimeType: "application/pdf", size: 100 }, }); }); @@ -2053,29 +2054,18 @@ 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, - }, - }, - }); - - mockAgent.com.atproto.repo.uploadBlob.mockResolvedValueOnce({ - success: true, - data: { - blob: { - $type: "blob", - ref: { $link: "bafyrei-header" }, - mimeType: "image/jpeg", - size: 250, - }, - }, - }); + // Mock blob uploads via BlobOperations (not agent.uploadBlob) + mockBlobs.upload + .mockResolvedValueOnce({ + ref: { $link: "bafyrei-logo" }, + mimeType: "image/png", + size: 150, + }) + .mockResolvedValueOnce({ + ref: { $link: "bafyrei-header" }, + mimeType: "image/jpeg", + size: 250, + }); mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ success: true, @@ -2091,6 +2081,7 @@ describe("HypercertOperationsImpl", () => { }); expect(result.uri).toContain("collection"); + expect(mockBlobs.upload).toHaveBeenCalledTimes(2); expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ @@ -2098,9 +2089,15 @@ describe("HypercertOperationsImpl", () => { type: "project", avatar: expect.objectContaining({ $type: "org.hypercerts.defs#smallImage", + image: expect.objectContaining({ + $type: "blob", + }), }), banner: expect.objectContaining({ $type: "org.hypercerts.defs#largeImage", + image: expect.objectContaining({ + $type: "blob", + }), }), }), }), @@ -2683,7 +2680,7 @@ describe("HypercertOperationsImpl", () => { 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 }, + image: { $type: "blob", ref: { $link: "avatar-cid" }, mimeType: "image/png", size: 100 }, }); }); @@ -2705,7 +2702,7 @@ describe("HypercertOperationsImpl", () => { 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 }, + image: { $type: "blob", ref: { $link: "banner-cid" }, mimeType: "image/jpeg", size: 200 }, }); }); @@ -3220,7 +3217,7 @@ describe("HypercertOperationsImpl", () => { 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 }, + image: { $type: "blob", ref: { $link: "new-avatar-cid" }, mimeType: "image/png", size: 150 }, }); }); diff --git a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts index fd498cdc..5eac1dbe 100644 --- a/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/ProfileOperationsImpl.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { 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 type { BlobOperations, BlobInput } from "../../src/repository/interfaces.js"; import { createMockAgent, createMockBlobOperations, TEST_REPO_DID } from "../utils/mocks.js"; describe("ProfileOperationsImpl", () => { @@ -178,6 +178,56 @@ describe("ProfileOperationsImpl", () => { expect(createCall.record.description).toBeUndefined(); }); + it("should create profile with existing avatar blob ref (no upload)", async () => { + const existingBlobRef = { + $type: "blob" as const, + ref: { $link: "existing-avatar-cid" }, + mimeType: "image/png", + size: 2048, + }; + + await profileOps.create({ + displayName: "User with Existing Avatar", + avatar: existingBlobRef, + }); + + // Should NOT upload - existing blob ref is used directly + expect(mockBlobs.upload).not.toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + displayName: "User with Existing Avatar", + avatar: existingBlobRef, + }), + }), + ); + }); + + it("should create profile with existing banner blob ref (no upload)", async () => { + const existingBlobRef = { + $type: "blob" as const, + ref: { $link: "existing-banner-cid" }, + mimeType: "image/jpeg", + size: 4096, + }; + + await profileOps.create({ + displayName: "User with Existing Banner", + banner: existingBlobRef, + }); + + // Should NOT upload - existing blob ref is used directly + expect(mockBlobs.upload).not.toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + displayName: "User with Existing Banner", + banner: existingBlobRef, + }), + }), + ); + }); + it("should throw NetworkError when createRecord returns success: false", async () => { mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ success: false, @@ -346,6 +396,174 @@ describe("ProfileOperationsImpl", () => { expect(putCall.record.banner).toBeUndefined(); }); + it("should use existing avatar blob ref without re-uploading", async () => { + const existingBlobRef = { + $type: "blob" as const, + ref: { $link: "existing-avatar-cid" }, + mimeType: "image/png", + size: 2048, + }; + + await profileOps.update({ + avatar: existingBlobRef, + }); + + // Should NOT upload - existing blob ref is used directly + expect(mockBlobs.upload).not.toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + avatar: existingBlobRef, + }), + }), + ); + }); + + it("should use existing banner blob ref without re-uploading", async () => { + const existingBlobRef = { + $type: "blob" as const, + ref: { $link: "existing-banner-cid" }, + mimeType: "image/jpeg", + size: 4096, + }; + + await profileOps.update({ + banner: existingBlobRef, + }); + + // Should NOT upload - existing blob ref is used directly + expect(mockBlobs.upload).not.toHaveBeenCalled(); + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + banner: existingBlobRef, + }), + }), + ); + }); + + it("should handle mixed blob and existing ref for avatar and banner", async () => { + const avatarBlob = new Blob(["avatar data"], { type: "image/png" }); + const existingBannerRef = { + $type: "blob" as const, + ref: { $link: "existing-banner-cid" }, + mimeType: "image/jpeg", + size: 4096, + }; + + mockBlobs.upload.mockResolvedValue({ + ref: { $link: "new-avatar-cid" }, + mimeType: "image/png", + size: 1024, + }); + + await profileOps.update({ + avatar: avatarBlob, // Upload this + banner: existingBannerRef, // Use directly + }); + + // Should upload avatar only + expect(mockBlobs.upload).toHaveBeenCalledTimes(1); + expect(mockBlobs.upload).toHaveBeenCalledWith(avatarBlob); + + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + avatar: { $type: "blob", ref: { $link: "new-avatar-cid" }, mimeType: "image/png", size: 1024 }, + banner: existingBannerRef, + }), + }), + ); + }); + + it("should treat malformed blob ref (missing $link) as regular Blob and upload", async () => { + const malformedRef = { + $type: "blob" as const, + ref: {}, // Missing $link + mimeType: "image/png", + size: 1024, + }; + + mockBlobs.upload.mockResolvedValue({ + ref: { $link: "uploaded-cid" }, + mimeType: "image/png", + size: 1024, + }); + + await profileOps.update({ + avatar: malformedRef as unknown as BlobInput, + }); + + // Should upload because it's not a valid JsonBlobRef + expect(mockBlobs.upload).toHaveBeenCalledWith(malformedRef); + }); + + it("should treat malformed blob ref (ref is not an object) as regular Blob and upload", async () => { + const malformedRef = { + $type: "blob" as const, + ref: "string-instead-of-object", + mimeType: "image/png", + size: 1024, + }; + + mockBlobs.upload.mockResolvedValue({ + ref: { $link: "uploaded-cid" }, + mimeType: "image/png", + size: 1024, + }); + + await profileOps.update({ + avatar: malformedRef as unknown as BlobInput, + }); + + // Should upload because it's not a valid JsonBlobRef + expect(mockBlobs.upload).toHaveBeenCalledWith(malformedRef); + }); + + it("should treat malformed blob ref (ref is null) as regular Blob and upload", async () => { + const malformedRef = { + $type: "blob" as const, + ref: null, + mimeType: "image/png", + size: 1024, + }; + + mockBlobs.upload.mockResolvedValue({ + ref: { $link: "uploaded-cid" }, + mimeType: "image/png", + size: 1024, + }); + + await profileOps.update({ + avatar: malformedRef as unknown as BlobInput, + }); + + // Should upload because it's not a valid JsonBlobRef + expect(mockBlobs.upload).toHaveBeenCalledWith(malformedRef); + }); + + it("should treat malformed blob ref (missing mimeType) as regular Blob and upload", async () => { + const malformedRef = { + $type: "blob" as const, + ref: { $link: "some-cid" }, + // Missing mimeType + size: 1024, + }; + + mockBlobs.upload.mockResolvedValue({ + ref: { $link: "uploaded-cid" }, + mimeType: "image/png", + size: 1024, + }); + + await profileOps.update({ + avatar: malformedRef as unknown as BlobInput, + }); + + // Should upload because it's not a valid JsonBlobRef + expect(mockBlobs.upload).toHaveBeenCalledWith(malformedRef); + }); + it("should throw NetworkError when getRecord returns success: false", async () => { mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3291103..73fafbbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,7 +72,7 @@ importers: specifier: ^5.4.1 version: 5.4.1 zod: - specifier: ^3.24.4 + specifier: ^3.25.76 version: 3.25.76 devDependencies: '@rollup/plugin-commonjs':