@hypercerts-org/sdk-core 0.10.0-beta.8 → 0.10.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4,9 +4,52 @@ var oauthClientNode = require('@atproto/oauth-client-node');
4
4
  var zod = require('zod');
5
5
  var api = require('@atproto/api');
6
6
  var lexicon$1 = require('@atproto/lexicon');
7
+ var cid = require('multiformats/cid');
7
8
  var lexicon = require('@hypercerts-org/lexicon');
8
9
  var eventemitter3 = require('eventemitter3');
9
10
 
11
+ /**
12
+ * Regular expression to match a valid URI with a scheme.
13
+ *
14
+ * Matches strings that start with a scheme (one or more alphanumeric characters,
15
+ * plus, period, or hyphen) followed by a colon. This covers schemes that the
16
+ * native URL constructor may not support (e.g., `at://`, `ipfs://`).
17
+ *
18
+ * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.1
19
+ * @internal
20
+ */
21
+ const URI_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;
22
+ /**
23
+ * Check if a string is a valid URI with a scheme.
24
+ *
25
+ * Validates that the string is a properly formatted URI. Uses the native
26
+ * `URL` constructor for standard schemes (http, https, ftp, etc.) and falls
27
+ * back to scheme detection for non-standard schemes like `at://` and `ipfs://`
28
+ * that the `URL` constructor does not support.
29
+ *
30
+ * @param uri - The string to validate
31
+ * @returns True if the string is a valid URI with a scheme, false otherwise
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * isValidUri("https://example.com/report.pdf"); // true
36
+ * isValidUri("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); // true
37
+ * isValidUri("at://did:plc:abc/org.col/rkey"); // true
38
+ * isValidUri("not-a-uri"); // false
39
+ * isValidUri(""); // false
40
+ * ```
41
+ */
42
+ function isValidUri(uri) {
43
+ if (!uri)
44
+ return false;
45
+ try {
46
+ new URL(uri);
47
+ return true;
48
+ }
49
+ catch {
50
+ return URI_SCHEME_REGEX.test(uri);
51
+ }
52
+ }
10
53
  /**
11
54
  * Type guard to check if a URL is a loopback address.
12
55
  *
@@ -1642,7 +1685,7 @@ function validateScope(scope) {
1642
1685
  const permissions = parseScope(scope);
1643
1686
  const invalidPermissions = [];
1644
1687
  // Pattern for valid permission prefixes
1645
- const validPrefixes = /^(atproto|transition:|account:|repo:|blob:?|rpc:|identity:|include:)/;
1688
+ const validPrefixes = /^(atproto$|transition:|account:|repo[:?]|blob[:?]|rpc[:?]|identity:|include:)/;
1646
1689
  for (const permission of permissions) {
1647
1690
  if (!validPrefixes.test(permission)) {
1648
1691
  invalidPermissions.push(permission);
@@ -1686,7 +1729,7 @@ function validateScope(scope) {
1686
1729
  * jwksUri: "https://my-app.com/.well-known/jwks.json",
1687
1730
  * jwkPrivate: process.env.JWK_PRIVATE_KEY!,
1688
1731
  * },
1689
- * servers: { pds: "https://bsky.social" },
1732
+ * handleResolver: "https://pds-eu-west4.test.certified.app",
1690
1733
  * });
1691
1734
  *
1692
1735
  * // Start authorization
@@ -1756,7 +1799,7 @@ class OAuthClient {
1756
1799
  keyset,
1757
1800
  stateStore: this.createStateStoreAdapter(stateStore),
1758
1801
  sessionStore: this.createSessionStoreAdapter(sessionStore),
1759
- handleResolver: this.config.servers?.pds,
1802
+ handleResolver: this.config.handleResolver,
1760
1803
  fetch: this.config.fetch ?? fetchWithTimeout,
1761
1804
  });
1762
1805
  return this.client;
@@ -2878,6 +2921,148 @@ class RecordOperationsImpl {
2878
2921
  }
2879
2922
  }
2880
2923
 
2924
+ /**
2925
+ * Validates that a string is a valid DID format.
2926
+ *
2927
+ * DIDs must follow the format: `did:<method>:<method-specific-id>`
2928
+ * where method is lowercase letters and digits, and the identifier contains
2929
+ * alphanumeric characters plus `.`, `_`, `:`, `%`, and `-`.
2930
+ *
2931
+ * @param did - The string to validate
2932
+ * @returns true if the string is a valid DID format
2933
+ *
2934
+ * @example
2935
+ * ```typescript
2936
+ * isValidDid("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); // true
2937
+ * isValidDid("did:web:example.com"); // true
2938
+ * isValidDid("not-a-did"); // false
2939
+ * isValidDid("did:"); // false
2940
+ * ```
2941
+ *
2942
+ * @see https://www.w3.org/TR/did-core/#did-syntax for DID syntax specification
2943
+ */
2944
+ function isValidDid(did) {
2945
+ // DID format: did:<method>:<method-specific-id>
2946
+ // Method: lowercase letters and digits (per W3C DID Core spec)
2947
+ // Identifier: alphanumeric plus . _ : % -
2948
+ // method-specific-id must end with at least one non-colon idchar (W3C DID Core 1.0)
2949
+ return /^did:[a-z0-9]+:(?:[a-zA-Z0-9._%-]+:)*[a-zA-Z0-9._%-]+$/.test(did);
2950
+ }
2951
+ /**
2952
+ * Zod schema for collaborator permissions in SDS repositories.
2953
+ *
2954
+ * Defines the granular permissions a collaborator can have on a shared repository.
2955
+ * Permissions follow a hierarchical model where higher-level permissions
2956
+ * typically imply lower-level ones.
2957
+ */
2958
+ const CollaboratorPermissionsSchema = zod.z.object({
2959
+ /**
2960
+ * Can read/view records in the repository.
2961
+ * This is the most basic permission level.
2962
+ */
2963
+ read: zod.z.boolean(),
2964
+ /**
2965
+ * Can create new records in the repository.
2966
+ * Typically implies `read` permission.
2967
+ */
2968
+ create: zod.z.boolean(),
2969
+ /**
2970
+ * Can modify existing records in the repository.
2971
+ * Typically implies `read` and `create` permissions.
2972
+ */
2973
+ update: zod.z.boolean(),
2974
+ /**
2975
+ * Can delete records from the repository.
2976
+ * Typically implies `read`, `create`, and `update` permissions.
2977
+ */
2978
+ delete: zod.z.boolean(),
2979
+ /**
2980
+ * Can manage collaborators and their permissions.
2981
+ * Administrative permission that allows inviting/removing collaborators.
2982
+ */
2983
+ admin: zod.z.boolean(),
2984
+ /**
2985
+ * Full ownership of the repository.
2986
+ * Owners have all permissions and cannot be removed by other admins.
2987
+ * There must always be at least one owner.
2988
+ */
2989
+ owner: zod.z.boolean(),
2990
+ });
2991
+ /**
2992
+ * Zod schema for SDS organization data.
2993
+ *
2994
+ * Organizations are top-level entities in SDS that can own repositories
2995
+ * and have multiple collaborators with different permission levels.
2996
+ */
2997
+ const OrganizationSchema = zod.z.object({
2998
+ /**
2999
+ * The organization's DID - unique identifier.
3000
+ * Format: "did:plc:..." or "did:web:..."
3001
+ */
3002
+ did: zod.z.string(),
3003
+ /**
3004
+ * The organization's handle - human-readable identifier.
3005
+ * Format: "orgname.sds.hypercerts.org" or similar
3006
+ */
3007
+ handle: zod.z.string(),
3008
+ /**
3009
+ * Display name for the organization.
3010
+ */
3011
+ name: zod.z.string(),
3012
+ /**
3013
+ * Optional description of the organization's purpose.
3014
+ */
3015
+ description: zod.z.string().optional(),
3016
+ /**
3017
+ * ISO 8601 timestamp when the organization was created.
3018
+ * Format: "2024-01-15T10:30:00.000Z"
3019
+ */
3020
+ createdAt: zod.z.string(),
3021
+ /**
3022
+ * The current user's permissions within this organization.
3023
+ */
3024
+ permissions: CollaboratorPermissionsSchema,
3025
+ /**
3026
+ * How the current user relates to this organization.
3027
+ * - `"owner"`: User created or owns the organization
3028
+ * - `"shared"`: User was invited to collaborate (has permissions)
3029
+ * - `"none"`: User has no access to this organization
3030
+ */
3031
+ accessType: zod.z.enum(["owner", "shared", "none"]),
3032
+ });
3033
+ /**
3034
+ * Zod schema for collaborator data.
3035
+ *
3036
+ * Represents a user who has been granted access to a shared repository
3037
+ * or organization with specific permissions.
3038
+ */
3039
+ const CollaboratorSchema = zod.z.object({
3040
+ /**
3041
+ * The collaborator's DID - their unique identifier.
3042
+ * Format: "did:plc:..." or "did:web:..."
3043
+ */
3044
+ userDid: zod.z.string(),
3045
+ /**
3046
+ * The permissions granted to this collaborator.
3047
+ */
3048
+ permissions: CollaboratorPermissionsSchema,
3049
+ /**
3050
+ * DID of the user who granted these permissions.
3051
+ * Useful for audit trails.
3052
+ */
3053
+ grantedBy: zod.z.string(),
3054
+ /**
3055
+ * ISO 8601 timestamp when permissions were granted.
3056
+ * Format: "2024-01-15T10:30:00.000Z"
3057
+ */
3058
+ grantedAt: zod.z.string(),
3059
+ /**
3060
+ * ISO 8601 timestamp when permissions were revoked, if applicable.
3061
+ * Undefined if the collaborator is still active.
3062
+ */
3063
+ revokedAt: zod.z.string().optional(),
3064
+ });
3065
+
2881
3066
  /**
2882
3067
  * BlobOperationsImpl - Blob upload and retrieval operations.
2883
3068
  *
@@ -2941,6 +3126,9 @@ class BlobOperationsImpl {
2941
3126
  this.repoDid = repoDid;
2942
3127
  this._serverUrl = _serverUrl;
2943
3128
  this.isSDS = isSDS;
3129
+ if (!isValidDid(repoDid)) {
3130
+ throw new ValidationError(`Invalid DID format: "${repoDid}". DIDs must start with "did:" (e.g., "did:plc:abc123")`);
3131
+ }
2944
3132
  }
2945
3133
  /**
2946
3134
  * Uploads a blob to the server.
@@ -3000,11 +3188,7 @@ class BlobOperationsImpl {
3000
3188
  if (!result.success) {
3001
3189
  throw new NetworkError("Failed to upload blob");
3002
3190
  }
3003
- return {
3004
- ref: { $link: result.data.blob.ref.toString() },
3005
- mimeType: result.data.blob.mimeType,
3006
- size: result.data.blob.size,
3007
- };
3191
+ return result.data.blob;
3008
3192
  }
3009
3193
  catch (error) {
3010
3194
  if (error instanceof NetworkError)
@@ -3038,13 +3222,18 @@ class BlobOperationsImpl {
3038
3222
  if (!response.ok) {
3039
3223
  throw new NetworkError(`SDS blob upload failed: ${response.statusText}`);
3040
3224
  }
3225
+ // SDS returns { blob: { ref: { $link: string }, mimeType: string, size: number } }
3226
+ // which is a JSON-serialized blob ref, not a BlobRef instance.
3227
+ // Construct a BlobRef directly using CID.parse to preserve the size from the SDS response.
3041
3228
  const result = (await response.json());
3042
- const ref = typeof result.blob.ref === "string" ? result.blob.ref : result.blob.ref.$link;
3043
- return {
3044
- ref: { $link: ref },
3045
- mimeType: result.blob.mimeType,
3046
- size: result.blob.size,
3047
- };
3229
+ let cid$1;
3230
+ try {
3231
+ cid$1 = cid.CID.parse(result.blob.ref.$link);
3232
+ }
3233
+ catch {
3234
+ throw new NetworkError("SDS blob upload returned an invalid blob reference");
3235
+ }
3236
+ return new lexicon$1.BlobRef(cid$1, result.blob.mimeType, result.blob.size);
3048
3237
  }
3049
3238
  /**
3050
3239
  * Retrieves a blob by its CID.
@@ -3110,556 +3299,1125 @@ class BlobOperationsImpl {
3110
3299
  }
3111
3300
 
3112
3301
  /**
3113
- * Repository types - Shared types for repository operations
3114
- * @packageDocumentation
3115
- */
3116
- /**
3117
- * Converts a blob upload result to JsonBlobRef format.
3302
+ * Lexicons entrypoint - Lexicon definitions and registry.
3118
3303
  *
3119
- * AT Protocol requires blob fields to include the full structure:
3120
- * `{ $type: "blob", ref: { $link }, mimeType, size }`
3304
+ * This sub-entrypoint exports the lexicon registry and hypercert
3305
+ * lexicon constants for working with AT Protocol record schemas.
3121
3306
  *
3122
- * @param uploadResult - Result from BlobOperations.upload()
3123
- * @returns JsonBlobRef formatted for records
3124
- */
3125
- function uploadResultToBlobRef(uploadResult) {
3126
- return {
3127
- $type: "blob",
3128
- ref: uploadResult.ref,
3129
- mimeType: uploadResult.mimeType,
3130
- size: uploadResult.size,
3131
- };
3132
- }
3133
-
3134
- /**
3135
- * ProfileOperationsImpl - User profile operations.
3307
+ * @remarks
3308
+ * Import from `@hypercerts-org/sdk/lexicons`:
3136
3309
  *
3137
- * This module provides the implementation for AT Protocol profile
3138
- * management, including fetching and updating user profiles.
3310
+ * ```typescript
3311
+ * import {
3312
+ * LexiconRegistry,
3313
+ * HYPERCERT_LEXICONS,
3314
+ * HYPERCERT_COLLECTIONS,
3315
+ * } from "@hypercerts-org/sdk/lexicons";
3316
+ * ```
3139
3317
  *
3140
- * @packageDocumentation
3141
- */
3142
- /**
3143
- * Implementation of profile operations for user profile management.
3318
+ * **Exports**:
3319
+ * - {@link LexiconRegistry} - Registry for managing and validating lexicons
3320
+ * - {@link HYPERCERT_LEXICONS} - Array of all hypercert lexicon documents
3321
+ * - {@link HYPERCERT_COLLECTIONS} - Constants for collection NSIDs
3144
3322
  *
3145
- * Profiles in AT Protocol are stored as records in the `app.bsky.actor.profile`
3146
- * collection with the special rkey "self". This class provides a convenient
3147
- * API for reading and updating profile data.
3323
+ * @example Using collection constants
3324
+ * ```typescript
3325
+ * import { HYPERCERT_COLLECTIONS } from "@hypercerts-org/sdk/lexicons";
3148
3326
  *
3149
- * @remarks
3150
- * This class is typically not instantiated directly. Access it through
3151
- * {@link Repository.profile}.
3327
+ * // List hypercerts using the correct collection name
3328
+ * const records = await repo.records.list({
3329
+ * collection: HYPERCERT_COLLECTIONS.RECORD,
3330
+ * });
3152
3331
  *
3153
- * **Profile Fields**:
3154
- * - `handle`: Read-only, managed by the PDS
3155
- * - `displayName`: User's display name (max 64 chars typically)
3156
- * - `description`: Profile bio (max 256 chars typically)
3157
- * - `avatar`: Profile picture blob reference
3158
- * - `banner`: Banner image blob reference
3159
- * - `website`: User's website URL (may not be available on all servers)
3332
+ * // List contributions
3333
+ * const contributions = await repo.records.list({
3334
+ * collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
3335
+ * });
3336
+ * ```
3160
3337
  *
3161
- * @example
3338
+ * @example Custom lexicon registration
3162
3339
  * ```typescript
3163
- * // Get profile
3164
- * const profile = await repo.profile.get();
3165
- * console.log(`${profile.displayName} (@${profile.handle})`);
3166
- *
3167
- * // Update profile
3168
- * await repo.profile.update({
3169
- * displayName: "New Name",
3170
- * description: "Updated bio",
3171
- * });
3340
+ * import { LexiconRegistry } from "@hypercerts-org/sdk/lexicons";
3341
+ *
3342
+ * const registry = sdk.getLexiconRegistry();
3172
3343
  *
3173
- * // Update with new avatar
3174
- * const avatarBlob = new Blob([imageData], { type: "image/png" });
3175
- * await repo.profile.update({ avatar: avatarBlob });
3344
+ * // Register custom lexicon
3345
+ * registry.register({
3346
+ * lexicon: 1,
3347
+ * id: "org.myapp.customRecord",
3348
+ * defs: { ... },
3349
+ * });
3176
3350
  *
3177
- * // Remove a field
3178
- * await repo.profile.update({ website: null });
3351
+ * // Validate a record
3352
+ * const result = registry.validate("org.myapp.customRecord", record);
3353
+ * if (!result.valid) {
3354
+ * console.error(result.error);
3355
+ * }
3179
3356
  * ```
3180
3357
  *
3181
- * @internal
3358
+ * @packageDocumentation
3182
3359
  */
3183
- class ProfileOperationsImpl {
3360
+ /**
3361
+ * All hypercert-related lexicons for registration with AT Protocol Agent.
3362
+ * This array contains all lexicon documents from the published package.
3363
+ */
3364
+ const HYPERCERT_LEXICONS = [
3365
+ lexicon.CERTIFIED_DEFS_LEXICON_JSON,
3366
+ lexicon.LOCATION_LEXICON_JSON,
3367
+ lexicon.STRONG_REF_LEXICON_JSON,
3368
+ lexicon.HYPERCERTS_DEFS_LEXICON_JSON,
3369
+ lexicon.ACTIVITY_LEXICON_JSON,
3370
+ lexicon.COLLECTION_LEXICON_JSON,
3371
+ lexicon.CONTRIBUTION_DETAILS_LEXICON_JSON,
3372
+ lexicon.CONTRIBUTOR_INFORMATION_LEXICON_JSON,
3373
+ lexicon.EVALUATION_LEXICON_JSON,
3374
+ lexicon.ATTACHMENT_LEXICON_JSON,
3375
+ lexicon.MEASUREMENT_LEXICON_JSON,
3376
+ lexicon.RIGHTS_LEXICON_JSON,
3377
+ lexicon.ACTOR_PROFILE_LEXICON_JSON,
3378
+ lexicon.BADGE_AWARD_LEXICON_JSON,
3379
+ lexicon.BADGE_DEFINITION_LEXICON_JSON,
3380
+ lexicon.BADGE_RESPONSE_LEXICON_JSON,
3381
+ lexicon.FUNDING_RECEIPT_LEXICON_JSON,
3382
+ lexicon.WORK_SCOPE_TAG_LEXICON_JSON,
3383
+ ];
3384
+ /**
3385
+ * Collection NSIDs (Namespaced Identifiers) for hypercert records.
3386
+ *
3387
+ * Use these constants when performing record operations to ensure
3388
+ * correct collection names.
3389
+ */
3390
+ const HYPERCERT_COLLECTIONS = {
3184
3391
  /**
3185
- * Creates a new ProfileOperationsImpl.
3186
- *
3187
- * @param agent - AT Protocol Agent for making API calls
3188
- * @param repoDid - DID of the repository/user
3189
- * @param blobs - Blob operations for uploading images
3190
- *
3191
- * @internal
3392
+ * Main hypercert claim record collection.
3192
3393
  */
3193
- constructor(agent, repoDid, blobs) {
3394
+ CLAIM: lexicon.ACTIVITY_NSID,
3395
+ /**
3396
+ * Rights record collection.
3397
+ */
3398
+ RIGHTS: lexicon.RIGHTS_NSID,
3399
+ /**
3400
+ * Location record collection (shared certified lexicon).
3401
+ */
3402
+ LOCATION: lexicon.LOCATION_NSID,
3403
+ /**
3404
+ * Contribution details record collection.
3405
+ * For storing details about a specific contribution (role, description, timeframe).
3406
+ */
3407
+ CONTRIBUTION_DETAILS: lexicon.CONTRIBUTION_DETAILS_NSID,
3408
+ /**
3409
+ * Contributor information record collection.
3410
+ * For storing contributor profile information (identifier, displayName, image).
3411
+ */
3412
+ CONTRIBUTOR_INFORMATION: lexicon.CONTRIBUTOR_INFORMATION_NSID,
3413
+ /**
3414
+ * Measurement record collection.
3415
+ */
3416
+ MEASUREMENT: lexicon.MEASUREMENT_NSID,
3417
+ /**
3418
+ * Evaluation record collection.
3419
+ */
3420
+ EVALUATION: lexicon.EVALUATION_NSID,
3421
+ /**
3422
+ * Attachment record collection.
3423
+ */
3424
+ ATTACHMENT: lexicon.ATTACHMENT_NSID,
3425
+ /**
3426
+ * Collection record collection (groups of hypercerts).
3427
+ * Projects are now collections with type='project'.
3428
+ */
3429
+ COLLECTION: lexicon.COLLECTION_NSID,
3430
+ /**
3431
+ * Badge award record collection.
3432
+ */
3433
+ BADGE_AWARD: lexicon.BADGE_AWARD_NSID,
3434
+ /**
3435
+ * Badge definition record collection.
3436
+ */
3437
+ BADGE_DEFINITION: lexicon.BADGE_DEFINITION_NSID,
3438
+ /**
3439
+ * Badge response record collection.
3440
+ */
3441
+ BADGE_RESPONSE: lexicon.BADGE_RESPONSE_NSID,
3442
+ /**
3443
+ * Funding receipt record collection.
3444
+ */
3445
+ FUNDING_RECEIPT: lexicon.FUNDING_RECEIPT_NSID,
3446
+ /**
3447
+ * Work scope tag record collection.
3448
+ * For defining reusable work scope atoms.
3449
+ */
3450
+ WORK_SCOPE_TAG: lexicon.WORK_SCOPE_TAG_NSID,
3451
+ /**
3452
+ * Bluesky profile collection (app.bsky.actor.profile).
3453
+ */
3454
+ BSKY_PROFILE: "app.bsky.actor.profile",
3455
+ /**
3456
+ * Certified profile collection (app.certified.actor.profile).
3457
+ */
3458
+ CERTIFIED_PROFILE: lexicon.ACTOR_PROFILE_NSID,
3459
+ };
3460
+
3461
+ /**
3462
+ * Blob URL Utilities
3463
+ *
3464
+ * Utilities for constructing AT Protocol blob URLs and extracting image references
3465
+ * from Hypercert image records.
3466
+ *
3467
+ * @remarks
3468
+ *
3469
+ * ## Why these utilities exist
3470
+ *
3471
+ * AT Protocol stores blobs (images, videos, etc.) on PDS servers and returns blob objects
3472
+ * containing a CID (Content Identifier) and mimetype. To make it easier for SDK users to
3473
+ * consume these blobs, we provide utilities to:
3474
+ *
3475
+ * 1. **Convert blob references to URLs**: Transform CID + DID + PDS into a direct URL
3476
+ * 2. **Extract image references**: Handle both blob-based images (CID) and direct URIs
3477
+ *
3478
+ * This allows users to consume images directly without manual URL construction:
3479
+ * This can and will be reused across any response that has to return a blob.
3480
+ *
3481
+ *
3482
+ * @packageDocumentation
3483
+ */
3484
+ /**
3485
+ * Constructs a URL for retrieving a blob from an AT Protocol server.
3486
+ *
3487
+ * @param pdsUrl - The PDS/server base URL (e.g., "https://pds1.certified.app")
3488
+ * @param did - The DID of the repository owner
3489
+ * @param cid - The Content Identifier (CID) of the blob
3490
+ * @returns Full blob URL
3491
+ *
3492
+ * @throws {Error} If any parameter is empty or invalid
3493
+ *
3494
+ * @example
3495
+ * ```typescript
3496
+ * const url = getBlobUrl(
3497
+ * "https://pds1.certified.app",
3498
+ * "did:plc:r5p2aletd4fegsklphgiog3s",
3499
+ * "bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q"
3500
+ * );
3501
+ * // Returns: "https://pds1.certified.app/xrpc/com.atproto.sync.getBlob?did=did:plc:r5p2aletd4fegsklphgiog3s&cid=bafkreieie3unmfnzt6j7w2y3zkkcjhisvjtg3au5myonvpuyel6ecau52q"
3502
+ * ```
3503
+ *
3504
+ * @public
3505
+ */
3506
+ function getBlobUrl(pdsUrl, did, cid) {
3507
+ if (!pdsUrl || typeof pdsUrl !== "string") {
3508
+ throw new Error("pdsUrl must be a non-empty string");
3509
+ }
3510
+ if (!did || typeof did !== "string") {
3511
+ throw new Error("did must be a non-empty string");
3512
+ }
3513
+ if (!cid || typeof cid !== "string") {
3514
+ throw new Error("cid must be a non-empty string");
3515
+ }
3516
+ const normalizedPdsUrl = pdsUrl.replace(/\/$/, "");
3517
+ return `${normalizedPdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
3518
+ }
3519
+ /**
3520
+ * Extracts the CID or URI from a Hypercert image record.
3521
+ *
3522
+ * Handles all Hypercert image formats:
3523
+ * - `smallImage`: Avatar/thumbnail format with blob reference - returns CID
3524
+ * - `largeImage`: Banner/cover format with blob reference - returns CID
3525
+ * - `uri`: Direct URL - returns the URI string
3526
+ *
3527
+ * @param image - Hypercert image record
3528
+ * @returns CID string if image contains a blob reference, URI string if uri format, undefined otherwise
3529
+ *
3530
+ * @example Blob format
3531
+ * ```typescript
3532
+ * const smallImage = {
3533
+ * $type: "org.hypercerts.defs#smallImage",
3534
+ * image: {
3535
+ * $type: "blob",
3536
+ * ref: { $link: "bafyrei123" },
3537
+ * mimeType: "image/png",
3538
+ * size: 1000
3539
+ * }
3540
+ * };
3541
+ *
3542
+ * const cid = extractCidFromImage(smallImage);
3543
+ * // Returns: "bafyrei123"
3544
+ * ```
3545
+ *
3546
+ * @example URI format
3547
+ * ```typescript
3548
+ * const uriImage = {
3549
+ * $type: "org.hypercerts.defs#uri",
3550
+ * uri: "https://example.com/image.jpg"
3551
+ * };
3552
+ *
3553
+ * const uri = extractCidFromImage(uriImage);
3554
+ * // Returns: "https://example.com/image.jpg"
3555
+ * ```
3556
+ *
3557
+ * @public
3558
+ */
3559
+ function extractCidFromImage(image) {
3560
+ if (!image || typeof image !== "object") {
3561
+ return undefined;
3562
+ }
3563
+ // Check $type to determine format
3564
+ const imageType = image.$type;
3565
+ // If it's a URI format, return the URI string directly
3566
+ if (imageType === "org.hypercerts.defs#uri") {
3567
+ const uri = image.uri;
3568
+ if (uri && typeof uri === "string") {
3569
+ return uri;
3570
+ }
3571
+ return undefined;
3572
+ }
3573
+ if (imageType === "org.hypercerts.defs#smallImage" || imageType === "org.hypercerts.defs#largeImage") {
3574
+ // Blob format: image.image.ref.$link
3575
+ const imageData = image.image;
3576
+ if (imageData && typeof imageData === "object") {
3577
+ const ref = imageData.ref;
3578
+ if (ref) {
3579
+ return ref.toString();
3580
+ }
3581
+ }
3582
+ }
3583
+ return undefined;
3584
+ }
3585
+
3586
+ /**
3587
+ * ProfileOperationsImpl - User profile operations supporting dual profiles.
3588
+ *
3589
+ * This module provides operations for managing AT Protocol profiles:
3590
+ * - Bluesky profiles (app.bsky.actor.profile) - Simple profiles with CDN images
3591
+ * - Certified profiles (app.certified.actor.profile) - Hypercerts profiles with pronouns/website
3592
+ *
3593
+ * @packageDocumentation
3594
+ */
3595
+ const BSKY_PROFILE_NSID = HYPERCERT_COLLECTIONS.BSKY_PROFILE;
3596
+ const CERTIFIED_PROFILE_NSID = HYPERCERT_COLLECTIONS.CERTIFIED_PROFILE;
3597
+ /** Profile record key (always "self" for the user's own profile) */
3598
+ const PROFILE_RKEY = "self";
3599
+ /**
3600
+ * Implementation of profile operations supporting dual profiles.
3601
+ *
3602
+ * This class provides operations for both Bluesky and Certified profiles:
3603
+ * - Bluesky profiles: Simple AT Protocol profiles with avatar/banner as CDN URLs
3604
+ * - Certified profiles: Hypercerts profiles hypercerts image format
3605
+ *
3606
+ * @remarks
3607
+ * This class is typically not instantiated directly. Access it through
3608
+ * {@link Repository.profile}.
3609
+ *
3610
+ * **Profile Types**:
3611
+ * - `app.bsky.actor.profile`: Standard Bluesky profile, images are simple blob refs
3612
+ * - `app.certified.actor.profile`: Hypercerts profile, images wrapped in smallImage/largeImage. Omits some bsky profile properties like pinnedPost labels etc
3613
+ *
3614
+ * @example
3615
+ * ```typescript
3616
+ * // Get Bluesky profile
3617
+ * const bskyProfile = await repo.profile.getBskyProfile();
3618
+ * console.log(bskyProfile.displayName);
3619
+ * console.log(bskyProfile.avatar); // CDN URL
3620
+ *
3621
+ * // Get Certified profile
3622
+ * const certifiedProfile = await repo.profile.getCertifiedProfile();
3623
+ * console.log(certifiedProfile.pronouns); // "she/her"
3624
+ * console.log(certifiedProfile.avatar); // Blob URL
3625
+ *
3626
+ * // Create/update profiles
3627
+ * await repo.profile.createBskyProfile({ displayName: "Alice" });
3628
+ * await repo.profile.updateBskyProfile({ description: "New bio" });
3629
+ *
3630
+ * await repo.profile.createCertifiedProfile({
3631
+ * displayName: "Alice",
3632
+ * pronouns: "she/her",
3633
+ * });
3634
+ * ```
3635
+ *
3636
+ * @internal
3637
+ */
3638
+ class ProfileOperationsImpl {
3639
+ /**
3640
+ * Creates a new ProfileOperationsImpl.
3641
+ *
3642
+ * @param agent - AT Protocol Agent for making API calls
3643
+ * @param repoDid - DID of the repository/user
3644
+ * @param blobs - Blob operations for uploading images
3645
+ * @param pdsUrl - PDS URL for constructing blob URLs
3646
+ *
3647
+ * @internal
3648
+ */
3649
+ constructor(agent, repoDid, blobs, pdsUrl) {
3194
3650
  this.agent = agent;
3195
3651
  this.repoDid = repoDid;
3196
3652
  this.blobs = blobs;
3653
+ this.pdsUrl = pdsUrl;
3197
3654
  }
3198
3655
  /**
3199
- * Applies a simple field (string or null) to the profile.
3656
+ * Converts a Hypercert image record to a URL string.
3657
+ *
3658
+ * - URI format: returns the URI string directly
3659
+ * - Blob format (smallImage/largeImage): constructs blob URL using PDS
3660
+ *
3661
+ * @param image - Hypercert image record
3662
+ * @returns URL string
3663
+ * @throws {Error} If image format is invalid or blob CID cannot be extracted
3200
3664
  *
3201
3665
  * @internal
3202
3666
  */
3203
- applySimpleField(result, field, value) {
3204
- if (value === undefined)
3205
- return;
3206
- if (value === null) {
3207
- delete result[field];
3667
+ imageToUrl(image) {
3668
+ const result = extractCidFromImage(image);
3669
+ if (!result) {
3670
+ throw new Error("Unable to extract CID or URI from image record");
3208
3671
  }
3209
- else {
3210
- result[field] = value;
3672
+ if (isValidUri(result)) {
3673
+ return result;
3211
3674
  }
3675
+ // Otherwise, it's a CID - construct blob URL
3676
+ return getBlobUrl(this.pdsUrl, this.repoDid, result);
3212
3677
  }
3213
3678
  /**
3214
- * Applies a blob field to the profile, uploading if needed.
3679
+ * Applies an image field (avatar/banner) with format-specific wrapping.
3680
+ *
3681
+ * - null: removes the field
3682
+ * - undefined: no change
3683
+ * - Blob: uploads and wraps according to collection format
3684
+ *
3685
+ * @param result - The profile record being built
3686
+ * @param field - Field name ("avatar" or "banner")
3687
+ * @param value - Blob to upload, null to remove, or undefined to skip
3688
+ * @param collection - Profile collection NSID (determines image wrapping format)
3215
3689
  *
3216
3690
  * @internal
3217
3691
  */
3218
- async applyBlobField(result, field, blob) {
3219
- if (blob === undefined)
3692
+ async applyImageField(result, field, value, collection) {
3693
+ if (value === undefined)
3220
3694
  return;
3221
- if (blob === null) {
3695
+ if (value === null) {
3222
3696
  delete result[field];
3697
+ return;
3698
+ }
3699
+ const blobRef = await this.blobs.upload(value);
3700
+ // Bsky profiles use simple blob refs, Certified profiles wrap in smallImage/largeImage
3701
+ if (collection === BSKY_PROFILE_NSID) {
3702
+ result[field] = blobRef;
3223
3703
  }
3224
3704
  else {
3225
- const uploadResult = await this.blobs.upload(blob);
3226
- result[field] = uploadResultToBlobRef(uploadResult);
3705
+ const isLargeImage = field === "banner";
3706
+ result[field] = {
3707
+ $type: isLargeImage ? "org.hypercerts.defs#largeImage" : "org.hypercerts.defs#smallImage",
3708
+ image: blobRef,
3709
+ };
3227
3710
  }
3228
3711
  }
3229
3712
  /**
3230
- * Applies profile params to a profile record, handling null values for deletion.
3231
- *
3232
- * Ensures $type and createdAt are always present on the record, using
3233
- * nullish coalescing to allow callers to override defaults.
3713
+ * Validates a profile record against the appropriate lexicon schema.
3234
3714
  *
3715
+ * @param profile - The profile record to validate
3716
+ * @param collection - Profile collection NSID (determines validation schema)
3717
+ * @throws {ValidationError} If validation fails
3235
3718
  * @internal
3236
3719
  */
3237
- async mergeParamsIntoProfile(profile, params) {
3238
- const result = { ...profile };
3239
- // Ensure $type and createdAt are always present
3240
- result.$type = params.$type ?? result.$type ?? "app.bsky.actor.profile";
3241
- result.createdAt = params.createdAt ?? result.createdAt ?? new Date().toISOString();
3242
- this.applySimpleField(result, "displayName", params.displayName);
3243
- this.applySimpleField(result, "description", params.description);
3244
- this.applySimpleField(result, "website", params.website);
3245
- await this.applyBlobField(result, "avatar", params.avatar);
3246
- await this.applyBlobField(result, "banner", params.banner);
3247
- return result;
3248
- }
3249
- /**
3250
- * Gets the repository's profile.
3251
- *
3252
- * @returns Promise resolving to profile data
3253
- * @throws {@link NetworkError} if the profile cannot be fetched
3254
- *
3255
- * @remarks
3256
- * This method fetches the full profile using the `getProfile` API,
3257
- * which includes resolved information like follower counts on some
3258
- * servers. For hypercerts SDK usage, the basic profile fields are
3259
- * returned.
3260
- *
3261
- * **Note**: The `website` field may not be available on all AT Protocol
3262
- * servers. Standard Bluesky profiles don't include this field.
3263
- *
3264
- * @example
3265
- * ```typescript
3266
- * const profile = await repo.profile.get();
3267
- *
3268
- * console.log(`Handle: @${profile.handle}`);
3269
- * console.log(`Name: ${profile.displayName || "(not set)"}`);
3270
- * console.log(`Bio: ${profile.description || "(no bio)"}`);
3271
- *
3272
- * if (profile.avatar) {
3273
- * // Avatar is a URL or blob reference
3274
- * console.log(`Avatar: ${profile.avatar}`);
3275
- * }
3276
- * ```
3277
- */
3278
- async get() {
3279
- try {
3280
- const result = await this.agent.getProfile({ actor: this.repoDid });
3281
- if (!result.success) {
3282
- throw new NetworkError("Failed to get profile");
3720
+ validateProfileRecord(profile, collection) {
3721
+ if (collection === CERTIFIED_PROFILE_NSID) {
3722
+ const validation = lexicon.validate(profile, CERTIFIED_PROFILE_NSID, "main", false);
3723
+ if (!validation.success) {
3724
+ throw new ValidationError(`Invalid profile record: ${validation.error?.message}`);
3283
3725
  }
3284
- return {
3285
- handle: result.data.handle,
3286
- displayName: result.data.displayName,
3287
- description: result.data.description,
3288
- avatar: result.data.avatar,
3289
- banner: result.data.banner,
3290
- // Note: website may not be available in standard profile
3291
- };
3292
3726
  }
3293
- catch (error) {
3294
- if (error instanceof NetworkError)
3295
- throw error;
3296
- throw new NetworkError(`Failed to get profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3727
+ if (collection === BSKY_PROFILE_NSID) {
3728
+ const validation = api.AppBskyActorProfile.validateMain(profile);
3729
+ if (!validation.success) {
3730
+ throw new ValidationError(`Invalid profile record: ${validation.error?.message}`);
3731
+ }
3297
3732
  }
3298
3733
  }
3299
3734
  /**
3300
- * Creates a new profile for the repository.
3735
+ * Creates a profile record with lexicon validation.
3301
3736
  *
3302
- * @param params - Profile fields to set
3737
+ * @param collection - NSID of the collection (Bsky or Certified profile)
3738
+ * @param params - Profile creation parameters
3303
3739
  * @returns Promise resolving to create result with URI and CID
3304
- * @throws {@link NetworkError} if the creation fails
3305
- *
3306
- * @remarks
3307
- * Use this method when no profile exists yet. If a profile already exists,
3308
- * use {@link update} instead.
3309
- *
3310
- * **Image Handling**: When providing `avatar` or `banner` as a Blob,
3311
- * the image is automatically uploaded and the blob reference is stored
3312
- * in the profile.
3313
- *
3314
- * @example Create a basic profile
3315
- * ```typescript
3316
- * await repo.profile.create({
3317
- * displayName: "Alice",
3318
- * description: "Building impact certificates",
3319
- * });
3320
- * ```
3321
- *
3322
- * @example Create a profile with avatar
3323
- * ```typescript
3324
- * const avatarBlob = new Blob([avatarData], { type: "image/png" });
3325
- * await repo.profile.create({
3326
- * displayName: "Alice",
3327
- * description: "Building impact certificates",
3328
- * avatar: avatarBlob,
3329
- * });
3330
- * ```
3740
+ * @throws {ValidationError} if validation fails
3741
+ * @throws {NetworkError} if creation fails
3742
+ * @internal
3331
3743
  */
3332
- async create(params) {
3744
+ async createProfileRecord(collection, params) {
3333
3745
  try {
3334
- const profile = await this.mergeParamsIntoProfile({}, params);
3335
- const createParams = {
3746
+ const { avatar, banner, $type, createdAt, ...otherFields } = params;
3747
+ const profile = {
3748
+ $type: $type ?? collection,
3749
+ createdAt: createdAt ?? new Date().toISOString(),
3750
+ ...otherFields,
3751
+ };
3752
+ await this.applyImageField(profile, "avatar", avatar, collection);
3753
+ await this.applyImageField(profile, "banner", banner, collection);
3754
+ // Validate profile record against lexicon schema
3755
+ this.validateProfileRecord(profile, collection);
3756
+ const result = await this.agent.com.atproto.repo.createRecord({
3336
3757
  repo: this.repoDid,
3337
- collection: "app.bsky.actor.profile",
3338
- rkey: "self",
3758
+ collection,
3759
+ rkey: PROFILE_RKEY,
3339
3760
  record: profile,
3340
- };
3341
- const result = await this.agent.com.atproto.repo.createRecord(createParams);
3761
+ });
3342
3762
  if (!result.success) {
3343
3763
  throw new NetworkError("Failed to create profile");
3344
3764
  }
3345
3765
  return { uri: result.data.uri, cid: result.data.cid };
3346
3766
  }
3347
3767
  catch (error) {
3348
- if (error instanceof NetworkError)
3768
+ if (error instanceof NetworkError || error instanceof ValidationError)
3349
3769
  throw error;
3350
3770
  throw new NetworkError(`Failed to create profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3351
3771
  }
3352
3772
  }
3353
3773
  /**
3354
- * Updates the repository's profile.
3355
- *
3356
- * @param params - Fields to update. Pass `null` to remove a field.
3357
- * Omitted fields are preserved from the existing profile.
3358
- * @returns Promise resolving to update result with new URI and CID
3359
- * @throws {@link NetworkError} if the update fails
3360
- *
3361
- * @remarks
3362
- * This method performs a read-modify-write operation:
3363
- * 1. Fetches the existing profile record
3364
- * 2. Merges in the provided updates
3365
- * 3. Writes the updated profile back
3366
- *
3367
- * **Image Handling**: When providing `avatar` or `banner` as a Blob,
3368
- * the image is automatically uploaded and the blob reference is stored
3369
- * in the profile.
3370
- *
3371
- * **Field Removal**: Pass `null` to explicitly remove a field. Omitting
3372
- * a field (not including it in params) preserves the existing value.
3373
- *
3374
- * @example Update display name and bio
3375
- * ```typescript
3376
- * await repo.profile.update({
3377
- * displayName: "Alice",
3378
- * description: "Building impact certificates",
3379
- * });
3380
- * ```
3381
- *
3382
- * @example Update avatar image
3383
- * ```typescript
3384
- * // From a file input
3385
- * const file = document.getElementById("avatar").files[0];
3386
- * await repo.profile.update({ avatar: file });
3387
- *
3388
- * // From raw data
3389
- * const response = await fetch("https://example.com/my-avatar.png");
3390
- * const blob = await response.blob();
3391
- * await repo.profile.update({ avatar: blob });
3392
- * ```
3393
- *
3394
- * @example Remove description
3395
- * ```typescript
3396
- * // Removes the description field entirely
3397
- * await repo.profile.update({ description: null });
3398
- * ```
3399
- *
3400
- * @example Multiple updates at once
3401
- * ```typescript
3402
- * const newAvatar = new Blob([avatarData], { type: "image/png" });
3403
- * const newBanner = new Blob([bannerData], { type: "image/jpeg" });
3774
+ * Updates a profile record with proper null handling and validation.
3404
3775
  *
3405
- * await repo.profile.update({
3406
- * displayName: "New Name",
3407
- * description: "New bio",
3408
- * avatar: newAvatar,
3409
- * banner: newBanner,
3410
- * });
3411
- * ```
3776
+ * @param collection - NSID of the collection (Bsky or Certified profile)
3777
+ * @param params - Profile update parameters (partial, with null for deletions)
3778
+ * @returns Promise resolving to update result with URI and CID
3779
+ * @throws {NetworkError} if profile not found or update fails
3780
+ * @throws {ValidationError} if validation fails
3781
+ * @internal
3412
3782
  */
3413
- async update(params) {
3783
+ async updateProfileRecord(collection, params) {
3414
3784
  try {
3415
- // Get existing profile record
3416
- const getParams = {
3785
+ const existing = await this.agent.com.atproto.repo.getRecord({
3417
3786
  repo: this.repoDid,
3418
- collection: "app.bsky.actor.profile",
3419
- rkey: "self",
3420
- };
3421
- const existing = await this.agent.com.atproto.repo.getRecord(getParams);
3787
+ collection,
3788
+ rkey: PROFILE_RKEY,
3789
+ });
3422
3790
  if (!existing.success) {
3423
- throw new NetworkError("Profile not found. Use create() for new profiles.");
3791
+ throw new NetworkError("Profile not found");
3792
+ }
3793
+ const { avatar, banner, ...otherFields } = params;
3794
+ const updatedProfile = {
3795
+ ...existing.data.value,
3796
+ };
3797
+ // Apply non-image field updates, handling null deletions
3798
+ for (const [key, value] of Object.entries(otherFields)) {
3799
+ if (value === null) {
3800
+ delete updatedProfile[key];
3801
+ }
3802
+ else if (value !== undefined) {
3803
+ updatedProfile[key] = value;
3804
+ }
3424
3805
  }
3425
- const existingProfile = existing.data.value || {};
3426
- const updatedProfile = await this.mergeParamsIntoProfile(existingProfile, params);
3427
- const putParams = {
3806
+ await this.applyImageField(updatedProfile, "avatar", avatar, collection);
3807
+ await this.applyImageField(updatedProfile, "banner", banner, collection);
3808
+ // Validate updated record against lexicon schema
3809
+ this.validateProfileRecord(updatedProfile, collection);
3810
+ const result = await this.agent.com.atproto.repo.putRecord({
3428
3811
  repo: this.repoDid,
3429
- collection: "app.bsky.actor.profile",
3430
- rkey: "self",
3812
+ collection,
3813
+ rkey: PROFILE_RKEY,
3431
3814
  record: updatedProfile,
3432
- };
3433
- const result = await this.agent.com.atproto.repo.putRecord(putParams);
3815
+ });
3434
3816
  if (!result.success) {
3435
3817
  throw new NetworkError("Failed to update profile");
3436
3818
  }
3437
3819
  return { uri: result.data.uri, cid: result.data.cid };
3438
3820
  }
3439
3821
  catch (error) {
3440
- if (error instanceof NetworkError)
3822
+ if (error instanceof NetworkError || error instanceof ValidationError)
3441
3823
  throw error;
3442
3824
  throw new NetworkError(`Failed to update profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3443
3825
  }
3444
3826
  }
3445
- }
3446
-
3447
- /**
3448
- * Lexicons entrypoint - Lexicon definitions and registry.
3827
+ /**
3828
+ * Upserts a profile record (creates if missing, updates if exists).
3829
+ *
3830
+ * @param collection - NSID of the collection (Bsky or Certified profile)
3831
+ * @param params - Profile fields to set
3832
+ * @returns Promise resolving to update result with URI and CID
3833
+ * @throws {ValidationError} if validation fails
3834
+ * @throws {NetworkError} if operation fails
3835
+ * @internal
3836
+ */
3837
+ async upsertProfileRecord(collection, params) {
3838
+ try {
3839
+ // Check if profile exists
3840
+ const existing = await this.agent.com.atproto.repo.getRecord({
3841
+ repo: this.repoDid,
3842
+ collection,
3843
+ rkey: PROFILE_RKEY,
3844
+ });
3845
+ if (!existing.success) {
3846
+ return this.createProfileRecord(collection, params);
3847
+ }
3848
+ const { avatar, banner, ...otherFields } = params;
3849
+ const updatedProfile = {
3850
+ ...existing.data.value,
3851
+ };
3852
+ for (const [key, value] of Object.entries(otherFields)) {
3853
+ // ignore these since profile has already created
3854
+ if (["$type", "createdAt"].includes(key))
3855
+ continue;
3856
+ if (value === null) {
3857
+ delete updatedProfile[key];
3858
+ }
3859
+ else if (value !== undefined) {
3860
+ updatedProfile[key] = value;
3861
+ }
3862
+ }
3863
+ await this.applyImageField(updatedProfile, "avatar", avatar, collection);
3864
+ await this.applyImageField(updatedProfile, "banner", banner, collection);
3865
+ this.validateProfileRecord(updatedProfile, collection);
3866
+ const result = await this.agent.com.atproto.repo.putRecord({
3867
+ repo: this.repoDid,
3868
+ collection,
3869
+ rkey: PROFILE_RKEY,
3870
+ record: updatedProfile,
3871
+ });
3872
+ if (!result.success) {
3873
+ throw new NetworkError("Failed to upsert profile");
3874
+ }
3875
+ return { uri: result.data.uri, cid: result.data.cid };
3876
+ }
3877
+ catch (error) {
3878
+ if (error instanceof NetworkError || error instanceof ValidationError)
3879
+ throw error;
3880
+ throw new NetworkError(`Failed to upsert profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3881
+ }
3882
+ }
3883
+ /**
3884
+ * Gets Bluesky profile (app.bsky.actor.profile).
3885
+ *
3886
+ * @returns Promise resolving to Bluesky profile data
3887
+ * @throws {NetworkError} If profile cannot be fetched
3888
+ *
3889
+ * @example
3890
+ * ```typescript
3891
+ * const bskyProfile = await repo.profile.getBskyProfile();
3892
+ * console.log(bskyProfile.displayName); // "Alice"
3893
+ * console.log(bskyProfile.avatar); // "https://cdn.bsky.app/..."
3894
+ * ```
3895
+ */
3896
+ async getBskyProfile() {
3897
+ try {
3898
+ const profileResult = await this.agent.getProfile({ actor: this.repoDid });
3899
+ if (!profileResult.success) {
3900
+ throw new NetworkError("Failed to get Bluesky profile");
3901
+ }
3902
+ return profileResult.data;
3903
+ }
3904
+ catch (error) {
3905
+ if (error instanceof NetworkError)
3906
+ throw error;
3907
+ throw new NetworkError(`Failed to get Bluesky profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3908
+ }
3909
+ }
3910
+ /**
3911
+ * Gets Certified profile (app.certified.actor.profile).
3912
+ *
3913
+ * Returns the profile record with avatar and banner converted to blob URLs.
3914
+ * Includes the user's handle fetched from getProfile(). If getProfile() fails,
3915
+ * handle is set to empty string.
3916
+ *
3917
+ * @returns Promise resolving to Certified profile data, or null if no profile exists
3918
+ * @throws {NetworkError} If profile fetch fails due to network/server issues
3919
+ *
3920
+ * @example
3921
+ * ```typescript
3922
+ * const certifiedProfile = await repo.profile.getCertifiedProfile();
3923
+ * if (certifiedProfile) {
3924
+ * console.log(certifiedProfile.displayName); // "Alice"
3925
+ * console.log(certifiedProfile.pronouns); // "she/her"
3926
+ * console.log(certifiedProfile.avatar); // "https://pds.../xrpc/..."
3927
+ * } else {
3928
+ * console.log("User hasn't created a certified profile yet");
3929
+ * }
3930
+ * ```
3931
+ */
3932
+ async getCertifiedProfile() {
3933
+ try {
3934
+ // Fetch handle from Bluesky profile (non-blocking)
3935
+ let handle = "";
3936
+ try {
3937
+ const profileResult = await this.agent.getProfile({ actor: this.repoDid });
3938
+ if (profileResult.success) {
3939
+ handle = profileResult.data.handle;
3940
+ }
3941
+ }
3942
+ catch {
3943
+ // Ignore error, use empty string for handle
3944
+ handle = "";
3945
+ }
3946
+ // Fetch certified profile record
3947
+ const recordResult = await this.agent.com.atproto.repo.getRecord({
3948
+ repo: this.repoDid,
3949
+ collection: CERTIFIED_PROFILE_NSID,
3950
+ rkey: PROFILE_RKEY,
3951
+ });
3952
+ if (!recordResult.success) {
3953
+ return null;
3954
+ }
3955
+ const profileRecord = recordResult.data.value;
3956
+ let avatar;
3957
+ let banner;
3958
+ if (profileRecord.avatar) {
3959
+ avatar = this.imageToUrl(profileRecord.avatar);
3960
+ }
3961
+ if (profileRecord.banner) {
3962
+ banner = this.imageToUrl(profileRecord.banner);
3963
+ }
3964
+ return {
3965
+ ...profileRecord,
3966
+ handle,
3967
+ avatar,
3968
+ banner,
3969
+ };
3970
+ }
3971
+ catch (error) {
3972
+ // Check for RecordNotFoundError from AT Protocol SDK
3973
+ if (error && typeof error === "object" && "error" in error && error.error === "RecordNotFound") {
3974
+ return null;
3975
+ }
3976
+ // Actual network/server errors still throw
3977
+ if (error instanceof NetworkError)
3978
+ throw error;
3979
+ throw new NetworkError(`Failed to get Certified profile: ${error instanceof Error ? error.message : "Unknown error"}`, error);
3980
+ }
3981
+ }
3982
+ /**
3983
+ * Creates Bluesky profile (app.bsky.actor.profile).
3984
+ *
3985
+ * @param params - Profile fields to set
3986
+ * @returns Promise resolving to create result with URI and CID
3987
+ * @throws {NetworkError} If creation fails
3988
+ *
3989
+ * @example
3990
+ * ```typescript
3991
+ * await repo.profile.createBskyProfile({
3992
+ * displayName: "Alice",
3993
+ * description: "Building impact certificates",
3994
+ * });
3995
+ * ```
3996
+ */
3997
+ async createBskyProfile(params) {
3998
+ return this.createProfileRecord(BSKY_PROFILE_NSID, params);
3999
+ }
4000
+ /**
4001
+ * Updates Bluesky profile (app.bsky.actor.profile).
4002
+ *
4003
+ * @param params - Fields to update (pass null to remove)
4004
+ * @returns Promise resolving to update result with URI and CID
4005
+ * @throws {NetworkError} If update fails
4006
+ *
4007
+ * @example
4008
+ * ```typescript
4009
+ * await repo.profile.updateBskyProfile({
4010
+ * displayName: "New Name",
4011
+ * description: null, // Remove description
4012
+ * });
4013
+ * ```
4014
+ */
4015
+ async updateBskyProfile(params) {
4016
+ return this.updateProfileRecord(BSKY_PROFILE_NSID, params);
4017
+ }
4018
+ /**
4019
+ * Creates Certified profile (app.certified.actor.profile).
4020
+ *
4021
+ * @param params - Profile fields to set
4022
+ * @returns Promise resolving to create result with URI and CID
4023
+ * @throws {NetworkError} If creation fails
4024
+ *
4025
+ * @example
4026
+ * ```typescript
4027
+ * await repo.profile.createCertifiedProfile({
4028
+ * displayName: "Alice",
4029
+ * description: "Building impact certificates",
4030
+ * pronouns: "she/her",
4031
+ * website: "https://alice.com",
4032
+ * });
4033
+ * ```
4034
+ */
4035
+ async createCertifiedProfile(params) {
4036
+ return this.createProfileRecord(CERTIFIED_PROFILE_NSID, params);
4037
+ }
4038
+ /**
4039
+ * Updates Certified profile (app.certified.actor.profile).
4040
+ *
4041
+ * @param params - Fields to update (pass null to remove)
4042
+ * @returns Promise resolving to update result with URI and CID
4043
+ * @throws {NetworkError} If update fails
4044
+ *
4045
+ * @example
4046
+ * ```typescript
4047
+ * await repo.profile.updateCertifiedProfile({
4048
+ * displayName: "New Name",
4049
+ * pronouns: null, // Remove pronouns
4050
+ * });
4051
+ * ```
4052
+ */
4053
+ async updateCertifiedProfile(params) {
4054
+ return this.updateProfileRecord(CERTIFIED_PROFILE_NSID, params);
4055
+ }
4056
+ /**
4057
+ * Upserts Bluesky profile (creates if missing, updates if exists).
4058
+ *
4059
+ * Automatically detects whether the profile exists and creates or updates accordingly.
4060
+ * This is the recommended method for most use cases.
4061
+ *
4062
+ * @param params - Profile fields to set
4063
+ * @returns Promise resolving to update result with URI and CID
4064
+ * @throws {NetworkError} If operation fails
4065
+ * @throws {ValidationError} If validation fails
4066
+ *
4067
+ * @example
4068
+ * ```typescript
4069
+ * // Works whether profile exists or not
4070
+ * await repo.profile.upsertBskyProfile({
4071
+ * displayName: "Alice",
4072
+ * description: "Building on AT Protocol",
4073
+ * });
4074
+ * ```
4075
+ */
4076
+ async upsertBskyProfile(params) {
4077
+ return this.upsertProfileRecord(BSKY_PROFILE_NSID, params);
4078
+ }
4079
+ /**
4080
+ * Upserts Certified profile (creates if missing, updates if exists).
4081
+ *
4082
+ * Automatically detects whether the profile exists and creates or updates accordingly.
4083
+ * This is the recommended method for most use cases.
4084
+ *
4085
+ * @param params - Profile fields to set
4086
+ * @returns Promise resolving to update result with URI and CID
4087
+ * @throws {NetworkError} If operation fails
4088
+ * @throws {ValidationError} If validation fails
4089
+ *
4090
+ * @example
4091
+ * ```typescript
4092
+ * // Works whether profile exists or not
4093
+ * await repo.profile.upsertCertifiedProfile({
4094
+ * displayName: "Alice",
4095
+ * pronouns: "she/her",
4096
+ * website: "https://alice.com",
4097
+ * });
4098
+ * ```
4099
+ */
4100
+ async upsertCertifiedProfile(params) {
4101
+ return this.upsertProfileRecord(CERTIFIED_PROFILE_NSID, params);
4102
+ }
4103
+ }
4104
+
4105
+ /**
4106
+ * Crypto utilities for the SDK.
4107
+ */
4108
+ /**
4109
+ * Deterministically stringifies an object by sorting keys recursively.
4110
+ * Handles deeply nested objects and null values correctly.
4111
+ */
4112
+ function stableStringify(obj) {
4113
+ if (obj === undefined || typeof obj === "function" || typeof obj === "symbol") {
4114
+ return undefined;
4115
+ }
4116
+ if (obj === null || typeof obj !== "object") {
4117
+ return JSON.stringify(obj);
4118
+ }
4119
+ if (Array.isArray(obj)) {
4120
+ return JSON.stringify(obj.map((item) => {
4121
+ const val = stableStringify(item);
4122
+ return val === undefined ? null : JSON.parse(val);
4123
+ }));
4124
+ }
4125
+ const sortedKeys = Object.keys(obj).sort();
4126
+ const sortedObj = {};
4127
+ for (const key of sortedKeys) {
4128
+ const value = obj[key];
4129
+ const str = stableStringify(value);
4130
+ // Skip undefined or non-serializable values
4131
+ if (str === undefined)
4132
+ continue;
4133
+ sortedObj[key] = JSON.parse(str);
4134
+ }
4135
+ return JSON.stringify(sortedObj);
4136
+ }
4137
+ /**
4138
+ * Computes the SHA-256 hash of a JSON-serializable object.
4139
+ * Returns the hash as a hexadecimal string.
3449
4140
  *
3450
- * This sub-entrypoint exports the lexicon registry and hypercert
3451
- * lexicon constants for working with AT Protocol record schemas.
4141
+ * @param content - The content to hash (will be JSON serialized)
4142
+ * @returns The SHA-256 hash of the content
4143
+ * @throws {ValidationError} If content is not serializable (e.g. undefined, function, symbol)
4144
+ */
4145
+ async function sha256Hash(content) {
4146
+ // Use stable stringification to ensure deterministic output
4147
+ const jsonString = stableStringify(content);
4148
+ if (jsonString === undefined) {
4149
+ throw new ValidationError(`Content illegal: not serializable (type: ${typeof content})`);
4150
+ }
4151
+ const msgBuffer = new TextEncoder().encode(jsonString);
4152
+ if (typeof crypto !== "undefined" && crypto.subtle) {
4153
+ // Browser / Modern Node.js
4154
+ const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
4155
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
4156
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
4157
+ }
4158
+ else {
4159
+ // Fallback for older environments or specific setups if global crypto isn't available
4160
+ try {
4161
+ // Dynamic import to avoid breaking browser builds if bundler doesn't handle it
4162
+ const { createHash } = await import('node:crypto');
4163
+ const hash = createHash("sha256").update(jsonString).digest("hex");
4164
+ return hash;
4165
+ }
4166
+ catch (e) {
4167
+ throw new NetworkError("SHA-256 hashing not supported in this environment", e);
4168
+ }
4169
+ }
4170
+ }
4171
+
4172
+ /**
4173
+ * Lexicon Development Utilities - AT-URI and StrongRef Helpers
4174
+ *
4175
+ * This module provides utilities for working with AT Protocol URIs and strongRefs
4176
+ * when building custom lexicons. These tools help developers create type-safe
4177
+ * references between records.
4178
+ *
4179
+ * @packageDocumentation
4180
+ */
4181
+ /**
4182
+ * Regular expression for parsing AT-URIs.
4183
+ *
4184
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
4185
+ *
4186
+ * Capture groups:
4187
+ * - [1] did - The DID of the repository owner (e.g., "did:plc:abc123")
4188
+ * - [2] collection - The NSID of the record type (e.g., "org.hypercerts.claim.activity")
4189
+ * - [3] rkey - The record key (e.g., "3km2vj4kfqp2a")
4190
+ *
4191
+ * @example Direct regex usage
4192
+ * ```typescript
4193
+ * const match = AT_URI_REGEX.exec("at://did:plc:abc/org.hypercerts.claim.activity/xyz");
4194
+ * if (match) {
4195
+ * const [, did, collection, rkey] = match;
4196
+ * }
4197
+ * ```
4198
+ *
4199
+ * @example Validation
4200
+ * ```typescript
4201
+ * if (AT_URI_REGEX.test(userInput)) {
4202
+ * // Valid AT-URI format
4203
+ * }
4204
+ * ```
3452
4205
  *
3453
4206
  * @remarks
3454
- * Import from `@hypercerts-org/sdk/lexicons`:
4207
+ * For most use cases, prefer using {@link parseAtUri} which provides
4208
+ * better error messages and returns a typed object.
4209
+ */
4210
+ const AT_URI_REGEX = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/;
4211
+ /**
4212
+ * Parse an AT-URI into its component parts.
4213
+ *
4214
+ * Extracts the DID, collection NSID, and record key from an AT-URI string.
4215
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
4216
+ *
4217
+ * @param uri - The AT-URI to parse
4218
+ * @returns The components of the URI
4219
+ * @throws {Error} If the URI format is invalid
3455
4220
  *
4221
+ * @example
3456
4222
  * ```typescript
3457
- * import {
3458
- * LexiconRegistry,
3459
- * HYPERCERT_LEXICONS,
3460
- * HYPERCERT_COLLECTIONS,
3461
- * } from "@hypercerts-org/sdk/lexicons";
4223
+ * const components = parseAtUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
4224
+ * console.log(components);
4225
+ * // {
4226
+ * // did: "did:plc:abc123",
4227
+ * // collection: "org.hypercerts.claim.activity",
4228
+ * // rkey: "3km2vj4kfqp2a"
4229
+ * // }
3462
4230
  * ```
4231
+ */
4232
+ function parseAtUri(uri) {
4233
+ if (!uri.startsWith("at://")) {
4234
+ throw new Error(`Invalid AT-URI format: must start with "at://", got "${uri}"`);
4235
+ }
4236
+ const withoutProtocol = uri.slice(5); // Remove "at://"
4237
+ const parts = withoutProtocol.split("/");
4238
+ if (parts.length !== 3) {
4239
+ throw new Error(`Invalid AT-URI format: expected "at://{did}/{collection}/{rkey}", got "${uri}"`);
4240
+ }
4241
+ const [did, collection, rkey] = parts;
4242
+ if (!did || !collection || !rkey) {
4243
+ throw new Error(`Invalid AT-URI format: all components must be non-empty, got "${uri}"`);
4244
+ }
4245
+ return { did, collection, rkey };
4246
+ }
4247
+ /**
4248
+ * Build an AT-URI from its component parts.
3463
4249
  *
3464
- * **Exports**:
3465
- * - {@link LexiconRegistry} - Registry for managing and validating lexicons
3466
- * - {@link HYPERCERT_LEXICONS} - Array of all hypercert lexicon documents
3467
- * - {@link HYPERCERT_COLLECTIONS} - Constants for collection NSIDs
4250
+ * Constructs a valid AT-URI string from a DID, collection NSID, and record key.
4251
+ * The resulting URI follows the format: `at://{did}/{collection}/{rkey}`
3468
4252
  *
3469
- * @example Using collection constants
4253
+ * @param did - The repository owner's DID
4254
+ * @param collection - The collection NSID (lexicon identifier)
4255
+ * @param rkey - The record key (TID or custom string)
4256
+ * @returns The complete AT-URI
4257
+ *
4258
+ * @example
3470
4259
  * ```typescript
3471
- * import { HYPERCERT_COLLECTIONS } from "@hypercerts-org/sdk/lexicons";
4260
+ * const uri = buildAtUri(
4261
+ * "did:plc:abc123",
4262
+ * "org.hypercerts.claim.activity",
4263
+ * "3km2vj4kfqp2a"
4264
+ * );
4265
+ * console.log(uri); // "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a"
4266
+ * ```
4267
+ */
4268
+ function buildAtUri(did, collection, rkey) {
4269
+ if (!did || !collection || !rkey) {
4270
+ throw new Error("All AT-URI components (did, collection, rkey) must be non-empty");
4271
+ }
4272
+ return `at://${did}/${collection}/${rkey}`;
4273
+ }
4274
+ /**
4275
+ * Extract the record key (TID or custom key) from an AT-URI.
3472
4276
  *
3473
- * // List hypercerts using the correct collection name
3474
- * const records = await repo.records.list({
3475
- * collection: HYPERCERT_COLLECTIONS.RECORD,
3476
- * });
4277
+ * Returns the last component of the AT-URI, which is the record key.
4278
+ * This is equivalent to `parseAtUri(uri).rkey` but more efficient.
3477
4279
  *
3478
- * // List contributions
3479
- * const contributions = await repo.records.list({
3480
- * collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
3481
- * });
4280
+ * @param uri - The AT-URI to extract from
4281
+ * @returns The record key (TID or custom string)
4282
+ * @throws {Error} If the URI format is invalid
4283
+ *
4284
+ * @example
4285
+ * ```typescript
4286
+ * const rkey = extractRkeyFromUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
4287
+ * console.log(rkey); // "3km2vj4kfqp2a"
3482
4288
  * ```
4289
+ */
4290
+ function extractRkeyFromUri(uri) {
4291
+ const { rkey } = parseAtUri(uri);
4292
+ return rkey;
4293
+ }
4294
+ /**
4295
+ * Check if a string is a valid AT-URI format.
3483
4296
  *
3484
- * @example Custom lexicon registration
4297
+ * Validates that the string follows the AT-URI format without throwing errors.
4298
+ * This is useful for input validation before parsing.
4299
+ *
4300
+ * @param uri - The string to validate
4301
+ * @returns True if the string is a valid AT-URI, false otherwise
4302
+ *
4303
+ * @example
3485
4304
  * ```typescript
3486
- * import { LexiconRegistry } from "@hypercerts-org/sdk/lexicons";
4305
+ * if (isValidAtUri(userInput)) {
4306
+ * const components = parseAtUri(userInput);
4307
+ * // ... use components
4308
+ * } else {
4309
+ * console.error("Invalid AT-URI");
4310
+ * }
4311
+ * ```
4312
+ */
4313
+ function isValidAtUri(uri) {
4314
+ try {
4315
+ parseAtUri(uri);
4316
+ return true;
4317
+ }
4318
+ catch {
4319
+ return false;
4320
+ }
4321
+ }
4322
+ /**
4323
+ * Create a strongRef from a URI and CID.
3487
4324
  *
3488
- * const registry = sdk.getLexiconRegistry();
4325
+ * StrongRefs are the canonical way to reference specific versions of records
4326
+ * in AT Protocol. They combine an AT-URI (which identifies the record) with
4327
+ * a CID (which identifies the specific version).
4328
+ *
4329
+ * @param uri - The AT-URI of the record
4330
+ * @param cid - The CID (Content Identifier) of the record version
4331
+ * @returns A strongRef object
4332
+ *
4333
+ * @example
4334
+ * ```typescript
4335
+ * const ref = createStrongRef(
4336
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
4337
+ * "bafyreiabc123..."
4338
+ * );
4339
+ * console.log(ref);
4340
+ * // {
4341
+ * // uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
4342
+ * // cid: "bafyreiabc123..."
4343
+ * // }
4344
+ * ```
4345
+ */
4346
+ function createStrongRef(uri, cid) {
4347
+ if (!uri || !cid) {
4348
+ throw new Error("Both uri and cid are required to create a strongRef");
4349
+ }
4350
+ return { uri, cid };
4351
+ }
4352
+ /**
4353
+ * Create a strongRef from a CreateResult or UpdateResult.
4354
+ *
4355
+ * This is a convenience function that extracts the URI and CID from
4356
+ * the result of a record creation or update operation.
4357
+ *
4358
+ * @param result - The result from creating or updating a record
4359
+ * @returns A strongRef object
3489
4360
  *
3490
- * // Register custom lexicon
3491
- * registry.register({
3492
- * lexicon: 1,
3493
- * id: "org.myapp.customRecord",
3494
- * defs: { ... },
4361
+ * @example
4362
+ * ```typescript
4363
+ * const hypercert = await repo.hypercerts.create({
4364
+ * title: "Climate Research",
4365
+ * // ... other params
3495
4366
  * });
3496
4367
  *
3497
- * // Validate a record
3498
- * const result = registry.validate("org.myapp.customRecord", record);
3499
- * if (!result.valid) {
3500
- * console.error(result.error);
3501
- * }
4368
+ * const ref = createStrongRefFromResult(hypercert);
4369
+ * // Now use ref in another record to reference this hypercert
3502
4370
  * ```
3503
- *
3504
- * @packageDocumentation
3505
- */
3506
- /**
3507
- * All hypercert-related lexicons for registration with AT Protocol Agent.
3508
- * This array contains all lexicon documents from the published package.
3509
4371
  */
3510
- const HYPERCERT_LEXICONS = [
3511
- lexicon.CERTIFIED_DEFS_LEXICON_JSON,
3512
- lexicon.LOCATION_LEXICON_JSON,
3513
- lexicon.STRONG_REF_LEXICON_JSON,
3514
- lexicon.HYPERCERTS_DEFS_LEXICON_JSON,
3515
- lexicon.ACTIVITY_LEXICON_JSON,
3516
- lexicon.COLLECTION_LEXICON_JSON,
3517
- lexicon.CONTRIBUTION_DETAILS_LEXICON_JSON,
3518
- lexicon.CONTRIBUTOR_INFORMATION_LEXICON_JSON,
3519
- lexicon.EVALUATION_LEXICON_JSON,
3520
- lexicon.ATTACHMENT_LEXICON_JSON,
3521
- lexicon.MEASUREMENT_LEXICON_JSON,
3522
- lexicon.RIGHTS_LEXICON_JSON,
3523
- lexicon.BADGE_AWARD_LEXICON_JSON,
3524
- lexicon.BADGE_DEFINITION_LEXICON_JSON,
3525
- lexicon.BADGE_RESPONSE_LEXICON_JSON,
3526
- lexicon.FUNDING_RECEIPT_LEXICON_JSON,
3527
- lexicon.WORK_SCOPE_TAG_LEXICON_JSON,
3528
- ];
4372
+ function createStrongRefFromResult(result) {
4373
+ return createStrongRef(result.uri, result.cid);
4374
+ }
3529
4375
  /**
3530
- * Collection NSIDs (Namespaced Identifiers) for hypercert records.
4376
+ * Validate that an object is a valid strongRef.
3531
4377
  *
3532
- * Use these constants when performing record operations to ensure
3533
- * correct collection names.
3534
- */
3535
- const HYPERCERT_COLLECTIONS = {
3536
- /**
3537
- * Main hypercert claim record collection.
3538
- */
3539
- CLAIM: lexicon.ACTIVITY_NSID,
3540
- /**
3541
- * Rights record collection.
3542
- */
3543
- RIGHTS: lexicon.RIGHTS_NSID,
3544
- /**
3545
- * Location record collection (shared certified lexicon).
3546
- */
3547
- LOCATION: lexicon.LOCATION_NSID,
3548
- /**
3549
- * Contribution details record collection.
3550
- * For storing details about a specific contribution (role, description, timeframe).
3551
- */
3552
- CONTRIBUTION_DETAILS: lexicon.CONTRIBUTION_DETAILS_NSID,
3553
- /**
3554
- * Contributor information record collection.
3555
- * For storing contributor profile information (identifier, displayName, image).
3556
- */
3557
- CONTRIBUTOR_INFORMATION: lexicon.CONTRIBUTOR_INFORMATION_NSID,
3558
- /**
3559
- * Measurement record collection.
3560
- */
3561
- MEASUREMENT: lexicon.MEASUREMENT_NSID,
3562
- /**
3563
- * Evaluation record collection.
3564
- */
3565
- EVALUATION: lexicon.EVALUATION_NSID,
3566
- /**
3567
- * Attachment record collection.
3568
- */
3569
- ATTACHMENT: lexicon.ATTACHMENT_NSID,
3570
- /**
3571
- * Collection record collection (groups of hypercerts).
3572
- * Projects are now collections with type='project'.
3573
- */
3574
- COLLECTION: lexicon.COLLECTION_NSID,
3575
- /**
3576
- * Badge award record collection.
3577
- */
3578
- BADGE_AWARD: lexicon.BADGE_AWARD_NSID,
3579
- /**
3580
- * Badge definition record collection.
3581
- */
3582
- BADGE_DEFINITION: lexicon.BADGE_DEFINITION_NSID,
3583
- /**
3584
- * Badge response record collection.
3585
- */
3586
- BADGE_RESPONSE: lexicon.BADGE_RESPONSE_NSID,
3587
- /**
3588
- * Funding receipt record collection.
3589
- */
3590
- FUNDING_RECEIPT: lexicon.FUNDING_RECEIPT_NSID,
3591
- /**
3592
- * Work scope tag record collection.
3593
- * For defining reusable work scope atoms.
3594
- */
3595
- WORK_SCOPE_TAG: lexicon.WORK_SCOPE_TAG_NSID,
3596
- };
3597
-
3598
- /**
3599
- * Crypto utilities for the SDK.
3600
- */
3601
- /**
3602
- * Deterministically stringifies an object by sorting keys recursively.
3603
- * Handles deeply nested objects and null values correctly.
4378
+ * Checks that the object has the required `uri` and `cid` properties
4379
+ * and that they are non-empty strings.
4380
+ *
4381
+ * @param ref - The object to validate
4382
+ * @returns True if the object is a valid strongRef, false otherwise
4383
+ *
4384
+ * @example
4385
+ * ```typescript
4386
+ * const maybeRef = { uri: "at://...", cid: "bafyrei..." };
4387
+ * if (validateStrongRef(maybeRef)) {
4388
+ * // Safe to use as strongRef
4389
+ * record.subject = maybeRef;
4390
+ * }
4391
+ * ```
3604
4392
  */
3605
- function stableStringify(obj) {
3606
- if (obj === undefined || typeof obj === "function" || typeof obj === "symbol") {
3607
- return undefined;
3608
- }
3609
- if (obj === null || typeof obj !== "object") {
3610
- return JSON.stringify(obj);
3611
- }
3612
- if (Array.isArray(obj)) {
3613
- return JSON.stringify(obj.map((item) => {
3614
- const val = stableStringify(item);
3615
- return val === undefined ? null : JSON.parse(val);
3616
- }));
3617
- }
3618
- const sortedKeys = Object.keys(obj).sort();
3619
- const sortedObj = {};
3620
- for (const key of sortedKeys) {
3621
- const value = obj[key];
3622
- const str = stableStringify(value);
3623
- // Skip undefined or non-serializable values
3624
- if (str === undefined)
3625
- continue;
3626
- sortedObj[key] = JSON.parse(str);
4393
+ function validateStrongRef(ref) {
4394
+ if (!ref || typeof ref !== "object") {
4395
+ return false;
3627
4396
  }
3628
- return JSON.stringify(sortedObj);
4397
+ const obj = ref;
4398
+ return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
3629
4399
  }
3630
4400
  /**
3631
- * Computes the SHA-256 hash of a JSON-serializable object.
3632
- * Returns the hash as a hexadecimal string.
4401
+ * Type guard to check if a value is a strongRef.
3633
4402
  *
3634
- * @param content - The content to hash (will be JSON serialized)
3635
- * @returns The SHA-256 hash of the content
3636
- * @throws {ValidationError} If content is not serializable (e.g. undefined, function, symbol)
4403
+ * This is an alias for `validateStrongRef` that provides better semantics
4404
+ * for type narrowing in TypeScript.
4405
+ *
4406
+ * @param value - The value to check
4407
+ * @returns True if the value is a strongRef, false otherwise
4408
+ *
4409
+ * @example
4410
+ * ```typescript
4411
+ * function processReference(ref: unknown) {
4412
+ * if (isStrongRef(ref)) {
4413
+ * // TypeScript knows ref is StrongRef here
4414
+ * console.log(ref.uri);
4415
+ * }
4416
+ * }
4417
+ * ```
3637
4418
  */
3638
- async function sha256Hash(content) {
3639
- // Use stable stringification to ensure deterministic output
3640
- const jsonString = stableStringify(content);
3641
- if (jsonString === undefined) {
3642
- throw new ValidationError(`Content illegal: not serializable (type: ${typeof content})`);
3643
- }
3644
- const msgBuffer = new TextEncoder().encode(jsonString);
3645
- if (typeof crypto !== "undefined" && crypto.subtle) {
3646
- // Browser / Modern Node.js
3647
- const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
3648
- const hashArray = Array.from(new Uint8Array(hashBuffer));
3649
- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
3650
- }
3651
- else {
3652
- // Fallback for older environments or specific setups if global crypto isn't available
3653
- try {
3654
- // Dynamic import to avoid breaking browser builds if bundler doesn't handle it
3655
- const { createHash } = await import('node:crypto');
3656
- const hash = createHash("sha256").update(jsonString).digest("hex");
3657
- return hash;
3658
- }
3659
- catch (e) {
3660
- throw new NetworkError("SHA-256 hashing not supported in this environment", e);
3661
- }
3662
- }
4419
+ function isStrongRef(value) {
4420
+ return validateStrongRef(value);
3663
4421
  }
3664
4422
 
3665
4423
  /**
@@ -3720,21 +4478,98 @@ async function sha256Hash(content) {
3720
4478
  */
3721
4479
  class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3722
4480
  /**
3723
- * Creates a new HypercertOperationsImpl.
3724
- *
3725
- * @param agent - AT Protocol Agent for making API calls
3726
- * @param repoDid - DID of the repository to operate on
3727
- * @param blobs - Blob operations for uploading images and files
3728
- * @param logger - Optional logger for debugging
4481
+ * Creates a new HypercertOperationsImpl.
4482
+ *
4483
+ * @param agent - AT Protocol Agent for making API calls
4484
+ * @param repoDid - DID of the repository to operate on
4485
+ * @param blobs - Blob operations for uploading images and files
4486
+ * @param logger - Optional logger for debugging
4487
+ *
4488
+ * @internal
4489
+ */
4490
+ constructor(agent, repoDid, blobs, logger) {
4491
+ super();
4492
+ this.agent = agent;
4493
+ this.repoDid = repoDid;
4494
+ this.blobs = blobs;
4495
+ this.logger = logger;
4496
+ }
4497
+ /**
4498
+ * Parses an AT-URI and throws ValidationError if invalid.
4499
+ *
4500
+ * This wrapper converts the generic Error from parseAtUri() to a ValidationError
4501
+ * for consistent error handling throughout the SDK.
4502
+ *
4503
+ * @param uri - AT-URI to parse
4504
+ * @returns Parsed URI components
4505
+ * @throws {@link ValidationError} if URI format is invalid
4506
+ * @internal
4507
+ */
4508
+ parseUri(uri) {
4509
+ try {
4510
+ return parseAtUri(uri);
4511
+ }
4512
+ catch (error) {
4513
+ throw new ValidationError(error instanceof Error ? error.message : `Invalid URI format: ${uri}`);
4514
+ }
4515
+ }
4516
+ /**
4517
+ * Fetches any record by AT-URI with generic typing.
4518
+ *
4519
+ * Returns the record along with parsed URI components needed for updates.
4520
+ * Unlike the public `get()` method which is typed for HypercertClaim,
4521
+ * this method can fetch any record type.
4522
+ *
4523
+ * @typeParam T - The expected type of the record
4524
+ * @param uri - AT-URI of the record to fetch
4525
+ * @returns Record data with parsed URI components
4526
+ * @throws {@link ValidationError} if URI format is invalid
4527
+ * @throws {@link NetworkError} if record cannot be fetched
4528
+ * @internal
4529
+ */
4530
+ async fetchRecord(uri) {
4531
+ const { did, collection, rkey } = this.parseUri(uri);
4532
+ const result = await this.agent.com.atproto.repo.getRecord({
4533
+ repo: did,
4534
+ collection,
4535
+ rkey,
4536
+ });
4537
+ if (!result.success) {
4538
+ throw new NetworkError(`Failed to fetch record: ${uri}`);
4539
+ }
4540
+ const cid = result.data.cid;
4541
+ if (!cid) {
4542
+ throw new NetworkError(`Record at ${uri} returned no CID`);
4543
+ }
4544
+ return {
4545
+ uri: result.data.uri,
4546
+ cid,
4547
+ record: result.data.value,
4548
+ collection,
4549
+ rkey,
4550
+ };
4551
+ }
4552
+ /**
4553
+ * Updates a record in the repository.
3729
4554
  *
4555
+ * @param collection - NSID of the collection
4556
+ * @param rkey - Record key
4557
+ * @param record - Updated record data
4558
+ * @returns Update result with URI and CID
4559
+ * @throws {@link NetworkError} if update fails
3730
4560
  * @internal
3731
4561
  */
3732
- constructor(agent, repoDid, blobs, logger) {
3733
- super();
3734
- this.agent = agent;
3735
- this.repoDid = repoDid;
3736
- this.blobs = blobs;
3737
- this.logger = logger;
4562
+ async saveRecord(collection, rkey, record) {
4563
+ const result = await this.agent.com.atproto.repo.putRecord({
4564
+ repo: this.repoDid,
4565
+ collection,
4566
+ rkey,
4567
+ record,
4568
+ });
4569
+ if (!result.success) {
4570
+ throw new NetworkError(`Failed to save record: ${collection}/${rkey}`);
4571
+ }
4572
+ return { uri: result.data.uri, cid: result.data.cid };
3738
4573
  }
3739
4574
  /**
3740
4575
  * Emits a progress event to the optional progress handler.
@@ -3753,16 +4588,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3753
4588
  }
3754
4589
  }
3755
4590
  }
3756
- /**
3757
- * Converts a blob upload result to JsonBlobRef format.
3758
- *
3759
- * @param uploadResult - Result from BlobOperations.upload()
3760
- * @returns JsonBlobRef formatted for records
3761
- * @internal
3762
- */
3763
- blobToJsonRef(uploadResult) {
3764
- return uploadResultToBlobRef(uploadResult);
3765
- }
3766
4591
  /**
3767
4592
  * Uploads an image blob and returns a blob reference.
3768
4593
  *
@@ -3781,7 +4606,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3781
4606
  status: "success",
3782
4607
  data: { size: image.size },
3783
4608
  });
3784
- return this.blobToJsonRef(uploadResult);
4609
+ return uploadResult;
3785
4610
  }
3786
4611
  catch (error) {
3787
4612
  this.emitProgress(onProgress, { name: "uploadImage", status: "error", error: error });
@@ -3860,7 +4685,10 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3860
4685
  createdAt,
3861
4686
  };
3862
4687
  if (imageBlobRef) {
3863
- hypercertRecord.image = imageBlobRef;
4688
+ hypercertRecord.image = {
4689
+ $type: "org.hypercerts.defs#smallImage",
4690
+ image: imageBlobRef,
4691
+ };
3864
4692
  }
3865
4693
  // Add locations as embedded StrongRefs if provided
3866
4694
  if (locationRefs && locationRefs.length > 0) {
@@ -3892,6 +4720,11 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3892
4720
  if (!hypercertValidation.success) {
3893
4721
  throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
3894
4722
  }
4723
+ // if its a blob ref guaranteed to have ref and a .toString method
4724
+ let imageRef;
4725
+ if (imageBlobRef) {
4726
+ imageRef = imageBlobRef.ref.toString();
4727
+ }
3895
4728
  // Generate rKey from stable content hash (idempotency)
3896
4729
  // Use NORMALIZED values (already resolved StrongRefs and processed data)
3897
4730
  // to ensure JSON-serializability and deterministic hashing.
@@ -3904,15 +4737,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3904
4737
  workScope: params.workScope,
3905
4738
  startDate: params.startDate,
3906
4739
  endDate: params.endDate,
3907
- // Image: extract CID string from blob ref (stable content hash)
3908
- // JsonBlobRef can have ref.$link (upload result) or cid (existing record)
3909
- imageRef: imageBlobRef
3910
- ? "ref" in imageBlobRef && imageBlobRef.ref
3911
- ? imageBlobRef.ref.$link
3912
- : "cid" in imageBlobRef
3913
- ? imageBlobRef.cid
3914
- : undefined
3915
- : undefined,
4740
+ imageRef,
3916
4741
  // Rights: canonical object with only known fields
3917
4742
  rights: {
3918
4743
  name: params.rights.name,
@@ -3971,41 +4796,6 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
3971
4796
  throw error;
3972
4797
  }
3973
4798
  }
3974
- /**
3975
- * Creates contribution records with progress tracking.
3976
- *
3977
- * @param hypercertUri - URI of the hypercert
3978
- * @param contributions - Array of contribution data
3979
- * @param onProgress - Optional progress callback
3980
- * @returns Promise resolving to array of contribution URIs
3981
- * @internal
3982
- */
3983
- async createContributionsWithProgress(hypercertUri, contributions, onProgress) {
3984
- this.emitProgress(onProgress, { name: "createContributions", status: "start" });
3985
- try {
3986
- const contributionUris = [];
3987
- for (const contrib of contributions) {
3988
- const contribResult = await this.addContribution({
3989
- hypercertUri,
3990
- contributors: contrib.contributors.filter((c) => typeof c === "string"),
3991
- role: contrib.role,
3992
- description: contrib.description,
3993
- });
3994
- contributionUris.push(contribResult.uri);
3995
- }
3996
- this.emitProgress(onProgress, {
3997
- name: "createContributions",
3998
- status: "success",
3999
- data: { count: contributionUris.length },
4000
- });
4001
- return contributionUris;
4002
- }
4003
- catch (error) {
4004
- this.emitProgress(onProgress, { name: "createContributions", status: "error", error: error });
4005
- this.logger?.warn(`Failed to create contributions: ${error instanceof Error ? error.message : "Unknown"}`);
4006
- throw error;
4007
- }
4008
- }
4009
4799
  /**
4010
4800
  * Creates attachment records and returns their URIs.
4011
4801
  *
@@ -4194,19 +4984,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4194
4984
  */
4195
4985
  async update(params) {
4196
4986
  try {
4197
- const uriMatch = params.uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4198
- if (!uriMatch) {
4199
- throw new ValidationError(`Invalid URI format: ${params.uri}`);
4200
- }
4201
- const [, , collection, rkey] = uriMatch;
4202
- const existing = await this.agent.com.atproto.repo.getRecord({
4203
- repo: this.repoDid,
4204
- collection,
4205
- rkey,
4206
- });
4207
- // The existing record comes from ATProto, use it directly
4208
- // TypeScript ensures type safety through the HypercertClaim interface
4209
- const existingRecord = existing.data.value;
4987
+ const { record: existingRecord, collection, rkey } = await this.fetchRecord(params.uri);
4210
4988
  const recordForUpdate = {
4211
4989
  ...existingRecord,
4212
4990
  ...params.updates,
@@ -4221,7 +4999,10 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4221
4999
  }
4222
5000
  else {
4223
5001
  const uploadResult = await this.blobs.upload(params.image);
4224
- recordForUpdate.image = this.blobToJsonRef(uploadResult);
5002
+ recordForUpdate.image = {
5003
+ $type: "org.hypercerts.defs#smallImage",
5004
+ image: uploadResult,
5005
+ };
4225
5006
  }
4226
5007
  }
4227
5008
  else if (existingRecord.image) {
@@ -4232,17 +5013,9 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4232
5013
  if (!validation.success) {
4233
5014
  throw new ValidationError(`Invalid hypercert record: ${validation.error?.message}`);
4234
5015
  }
4235
- const result = await this.agent.com.atproto.repo.putRecord({
4236
- repo: this.repoDid,
4237
- collection,
4238
- rkey,
4239
- record: recordForUpdate,
4240
- });
4241
- if (!result.success) {
4242
- throw new NetworkError("Failed to update hypercert");
4243
- }
4244
- this.emit("recordUpdated", { uri: result.data.uri, cid: result.data.cid });
4245
- return { uri: result.data.uri, cid: result.data.cid };
5016
+ const result = await this.saveRecord(collection, rkey, recordForUpdate);
5017
+ this.emit("recordUpdated", { uri: result.uri, cid: result.cid });
5018
+ return result;
4246
5019
  }
4247
5020
  catch (error) {
4248
5021
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -4266,24 +5039,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4266
5039
  */
4267
5040
  async get(uri) {
4268
5041
  try {
4269
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4270
- if (!uriMatch) {
4271
- throw new ValidationError(`Invalid URI format: ${uri}`);
4272
- }
4273
- const [, , collection, rkey] = uriMatch;
4274
- const result = await this.agent.com.atproto.repo.getRecord({
4275
- repo: this.repoDid,
4276
- collection,
4277
- rkey,
4278
- });
4279
- if (!result.success) {
4280
- throw new NetworkError("Failed to get hypercert");
4281
- }
4282
- return {
4283
- uri: result.data.uri,
4284
- cid: result.data.cid ?? "",
4285
- record: result.data.value,
4286
- };
5042
+ const { uri: resultUri, cid, record } = await this.fetchRecord(uri);
5043
+ return { uri: resultUri, cid, record };
4287
5044
  }
4288
5045
  catch (error) {
4289
5046
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -4353,11 +5110,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4353
5110
  */
4354
5111
  async delete(uri) {
4355
5112
  try {
4356
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4357
- if (!uriMatch) {
4358
- throw new ValidationError(`Invalid URI format: ${uri}`);
4359
- }
4360
- const [, , collection, rkey] = uriMatch;
5113
+ const { collection, rkey } = this.parseUri(uri);
4361
5114
  const result = await this.agent.com.atproto.repo.deleteRecord({
4362
5115
  repo: this.repoDid,
4363
5116
  collection,
@@ -4521,23 +5274,13 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4521
5274
  if (this.isLocationObject(location)) {
4522
5275
  return this.createLocationRecord(location);
4523
5276
  }
4524
- // Otherwise it's string | StrongRef, resolve to StrongRef
5277
+ // Otherwise it's RefUri, resolve to StrongRef
4525
5278
  return this.resolveToStrongRef(location);
4526
5279
  }
4527
5280
  async resolveStrongRefFromUri(uri) {
4528
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
4529
- if (!uriMatch) {
4530
- throw new ValidationError(`Invalid AT-URI format: "${uri}"`);
4531
- }
4532
- const [, repo, collection, rkey] = uriMatch;
4533
- const record = await this.agent.com.atproto.repo.getRecord({ repo, collection, rkey });
4534
- if (!record.success) {
4535
- throw new NetworkError(`Failed to fetch record for repo=${repo}, collection=${collection}, rkey=${rkey}`);
4536
- }
4537
- if (!record.data.cid) {
4538
- throw new NetworkError(`Record missing CID for repo=${repo}, collection=${collection}, rkey=${rkey}`);
4539
- }
4540
- return { $type: "com.atproto.repo.strongRef", uri, cid: record.data.cid };
5281
+ // fetchRecord already validates CID presence and throws NetworkError if absent
5282
+ const fetchResult = await this.fetchRecord(uri);
5283
+ return { $type: "com.atproto.repo.strongRef", uri, cid: fetchResult.cid };
4541
5284
  }
4542
5285
  /**
4543
5286
  * Resolves a string URI or StrongRef to a StrongRef.
@@ -4586,11 +5329,18 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4586
5329
  *
4587
5330
  * @param contentInput - Single content item or array (URI strings or Blobs)
4588
5331
  * @returns Promise resolving to array of URI refs or Blob refs
5332
+ * @throws {@link ValidationError} if a string content item is not a valid URI
4589
5333
  * @throws {@link NetworkError} if blob upload fails
4590
5334
  * @internal
4591
5335
  */
4592
5336
  async resolveAttachmentContent(contentInput) {
4593
5337
  const contentArray = Array.isArray(contentInput) ? contentInput : [contentInput];
5338
+ // Validate that all string content items are valid URIs before resolving
5339
+ for (const item of contentArray) {
5340
+ if (typeof item === "string" && !isValidUri(item)) {
5341
+ throw new ValidationError(`Invalid URI: "${item}". Content must be a valid URI with a scheme (e.g., https://example.com)`);
5342
+ }
5343
+ }
4594
5344
  return await Promise.all(contentArray.map((item) => this.resolveUriOrBlob(item, "application/octet-stream")));
4595
5345
  }
4596
5346
  /**
@@ -4766,23 +5516,42 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4766
5516
  async processContributors(contributions, onProgress) {
4767
5517
  if (!contributions || contributions.length === 0)
4768
5518
  return undefined;
4769
- const contributorsPromises = contributions.map(async (contrib) => {
4770
- // Resolve contributionDetails
4771
- const detailsRef = await this.resolveContributionDetails(contrib.contributionDetails, onProgress);
4772
- // Resolve each contributor identity
4773
- const resolvedContributors = await Promise.all(contrib.contributors.map((identity) => this.resolveContributorIdentity(identity, onProgress)));
4774
- // Expand to one entry per contributor
4775
- return resolvedContributors.map((identity) => ({
4776
- contributorIdentity: identity,
4777
- contributionWeight: contrib.weight,
4778
- contributionDetails: detailsRef,
4779
- }));
4780
- });
4781
- const nestedContributors = await Promise.all(contributorsPromises);
5519
+ const contributorPromises = contributions.map((contrib) => this.buildContributorEntries(contrib.contributors, contrib.contributionDetails, contrib.weight, onProgress));
5520
+ const nestedContributors = await Promise.all(contributorPromises);
4782
5521
  return nestedContributors.flat();
4783
5522
  }
4784
5523
  /**
4785
- * Resolves ContributionDetailsParams to a ResolvedContributionDetails.
5524
+ * Creates a standalone contributionDetails record.
5525
+ * @internal
5526
+ */
5527
+ async createContributionDetailsRecord(params) {
5528
+ const createdAt = new Date().toISOString();
5529
+ const { role, contributionDescription, startDate, endDate, ...extraProps } = params;
5530
+ const contributionRecord = {
5531
+ $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
5532
+ role,
5533
+ createdAt,
5534
+ contributionDescription,
5535
+ startDate,
5536
+ endDate,
5537
+ ...extraProps,
5538
+ };
5539
+ const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
5540
+ if (!validation.success) {
5541
+ throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
5542
+ }
5543
+ const result = await this.agent.com.atproto.repo.createRecord({
5544
+ repo: this.repoDid,
5545
+ collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
5546
+ record: contributionRecord,
5547
+ });
5548
+ if (!result.success) {
5549
+ throw new NetworkError("Failed to create contribution details");
5550
+ }
5551
+ return { uri: result.data.uri, cid: result.data.cid };
5552
+ }
5553
+ /**
5554
+ * Resolves ContributionDetailsParams to a RefUri.
4786
5555
  * Creates a record if CreateContributionDetailsParams is provided.
4787
5556
  * @internal
4788
5557
  */
@@ -4799,14 +5568,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4799
5568
  // CreateContributionDetailsParams - auto-create record
4800
5569
  try {
4801
5570
  this.emitProgress(onProgress, { name: "createContribution", status: "start" });
4802
- const { role, contributionDescription, startDate, endDate, ...extraProps } = details;
4803
- const result = await this.addContribution({
4804
- role,
4805
- description: contributionDescription,
4806
- startDate,
4807
- endDate,
4808
- ...extraProps,
4809
- });
5571
+ const result = await this.createContributionDetailsRecord(details);
4810
5572
  this.emitProgress(onProgress, {
4811
5573
  name: "createContribution",
4812
5574
  status: "success",
@@ -4826,13 +5588,13 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4826
5588
  throw new ValidationError("Invalid contributionDetails format");
4827
5589
  }
4828
5590
  /**
4829
- * Resolves ContributorIdentityParams to a ResolvedContributorIdentity.
5591
+ * Resolves ContributorIdentityParams to a RefUri.
4830
5592
  * Creates a contributorInformation record if CreateContributorInformationParams is provided.
4831
5593
  * @internal
4832
5594
  */
4833
5595
  async resolveContributorIdentity(identity, onProgress) {
4834
5596
  if (typeof identity === "string") {
4835
- // we still store as contribtorInformation since it cant directly be a string
5597
+ // we still store as contributorInformation since it can't directly be a string
4836
5598
  const result = await this.addContributorInformation({ identifier: identity });
4837
5599
  return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" };
4838
5600
  }
@@ -4879,62 +5641,100 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4879
5641
  throw new ValidationError("Invalid contributorIdentity format");
4880
5642
  }
4881
5643
  /**
4882
- * Creates a contribution details record.
5644
+ * Builds contributor entries from parameters by resolving identities and details.
4883
5645
  *
4884
- * This creates a standalone contribution details record that can be referenced
4885
- * from an activity's `contributors` array via a strong reference.
5646
+ * This helper resolves contributor identities and contribution details,
5647
+ * creating records as needed, then assembles them into the contributor entry
5648
+ * format used in hypercert records.
5649
+ *
5650
+ * @param contributorParams - Array of contributor identity params (DID, StrongRef, or create params)
5651
+ * @param detailsParams - Contribution details (inline role, StrongRef, or create params)
5652
+ * @param weight - Optional contribution weight
5653
+ * @param onProgress - Optional progress callback
5654
+ * @returns Promise resolving to array of contributor entries ready for embedding
5655
+ * @internal
5656
+ * @protected
5657
+ */
5658
+ async buildContributorEntries(contributorParams, detailsParams, weight, onProgress) {
5659
+ const detailsRef = await this.resolveContributionDetails(detailsParams, onProgress);
5660
+ const resolvedIdentities = await Promise.all(contributorParams.map((identity) => this.resolveContributorIdentity(identity, onProgress)));
5661
+ return resolvedIdentities.map((identity) => ({
5662
+ contributorIdentity: identity,
5663
+ contributionWeight: weight,
5664
+ contributionDetails: detailsRef,
5665
+ }));
5666
+ }
5667
+ /**
5668
+ * Attaches contributor entries to a hypercert by appending to its contributors array.
5669
+ *
5670
+ * Fetches the existing hypercert, merges new contributors with existing ones,
5671
+ * and updates the hypercert record.
5672
+ *
5673
+ * @param hypercertUri - URI of the hypercert to update
5674
+ * @param newContributors - Array of contributor entries to add
5675
+ * @returns Promise resolving to update result with new URI and CID
5676
+ * @throws {@link ValidationError} if URI format is invalid or validation fails
5677
+ * @throws {@link NetworkError} if fetching or updating fails
5678
+ * @internal
5679
+ * @protected
5680
+ */
5681
+ async attachContributorsToHypercert(hypercertUri, newContributors) {
5682
+ const existing = await this.get(hypercertUri);
5683
+ const existingContributors = existing.record.contributors || [];
5684
+ const updatedContributors = [...existingContributors, ...newContributors];
5685
+ return await this.update({
5686
+ uri: hypercertUri,
5687
+ updates: {
5688
+ contributors: updatedContributors,
5689
+ },
5690
+ });
5691
+ }
5692
+ /**
5693
+ * Adds contributors to an existing hypercert.
5694
+ *
5695
+ * This method creates or references contribution records and updates the hypercert
5696
+ * to include the new contributors in its contributors array.
4886
5697
  *
4887
5698
  * @param params - Contribution parameters
4888
- * @param params.hypercertUri - Optional hypercert (unused, kept for backward compatibility)
4889
- * @param params.contributors - Array of contributor DIDs (unused, kept for backward compatibility)
4890
- * @param params.role - Role of the contributor (e.g., "coordinator", "implementer")
4891
- * @param params.description - Optional description of the contribution
4892
- * @returns Promise resolving to contribution details record URI and CID
5699
+ * @returns Promise resolving to updated hypercert URI and CID
4893
5700
  * @throws {@link ValidationError} if validation fails
4894
5701
  * @throws {@link NetworkError} if the operation fails
4895
5702
  *
4896
- * @remarks
4897
- * In the new lexicon structure, contributions are stored differently:
4898
- * - Use `contributionDetails` for detailed contribution records (role, description, timeframe)
4899
- * - Use `contributorInformation` for contributor profiles (identifier, displayName, image)
4900
- * - Reference these from the activity's `contributors` array using strong refs
5703
+ * @example Add multiple contributors with inline role
5704
+ * ```typescript
5705
+ * await repo.hypercerts.addContribution({
5706
+ * hypercertUri: "at://did:plc:abc/org.hypercerts.claim.activity/xyz",
5707
+ * contributors: ["did:plc:user1", "did:plc:user2"],
5708
+ * contributionDetails: "Developer",
5709
+ * weight: "1.0"
5710
+ * });
5711
+ * ```
4901
5712
  *
4902
- * @example
5713
+ * @example Add contributor with detailed contribution record
4903
5714
  * ```typescript
4904
5715
  * await repo.hypercerts.addContribution({
4905
- * role: "implementer",
4906
- * description: "On-ground implementation team",
5716
+ * hypercertUri: hypercertUri,
5717
+ * contributors: [{
5718
+ * identifier: "did:plc:coordinator",
5719
+ * displayName: "Alice",
5720
+ * image: avatarBlob
5721
+ * }],
5722
+ * contributionDetails: {
5723
+ * role: "Project Coordinator",
5724
+ * contributionDescription: "Led coordination efforts",
5725
+ * startDate: "2024-01-01",
5726
+ * endDate: "2024-06-30"
5727
+ * },
5728
+ * weight: "2.0"
4907
5729
  * });
4908
5730
  * ```
4909
5731
  */
4910
5732
  async addContribution(params) {
4911
5733
  try {
4912
- const createdAt = new Date().toISOString();
4913
- // Extract known fields, spread the rest
4914
- const { hypercertUri: _hypercertUri, contributors: _contributors, role, description, startDate, endDate, ...extraProps } = params;
4915
- const contributionRecord = {
4916
- $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
4917
- role,
4918
- createdAt,
4919
- contributionDescription: description,
4920
- startDate,
4921
- endDate,
4922
- ...extraProps,
4923
- };
4924
- const validation = lexicon.validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false);
4925
- if (!validation.success) {
4926
- throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`);
4927
- }
4928
- const result = await this.agent.com.atproto.repo.createRecord({
4929
- repo: this.repoDid,
4930
- collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS,
4931
- record: contributionRecord,
4932
- });
4933
- if (!result.success) {
4934
- throw new NetworkError("Failed to create contribution details");
4935
- }
4936
- this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid });
4937
- return { uri: result.data.uri, cid: result.data.cid };
5734
+ const newContributors = await this.buildContributorEntries(params.contributors, params.contributionDetails, params.weight, params.onProgress);
5735
+ const result = await this.attachContributorsToHypercert(params.hypercertUri, newContributors);
5736
+ this.emit("contributionCreated", { uri: result.uri, cid: result.cid });
5737
+ return result;
4938
5738
  }
4939
5739
  catch (error) {
4940
5740
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -4976,7 +5776,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
4976
5776
  resolvedImage = { $type: "org.hypercerts.defs#uri", uri: image };
4977
5777
  }
4978
5778
  else {
4979
- // JsonBlobRef from upload - wrap in smallImage
5779
+ // BlobRef from upload - wrap in smallImage
4980
5780
  resolvedImage = {
4981
5781
  $type: "org.hypercerts.defs#smallImage",
4982
5782
  image: image,
@@ -5114,38 +5914,19 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5114
5914
  */
5115
5915
  async updateMeasurement(uri, updates) {
5116
5916
  try {
5117
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5118
- if (!uriMatch) {
5119
- throw new ValidationError(`Invalid URI format: ${uri}`);
5120
- }
5121
- const [, , collection, rkey] = uriMatch;
5917
+ const { collection, rkey } = this.parseUri(uri);
5122
5918
  if (collection !== HYPERCERT_COLLECTIONS.MEASUREMENT) {
5123
5919
  throw new ValidationError(`URI must target a measurement collection. Expected '${HYPERCERT_COLLECTIONS.MEASUREMENT}', got '${collection}'`);
5124
5920
  }
5125
- const existing = await this.agent.com.atproto.repo.getRecord({
5126
- repo: this.repoDid,
5127
- collection,
5128
- rkey,
5129
- });
5130
- if (!existing.success) {
5131
- throw new NetworkError(`Measurement not found: ${uri}`);
5132
- }
5133
- const recordForUpdate = await this.applyMeasurementUpdates(existing.data.value, updates);
5921
+ const { record: existingRecord } = await this.fetchRecord(uri);
5922
+ const recordForUpdate = await this.applyMeasurementUpdates(existingRecord, updates);
5134
5923
  const validation = lexicon.validate(recordForUpdate, HYPERCERT_COLLECTIONS.MEASUREMENT, "main", false);
5135
5924
  if (!validation.success) {
5136
5925
  throw new ValidationError(`Invalid measurement record: ${validation.error?.message}`);
5137
5926
  }
5138
- const result = await this.agent.com.atproto.repo.putRecord({
5139
- repo: this.repoDid,
5140
- collection,
5141
- rkey,
5142
- record: recordForUpdate,
5143
- });
5144
- if (!result.success) {
5145
- throw new NetworkError("Failed to update measurement");
5146
- }
5147
- this.emit("measurementUpdated", { uri: result.data.uri, cid: result.data.cid });
5148
- return { uri: result.data.uri, cid: result.data.cid };
5927
+ const result = await this.saveRecord(collection, rkey, recordForUpdate);
5928
+ this.emit("measurementUpdated", { uri: result.uri, cid: result.cid });
5929
+ return result;
5149
5930
  }
5150
5931
  catch (error) {
5151
5932
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -5182,7 +5963,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5182
5963
  const evaluationRecord = {
5183
5964
  $type: HYPERCERT_COLLECTIONS.EVALUATION,
5184
5965
  subject: { uri: subject.uri, cid: subject.cid },
5185
- evaluators: params.evaluators,
5966
+ evaluators: params.evaluators.map((evaluator) => ({ did: evaluator })),
5186
5967
  summary: params.summary,
5187
5968
  createdAt,
5188
5969
  };
@@ -5330,29 +6111,13 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5330
6111
  */
5331
6112
  async getCollection(uri) {
5332
6113
  try {
5333
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5334
- if (!uriMatch) {
5335
- throw new ValidationError(`Invalid URI format: ${uri}`);
5336
- }
5337
- const [, , collection, rkey] = uriMatch;
5338
- const result = await this.agent.com.atproto.repo.getRecord({
5339
- repo: this.repoDid,
5340
- collection,
5341
- rkey,
5342
- });
5343
- if (!result.success) {
5344
- throw new NetworkError("Failed to get collection");
5345
- }
6114
+ const { uri: resultUri, cid, record } = await this.fetchRecord(uri);
5346
6115
  // Validate with lexicon registry (more lenient - doesn't require $type)
5347
- const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
6116
+ const validation = lexicon.validate(record, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
5348
6117
  if (!validation.success) {
5349
6118
  throw new ValidationError(`Invalid collection record format: ${validation.error?.message}`);
5350
6119
  }
5351
- return {
5352
- uri: result.data.uri,
5353
- cid: result.data.cid ?? "",
5354
- record: result.data.value,
5355
- };
6120
+ return { uri: resultUri, cid, record };
5356
6121
  }
5357
6122
  catch (error) {
5358
6123
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -5366,8 +6131,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5366
6131
  * @param params - Optional pagination parameters
5367
6132
  * @returns Promise resolving to paginated list of collections
5368
6133
  * @throws {@link NetworkError} if the list operation fails
5369
- *
5370
- * @example
6134
+ * * @example
5371
6135
  * ```typescript
5372
6136
  * const { records } = await repo.hypercerts.listCollections();
5373
6137
  * for (const { record } of records) {
@@ -5470,36 +6234,17 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5470
6234
  */
5471
6235
  async getProject(uri) {
5472
6236
  try {
5473
- // Parse URI
5474
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5475
- if (!uriMatch) {
5476
- throw new ValidationError(`Invalid URI format: ${uri}`);
5477
- }
5478
- const [, , collection, rkey] = uriMatch;
5479
- // Fetch record
5480
- const result = await this.agent.com.atproto.repo.getRecord({
5481
- repo: this.repoDid,
5482
- collection,
5483
- rkey,
5484
- });
5485
- if (!result.success) {
5486
- throw new NetworkError("Failed to get project");
5487
- }
6237
+ const { uri: resultUri, cid, record } = await this.fetchRecord(uri);
5488
6238
  // Validate as collection
5489
- const validation = lexicon.validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
6239
+ const validation = lexicon.validate(record, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
5490
6240
  if (!validation.success) {
5491
6241
  throw new ValidationError(`Invalid project record format: ${validation.error?.message}`);
5492
6242
  }
5493
6243
  // Verify it's actually a project (collection with type='project')
5494
- const record = result.data.value;
5495
6244
  if (record.type !== "project") {
5496
6245
  throw new ValidationError(`Record is not a project (type='${record.type}')`);
5497
6246
  }
5498
- return {
5499
- uri: result.data.uri,
5500
- cid: result.data.cid ?? "",
5501
- record,
5502
- };
6247
+ return { uri: resultUri, cid, record };
5503
6248
  }
5504
6249
  catch (error) {
5505
6250
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -5594,25 +6339,12 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5594
6339
  */
5595
6340
  async updateProject(uri, updates) {
5596
6341
  // Verify it's a project before updating
5597
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5598
- if (!uriMatch) {
5599
- throw new ValidationError(`Invalid URI format: ${uri}`);
5600
- }
5601
- const [, , collection, rkey] = uriMatch;
5602
- const existing = await this.agent.com.atproto.repo.getRecord({
5603
- repo: this.repoDid,
5604
- collection,
5605
- rkey,
5606
- });
5607
- if (!existing.success) {
5608
- throw new NetworkError(`Project not found: ${uri}`);
6342
+ const fetchResult = await this.fetchRecord(uri);
6343
+ if (fetchResult.record.type !== "project") {
6344
+ throw new ValidationError(`Record is not a project (type='${fetchResult.record.type}')`);
5609
6345
  }
5610
- const record = existing.data.value;
5611
- if (record.type !== "project") {
5612
- throw new ValidationError(`Record is not a project (type='${record.type}')`);
5613
- }
5614
- // Delegate to updateCollection
5615
- const result = await this.updateCollection(uri, updates);
6346
+ // Pass pre-fetched record to avoid a second fetch in updateCollectionRecord
6347
+ const result = await this.updateCollectionRecord(fetchResult, updates);
5616
6348
  this.emit("projectUpdated", { uri: result.uri, cid: result.cid });
5617
6349
  return result;
5618
6350
  }
@@ -5631,20 +6363,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5631
6363
  * ```
5632
6364
  */
5633
6365
  async deleteProject(uri) {
5634
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5635
- if (!uriMatch) {
5636
- throw new ValidationError(`Invalid URI format: ${uri}`);
5637
- }
5638
- const [, , collection, rkey] = uriMatch;
5639
- const existing = await this.agent.com.atproto.repo.getRecord({
5640
- repo: this.repoDid,
5641
- collection,
5642
- rkey,
5643
- });
5644
- if (!existing.success) {
5645
- throw new NetworkError(`Project not found: ${uri}`);
5646
- }
5647
- const record = existing.data.value;
6366
+ const { record } = await this.fetchRecord(uri);
5648
6367
  if (record.type !== "project") {
5649
6368
  throw new ValidationError(`Record is not a project (type='${record.type}')`);
5650
6369
  }
@@ -5691,21 +6410,25 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5691
6410
  * ```
5692
6411
  */
5693
6412
  async updateCollection(uri, updates) {
6413
+ const fetchResult = await this.fetchRecord(uri);
6414
+ const result = await this.updateCollectionRecord(fetchResult, updates);
6415
+ this.emit("collectionUpdated", { uri: result.uri, cid: result.cid });
6416
+ return result;
6417
+ }
6418
+ /**
6419
+ * Core collection update logic operating on a pre-fetched record.
6420
+ *
6421
+ * Extracted so that callers like {@link updateProject} can fetch once,
6422
+ * validate the type, and then delegate here without a redundant fetch.
6423
+ *
6424
+ * @param fetchResult - The pre-fetched record, collection, and rkey
6425
+ * @param updates - Fields to update (partial)
6426
+ * @returns Promise resolving to updated URI and CID
6427
+ * @internal
6428
+ */
6429
+ async updateCollectionRecord(fetchResult, updates) {
5694
6430
  try {
5695
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5696
- if (!uriMatch) {
5697
- throw new ValidationError(`Invalid URI format: ${uri}`);
5698
- }
5699
- const [, , collection, rkey] = uriMatch;
5700
- const existing = await this.agent.com.atproto.repo.getRecord({
5701
- repo: this.repoDid,
5702
- collection,
5703
- rkey,
5704
- });
5705
- if (!existing.success) {
5706
- throw new NetworkError(`Collection not found: ${uri}`);
5707
- }
5708
- const existingRecord = existing.data.value;
6431
+ const { record: existingRecord, collection, rkey } = fetchResult;
5709
6432
  const recordForUpdate = {
5710
6433
  ...existingRecord,
5711
6434
  createdAt: existingRecord.createdAt,
@@ -5771,17 +6494,8 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5771
6494
  if (!validation.success) {
5772
6495
  throw new ValidationError(`Invalid collection record: ${validation.error?.message}`);
5773
6496
  }
5774
- const result = await this.agent.com.atproto.repo.putRecord({
5775
- repo: this.repoDid,
5776
- collection,
5777
- rkey,
5778
- record: recordForUpdate,
5779
- });
5780
- if (!result.success) {
5781
- throw new NetworkError("Failed to update collection");
5782
- }
5783
- this.emit("collectionUpdated", { uri: result.data.uri, cid: result.data.cid });
5784
- return { uri: result.data.uri, cid: result.data.cid };
6497
+ const result = await this.saveRecord(collection, rkey, recordForUpdate);
6498
+ return result;
5785
6499
  }
5786
6500
  catch (error) {
5787
6501
  if (error instanceof ValidationError || error instanceof NetworkError)
@@ -5796,11 +6510,7 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5796
6510
  */
5797
6511
  async deleteCollection(uri) {
5798
6512
  try {
5799
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5800
- if (!uriMatch) {
5801
- throw new ValidationError(`Invalid URI format: ${uri}`);
5802
- }
5803
- const [, , collection, rkey] = uriMatch;
6513
+ const { collection, rkey } = this.parseUri(uri);
5804
6514
  const result = await this.agent.com.atproto.repo.deleteRecord({
5805
6515
  repo: this.repoDid,
5806
6516
  collection,
@@ -5826,36 +6536,16 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5826
6536
  */
5827
6537
  async attachLocationToCollection(uri, location) {
5828
6538
  try {
5829
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5830
- if (!uriMatch) {
5831
- throw new ValidationError(`Invalid URI format: ${uri}`);
5832
- }
5833
- const [, , collection, rkey] = uriMatch;
5834
- const existing = await this.agent.com.atproto.repo.getRecord({
5835
- repo: this.repoDid,
5836
- collection,
5837
- rkey,
5838
- });
5839
- if (!existing.success) {
5840
- throw new NetworkError(`Collection not found: ${uri}`);
5841
- }
6539
+ const { record, collection, rkey } = await this.fetchRecord(uri);
5842
6540
  const resolvedLocation = await this.resolveLocation(location);
5843
6541
  if (!resolvedLocation) {
5844
6542
  throw new ValidationError("attachLocationToCollection: failed to resolve location");
5845
6543
  }
5846
6544
  const recordForUpdate = {
5847
- ...existing.data.value,
6545
+ ...record,
5848
6546
  location: resolvedLocation,
5849
6547
  };
5850
- const updateResult = await this.agent.com.atproto.repo.putRecord({
5851
- repo: this.repoDid,
5852
- collection,
5853
- rkey,
5854
- record: recordForUpdate,
5855
- });
5856
- if (!updateResult.success) {
5857
- throw new NetworkError("Failed to update collection with location");
5858
- }
6548
+ await this.saveRecord(collection, rkey, recordForUpdate);
5859
6549
  this.emit("locationAttachedToCollection", {
5860
6550
  uri: resolvedLocation.uri,
5861
6551
  cid: resolvedLocation.cid,
@@ -5876,30 +6566,10 @@ class HypercertOperationsImpl extends eventemitter3.EventEmitter {
5876
6566
  */
5877
6567
  async removeLocationFromCollection(uri) {
5878
6568
  try {
5879
- const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
5880
- if (!uriMatch) {
5881
- throw new ValidationError(`Invalid URI format: ${uri}`);
5882
- }
5883
- const [, , collection, rkey] = uriMatch;
5884
- const existing = await this.agent.com.atproto.repo.getRecord({
5885
- repo: this.repoDid,
5886
- collection,
5887
- rkey,
5888
- });
5889
- if (!existing.success) {
5890
- throw new NetworkError(`Collection not found: ${uri}`);
5891
- }
5892
- const recordForUpdate = { ...existing.data.value };
6569
+ const { record, collection, rkey } = await this.fetchRecord(uri);
6570
+ const recordForUpdate = { ...record };
5893
6571
  delete recordForUpdate.location;
5894
- const result = await this.agent.com.atproto.repo.putRecord({
5895
- repo: this.repoDid,
5896
- collection,
5897
- rkey,
5898
- record: recordForUpdate,
5899
- });
5900
- if (!result.success) {
5901
- throw new NetworkError("Failed to remove location from collection");
5902
- }
6572
+ await this.saveRecord(collection, rkey, recordForUpdate);
5903
6573
  this.emit("locationRemovedFromCollection", { collectionUri: uri });
5904
6574
  }
5905
6575
  catch (error) {
@@ -6701,7 +7371,7 @@ class OrganizationOperationsImpl {
6701
7371
  * const repo = sdk.repository(session);
6702
7372
  *
6703
7373
  * // Access user profile
6704
- * const profile = await repo.profile.get();
7374
+ * const bskyProfile = await repo.profile.getBskyProfile();
6705
7375
  *
6706
7376
  * // Create a hypercert
6707
7377
  * const result = await repo.hypercerts.create({
@@ -6725,7 +7395,7 @@ class OrganizationOperationsImpl {
6725
7395
  *
6726
7396
  * // Get another user's repo (read-only for most operations)
6727
7397
  * const otherRepo = myRepo.repo("did:plc:other-user-did");
6728
- * const theirProfile = await otherRepo.profile.get();
7398
+ * const theirProfile = await otherRepo.profile.getCertifiedProfile();
6729
7399
  * ```
6730
7400
  *
6731
7401
  * @example SDS operations
@@ -6836,7 +7506,7 @@ class Repository {
6836
7506
  * ```typescript
6837
7507
  * // Read another user's profile
6838
7508
  * const otherRepo = repo.repo("did:plc:other-user");
6839
- * const profile = await otherRepo.profile.get();
7509
+ * const profile = await otherRepo.profile.getBskyProfile();
6840
7510
  *
6841
7511
  * // List their public hypercerts
6842
7512
  * const hypercerts = await otherRepo.hypercerts.list();
@@ -6960,21 +7630,22 @@ class Repository {
6960
7630
  *
6961
7631
  * @example
6962
7632
  * ```typescript
6963
- * // Get current profile
6964
- * const profile = await repo.profile.get();
6965
- * console.log(profile.displayName);
7633
+ * // Get Certified profile (with hypercerts fields)
7634
+ * const certProfile = await repo.profile.getCertifiedProfile();
7635
+ * console.log(certProfile.displayName);
7636
+ * console.log(certProfile.pronouns);
6966
7637
  *
6967
- * // Update profile
6968
- * await repo.profile.update({
7638
+ * // Update Certified profile
7639
+ * await repo.profile.updateCertifiedProfile({
6969
7640
  * displayName: "New Name",
6970
- * description: "Updated bio",
7641
+ * pronouns: "they/them",
6971
7642
  * avatar: avatarBlob, // Optional: update avatar image
6972
7643
  * });
6973
7644
  * ```
6974
7645
  */
6975
7646
  get profile() {
6976
7647
  if (!this._profile) {
6977
- this._profile = new ProfileOperationsImpl(this.agent, this.repoDid, this.blobs);
7648
+ this._profile = new ProfileOperationsImpl(this.agent, this.repoDid, this.blobs, this.serverUrl);
6978
7649
  }
6979
7650
  return this._profile;
6980
7651
  }
@@ -7231,25 +7902,11 @@ const OAuthConfigSchema = zod.z.object({
7231
7902
  * Zod schema for server URL configuration.
7232
7903
  *
7233
7904
  * @remarks
7234
- * At least one server (PDS or SDS) should be configured for the SDK to be useful.
7905
+ * Configure SDS here for collaborative operations.
7906
+ * PDS URLs are auto-detected from the user's OAuth session and do not need configuration.
7235
7907
  * For local development, HTTP loopback URLs are allowed.
7236
7908
  */
7237
7909
  const ServerConfigSchema = zod.z.object({
7238
- /**
7239
- * Personal Data Server URL - the user's own AT Protocol server.
7240
- * This is the primary server for user data operations.
7241
- *
7242
- * @example Production
7243
- * ```typescript
7244
- * pds: "https://bsky.social"
7245
- * ```
7246
- *
7247
- * @example Local development
7248
- * ```typescript
7249
- * pds: "http://localhost:2583"
7250
- * ```
7251
- */
7252
- pds: urlOrLoopback.optional(),
7253
7910
  /**
7254
7911
  * Shared Data Server URL - for collaborative data storage.
7255
7912
  * Required for collaborator and organization operations.
@@ -7294,6 +7951,19 @@ const TimeoutConfigSchema = zod.z.object({
7294
7951
  */
7295
7952
  const ATProtoSDKConfigSchema = zod.z.object({
7296
7953
  oauth: OAuthConfigSchema,
7954
+ /**
7955
+ * URL string used for resolving AT Protocol handles to DIDs
7956
+ * during the OAuth authorization flow. This can be any server that speaks
7957
+ * the `com.atproto.identity.resolveHandle` XRPC method.
7958
+ *
7959
+ * If not provided, the `@atproto` library falls back to DNS-based handle resolution.
7960
+ *
7961
+ * @example
7962
+ * ```typescript
7963
+ * handleResolver: "https://pds-eu-west4.test.certified.app"
7964
+ * ```
7965
+ */
7966
+ handleResolver: urlOrLoopback.optional(),
7297
7967
  servers: ServerConfigSchema.optional(),
7298
7968
  timeouts: TimeoutConfigSchema.optional(),
7299
7969
  });
@@ -7318,8 +7988,8 @@ const ATProtoSDKConfigSchema = zod.z.object({
7318
7988
  * jwksUri: "https://my-app.com/.well-known/jwks.json",
7319
7989
  * jwkPrivate: process.env.JWK_PRIVATE_KEY!,
7320
7990
  * },
7991
+ * handleResolver: "https://bsky.social",
7321
7992
  * servers: {
7322
- * pds: "https://bsky.social",
7323
7993
  * sds: "https://sds.hypercerts.org",
7324
7994
  * },
7325
7995
  * });
@@ -7371,11 +8041,13 @@ class ATProtoSDK {
7371
8041
  * jwksUri: "https://my-app.com/.well-known/jwks.json",
7372
8042
  * jwkPrivate: privateKeyJwk,
7373
8043
  * },
7374
- * servers: { pds: "https://bsky.social" },
8044
+ * handleResolver: "https://pds-eu-west4.test.certified.app",
7375
8045
  * });
7376
8046
  * ```
7377
8047
  */
7378
8048
  constructor(config) {
8049
+ /** Cache of session DID → PDS URL, populated during callback/restoreSession */
8050
+ this.sessionPdsMap = new Map();
7379
8051
  // Validate configuration
7380
8052
  const validationResult = ATProtoSDKConfigSchema.safeParse(config);
7381
8053
  if (!validationResult.success) {
@@ -7464,7 +8136,18 @@ class ATProtoSDK {
7464
8136
  * ```
7465
8137
  */
7466
8138
  async callback(params) {
7467
- return this.oauthClient.callback(params);
8139
+ const session = await this.oauthClient.callback(params);
8140
+ // Cache the user's actual PDS URL from the token
8141
+ try {
8142
+ const tokenInfo = await session.getTokenInfo();
8143
+ if (tokenInfo.aud) {
8144
+ this.sessionPdsMap.set(session.did, tokenInfo.aud);
8145
+ }
8146
+ }
8147
+ catch {
8148
+ this.logger?.warn?.("Could not resolve PDS URL from session token during callback");
8149
+ }
8150
+ return session;
7468
8151
  }
7469
8152
  /**
7470
8153
  * Restores an existing OAuth session by DID.
@@ -7498,7 +8181,20 @@ class ATProtoSDK {
7498
8181
  if (!did || !did.trim()) {
7499
8182
  throw new ValidationError("DID is required");
7500
8183
  }
7501
- return this.oauthClient.restore(did.trim());
8184
+ const session = await this.oauthClient.restore(did.trim());
8185
+ if (session) {
8186
+ // Cache the user's actual PDS URL from the token
8187
+ try {
8188
+ const tokenInfo = await session.getTokenInfo();
8189
+ if (tokenInfo.aud) {
8190
+ this.sessionPdsMap.set(session.did, tokenInfo.aud);
8191
+ }
8192
+ }
8193
+ catch {
8194
+ this.logger?.warn?.("Could not resolve PDS URL from session token during restore");
8195
+ }
8196
+ }
8197
+ return session;
7502
8198
  }
7503
8199
  /**
7504
8200
  * Revokes an OAuth session, logging the user out.
@@ -7573,13 +8269,9 @@ class ATProtoSDK {
7573
8269
  throw new ValidationError("Session is required");
7574
8270
  }
7575
8271
  try {
7576
- // Determine PDS URL from session or config
7577
- const pdsUrl = this.config.servers?.pds;
7578
- if (!pdsUrl) {
7579
- throw new ValidationError("PDS server URL not configured");
7580
- }
7581
8272
  // Call com.atproto.server.getSession endpoint using session's fetchHandler
7582
- // which automatically includes proper authorization with DPoP
8273
+ // which automatically includes proper authorization with DPoP.
8274
+ // The session internally routes this to the user's actual PDS via tokenSet.aud.
7583
8275
  const response = await session.fetchHandler("/xrpc/com.atproto.server.getSession", {
7584
8276
  method: "GET",
7585
8277
  headers: {
@@ -7626,7 +8318,7 @@ class ATProtoSDK {
7626
8318
  * @example Using default PDS
7627
8319
  * ```typescript
7628
8320
  * const repo = sdk.repository(session);
7629
- * const profile = await repo.profile.get();
8321
+ * const profile = await repo.profile.getBskyProfile();
7630
8322
  * ```
7631
8323
  *
7632
8324
  * @example Using configured SDS
@@ -7642,7 +8334,7 @@ class ATProtoSDK {
7642
8334
  * });
7643
8335
  * ```
7644
8336
  */
7645
- repository(session, options) {
8337
+ async repository(session, options) {
7646
8338
  if (!session) {
7647
8339
  throw new ValidationError("Session is required");
7648
8340
  }
@@ -7664,11 +8356,20 @@ class ATProtoSDK {
7664
8356
  isSDS = true;
7665
8357
  }
7666
8358
  else if (options?.server === "pds" || !options?.server) {
7667
- // Use configured PDS (default)
7668
- if (!this.config.servers?.pds) {
7669
- throw new ValidationError("PDS server URL not configured");
8359
+ // Auto-detect PDS from cached session info
8360
+ const did = session.did || session.sub;
8361
+ let cachedPds = this.sessionPdsMap.get(did);
8362
+ if (!cachedPds) {
8363
+ // Try to resolve on the fly before giving up
8364
+ try {
8365
+ cachedPds = await this.resolveSessionPds(session);
8366
+ }
8367
+ catch {
8368
+ throw new ValidationError("Could not determine PDS URL. Ensure the session has valid token info, " +
8369
+ "or was created via SDK auth methods (callback/restoreSession).");
8370
+ }
7670
8371
  }
7671
- serverUrl = this.config.servers.pds;
8372
+ serverUrl = cachedPds;
7672
8373
  isSDS = false;
7673
8374
  }
7674
8375
  else {
@@ -7709,12 +8410,52 @@ class ATProtoSDK {
7709
8410
  return this.lexiconRegistry;
7710
8411
  }
7711
8412
  /**
7712
- * The configured PDS (Personal Data Server) URL.
8413
+ * Resolves and caches the PDS URL from a session's token info.
8414
+ *
8415
+ * This method is automatically called during `callback()` and `restoreSession()`.
8416
+ * Use it manually if you have a session obtained outside the SDK's auth flow
8417
+ * and need to enable PDS auto-detection for `repository()`.
8418
+ *
8419
+ * @param session - An authenticated OAuth session
8420
+ * @returns The resolved PDS URL
8421
+ * @throws {@link ValidationError} if the PDS URL cannot be determined from the session
8422
+ *
8423
+ * @example
8424
+ * ```typescript
8425
+ * // For sessions created outside the SDK auth flow
8426
+ * const pdsUrl = await sdk.resolveSessionPds(session);
8427
+ * const repo = sdk.repository(session); // Now works with auto-detected PDS
8428
+ * ```
8429
+ */
8430
+ async resolveSessionPds(session) {
8431
+ try {
8432
+ const tokenInfo = await session.getTokenInfo();
8433
+ if (tokenInfo.aud) {
8434
+ this.sessionPdsMap.set(session.did, tokenInfo.aud);
8435
+ return tokenInfo.aud;
8436
+ }
8437
+ throw new ValidationError("Could not determine PDS URL from session token info");
8438
+ }
8439
+ catch (error) {
8440
+ if (error instanceof ValidationError) {
8441
+ throw error;
8442
+ }
8443
+ throw new ValidationError("Could not determine PDS URL from session token info", error);
8444
+ }
8445
+ }
8446
+ /**
8447
+ * Gets the cached PDS URL for a specific DID, if available.
8448
+ *
8449
+ * @param did - The user's DID
8450
+ * @returns The cached PDS URL, or `undefined` if not cached
7713
8451
  *
7714
- * @returns The PDS URL if configured, otherwise `undefined`
8452
+ * @deprecated Use `resolveSessionPds()` to resolve and cache PDS URLs.
8453
+ * This getter is provided for backward compatibility with sdk-react.
7715
8454
  */
7716
8455
  get pdsUrl() {
7717
- return this.config.servers?.pds;
8456
+ // Return the first cached PDS URL for backward compat with sdk-react
8457
+ const firstEntry = this.sessionPdsMap.values().next();
8458
+ return firstEntry.done ? undefined : firstEntry.value;
7718
8459
  }
7719
8460
  /**
7720
8461
  * The configured SDS (Shared Data Server) URL.
@@ -7739,7 +8480,7 @@ class ATProtoSDK {
7739
8480
  *
7740
8481
  * const sdk = createATProtoSDK({
7741
8482
  * oauth: { ... },
7742
- * servers: { pds: "https://bsky.social" },
8483
+ * handleResolver: "https://bsky.social",
7743
8484
  * });
7744
8485
  * ```
7745
8486
  */
@@ -8006,300 +8747,79 @@ class BaseOperations {
8006
8747
  * results of create or update operations.
8007
8748
  *
8008
8749
  * @param result - Result from a create or update operation
8009
- * @returns StrongRef object with uri and cid properties
8010
- *
8011
- * @example
8012
- * ```typescript
8013
- * // Create a project
8014
- * const projectResult = await this.validateAndCreate("org.myapp.project", projectRecord);
8015
- *
8016
- * // Create a task that references the project
8017
- * const taskRecord = {
8018
- * $type: "org.myapp.task",
8019
- * project: this.createStrongRefFromResult(projectResult),
8020
- * title: "Implement feature",
8021
- * createdAt: new Date().toISOString(),
8022
- * };
8023
- * ```
8024
- */
8025
- createStrongRefFromResult(result) {
8026
- return { uri: result.uri, cid: result.cid };
8027
- }
8028
- /**
8029
- * Parses an AT-URI to extract its components.
8030
- *
8031
- * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
8032
- *
8033
- * @param uri - AT-URI to parse
8034
- * @returns Object containing did, collection, and rkey
8035
- * @throws Error if the URI format is invalid
8036
- *
8037
- * @example
8038
- * ```typescript
8039
- * const { did, collection, rkey } = this.parseAtUri(
8040
- * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789"
8041
- * );
8042
- * // did: "did:plc:abc123"
8043
- * // collection: "org.hypercerts.claim.activity"
8044
- * // rkey: "xyz789"
8045
- * ```
8046
- */
8047
- parseAtUri(uri) {
8048
- if (!uri.startsWith("at://")) {
8049
- throw new Error(`Invalid AT-URI format: ${uri}`);
8050
- }
8051
- const parts = uri.slice(5).split("/"); // Remove "at://" and split
8052
- if (parts.length !== 3) {
8053
- throw new Error(`Invalid AT-URI format: ${uri}`);
8054
- }
8055
- return {
8056
- did: parts[0],
8057
- collection: parts[1],
8058
- rkey: parts[2],
8059
- };
8060
- }
8061
- /**
8062
- * Builds an AT-URI from its components.
8063
- *
8064
- * @param did - DID of the repository
8065
- * @param collection - NSID of the collection
8066
- * @param rkey - Record key (typically a TID)
8067
- * @returns Complete AT-URI string
8068
- *
8069
- * @example
8070
- * ```typescript
8071
- * const uri = this.buildAtUri(
8072
- * "did:plc:abc123",
8073
- * "org.myapp.evaluation",
8074
- * "xyz789"
8075
- * );
8076
- * // Returns: "at://did:plc:abc123/org.myapp.evaluation/xyz789"
8077
- * ```
8078
- */
8079
- buildAtUri(did, collection, rkey) {
8080
- return `at://${did}/${collection}/${rkey}`;
8081
- }
8082
- }
8083
-
8084
- /**
8085
- * Lexicon Development Utilities - AT-URI and StrongRef Helpers
8086
- *
8087
- * This module provides utilities for working with AT Protocol URIs and strongRefs
8088
- * when building custom lexicons. These tools help developers create type-safe
8089
- * references between records.
8090
- *
8091
- * @packageDocumentation
8092
- */
8093
- /**
8094
- * Parse an AT-URI into its component parts.
8095
- *
8096
- * Extracts the DID, collection NSID, and record key from an AT-URI string.
8097
- * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
8098
- *
8099
- * @param uri - The AT-URI to parse
8100
- * @returns The components of the URI
8101
- * @throws {Error} If the URI format is invalid
8102
- *
8103
- * @example
8104
- * ```typescript
8105
- * const components = parseAtUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
8106
- * console.log(components);
8107
- * // {
8108
- * // did: "did:plc:abc123",
8109
- * // collection: "org.hypercerts.claim.activity",
8110
- * // rkey: "3km2vj4kfqp2a"
8111
- * // }
8112
- * ```
8113
- */
8114
- function parseAtUri(uri) {
8115
- if (!uri.startsWith("at://")) {
8116
- throw new Error(`Invalid AT-URI format: must start with "at://", got "${uri}"`);
8117
- }
8118
- const withoutProtocol = uri.slice(5); // Remove "at://"
8119
- const parts = withoutProtocol.split("/");
8120
- if (parts.length !== 3) {
8121
- throw new Error(`Invalid AT-URI format: expected "at://{did}/{collection}/{rkey}", got "${uri}"`);
8122
- }
8123
- const [did, collection, rkey] = parts;
8124
- if (!did || !collection || !rkey) {
8125
- throw new Error(`Invalid AT-URI format: all components must be non-empty, got "${uri}"`);
8126
- }
8127
- return { did, collection, rkey };
8128
- }
8129
- /**
8130
- * Build an AT-URI from its component parts.
8131
- *
8132
- * Constructs a valid AT-URI string from a DID, collection NSID, and record key.
8133
- * The resulting URI follows the format: `at://{did}/{collection}/{rkey}`
8134
- *
8135
- * @param did - The repository owner's DID
8136
- * @param collection - The collection NSID (lexicon identifier)
8137
- * @param rkey - The record key (TID or custom string)
8138
- * @returns The complete AT-URI
8139
- *
8140
- * @example
8141
- * ```typescript
8142
- * const uri = buildAtUri(
8143
- * "did:plc:abc123",
8144
- * "org.hypercerts.claim.activity",
8145
- * "3km2vj4kfqp2a"
8146
- * );
8147
- * console.log(uri); // "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a"
8148
- * ```
8149
- */
8150
- function buildAtUri(did, collection, rkey) {
8151
- if (!did || !collection || !rkey) {
8152
- throw new Error("All AT-URI components (did, collection, rkey) must be non-empty");
8153
- }
8154
- return `at://${did}/${collection}/${rkey}`;
8155
- }
8156
- /**
8157
- * Extract the record key (TID or custom key) from an AT-URI.
8158
- *
8159
- * Returns the last component of the AT-URI, which is the record key.
8160
- * This is equivalent to `parseAtUri(uri).rkey` but more efficient.
8161
- *
8162
- * @param uri - The AT-URI to extract from
8163
- * @returns The record key (TID or custom string)
8164
- * @throws {Error} If the URI format is invalid
8165
- *
8166
- * @example
8167
- * ```typescript
8168
- * const rkey = extractRkeyFromUri("at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a");
8169
- * console.log(rkey); // "3km2vj4kfqp2a"
8170
- * ```
8171
- */
8172
- function extractRkeyFromUri(uri) {
8173
- const { rkey } = parseAtUri(uri);
8174
- return rkey;
8175
- }
8176
- /**
8177
- * Check if a string is a valid AT-URI format.
8178
- *
8179
- * Validates that the string follows the AT-URI format without throwing errors.
8180
- * This is useful for input validation before parsing.
8181
- *
8182
- * @param uri - The string to validate
8183
- * @returns True if the string is a valid AT-URI, false otherwise
8184
- *
8185
- * @example
8186
- * ```typescript
8187
- * if (isValidAtUri(userInput)) {
8188
- * const components = parseAtUri(userInput);
8189
- * // ... use components
8190
- * } else {
8191
- * console.error("Invalid AT-URI");
8192
- * }
8193
- * ```
8194
- */
8195
- function isValidAtUri(uri) {
8196
- try {
8197
- parseAtUri(uri);
8198
- return true;
8199
- }
8200
- catch {
8201
- return false;
8202
- }
8203
- }
8204
- /**
8205
- * Create a strongRef from a URI and CID.
8206
- *
8207
- * StrongRefs are the canonical way to reference specific versions of records
8208
- * in AT Protocol. They combine an AT-URI (which identifies the record) with
8209
- * a CID (which identifies the specific version).
8210
- *
8211
- * @param uri - The AT-URI of the record
8212
- * @param cid - The CID (Content Identifier) of the record version
8213
- * @returns A strongRef object
8214
- *
8215
- * @example
8216
- * ```typescript
8217
- * const ref = createStrongRef(
8218
- * "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
8219
- * "bafyreiabc123..."
8220
- * );
8221
- * console.log(ref);
8222
- * // {
8223
- * // uri: "at://did:plc:abc123/org.hypercerts.claim.activity/3km2vj4kfqp2a",
8224
- * // cid: "bafyreiabc123..."
8225
- * // }
8226
- * ```
8227
- */
8228
- function createStrongRef(uri, cid) {
8229
- if (!uri || !cid) {
8230
- throw new Error("Both uri and cid are required to create a strongRef");
8750
+ * @returns StrongRef object with uri and cid properties
8751
+ *
8752
+ * @example
8753
+ * ```typescript
8754
+ * // Create a project
8755
+ * const projectResult = await this.validateAndCreate("org.myapp.project", projectRecord);
8756
+ *
8757
+ * // Create a task that references the project
8758
+ * const taskRecord = {
8759
+ * $type: "org.myapp.task",
8760
+ * project: this.createStrongRefFromResult(projectResult),
8761
+ * title: "Implement feature",
8762
+ * createdAt: new Date().toISOString(),
8763
+ * };
8764
+ * ```
8765
+ */
8766
+ createStrongRefFromResult(result) {
8767
+ return { uri: result.uri, cid: result.cid };
8231
8768
  }
8232
- return { uri, cid };
8233
- }
8234
- /**
8235
- * Create a strongRef from a CreateResult or UpdateResult.
8236
- *
8237
- * This is a convenience function that extracts the URI and CID from
8238
- * the result of a record creation or update operation.
8239
- *
8240
- * @param result - The result from creating or updating a record
8241
- * @returns A strongRef object
8242
- *
8243
- * @example
8244
- * ```typescript
8245
- * const hypercert = await repo.hypercerts.create({
8246
- * title: "Climate Research",
8247
- * // ... other params
8248
- * });
8249
- *
8250
- * const ref = createStrongRefFromResult(hypercert);
8251
- * // Now use ref in another record to reference this hypercert
8252
- * ```
8253
- */
8254
- function createStrongRefFromResult(result) {
8255
- return createStrongRef(result.uri, result.cid);
8256
- }
8257
- /**
8258
- * Validate that an object is a valid strongRef.
8259
- *
8260
- * Checks that the object has the required `uri` and `cid` properties
8261
- * and that they are non-empty strings.
8262
- *
8263
- * @param ref - The object to validate
8264
- * @returns True if the object is a valid strongRef, false otherwise
8265
- *
8266
- * @example
8267
- * ```typescript
8268
- * const maybeRef = { uri: "at://...", cid: "bafyrei..." };
8269
- * if (validateStrongRef(maybeRef)) {
8270
- * // Safe to use as strongRef
8271
- * record.subject = maybeRef;
8272
- * }
8273
- * ```
8274
- */
8275
- function validateStrongRef(ref) {
8276
- if (!ref || typeof ref !== "object") {
8277
- return false;
8769
+ /**
8770
+ * Parses an AT-URI to extract its components.
8771
+ *
8772
+ * AT-URIs follow the format: `at://{did}/{collection}/{rkey}`
8773
+ *
8774
+ * @param uri - AT-URI to parse
8775
+ * @returns Object containing did, collection, and rkey
8776
+ * @throws Error if the URI format is invalid
8777
+ *
8778
+ * @example
8779
+ * ```typescript
8780
+ * const { did, collection, rkey } = this.parseAtUri(
8781
+ * "at://did:plc:abc123/org.hypercerts.claim.activity/xyz789"
8782
+ * );
8783
+ * // did: "did:plc:abc123"
8784
+ * // collection: "org.hypercerts.claim.activity"
8785
+ * // rkey: "xyz789"
8786
+ * ```
8787
+ */
8788
+ parseAtUri(uri) {
8789
+ if (!uri.startsWith("at://")) {
8790
+ throw new Error(`Invalid AT-URI format: ${uri}`);
8791
+ }
8792
+ const parts = uri.slice(5).split("/"); // Remove "at://" and split
8793
+ if (parts.length !== 3) {
8794
+ throw new Error(`Invalid AT-URI format: ${uri}`);
8795
+ }
8796
+ return {
8797
+ did: parts[0],
8798
+ collection: parts[1],
8799
+ rkey: parts[2],
8800
+ };
8801
+ }
8802
+ /**
8803
+ * Builds an AT-URI from its components.
8804
+ *
8805
+ * @param did - DID of the repository
8806
+ * @param collection - NSID of the collection
8807
+ * @param rkey - Record key (typically a TID)
8808
+ * @returns Complete AT-URI string
8809
+ *
8810
+ * @example
8811
+ * ```typescript
8812
+ * const uri = this.buildAtUri(
8813
+ * "did:plc:abc123",
8814
+ * "org.myapp.evaluation",
8815
+ * "xyz789"
8816
+ * );
8817
+ * // Returns: "at://did:plc:abc123/org.myapp.evaluation/xyz789"
8818
+ * ```
8819
+ */
8820
+ buildAtUri(did, collection, rkey) {
8821
+ return `at://${did}/${collection}/${rkey}`;
8278
8822
  }
8279
- const obj = ref;
8280
- return typeof obj.uri === "string" && obj.uri.length > 0 && typeof obj.cid === "string" && obj.cid.length > 0;
8281
- }
8282
- /**
8283
- * Type guard to check if a value is a strongRef.
8284
- *
8285
- * This is an alias for `validateStrongRef` that provides better semantics
8286
- * for type narrowing in TypeScript.
8287
- *
8288
- * @param value - The value to check
8289
- * @returns True if the value is a strongRef, false otherwise
8290
- *
8291
- * @example
8292
- * ```typescript
8293
- * function processReference(ref: unknown) {
8294
- * if (isStrongRef(ref)) {
8295
- * // TypeScript knows ref is StrongRef here
8296
- * console.log(ref.uri);
8297
- * }
8298
- * }
8299
- * ```
8300
- */
8301
- function isStrongRef(value) {
8302
- return validateStrongRef(value);
8303
8823
  }
8304
8824
 
8305
8825
  /**
@@ -8834,121 +9354,6 @@ async function batchCreateSidecars(repo, sidecars) {
8834
9354
  return results;
8835
9355
  }
8836
9356
 
8837
- /**
8838
- * Zod schema for collaborator permissions in SDS repositories.
8839
- *
8840
- * Defines the granular permissions a collaborator can have on a shared repository.
8841
- * Permissions follow a hierarchical model where higher-level permissions
8842
- * typically imply lower-level ones.
8843
- */
8844
- const CollaboratorPermissionsSchema = zod.z.object({
8845
- /**
8846
- * Can read/view records in the repository.
8847
- * This is the most basic permission level.
8848
- */
8849
- read: zod.z.boolean(),
8850
- /**
8851
- * Can create new records in the repository.
8852
- * Typically implies `read` permission.
8853
- */
8854
- create: zod.z.boolean(),
8855
- /**
8856
- * Can modify existing records in the repository.
8857
- * Typically implies `read` and `create` permissions.
8858
- */
8859
- update: zod.z.boolean(),
8860
- /**
8861
- * Can delete records from the repository.
8862
- * Typically implies `read`, `create`, and `update` permissions.
8863
- */
8864
- delete: zod.z.boolean(),
8865
- /**
8866
- * Can manage collaborators and their permissions.
8867
- * Administrative permission that allows inviting/removing collaborators.
8868
- */
8869
- admin: zod.z.boolean(),
8870
- /**
8871
- * Full ownership of the repository.
8872
- * Owners have all permissions and cannot be removed by other admins.
8873
- * There must always be at least one owner.
8874
- */
8875
- owner: zod.z.boolean(),
8876
- });
8877
- /**
8878
- * Zod schema for SDS organization data.
8879
- *
8880
- * Organizations are top-level entities in SDS that can own repositories
8881
- * and have multiple collaborators with different permission levels.
8882
- */
8883
- const OrganizationSchema = zod.z.object({
8884
- /**
8885
- * The organization's DID - unique identifier.
8886
- * Format: "did:plc:..." or "did:web:..."
8887
- */
8888
- did: zod.z.string(),
8889
- /**
8890
- * The organization's handle - human-readable identifier.
8891
- * Format: "orgname.sds.hypercerts.org" or similar
8892
- */
8893
- handle: zod.z.string(),
8894
- /**
8895
- * Display name for the organization.
8896
- */
8897
- name: zod.z.string(),
8898
- /**
8899
- * Optional description of the organization's purpose.
8900
- */
8901
- description: zod.z.string().optional(),
8902
- /**
8903
- * ISO 8601 timestamp when the organization was created.
8904
- * Format: "2024-01-15T10:30:00.000Z"
8905
- */
8906
- createdAt: zod.z.string(),
8907
- /**
8908
- * The current user's permissions within this organization.
8909
- */
8910
- permissions: CollaboratorPermissionsSchema,
8911
- /**
8912
- * How the current user relates to this organization.
8913
- * - `"owner"`: User created or owns the organization
8914
- * - `"shared"`: User was invited to collaborate (has permissions)
8915
- * - `"none"`: User has no access to this organization
8916
- */
8917
- accessType: zod.z.enum(["owner", "shared", "none"]),
8918
- });
8919
- /**
8920
- * Zod schema for collaborator data.
8921
- *
8922
- * Represents a user who has been granted access to a shared repository
8923
- * or organization with specific permissions.
8924
- */
8925
- const CollaboratorSchema = zod.z.object({
8926
- /**
8927
- * The collaborator's DID - their unique identifier.
8928
- * Format: "did:plc:..." or "did:web:..."
8929
- */
8930
- userDid: zod.z.string(),
8931
- /**
8932
- * The permissions granted to this collaborator.
8933
- */
8934
- permissions: CollaboratorPermissionsSchema,
8935
- /**
8936
- * DID of the user who granted these permissions.
8937
- * Useful for audit trails.
8938
- */
8939
- grantedBy: zod.z.string(),
8940
- /**
8941
- * ISO 8601 timestamp when permissions were granted.
8942
- * Format: "2024-01-15T10:30:00.000Z"
8943
- */
8944
- grantedAt: zod.z.string(),
8945
- /**
8946
- * ISO 8601 timestamp when permissions were revoked, if applicable.
8947
- * Undefined if the collaborator is still active.
8948
- */
8949
- revokedAt: zod.z.string().optional(),
8950
- });
8951
-
8952
9357
  /**
8953
9358
  * Rich text utilities for creating facets from plain text.
8954
9359
  *
@@ -9154,6 +9559,7 @@ exports.ATPROTO_SCOPE = ATPROTO_SCOPE;
9154
9559
  exports.ATProtoSDK = ATProtoSDK;
9155
9560
  exports.ATProtoSDKConfigSchema = ATProtoSDKConfigSchema;
9156
9561
  exports.ATProtoSDKError = ATProtoSDKError;
9562
+ exports.AT_URI_REGEX = AT_URI_REGEX;
9157
9563
  exports.AccountActionSchema = AccountActionSchema;
9158
9564
  exports.AccountAttrSchema = AccountAttrSchema;
9159
9565
  exports.AccountPermissionSchema = AccountPermissionSchema;
@@ -9212,12 +9618,16 @@ exports.createStrongRef = createStrongRef;
9212
9618
  exports.createStrongRefField = createStrongRefField;
9213
9619
  exports.createStrongRefFromResult = createStrongRefFromResult;
9214
9620
  exports.createWithSidecars = createWithSidecars;
9621
+ exports.extractCidFromImage = extractCidFromImage;
9215
9622
  exports.extractRkeyFromUri = extractRkeyFromUri;
9623
+ exports.getBlobUrl = getBlobUrl;
9216
9624
  exports.hasAllPermissions = hasAllPermissions;
9217
9625
  exports.hasAnyPermission = hasAnyPermission;
9218
9626
  exports.hasPermission = hasPermission;
9219
9627
  exports.isStrongRef = isStrongRef;
9220
9628
  exports.isValidAtUri = isValidAtUri;
9629
+ exports.isValidDid = isValidDid;
9630
+ exports.isValidUri = isValidUri;
9221
9631
  exports.mergeScopes = mergeScopes;
9222
9632
  exports.parseAtUri = parseAtUri;
9223
9633
  exports.parseScope = parseScope;