@hypercerts-org/sdk-core 0.9.0-beta.0 → 0.10.0-beta.1

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.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { JoseKey, NodeOAuthClient } from '@atproto/oauth-client-node';
2
- import { Lexicons } from '@atproto/lexicon';
3
- import { HYPERCERT_COLLECTIONS, HYPERCERT_LEXICONS } from '@hypercerts-org/lexicon';
4
- export { AppCertifiedLocation, ComAtprotoRepoStrongRef, HYPERCERT_COLLECTIONS, HYPERCERT_LEXICONS, OrgHypercertsClaim, OrgHypercertsClaimContribution, OrgHypercertsClaimEvaluation, OrgHypercertsClaimEvidence, OrgHypercertsClaimMeasurement, OrgHypercertsClaimRights, OrgHypercertsCollection, ids, lexicons, schemaDict, schemas, validate } from '@hypercerts-org/lexicon';
2
+ import { z } from 'zod';
5
3
  import { Agent } from '@atproto/api';
6
4
  import { EventEmitter } from 'eventemitter3';
7
- import { z } from 'zod';
5
+ import { CERTIFIED_DEFS_LEXICON_JSON, LOCATION_LEXICON_JSON, STRONGREF_LEXICON_JSON, HYPERCERTS_DEFS_LEXICON_JSON, ACTIVITY_LEXICON_JSON, COLLECTION_LEXICON_JSON, CONTRIBUTION_LEXICON_JSON, EVALUATION_LEXICON_JSON, EVIDENCE_LEXICON_JSON, MEASUREMENT_LEXICON_JSON, RIGHTS_LEXICON_JSON, PROJECT_LEXICON_JSON, BADGE_AWARD_LEXICON_JSON, BADGE_DEFINITION_LEXICON_JSON, BADGE_RESPONSE_LEXICON_JSON, FUNDING_RECEIPT_LEXICON_JSON, FUNDING_RECEIPT_NSID, BADGE_RESPONSE_NSID, BADGE_DEFINITION_NSID, BADGE_AWARD_NSID, PROJECT_NSID, COLLECTION_NSID, EVIDENCE_NSID, EVALUATION_NSID, MEASUREMENT_NSID, CONTRIBUTION_NSID, LOCATION_NSID, RIGHTS_NSID, ACTIVITY_NSID, validate } from '@hypercerts-org/lexicon';
6
+ export { AppCertifiedBadgeAward, AppCertifiedBadgeDefinition, AppCertifiedBadgeResponse, AppCertifiedLocation, ComAtprotoRepoStrongRef, HYPERCERTS_NSIDS, HYPERCERTS_NSIDS_BY_TYPE, HYPERCERTS_SCHEMAS, HYPERCERTS_SCHEMA_DICT, OrgHypercertsClaimActivity, OrgHypercertsClaimCollection, OrgHypercertsClaimContribution, OrgHypercertsClaimEvaluation, OrgHypercertsClaimEvidence, OrgHypercertsClaimMeasurement, OrgHypercertsClaimProject, OrgHypercertsClaimRights, OrgHypercertsFundingReceipt, validate } from '@hypercerts-org/lexicon';
7
+ import '@atproto/lexicon';
8
8
 
9
9
  /**
10
10
  * Base error class for all SDK errors.
@@ -523,160 +523,1215 @@ class InMemoryStateStore {
523
523
  }
524
524
 
525
525
  /**
526
- * OAuth 2.0 client for AT Protocol authentication with DPoP support.
526
+ * OAuth Scopes and Granular Permissions
527
527
  *
528
- * This class wraps the `@atproto/oauth-client-node` library to provide
529
- * OAuth 2.0 authentication with the following features:
528
+ * This module provides type-safe, Zod-validated OAuth scope and permission management
529
+ * for the ATProto SDK. It supports both legacy transitional scopes and the new
530
+ * granular permissions model.
530
531
  *
531
- * - **DPoP (Demonstrating Proof of Possession)**: Binds tokens to cryptographic keys
532
- * to prevent token theft and replay attacks
533
- * - **PKCE (Proof Key for Code Exchange)**: Protects against authorization code interception
534
- * - **Automatic Token Refresh**: Transparently refreshes expired access tokens
535
- * - **Session Persistence**: Stores sessions in configurable storage backends
532
+ * @see https://atproto.com/specs/oauth
533
+ * @see https://atproto.com/specs/permission
536
534
  *
537
- * @remarks
538
- * This class is typically used internally by {@link ATProtoSDK}. Direct usage
539
- * is only needed for advanced scenarios.
535
+ * @module auth/permissions
536
+ */
537
+ /**
538
+ * Base OAuth scope - required for all sessions
540
539
  *
541
- * The client uses lazy initialization - the underlying `NodeOAuthClient` is
542
- * created asynchronously on first use. This allows the constructor to return
543
- * synchronously while deferring async key parsing.
540
+ * @constant
541
+ */
542
+ const ATPROTO_SCOPE = "atproto";
543
+ /**
544
+ * Transitional OAuth scopes for legacy compatibility.
544
545
  *
545
- * @example Direct usage (advanced)
546
+ * These scopes provide broad access and are maintained for backwards compatibility.
547
+ * New applications should use granular permissions instead.
548
+ *
549
+ * @deprecated Use granular permissions (account:*, repo:*, etc.) for better control
550
+ * @constant
551
+ */
552
+ const TRANSITION_SCOPES = {
553
+ /** Broad PDS permissions including record creation, blob uploads, and preferences */
554
+ GENERIC: "transition:generic",
555
+ /** Direct messages access (requires transition:generic) */
556
+ CHAT: "transition:chat.bsky",
557
+ /** Email address and confirmation status */
558
+ EMAIL: "transition:email",
559
+ };
560
+ /**
561
+ * Zod schema for transitional scopes.
562
+ *
563
+ * Validates that a scope string is one of the known transitional scopes.
564
+ *
565
+ * @example
546
566
  * ```typescript
547
- * import { OAuthClient } from "@hypercerts-org/sdk";
567
+ * TransitionScopeSchema.parse('transition:email'); // Valid
568
+ * TransitionScopeSchema.parse('invalid'); // Throws ZodError
569
+ * ```
570
+ */
571
+ const TransitionScopeSchema = z
572
+ .enum(["transition:generic", "transition:chat.bsky", "transition:email"])
573
+ .describe("Legacy transitional OAuth scopes");
574
+ /**
575
+ * Zod schema for account permission attributes.
548
576
  *
549
- * const client = new OAuthClient({
550
- * oauth: {
551
- * clientId: "https://my-app.com/client-metadata.json",
552
- * redirectUri: "https://my-app.com/callback",
553
- * scope: "atproto transition:generic",
554
- * jwksUri: "https://my-app.com/.well-known/jwks.json",
555
- * jwkPrivate: process.env.JWK_PRIVATE_KEY!,
556
- * },
557
- * servers: { pds: "https://bsky.social" },
558
- * });
577
+ * Account attributes specify what aspect of the account is being accessed.
578
+ */
579
+ const AccountAttrSchema = z.enum(["email", "repo"]);
580
+ /**
581
+ * Zod schema for account actions.
559
582
  *
560
- * // Start authorization
561
- * const authUrl = await client.authorize("user.bsky.social");
583
+ * Account actions specify the level of access (read-only or management).
584
+ */
585
+ const AccountActionSchema = z.enum(["read", "manage"]);
586
+ /**
587
+ * Zod schema for repository actions.
562
588
  *
563
- * // Handle callback
564
- * const session = await client.callback(new URLSearchParams(callbackUrl.search));
589
+ * Repository actions specify what operations can be performed on records.
590
+ */
591
+ const RepoActionSchema = z.enum(["create", "update", "delete"]);
592
+ /**
593
+ * Zod schema for identity permission attributes.
594
+ *
595
+ * Identity attributes specify what identity information can be managed.
596
+ */
597
+ const IdentityAttrSchema = z.enum(["handle", "*"]);
598
+ /**
599
+ * Zod schema for MIME type patterns.
600
+ *
601
+ * Validates MIME type strings like "image/*" or "video/mp4".
602
+ *
603
+ * @example
604
+ * ```typescript
605
+ * MimeTypeSchema.parse('image/*'); // Valid
606
+ * MimeTypeSchema.parse('video/mp4'); // Valid
607
+ * MimeTypeSchema.parse('invalid'); // Throws ZodError
565
608
  * ```
609
+ */
610
+ const MimeTypeSchema = z
611
+ .string()
612
+ .regex(/^[a-z]+\/[a-z0-9*+-]+$/i, 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")');
613
+ /**
614
+ * Zod schema for NSID (Namespaced Identifier).
566
615
  *
567
- * @see {@link ATProtoSDK} for the recommended high-level API
568
- * @see https://atproto.com/specs/oauth for AT Protocol OAuth specification
616
+ * NSIDs are reverse-DNS style identifiers used throughout ATProto
617
+ * (e.g., "app.bsky.feed.post" or "com.example.myrecord").
618
+ *
619
+ * @see https://atproto.com/specs/nsid
620
+ *
621
+ * @example
622
+ * ```typescript
623
+ * NsidSchema.parse('app.bsky.feed.post'); // Valid
624
+ * NsidSchema.parse('com.example.myrecord'); // Valid
625
+ * NsidSchema.parse('InvalidNSID'); // Throws ZodError
626
+ * ```
569
627
  */
570
- class OAuthClient {
628
+ const NsidSchema = z
629
+ .string()
630
+ .regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)+$/, 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")');
631
+ /**
632
+ * Zod schema for account permission.
633
+ *
634
+ * Account permissions control access to account-level information like email
635
+ * and repository management.
636
+ *
637
+ * @example Without action (read-only)
638
+ * ```typescript
639
+ * const input = { type: 'account', attr: 'email' };
640
+ * AccountPermissionSchema.parse(input); // Returns: "account:email"
641
+ * ```
642
+ *
643
+ * @example With action
644
+ * ```typescript
645
+ * const input = { type: 'account', attr: 'email', action: 'manage' };
646
+ * AccountPermissionSchema.parse(input); // Returns: "account:email?action=manage"
647
+ * ```
648
+ */
649
+ const AccountPermissionSchema = z
650
+ .object({
651
+ type: z.literal("account"),
652
+ attr: AccountAttrSchema,
653
+ action: AccountActionSchema.optional(),
654
+ })
655
+ .transform(({ attr, action }) => {
656
+ let perm = `account:${attr}`;
657
+ if (action) {
658
+ perm += `?action=${action}`;
659
+ }
660
+ return perm;
661
+ });
662
+ /**
663
+ * Zod schema for repository permission.
664
+ *
665
+ * Repository permissions control write access to records by collection type.
666
+ * The collection must be a valid NSID or wildcard (*).
667
+ *
668
+ * @example Without actions (all actions allowed)
669
+ * ```typescript
670
+ * const input = { type: 'repo', collection: 'app.bsky.feed.post' };
671
+ * RepoPermissionSchema.parse(input); // Returns: "repo:app.bsky.feed.post"
672
+ * ```
673
+ *
674
+ * @example With specific actions
675
+ * ```typescript
676
+ * const input = {
677
+ * type: 'repo',
678
+ * collection: 'app.bsky.feed.post',
679
+ * actions: ['create', 'update']
680
+ * };
681
+ * RepoPermissionSchema.parse(input); // Returns: "repo:app.bsky.feed.post?action=create&action=update"
682
+ * ```
683
+ *
684
+ * @example With wildcard collection
685
+ * ```typescript
686
+ * const input = { type: 'repo', collection: '*', actions: ['delete'] };
687
+ * RepoPermissionSchema.parse(input); // Returns: "repo:*?action=delete"
688
+ * ```
689
+ */
690
+ const RepoPermissionSchema = z
691
+ .object({
692
+ type: z.literal("repo"),
693
+ collection: NsidSchema.or(z.literal("*")),
694
+ actions: z.array(RepoActionSchema).optional(),
695
+ })
696
+ .transform(({ collection, actions }) => {
697
+ let perm = `repo:${collection}`;
698
+ if (actions && actions.length > 0) {
699
+ const params = actions.map((a) => `action=${a}`).join("&");
700
+ perm += `?${params}`;
701
+ }
702
+ return perm;
703
+ });
704
+ /**
705
+ * Zod schema for blob permission.
706
+ *
707
+ * Blob permissions control media file uploads constrained by MIME type patterns.
708
+ *
709
+ * @example Single MIME type
710
+ * ```typescript
711
+ * const input = { type: 'blob', mimeTypes: ['image/*'] };
712
+ * BlobPermissionSchema.parse(input); // Returns: "blob:image/*"
713
+ * ```
714
+ *
715
+ * @example Multiple MIME types
716
+ * ```typescript
717
+ * const input = { type: 'blob', mimeTypes: ['image/*', 'video/*'] };
718
+ * BlobPermissionSchema.parse(input); // Returns: "blob?accept=image/*&accept=video/*"
719
+ * ```
720
+ */
721
+ const BlobPermissionSchema = z
722
+ .object({
723
+ type: z.literal("blob"),
724
+ mimeTypes: z.array(MimeTypeSchema).min(1, "At least one MIME type required"),
725
+ })
726
+ .transform(({ mimeTypes }) => {
727
+ if (mimeTypes.length === 1) {
728
+ return `blob:${mimeTypes[0]}`;
729
+ }
730
+ const accepts = mimeTypes.map((t) => `accept=${encodeURIComponent(t)}`).join("&");
731
+ return `blob?${accepts}`;
732
+ });
733
+ /**
734
+ * Zod schema for RPC permission.
735
+ *
736
+ * RPC permissions control authenticated API calls to remote services.
737
+ * At least one of lexicon or aud must be restricted (both cannot be wildcards).
738
+ *
739
+ * @example Specific lexicon with wildcard audience
740
+ * ```typescript
741
+ * const input = {
742
+ * type: 'rpc',
743
+ * lexicon: 'com.atproto.repo.createRecord',
744
+ * aud: '*'
745
+ * };
746
+ * RpcPermissionSchema.parse(input);
747
+ * // Returns: "rpc:com.atproto.repo.createRecord?aud=*"
748
+ * ```
749
+ *
750
+ * @example With specific audience
751
+ * ```typescript
752
+ * const input = {
753
+ * type: 'rpc',
754
+ * lexicon: 'com.atproto.repo.createRecord',
755
+ * aud: 'did:web:api.example.com',
756
+ * inheritAud: true
757
+ * };
758
+ * RpcPermissionSchema.parse(input);
759
+ * // Returns: "rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com&inheritAud=true"
760
+ * ```
761
+ */
762
+ const RpcPermissionSchema = z
763
+ .object({
764
+ type: z.literal("rpc"),
765
+ lexicon: NsidSchema.or(z.literal("*")),
766
+ aud: z.string().min(1, "Audience is required"),
767
+ inheritAud: z.boolean().optional(),
768
+ })
769
+ .refine(({ lexicon, aud }) => lexicon !== "*" || aud !== "*", "At least one of lexicon or aud must be restricted (wildcards cannot both be used)")
770
+ .transform(({ lexicon, aud, inheritAud }) => {
771
+ let perm = `rpc:${lexicon}?aud=${encodeURIComponent(aud)}`;
772
+ if (inheritAud) {
773
+ perm += "&inheritAud=true";
774
+ }
775
+ return perm;
776
+ });
777
+ /**
778
+ * Zod schema for identity permission.
779
+ *
780
+ * Identity permissions control access to DID documents and handles.
781
+ *
782
+ * @example Handle management
783
+ * ```typescript
784
+ * const input = { type: 'identity', attr: 'handle' };
785
+ * IdentityPermissionSchema.parse(input); // Returns: "identity:handle"
786
+ * ```
787
+ *
788
+ * @example All identity attributes
789
+ * ```typescript
790
+ * const input = { type: 'identity', attr: '*' };
791
+ * IdentityPermissionSchema.parse(input); // Returns: "identity:*"
792
+ * ```
793
+ */
794
+ const IdentityPermissionSchema = z
795
+ .object({
796
+ type: z.literal("identity"),
797
+ attr: IdentityAttrSchema,
798
+ })
799
+ .transform(({ attr }) => `identity:${attr}`);
800
+ /**
801
+ * Zod schema for permission set inclusion.
802
+ *
803
+ * Include permissions reference permission sets bundled under a single NSID.
804
+ *
805
+ * @example Without audience
806
+ * ```typescript
807
+ * const input = { type: 'include', nsid: 'com.example.authBasicFeatures' };
808
+ * IncludePermissionSchema.parse(input);
809
+ * // Returns: "include:com.example.authBasicFeatures"
810
+ * ```
811
+ *
812
+ * @example With audience
813
+ * ```typescript
814
+ * const input = {
815
+ * type: 'include',
816
+ * nsid: 'com.example.authBasicFeatures',
817
+ * aud: 'did:web:api.example.com'
818
+ * };
819
+ * IncludePermissionSchema.parse(input);
820
+ * // Returns: "include:com.example.authBasicFeatures?aud=did%3Aweb%3Aapi.example.com"
821
+ * ```
822
+ */
823
+ const IncludePermissionSchema = z
824
+ .object({
825
+ type: z.literal("include"),
826
+ nsid: NsidSchema,
827
+ aud: z.string().optional(),
828
+ })
829
+ .transform(({ nsid, aud }) => {
830
+ let perm = `include:${nsid}`;
831
+ if (aud) {
832
+ perm += `?aud=${encodeURIComponent(aud)}`;
833
+ }
834
+ return perm;
835
+ });
836
+ /**
837
+ * Union schema for all permission types.
838
+ *
839
+ * This schema accepts any of the supported permission types and validates
840
+ * them according to their specific rules.
841
+ */
842
+ const PermissionSchema = z.union([
843
+ AccountPermissionSchema,
844
+ RepoPermissionSchema,
845
+ BlobPermissionSchema,
846
+ RpcPermissionSchema,
847
+ IdentityPermissionSchema,
848
+ IncludePermissionSchema,
849
+ ]);
850
+ /**
851
+ * Fluent builder for constructing OAuth permission arrays.
852
+ *
853
+ * This class provides a convenient, type-safe way to build arrays of permissions
854
+ * using method chaining.
855
+ *
856
+ * @example Basic usage
857
+ * ```typescript
858
+ * const builder = new PermissionBuilder()
859
+ * .accountEmail('read')
860
+ * .repoWrite('app.bsky.feed.post')
861
+ * .blob(['image/*', 'video/*']);
862
+ *
863
+ * const permissions = builder.build();
864
+ * // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post?action=create&action=update', 'blob:image/*,video/*']
865
+ * ```
866
+ *
867
+ * @example With transitional scopes
868
+ * ```typescript
869
+ * const builder = new PermissionBuilder()
870
+ * .transition('email')
871
+ * .transition('generic');
872
+ *
873
+ * const scopes = builder.build();
874
+ * // Returns: ['transition:email', 'transition:generic']
875
+ * ```
876
+ */
877
+ class PermissionBuilder {
878
+ constructor() {
879
+ this.permissions = [];
880
+ }
571
881
  /**
572
- * Creates a new OAuth client.
882
+ * Add a transitional scope.
573
883
  *
574
- * @param config - SDK configuration including OAuth credentials and server URLs
575
- * @throws {@link AuthenticationError} if the JWK private key is not valid JSON
884
+ * @param scope - The transitional scope name ('email', 'generic', or 'chat.bsky')
885
+ * @returns This builder for chaining
576
886
  *
577
- * @remarks
578
- * The constructor validates the JWK format synchronously but defers
579
- * the actual client initialization to the first API call.
887
+ * @example
888
+ * ```typescript
889
+ * builder.transition('email').transition('generic');
890
+ * ```
580
891
  */
581
- constructor(config) {
582
- /** The underlying NodeOAuthClient instance (lazily initialized) */
583
- this.client = null;
584
- this.config = config;
585
- this.logger = config.logger;
586
- // Validate JWK format synchronously (before async initialization)
587
- try {
588
- JSON.parse(config.oauth.jwkPrivate);
589
- }
590
- catch (error) {
591
- throw new AuthenticationError("Failed to parse JWK private key. Ensure it is valid JSON.", error);
592
- }
593
- // Initialize client lazily (async initialization)
594
- this.clientPromise = this.initializeClient();
892
+ transition(scope) {
893
+ const fullScope = `transition:${scope}`;
894
+ const validated = TransitionScopeSchema.parse(fullScope);
895
+ this.permissions.push(validated);
896
+ return this;
595
897
  }
596
898
  /**
597
- * Initializes the NodeOAuthClient asynchronously.
899
+ * Add an account permission.
598
900
  *
599
- * This method is called lazily on first use. It:
600
- * 1. Parses the JWK private key(s)
601
- * 2. Builds OAuth client metadata
602
- * 3. Creates the underlying NodeOAuthClient
901
+ * @param attr - The account attribute ('email' or 'repo')
902
+ * @param action - Optional action ('read' or 'manage')
903
+ * @returns This builder for chaining
603
904
  *
604
- * @returns Promise resolving to the initialized client
605
- * @internal
905
+ * @example
906
+ * ```typescript
907
+ * builder.accountEmail('read').accountRepo('manage');
908
+ * ```
606
909
  */
607
- async initializeClient() {
608
- if (this.client) {
609
- return this.client;
610
- }
611
- // Parse JWK private key (already validated in constructor)
612
- const privateJWK = JSON.parse(this.config.oauth.jwkPrivate);
613
- // Build client metadata
614
- const clientMetadata = this.buildClientMetadata();
615
- // Convert JWK keys to JoseKey instances (await here)
616
- const keyset = await Promise.all(privateJWK.keys.map((key) => JoseKey.fromImportable(key, key.kid)));
617
- // Create fetch with timeout
618
- const fetchWithTimeout = this.createFetchWithTimeout(this.config.timeouts?.pdsMetadata ?? 30000);
619
- // Use provided stores or fall back to in-memory implementations
620
- const stateStore = this.config.storage?.stateStore ?? new InMemoryStateStore();
621
- const sessionStore = this.config.storage?.sessionStore ?? new InMemorySessionStore();
622
- this.client = new NodeOAuthClient({
623
- clientMetadata,
624
- keyset,
625
- stateStore: this.createStateStoreAdapter(stateStore),
626
- sessionStore: this.createSessionStoreAdapter(sessionStore),
627
- handleResolver: this.config.servers?.pds,
628
- fetch: this.config.fetch ?? fetchWithTimeout,
910
+ account(attr, action) {
911
+ const permission = AccountPermissionSchema.parse({
912
+ type: "account",
913
+ attr,
914
+ action,
629
915
  });
630
- return this.client;
916
+ this.permissions.push(permission);
917
+ return this;
631
918
  }
632
919
  /**
633
- * Gets the OAuth client instance, initializing if needed.
920
+ * Convenience method for account:email permission.
634
921
  *
635
- * @returns Promise resolving to the initialized client
636
- * @internal
922
+ * @param action - Optional action ('read' or 'manage')
923
+ * @returns This builder for chaining
924
+ *
925
+ * @example
926
+ * ```typescript
927
+ * builder.accountEmail('read');
928
+ * ```
637
929
  */
638
- async getClient() {
639
- return this.clientPromise;
930
+ accountEmail(action) {
931
+ return this.account("email", action);
640
932
  }
641
933
  /**
642
- * Builds OAuth client metadata from configuration.
643
- *
644
- * The metadata describes your application to the authorization server
645
- * and must match what's published at your `clientId` URL.
934
+ * Convenience method for account:repo permission.
646
935
  *
647
- * @returns OAuth client metadata object
648
- * @internal
936
+ * @param action - Optional action ('read' or 'manage')
937
+ * @returns This builder for chaining
649
938
  *
650
- * @remarks
651
- * Key metadata fields:
652
- * - `client_id`: URL to your client metadata JSON
653
- * - `redirect_uris`: Where to redirect after auth (must match config)
654
- * - `dpop_bound_access_tokens`: Always true for AT Protocol
655
- * - `token_endpoint_auth_method`: Uses private_key_jwt for security
939
+ * @example
940
+ * ```typescript
941
+ * builder.accountRepo('manage');
942
+ * ```
656
943
  */
657
- buildClientMetadata() {
658
- const clientIdUrl = new URL(this.config.oauth.clientId);
659
- return {
660
- client_id: this.config.oauth.clientId,
661
- client_name: "ATProto SDK Client",
662
- client_uri: clientIdUrl.origin,
663
- redirect_uris: [this.config.oauth.redirectUri],
664
- scope: this.config.oauth.scope,
665
- grant_types: ["authorization_code", "refresh_token"],
666
- response_types: ["code"],
667
- application_type: "web",
668
- token_endpoint_auth_method: "private_key_jwt",
669
- token_endpoint_auth_signing_alg: "ES256",
670
- dpop_bound_access_tokens: true,
671
- jwks_uri: this.config.oauth.jwksUri,
672
- };
944
+ accountRepo(action) {
945
+ return this.account("repo", action);
673
946
  }
674
947
  /**
675
- * Creates a fetch handler with timeout support.
948
+ * Add a repository permission.
676
949
  *
677
- * @param timeoutMs - Request timeout in milliseconds
678
- * @returns A fetch function that aborts after the timeout
679
- * @internal
950
+ * @param collection - The NSID of the collection or '*' for all
951
+ * @param actions - Optional array of actions ('create', 'update', 'delete')
952
+ * @returns This builder for chaining
953
+ *
954
+ * @example
955
+ * ```typescript
956
+ * builder.repo('app.bsky.feed.post', ['create', 'update']);
957
+ * ```
958
+ */
959
+ repo(collection, actions) {
960
+ const permission = RepoPermissionSchema.parse({
961
+ type: "repo",
962
+ collection,
963
+ actions,
964
+ });
965
+ this.permissions.push(permission);
966
+ return this;
967
+ }
968
+ /**
969
+ * Convenience method for repository write permissions (create + update).
970
+ *
971
+ * @param collection - The NSID of the collection or '*' for all
972
+ * @returns This builder for chaining
973
+ *
974
+ * @example
975
+ * ```typescript
976
+ * builder.repoWrite('app.bsky.feed.post');
977
+ * ```
978
+ */
979
+ repoWrite(collection) {
980
+ return this.repo(collection, ["create", "update"]);
981
+ }
982
+ /**
983
+ * Convenience method for repository read permission (no actions).
984
+ *
985
+ * @param collection - The NSID of the collection or '*' for all
986
+ * @returns This builder for chaining
987
+ *
988
+ * @example
989
+ * ```typescript
990
+ * builder.repoRead('app.bsky.feed.post');
991
+ * ```
992
+ */
993
+ repoRead(collection) {
994
+ return this.repo(collection, []);
995
+ }
996
+ /**
997
+ * Convenience method for full repository permissions (create + update + delete).
998
+ *
999
+ * @param collection - The NSID of the collection or '*' for all
1000
+ * @returns This builder for chaining
1001
+ *
1002
+ * @example
1003
+ * ```typescript
1004
+ * builder.repoFull('app.bsky.feed.post');
1005
+ * ```
1006
+ */
1007
+ repoFull(collection) {
1008
+ return this.repo(collection, ["create", "update", "delete"]);
1009
+ }
1010
+ /**
1011
+ * Add a blob permission.
1012
+ *
1013
+ * @param mimeTypes - Array of MIME types or a single MIME type
1014
+ * @returns This builder for chaining
1015
+ *
1016
+ * @example
1017
+ * ```typescript
1018
+ * builder.blob(['image/*', 'video/*']);
1019
+ * builder.blob('image/*');
1020
+ * ```
1021
+ */
1022
+ blob(mimeTypes) {
1023
+ const types = Array.isArray(mimeTypes) ? mimeTypes : [mimeTypes];
1024
+ const permission = BlobPermissionSchema.parse({
1025
+ type: "blob",
1026
+ mimeTypes: types,
1027
+ });
1028
+ this.permissions.push(permission);
1029
+ return this;
1030
+ }
1031
+ /**
1032
+ * Add an RPC permission.
1033
+ *
1034
+ * @param lexicon - The NSID of the lexicon or '*' for all
1035
+ * @param aud - The audience (DID or URL)
1036
+ * @param inheritAud - Whether to inherit audience
1037
+ * @returns This builder for chaining
1038
+ *
1039
+ * @example
1040
+ * ```typescript
1041
+ * builder.rpc('com.atproto.repo.createRecord', 'did:web:api.example.com');
1042
+ * ```
1043
+ */
1044
+ rpc(lexicon, aud, inheritAud) {
1045
+ const permission = RpcPermissionSchema.parse({
1046
+ type: "rpc",
1047
+ lexicon,
1048
+ aud,
1049
+ inheritAud,
1050
+ });
1051
+ this.permissions.push(permission);
1052
+ return this;
1053
+ }
1054
+ /**
1055
+ * Add an identity permission.
1056
+ *
1057
+ * @param attr - The identity attribute ('handle' or '*')
1058
+ * @returns This builder for chaining
1059
+ *
1060
+ * @example
1061
+ * ```typescript
1062
+ * builder.identity('handle');
1063
+ * ```
1064
+ */
1065
+ identity(attr) {
1066
+ const permission = IdentityPermissionSchema.parse({
1067
+ type: "identity",
1068
+ attr,
1069
+ });
1070
+ this.permissions.push(permission);
1071
+ return this;
1072
+ }
1073
+ /**
1074
+ * Add an include permission.
1075
+ *
1076
+ * @param nsid - The NSID of the scope set to include
1077
+ * @param aud - Optional audience restriction
1078
+ * @returns This builder for chaining
1079
+ *
1080
+ * @example
1081
+ * ```typescript
1082
+ * builder.include('com.example.authBasicFeatures');
1083
+ * ```
1084
+ */
1085
+ include(nsid, aud) {
1086
+ const permission = IncludePermissionSchema.parse({
1087
+ type: "include",
1088
+ nsid,
1089
+ aud,
1090
+ });
1091
+ this.permissions.push(permission);
1092
+ return this;
1093
+ }
1094
+ /**
1095
+ * Add a custom permission string directly (bypasses validation).
1096
+ *
1097
+ * Use this for testing or special cases where you need to add
1098
+ * a permission that doesn't fit the standard types.
1099
+ *
1100
+ * @param permission - The permission string
1101
+ * @returns This builder for chaining
1102
+ *
1103
+ * @example
1104
+ * ```typescript
1105
+ * builder.custom('atproto');
1106
+ * ```
1107
+ */
1108
+ custom(permission) {
1109
+ this.permissions.push(permission);
1110
+ return this;
1111
+ }
1112
+ /**
1113
+ * Add the base atproto scope.
1114
+ *
1115
+ * @returns This builder for chaining
1116
+ *
1117
+ * @example
1118
+ * ```typescript
1119
+ * builder.atproto();
1120
+ * ```
1121
+ */
1122
+ atproto() {
1123
+ this.permissions.push(ATPROTO_SCOPE);
1124
+ return this;
1125
+ }
1126
+ /**
1127
+ * Build and return the array of permission strings.
1128
+ *
1129
+ * @returns Array of permission strings
1130
+ *
1131
+ * @example
1132
+ * ```typescript
1133
+ * const permissions = builder.build();
1134
+ * ```
1135
+ */
1136
+ build() {
1137
+ return [...this.permissions];
1138
+ }
1139
+ /**
1140
+ * Clear all permissions from the builder.
1141
+ *
1142
+ * @returns This builder for chaining
1143
+ *
1144
+ * @example
1145
+ * ```typescript
1146
+ * builder.clear().accountEmail('read');
1147
+ * ```
1148
+ */
1149
+ clear() {
1150
+ this.permissions = [];
1151
+ return this;
1152
+ }
1153
+ /**
1154
+ * Get the current number of permissions.
1155
+ *
1156
+ * @returns The number of permissions
1157
+ *
1158
+ * @example
1159
+ * ```typescript
1160
+ * const count = builder.count();
1161
+ * ```
1162
+ */
1163
+ count() {
1164
+ return this.permissions.length;
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Build a scope string from an array of permissions.
1169
+ *
1170
+ * This is a convenience function that joins permission strings with spaces,
1171
+ * which is the standard format for OAuth scope parameters.
1172
+ *
1173
+ * @param permissions - Array of permission strings
1174
+ * @returns Space-separated scope string
1175
+ *
1176
+ * @example
1177
+ * ```typescript
1178
+ * const permissions = ['account:email?action=read', 'repo:app.bsky.feed.post'];
1179
+ * const scope = buildScope(permissions);
1180
+ * // Returns: "account:email?action=read repo:app.bsky.feed.post"
1181
+ * ```
1182
+ */
1183
+ function buildScope(permissions) {
1184
+ return permissions.join(" ");
1185
+ }
1186
+ /**
1187
+ * Pre-built scope presets for common use cases.
1188
+ *
1189
+ * These presets provide ready-to-use permission sets for typical application scenarios.
1190
+ */
1191
+ const ScopePresets = {
1192
+ /**
1193
+ * Email access scope - allows reading user's email address.
1194
+ *
1195
+ * Includes:
1196
+ * - account:email?action=read
1197
+ *
1198
+ * @example
1199
+ * ```typescript
1200
+ * const scope = ScopePresets.EMAIL_READ;
1201
+ * // Use in OAuth flow to request email access
1202
+ * ```
1203
+ */
1204
+ EMAIL_READ: buildScope(new PermissionBuilder().accountEmail("read").build()),
1205
+ /**
1206
+ * Profile read scope - allows reading user's profile.
1207
+ *
1208
+ * Includes:
1209
+ * - repo:app.bsky.actor.profile (read-only)
1210
+ *
1211
+ * @example
1212
+ * ```typescript
1213
+ * const scope = ScopePresets.PROFILE_READ;
1214
+ * ```
1215
+ */
1216
+ PROFILE_READ: buildScope(new PermissionBuilder().repoRead("app.bsky.actor.profile").build()),
1217
+ /**
1218
+ * Profile write scope - allows updating user's profile.
1219
+ *
1220
+ * Includes:
1221
+ * - repo:app.bsky.actor.profile (create + update)
1222
+ *
1223
+ * @example
1224
+ * ```typescript
1225
+ * const scope = ScopePresets.PROFILE_WRITE;
1226
+ * ```
1227
+ */
1228
+ PROFILE_WRITE: buildScope(new PermissionBuilder().repoWrite("app.bsky.actor.profile").build()),
1229
+ /**
1230
+ * Post creation scope - allows creating and updating posts.
1231
+ *
1232
+ * Includes:
1233
+ * - repo:app.bsky.feed.post (create + update)
1234
+ *
1235
+ * @example
1236
+ * ```typescript
1237
+ * const scope = ScopePresets.POST_WRITE;
1238
+ * ```
1239
+ */
1240
+ POST_WRITE: buildScope(new PermissionBuilder().repoWrite("app.bsky.feed.post").build()),
1241
+ /**
1242
+ * Social interactions scope - allows liking, reposting, and following.
1243
+ *
1244
+ * Includes:
1245
+ * - repo:app.bsky.feed.like (create + update)
1246
+ * - repo:app.bsky.feed.repost (create + update)
1247
+ * - repo:app.bsky.graph.follow (create + update)
1248
+ *
1249
+ * @example
1250
+ * ```typescript
1251
+ * const scope = ScopePresets.SOCIAL_WRITE;
1252
+ * ```
1253
+ */
1254
+ SOCIAL_WRITE: buildScope(new PermissionBuilder()
1255
+ .repoWrite("app.bsky.feed.like")
1256
+ .repoWrite("app.bsky.feed.repost")
1257
+ .repoWrite("app.bsky.graph.follow")
1258
+ .build()),
1259
+ /**
1260
+ * Media upload scope - allows uploading images and videos.
1261
+ *
1262
+ * Includes:
1263
+ * - blob permissions for image/* and video/*
1264
+ *
1265
+ * @example
1266
+ * ```typescript
1267
+ * const scope = ScopePresets.MEDIA_UPLOAD;
1268
+ * ```
1269
+ */
1270
+ MEDIA_UPLOAD: buildScope(new PermissionBuilder().blob(["image/*", "video/*"]).build()),
1271
+ /**
1272
+ * Image upload only scope - allows uploading images.
1273
+ *
1274
+ * Includes:
1275
+ * - blob:image/*
1276
+ *
1277
+ * @example
1278
+ * ```typescript
1279
+ * const scope = ScopePresets.IMAGE_UPLOAD;
1280
+ * ```
1281
+ */
1282
+ IMAGE_UPLOAD: buildScope(new PermissionBuilder().blob("image/*").build()),
1283
+ /**
1284
+ * Posting app scope - full posting capabilities including media.
1285
+ *
1286
+ * Includes:
1287
+ * - repo:app.bsky.feed.post (create + update)
1288
+ * - repo:app.bsky.feed.like (create + update)
1289
+ * - repo:app.bsky.feed.repost (create + update)
1290
+ * - blob permissions for image/* and video/*
1291
+ *
1292
+ * @example
1293
+ * ```typescript
1294
+ * const scope = ScopePresets.POSTING_APP;
1295
+ * ```
1296
+ */
1297
+ POSTING_APP: buildScope(new PermissionBuilder()
1298
+ .repoWrite("app.bsky.feed.post")
1299
+ .repoWrite("app.bsky.feed.like")
1300
+ .repoWrite("app.bsky.feed.repost")
1301
+ .blob(["image/*", "video/*"])
1302
+ .build()),
1303
+ /**
1304
+ * Read-only app scope - allows reading all repository data.
1305
+ *
1306
+ * Includes:
1307
+ * - repo:* (read-only, no actions)
1308
+ *
1309
+ * @example
1310
+ * ```typescript
1311
+ * const scope = ScopePresets.READ_ONLY;
1312
+ * ```
1313
+ */
1314
+ READ_ONLY: buildScope(new PermissionBuilder().repoRead("*").build()),
1315
+ /**
1316
+ * Full access scope - allows all repository operations.
1317
+ *
1318
+ * Includes:
1319
+ * - repo:* (create + update + delete)
1320
+ *
1321
+ * @example
1322
+ * ```typescript
1323
+ * const scope = ScopePresets.FULL_ACCESS;
1324
+ * ```
1325
+ */
1326
+ FULL_ACCESS: buildScope(new PermissionBuilder().repoFull("*").build()),
1327
+ /**
1328
+ * Email + Profile scope - common combination for user identification.
1329
+ *
1330
+ * Includes:
1331
+ * - account:email?action=read
1332
+ * - repo:app.bsky.actor.profile (read-only)
1333
+ *
1334
+ * @example
1335
+ * ```typescript
1336
+ * const scope = ScopePresets.EMAIL_AND_PROFILE;
1337
+ * ```
1338
+ */
1339
+ EMAIL_AND_PROFILE: buildScope(new PermissionBuilder().accountEmail("read").repoRead("app.bsky.actor.profile").build()),
1340
+ /**
1341
+ * Transitional email scope (legacy).
1342
+ *
1343
+ * Uses the transitional scope format for backward compatibility.
1344
+ *
1345
+ * @example
1346
+ * ```typescript
1347
+ * const scope = ScopePresets.TRANSITION_EMAIL;
1348
+ * ```
1349
+ */
1350
+ TRANSITION_EMAIL: buildScope(new PermissionBuilder().transition("email").build()),
1351
+ /**
1352
+ * Transitional generic scope (legacy).
1353
+ *
1354
+ * Uses the transitional scope format for backward compatibility.
1355
+ *
1356
+ * @example
1357
+ * ```typescript
1358
+ * const scope = ScopePresets.TRANSITION_GENERIC;
1359
+ * ```
1360
+ */
1361
+ TRANSITION_GENERIC: buildScope(new PermissionBuilder().transition("generic").build()),
1362
+ };
1363
+ /**
1364
+ * Parse a scope string into an array of individual permissions.
1365
+ *
1366
+ * This splits a space-separated scope string into individual permission strings.
1367
+ *
1368
+ * @param scope - Space-separated scope string
1369
+ * @returns Array of permission strings
1370
+ *
1371
+ * @example
1372
+ * ```typescript
1373
+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
1374
+ * const permissions = parseScope(scope);
1375
+ * // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post']
1376
+ * ```
1377
+ */
1378
+ function parseScope(scope) {
1379
+ return scope.trim().split(/\s+/).filter(Boolean);
1380
+ }
1381
+ /**
1382
+ * Check if a scope string contains a specific permission.
1383
+ *
1384
+ * This function performs exact string matching. For more advanced
1385
+ * permission checking (e.g., wildcard matching), you'll need to
1386
+ * implement custom logic.
1387
+ *
1388
+ * @param scope - Space-separated scope string
1389
+ * @param permission - The permission to check for
1390
+ * @returns True if the scope contains the permission
1391
+ *
1392
+ * @example
1393
+ * ```typescript
1394
+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
1395
+ * hasPermission(scope, "account:email?action=read"); // true
1396
+ * hasPermission(scope, "account:repo"); // false
1397
+ * ```
1398
+ */
1399
+ function hasPermission(scope, permission) {
1400
+ const permissions = parseScope(scope);
1401
+ return permissions.includes(permission);
1402
+ }
1403
+ /**
1404
+ * Check if a scope string contains all of the specified permissions.
1405
+ *
1406
+ * @param scope - Space-separated scope string
1407
+ * @param requiredPermissions - Array of permissions to check for
1408
+ * @returns True if the scope contains all required permissions
1409
+ *
1410
+ * @example
1411
+ * ```typescript
1412
+ * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*";
1413
+ * hasAllPermissions(scope, ["account:email?action=read", "blob:image/*"]); // true
1414
+ * hasAllPermissions(scope, ["account:email?action=read", "account:repo"]); // false
1415
+ * ```
1416
+ */
1417
+ function hasAllPermissions(scope, requiredPermissions) {
1418
+ const permissions = parseScope(scope);
1419
+ return requiredPermissions.every((required) => permissions.includes(required));
1420
+ }
1421
+ /**
1422
+ * Check if a scope string contains any of the specified permissions.
1423
+ *
1424
+ * @param scope - Space-separated scope string
1425
+ * @param checkPermissions - Array of permissions to check for
1426
+ * @returns True if the scope contains at least one of the permissions
1427
+ *
1428
+ * @example
1429
+ * ```typescript
1430
+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
1431
+ * hasAnyPermission(scope, ["account:email?action=read", "account:repo"]); // true
1432
+ * hasAnyPermission(scope, ["account:repo", "identity:handle"]); // false
1433
+ * ```
1434
+ */
1435
+ function hasAnyPermission(scope, checkPermissions) {
1436
+ const permissions = parseScope(scope);
1437
+ return checkPermissions.some((check) => permissions.includes(check));
1438
+ }
1439
+ /**
1440
+ * Merge multiple scope strings into a single scope string with deduplicated permissions.
1441
+ *
1442
+ * @param scopes - Array of scope strings to merge
1443
+ * @returns Merged scope string with unique permissions
1444
+ *
1445
+ * @example
1446
+ * ```typescript
1447
+ * const scope1 = "account:email?action=read repo:app.bsky.feed.post";
1448
+ * const scope2 = "repo:app.bsky.feed.post blob:image/*";
1449
+ * const merged = mergeScopes([scope1, scope2]);
1450
+ * // Returns: "account:email?action=read repo:app.bsky.feed.post blob:image/*"
1451
+ * ```
1452
+ */
1453
+ function mergeScopes(scopes) {
1454
+ const allPermissions = scopes.flatMap(parseScope);
1455
+ const uniquePermissions = [...new Set(allPermissions)];
1456
+ return buildScope(uniquePermissions);
1457
+ }
1458
+ /**
1459
+ * Remove specific permissions from a scope string.
1460
+ *
1461
+ * @param scope - Space-separated scope string
1462
+ * @param permissionsToRemove - Array of permissions to remove
1463
+ * @returns New scope string without the specified permissions
1464
+ *
1465
+ * @example
1466
+ * ```typescript
1467
+ * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*";
1468
+ * const filtered = removePermissions(scope, ["blob:image/*"]);
1469
+ * // Returns: "account:email?action=read repo:app.bsky.feed.post"
1470
+ * ```
1471
+ */
1472
+ function removePermissions(scope, permissionsToRemove) {
1473
+ const permissions = parseScope(scope);
1474
+ const filtered = permissions.filter((p) => !permissionsToRemove.includes(p));
1475
+ return buildScope(filtered);
1476
+ }
1477
+ /**
1478
+ * Validate that all permissions in a scope string are well-formed.
1479
+ *
1480
+ * This checks that each permission matches expected patterns for transitional
1481
+ * or granular permissions. It does NOT validate against the full Zod schemas.
1482
+ *
1483
+ * @param scope - Space-separated scope string
1484
+ * @returns Object with isValid flag and array of invalid permissions
1485
+ *
1486
+ * @example
1487
+ * ```typescript
1488
+ * const scope = "account:email?action=read invalid:permission";
1489
+ * const result = validateScope(scope);
1490
+ * // Returns: { isValid: false, invalidPermissions: ['invalid:permission'] }
1491
+ * ```
1492
+ */
1493
+ function validateScope(scope) {
1494
+ const permissions = parseScope(scope);
1495
+ const invalidPermissions = [];
1496
+ // Pattern for valid permission prefixes
1497
+ const validPrefixes = /^(atproto|transition:|account:|repo:|blob:?|rpc:|identity:|include:)/;
1498
+ for (const permission of permissions) {
1499
+ if (!validPrefixes.test(permission)) {
1500
+ invalidPermissions.push(permission);
1501
+ }
1502
+ }
1503
+ return {
1504
+ isValid: invalidPermissions.length === 0,
1505
+ invalidPermissions,
1506
+ };
1507
+ }
1508
+
1509
+ /**
1510
+ * OAuth 2.0 client for AT Protocol authentication with DPoP support.
1511
+ *
1512
+ * This class wraps the `@atproto/oauth-client-node` library to provide
1513
+ * OAuth 2.0 authentication with the following features:
1514
+ *
1515
+ * - **DPoP (Demonstrating Proof of Possession)**: Binds tokens to cryptographic keys
1516
+ * to prevent token theft and replay attacks
1517
+ * - **PKCE (Proof Key for Code Exchange)**: Protects against authorization code interception
1518
+ * - **Automatic Token Refresh**: Transparently refreshes expired access tokens
1519
+ * - **Session Persistence**: Stores sessions in configurable storage backends
1520
+ *
1521
+ * @remarks
1522
+ * This class is typically used internally by {@link ATProtoSDK}. Direct usage
1523
+ * is only needed for advanced scenarios.
1524
+ *
1525
+ * The client uses lazy initialization - the underlying `NodeOAuthClient` is
1526
+ * created asynchronously on first use. This allows the constructor to return
1527
+ * synchronously while deferring async key parsing.
1528
+ *
1529
+ * @example Direct usage (advanced)
1530
+ * ```typescript
1531
+ * import { OAuthClient } from "@hypercerts-org/sdk";
1532
+ *
1533
+ * const client = new OAuthClient({
1534
+ * oauth: {
1535
+ * clientId: "https://my-app.com/client-metadata.json",
1536
+ * redirectUri: "https://my-app.com/callback",
1537
+ * scope: "atproto transition:generic",
1538
+ * jwksUri: "https://my-app.com/.well-known/jwks.json",
1539
+ * jwkPrivate: process.env.JWK_PRIVATE_KEY!,
1540
+ * },
1541
+ * servers: { pds: "https://bsky.social" },
1542
+ * });
1543
+ *
1544
+ * // Start authorization
1545
+ * const authUrl = await client.authorize("user.bsky.social");
1546
+ *
1547
+ * // Handle callback
1548
+ * const session = await client.callback(new URLSearchParams(callbackUrl.search));
1549
+ * ```
1550
+ *
1551
+ * @see {@link ATProtoSDK} for the recommended high-level API
1552
+ * @see https://atproto.com/specs/oauth for AT Protocol OAuth specification
1553
+ */
1554
+ class OAuthClient {
1555
+ /**
1556
+ * Creates a new OAuth client.
1557
+ *
1558
+ * @param config - SDK configuration including OAuth credentials and server URLs
1559
+ * @throws {@link AuthenticationError} if the JWK private key is not valid JSON
1560
+ *
1561
+ * @remarks
1562
+ * The constructor validates the JWK format synchronously but defers
1563
+ * the actual client initialization to the first API call.
1564
+ */
1565
+ constructor(config) {
1566
+ /** The underlying NodeOAuthClient instance (lazily initialized) */
1567
+ this.client = null;
1568
+ this.config = config;
1569
+ this.logger = config.logger;
1570
+ // Validate JWK format synchronously (before async initialization)
1571
+ try {
1572
+ JSON.parse(config.oauth.jwkPrivate);
1573
+ }
1574
+ catch (error) {
1575
+ throw new AuthenticationError("Failed to parse JWK private key. Ensure it is valid JSON.", error);
1576
+ }
1577
+ // Initialize client lazily (async initialization)
1578
+ this.clientPromise = this.initializeClient();
1579
+ }
1580
+ /**
1581
+ * Initializes the NodeOAuthClient asynchronously.
1582
+ *
1583
+ * This method is called lazily on first use. It:
1584
+ * 1. Parses the JWK private key(s)
1585
+ * 2. Builds OAuth client metadata
1586
+ * 3. Creates the underlying NodeOAuthClient
1587
+ *
1588
+ * @returns Promise resolving to the initialized client
1589
+ * @internal
1590
+ */
1591
+ async initializeClient() {
1592
+ if (this.client) {
1593
+ return this.client;
1594
+ }
1595
+ // Parse JWK private key (already validated in constructor)
1596
+ const privateJWK = JSON.parse(this.config.oauth.jwkPrivate);
1597
+ // Build client metadata
1598
+ const clientMetadata = this.buildClientMetadata();
1599
+ // Convert JWK keys to JoseKey instances (await here)
1600
+ const keyset = await Promise.all(privateJWK.keys.map((key) => JoseKey.fromImportable(key, key.kid)));
1601
+ // Create fetch with timeout
1602
+ const fetchWithTimeout = this.createFetchWithTimeout(this.config.timeouts?.pdsMetadata ?? 30000);
1603
+ // Use provided stores or fall back to in-memory implementations
1604
+ const stateStore = this.config.storage?.stateStore ?? new InMemoryStateStore();
1605
+ const sessionStore = this.config.storage?.sessionStore ?? new InMemorySessionStore();
1606
+ this.client = new NodeOAuthClient({
1607
+ clientMetadata,
1608
+ keyset,
1609
+ stateStore: this.createStateStoreAdapter(stateStore),
1610
+ sessionStore: this.createSessionStoreAdapter(sessionStore),
1611
+ handleResolver: this.config.servers?.pds,
1612
+ fetch: this.config.fetch ?? fetchWithTimeout,
1613
+ });
1614
+ return this.client;
1615
+ }
1616
+ /**
1617
+ * Gets the OAuth client instance, initializing if needed.
1618
+ *
1619
+ * @returns Promise resolving to the initialized client
1620
+ * @internal
1621
+ */
1622
+ async getClient() {
1623
+ return this.clientPromise;
1624
+ }
1625
+ /**
1626
+ * Builds OAuth client metadata from configuration.
1627
+ *
1628
+ * The metadata describes your application to the authorization server
1629
+ * and must match what's published at your `clientId` URL.
1630
+ *
1631
+ * @returns OAuth client metadata object
1632
+ * @internal
1633
+ *
1634
+ * @remarks
1635
+ * Key metadata fields:
1636
+ * - `client_id`: URL to your client metadata JSON
1637
+ * - `redirect_uris`: Where to redirect after auth (must match config)
1638
+ * - `dpop_bound_access_tokens`: Always true for AT Protocol
1639
+ * - `token_endpoint_auth_method`: Uses private_key_jwt for security
1640
+ */
1641
+ buildClientMetadata() {
1642
+ const clientIdUrl = new URL(this.config.oauth.clientId);
1643
+ const metadata = {
1644
+ client_id: this.config.oauth.clientId,
1645
+ client_name: "ATProto SDK Client",
1646
+ client_uri: clientIdUrl.origin,
1647
+ redirect_uris: [this.config.oauth.redirectUri],
1648
+ scope: this.config.oauth.scope,
1649
+ grant_types: ["authorization_code", "refresh_token"],
1650
+ response_types: ["code"],
1651
+ application_type: "web",
1652
+ token_endpoint_auth_method: "private_key_jwt",
1653
+ token_endpoint_auth_signing_alg: "ES256",
1654
+ dpop_bound_access_tokens: true,
1655
+ jwks_uri: this.config.oauth.jwksUri,
1656
+ };
1657
+ // Validate scope before returning metadata
1658
+ this.validateClientMetadataScope(metadata.scope);
1659
+ return metadata;
1660
+ }
1661
+ /**
1662
+ * Validates the OAuth scope in client metadata and logs warnings/suggestions.
1663
+ *
1664
+ * This method:
1665
+ * 1. Checks if the scope is well-formed using permission utilities
1666
+ * 2. Detects mixing of transitional and granular permissions
1667
+ * 3. Logs warnings for missing `atproto` scope
1668
+ * 4. Suggests migration to granular permissions for transitional scopes
1669
+ *
1670
+ * @param scope - The OAuth scope string to validate
1671
+ * @internal
1672
+ */
1673
+ validateClientMetadataScope(scope) {
1674
+ // Parse the scope into individual permissions
1675
+ const permissions = parseScope(scope);
1676
+ // Validate well-formedness
1677
+ const validation = validateScope(scope);
1678
+ if (!validation.isValid) {
1679
+ this.logger?.error("Invalid OAuth scope detected", {
1680
+ invalidPermissions: validation.invalidPermissions,
1681
+ scope,
1682
+ });
1683
+ }
1684
+ // Check for atproto scope
1685
+ const hasAtproto = permissions.includes(ATPROTO_SCOPE);
1686
+ if (!hasAtproto) {
1687
+ this.logger?.warn("OAuth scope missing 'atproto' - basic API access may be limited", {
1688
+ scope,
1689
+ suggestion: "Add 'atproto' to your scope for basic API access",
1690
+ });
1691
+ }
1692
+ // Detect transitional scopes
1693
+ const transitionalScopes = permissions.filter((p) => p.startsWith("transition:"));
1694
+ const granularScopes = permissions.filter((p) => p.startsWith("account:") ||
1695
+ p.startsWith("repo:") ||
1696
+ p.startsWith("blob") ||
1697
+ p.startsWith("rpc:") ||
1698
+ p.startsWith("identity:") ||
1699
+ p.startsWith("include:"));
1700
+ // Log info about transitional scopes
1701
+ if (transitionalScopes.length > 0) {
1702
+ this.logger?.info("Using transitional OAuth scopes (legacy)", {
1703
+ transitionalScopes,
1704
+ note: "Transitional scopes are supported but granular permissions are recommended",
1705
+ });
1706
+ // Suggest migration to granular permissions
1707
+ if (transitionalScopes.includes("transition:email")) {
1708
+ this.logger?.info("Consider migrating 'transition:email' to granular permissions", {
1709
+ suggestion: "Use: account:email?action=read",
1710
+ example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.EMAIL_READ",
1711
+ });
1712
+ }
1713
+ if (transitionalScopes.includes("transition:generic")) {
1714
+ this.logger?.info("Consider migrating 'transition:generic' to granular permissions", {
1715
+ suggestion: "Use specific permissions like: repo:* account:repo?action=read",
1716
+ example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.FULL_ACCESS",
1717
+ });
1718
+ }
1719
+ }
1720
+ // Warn if mixing transitional and granular
1721
+ if (transitionalScopes.length > 0 && granularScopes.length > 0) {
1722
+ this.logger?.warn("Mixing transitional and granular OAuth scopes", {
1723
+ transitionalScopes,
1724
+ granularScopes,
1725
+ note: "While supported, it's recommended to use either transitional or granular permissions consistently",
1726
+ });
1727
+ }
1728
+ }
1729
+ /**
1730
+ * Creates a fetch handler with timeout support.
1731
+ *
1732
+ * @param timeoutMs - Request timeout in milliseconds
1733
+ * @returns A fetch function that aborts after the timeout
1734
+ * @internal
680
1735
  */
681
1736
  createFetchWithTimeout(timeoutMs) {
682
1737
  return async (input, init) => {
@@ -918,330 +1973,33 @@ class OAuthClient {
918
1973
  *
919
1974
  * @example
920
1975
  * ```typescript
921
- * // Log out endpoint
922
- * app.post("/logout", async (req, res) => {
923
- * const userDid = req.session.userDid;
924
- * if (userDid) {
925
- * await client.revoke(userDid);
926
- * delete req.session.userDid;
927
- * }
928
- * res.redirect("/");
929
- * });
930
- * ```
931
- *
932
- * @remarks
933
- * Even if revocation fails on the server, the local session is
934
- * removed. The error is thrown to inform you that remote revocation
935
- * may not have succeeded.
936
- */
937
- async revoke(did) {
938
- try {
939
- this.logger?.debug("Revoking session", { did });
940
- const client = await this.getClient();
941
- await client.revoke(did);
942
- this.logger?.info("Session revoked", { did });
943
- }
944
- catch (error) {
945
- this.logger?.error("Failed to revoke session", { did, error });
946
- throw new AuthenticationError(`Failed to revoke session: ${error instanceof Error ? error.message : String(error)}`, error);
947
- }
948
- }
949
- }
950
-
951
- /**
952
- * Registry for managing and validating AT Protocol lexicon schemas.
953
- *
954
- * Lexicons are schema definitions that describe the structure of records
955
- * in the AT Protocol. This registry allows you to:
956
- *
957
- * - Register custom lexicons for your application's record types
958
- * - Validate records against their lexicon schemas
959
- * - Extend the AT Protocol Agent with custom lexicon support
960
- *
961
- * @remarks
962
- * The SDK automatically registers hypercert lexicons when creating a Repository.
963
- * You only need to use this class directly if you're working with custom
964
- * record types.
965
- *
966
- * **Lexicon IDs** follow the NSID (Namespaced Identifier) format:
967
- * `{authority}.{name}` (e.g., `org.hypercerts.hypercert`)
968
- *
969
- * @example Registering custom lexicons
970
- * ```typescript
971
- * const registry = sdk.getLexiconRegistry();
972
- *
973
- * // Register a single lexicon
974
- * registry.register({
975
- * lexicon: 1,
976
- * id: "org.example.myRecord",
977
- * defs: {
978
- * main: {
979
- * type: "record",
980
- * key: "tid",
981
- * record: {
982
- * type: "object",
983
- * required: ["title", "createdAt"],
984
- * properties: {
985
- * title: { type: "string" },
986
- * description: { type: "string" },
987
- * createdAt: { type: "string", format: "datetime" },
988
- * },
989
- * },
990
- * },
991
- * },
992
- * });
993
- *
994
- * // Register multiple lexicons at once
995
- * registry.registerMany([lexicon1, lexicon2, lexicon3]);
996
- * ```
997
- *
998
- * @example Validating records
999
- * ```typescript
1000
- * const result = registry.validate("org.example.myRecord", {
1001
- * title: "Test",
1002
- * createdAt: new Date().toISOString(),
1003
- * });
1004
- *
1005
- * if (!result.valid) {
1006
- * console.error(`Validation failed: ${result.error}`);
1007
- * }
1008
- * ```
1009
- *
1010
- * @see https://atproto.com/specs/lexicon for the Lexicon specification
1011
- */
1012
- class LexiconRegistry {
1013
- /**
1014
- * Creates a new LexiconRegistry.
1015
- *
1016
- * The registry starts empty. Use {@link register} or {@link registerMany}
1017
- * to add lexicons.
1018
- */
1019
- constructor() {
1020
- /** Map of lexicon ID to lexicon document */
1021
- this.lexicons = new Map();
1022
- this.lexiconsCollection = new Lexicons();
1023
- }
1024
- /**
1025
- * Registers a single lexicon schema.
1026
- *
1027
- * @param lexicon - The lexicon document to register
1028
- * @throws {@link ValidationError} if the lexicon doesn't have an `id` field
1029
- *
1030
- * @remarks
1031
- * If a lexicon with the same ID is already registered, it will be
1032
- * replaced with the new definition. This is useful for testing but
1033
- * should generally be avoided in production.
1034
- *
1035
- * @example
1036
- * ```typescript
1037
- * registry.register({
1038
- * lexicon: 1,
1039
- * id: "org.example.post",
1040
- * defs: {
1041
- * main: {
1042
- * type: "record",
1043
- * key: "tid",
1044
- * record: {
1045
- * type: "object",
1046
- * required: ["text", "createdAt"],
1047
- * properties: {
1048
- * text: { type: "string", maxLength: 300 },
1049
- * createdAt: { type: "string", format: "datetime" },
1050
- * },
1051
- * },
1052
- * },
1053
- * },
1054
- * });
1055
- * ```
1056
- */
1057
- register(lexicon) {
1058
- if (!lexicon.id) {
1059
- throw new ValidationError("Lexicon must have an 'id' field");
1060
- }
1061
- // Remove existing lexicon if present (to allow overwriting)
1062
- if (this.lexicons.has(lexicon.id)) {
1063
- // Lexicons collection doesn't support removal, so we create a new one
1064
- // This is a limitation - in practice, lexicons shouldn't be overwritten
1065
- // But we allow it for testing and flexibility
1066
- const existingLexicon = this.lexicons.get(lexicon.id);
1067
- if (existingLexicon) {
1068
- // Try to remove from collection (may fail if not supported)
1069
- try {
1070
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1071
- this.lexiconsCollection.remove?.(lexicon.id);
1072
- }
1073
- catch {
1074
- // If removal fails, create a new collection
1075
- this.lexiconsCollection = new Lexicons();
1076
- // Re-register all other lexicons
1077
- for (const [id, lex] of this.lexicons.entries()) {
1078
- if (id !== lexicon.id) {
1079
- this.lexiconsCollection.add(lex);
1080
- }
1081
- }
1082
- }
1083
- }
1084
- }
1085
- this.lexicons.set(lexicon.id, lexicon);
1086
- this.lexiconsCollection.add(lexicon);
1087
- }
1088
- /**
1089
- * Registers multiple lexicons at once.
1090
- *
1091
- * @param lexicons - Array of lexicon documents to register
1092
- *
1093
- * @example
1094
- * ```typescript
1095
- * import { HYPERCERT_LEXICONS } from "@hypercerts-org/sdk/lexicons";
1096
- *
1097
- * registry.registerMany(HYPERCERT_LEXICONS);
1098
- * ```
1099
- */
1100
- registerMany(lexicons) {
1101
- for (const lexicon of lexicons) {
1102
- this.register(lexicon);
1103
- }
1104
- }
1105
- /**
1106
- * Gets a lexicon document by ID.
1107
- *
1108
- * @param id - The lexicon NSID (e.g., "org.hypercerts.hypercert")
1109
- * @returns The lexicon document, or `undefined` if not registered
1110
- *
1111
- * @example
1112
- * ```typescript
1113
- * const lexicon = registry.get("org.hypercerts.hypercert");
1114
- * if (lexicon) {
1115
- * console.log(`Found lexicon: ${lexicon.id}`);
1116
- * }
1117
- * ```
1118
- */
1119
- get(id) {
1120
- return this.lexicons.get(id);
1121
- }
1122
- /**
1123
- * Validates a record against a collection's lexicon schema.
1124
- *
1125
- * @param collection - The collection NSID (same as lexicon ID)
1126
- * @param record - The record data to validate
1127
- * @returns Validation result with `valid` boolean and optional `error` message
1128
- *
1129
- * @remarks
1130
- * - If no lexicon is registered for the collection, validation passes
1131
- * (we can't validate against unknown schemas)
1132
- * - Validation checks required fields and type constraints defined
1133
- * in the lexicon schema
1134
- *
1135
- * @example
1136
- * ```typescript
1137
- * const result = registry.validate("org.hypercerts.hypercert", {
1138
- * title: "My Hypercert",
1139
- * description: "Description...",
1140
- * // ... other fields
1141
- * });
1142
- *
1143
- * if (!result.valid) {
1144
- * throw new Error(`Invalid record: ${result.error}`);
1145
- * }
1146
- * ```
1147
- */
1148
- validate(collection, record) {
1149
- // Check if we have a lexicon registered for this collection
1150
- // Collection format is typically "namespace.collection" (e.g., "app.bsky.feed.post")
1151
- // Lexicon ID format is the same
1152
- const lexiconId = collection;
1153
- const lexicon = this.lexicons.get(lexiconId);
1154
- if (!lexicon) {
1155
- // No lexicon registered - validation passes (can't validate unknown schemas)
1156
- return { valid: true };
1157
- }
1158
- // Check required fields if the lexicon defines them
1159
- const recordDef = lexicon.defs?.record;
1160
- if (recordDef && typeof recordDef === "object" && "record" in recordDef) {
1161
- const recordSchema = recordDef.record;
1162
- if (typeof recordSchema === "object" && "required" in recordSchema && Array.isArray(recordSchema.required)) {
1163
- const recordObj = record;
1164
- for (const requiredField of recordSchema.required) {
1165
- if (typeof requiredField === "string" && !(requiredField in recordObj)) {
1166
- return {
1167
- valid: false,
1168
- error: `Missing required field: ${requiredField}`,
1169
- };
1170
- }
1171
- }
1172
- }
1173
- }
1174
- try {
1175
- this.lexiconsCollection.assertValidRecord(collection, record);
1176
- return { valid: true };
1177
- }
1178
- catch (error) {
1179
- // If error indicates lexicon not found, treat as validation pass
1180
- // (the lexicon might exist in Agent's collection but not ours)
1181
- const errorMessage = error instanceof Error ? error.message : String(error);
1182
- if (errorMessage.includes("not found") || errorMessage.includes("Lexicon not found")) {
1183
- return { valid: true };
1184
- }
1185
- return {
1186
- valid: false,
1187
- error: errorMessage,
1188
- };
1189
- }
1190
- }
1191
- /**
1192
- * Adds all registered lexicons to an AT Protocol Agent instance.
1193
- *
1194
- * This allows the Agent to understand custom lexicon types when making
1195
- * API requests.
1196
- *
1197
- * @param agent - The Agent instance to extend
1198
- *
1199
- * @remarks
1200
- * This is called automatically when creating a Repository. You typically
1201
- * don't need to call this directly unless you're using the Agent
1202
- * independently.
1203
- *
1204
- * @internal
1205
- */
1206
- addToAgent(agent) {
1207
- // Access the internal lexicons collection and merge our lexicons
1208
- // The Agent's lex property is a Lexicons instance
1209
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1210
- const agentLex = agent.lex;
1211
- // Add each registered lexicon to the agent
1212
- for (const lexicon of this.lexicons.values()) {
1213
- agentLex.add(lexicon);
1214
- }
1215
- }
1216
- /**
1217
- * Gets all registered lexicon IDs.
1218
- *
1219
- * @returns Array of lexicon NSIDs
1220
- *
1221
- * @example
1222
- * ```typescript
1223
- * const ids = registry.getRegisteredIds();
1224
- * console.log(`Registered lexicons: ${ids.join(", ")}`);
1225
- * ```
1226
- */
1227
- getRegisteredIds() {
1228
- return Array.from(this.lexicons.keys());
1229
- }
1230
- /**
1231
- * Checks if a lexicon is registered.
1232
- *
1233
- * @param id - The lexicon NSID to check
1234
- * @returns `true` if the lexicon is registered
1235
- *
1236
- * @example
1237
- * ```typescript
1238
- * if (registry.has("org.hypercerts.hypercert")) {
1239
- * // Hypercert lexicon is available
1240
- * }
1976
+ * // Log out endpoint
1977
+ * app.post("/logout", async (req, res) => {
1978
+ * const userDid = req.session.userDid;
1979
+ * if (userDid) {
1980
+ * await client.revoke(userDid);
1981
+ * delete req.session.userDid;
1982
+ * }
1983
+ * res.redirect("/");
1984
+ * });
1241
1985
  * ```
1986
+ *
1987
+ * @remarks
1988
+ * Even if revocation fails on the server, the local session is
1989
+ * removed. The error is thrown to inform you that remote revocation
1990
+ * may not have succeeded.
1242
1991
  */
1243
- has(id) {
1244
- return this.lexicons.has(id);
1992
+ async revoke(did) {
1993
+ try {
1994
+ this.logger?.debug("Revoking session", { did });
1995
+ const client = await this.getClient();
1996
+ await client.revoke(did);
1997
+ this.logger?.info("Session revoked", { did });
1998
+ }
1999
+ catch (error) {
2000
+ this.logger?.error("Failed to revoke session", { did, error });
2001
+ throw new AuthenticationError(`Failed to revoke session: ${error instanceof Error ? error.message : String(error)}`, error);
2002
+ }
1245
2003
  }
1246
2004
  }
1247
2005
 
@@ -1373,14 +2131,12 @@ class RecordOperationsImpl {
1373
2131
  *
1374
2132
  * @param agent - AT Protocol Agent for making API calls
1375
2133
  * @param repoDid - DID of the repository to operate on
1376
- * @param lexiconRegistry - Registry for record validation
1377
2134
  *
1378
2135
  * @internal
1379
2136
  */
1380
- constructor(agent, repoDid, lexiconRegistry) {
2137
+ constructor(agent, repoDid) {
1381
2138
  this.agent = agent;
1382
2139
  this.repoDid = repoDid;
1383
- this.lexiconRegistry = lexiconRegistry;
1384
2140
  }
1385
2141
  /**
1386
2142
  * Creates a new record in the specified collection.
@@ -1416,10 +2172,6 @@ class RecordOperationsImpl {
1416
2172
  * ```
1417
2173
  */
1418
2174
  async create(params) {
1419
- const validation = this.lexiconRegistry.validate(params.collection, params.record);
1420
- if (!validation.valid) {
1421
- throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
1422
- }
1423
2175
  try {
1424
2176
  const result = await this.agent.com.atproto.repo.createRecord({
1425
2177
  repo: this.repoDid,
@@ -1474,10 +2226,6 @@ class RecordOperationsImpl {
1474
2226
  * ```
1475
2227
  */
1476
2228
  async update(params) {
1477
- const validation = this.lexiconRegistry.validate(params.collection, params.record);
1478
- if (!validation.valid) {
1479
- throw new ValidationError(`Invalid record for collection ${params.collection}: ${validation.error}`);
1480
- }
1481
2229
  try {
1482
2230
  const result = await this.agent.com.atproto.repo.putRecord({
1483
2231
  repo: this.repoDid,
@@ -2069,6 +2817,148 @@ class ProfileOperationsImpl {
2069
2817
  }
2070
2818
  }
2071
2819
 
2820
+ /**
2821
+ * Lexicons entrypoint - Lexicon definitions and registry.
2822
+ *
2823
+ * This sub-entrypoint exports the lexicon registry and hypercert
2824
+ * lexicon constants for working with AT Protocol record schemas.
2825
+ *
2826
+ * @remarks
2827
+ * Import from `@hypercerts-org/sdk/lexicons`:
2828
+ *
2829
+ * ```typescript
2830
+ * import {
2831
+ * LexiconRegistry,
2832
+ * HYPERCERT_LEXICONS,
2833
+ * HYPERCERT_COLLECTIONS,
2834
+ * } from "@hypercerts-org/sdk/lexicons";
2835
+ * ```
2836
+ *
2837
+ * **Exports**:
2838
+ * - {@link LexiconRegistry} - Registry for managing and validating lexicons
2839
+ * - {@link HYPERCERT_LEXICONS} - Array of all hypercert lexicon documents
2840
+ * - {@link HYPERCERT_COLLECTIONS} - Constants for collection NSIDs
2841
+ *
2842
+ * @example Using collection constants
2843
+ * ```typescript
2844
+ * import { HYPERCERT_COLLECTIONS } from "@hypercerts-org/sdk/lexicons";
2845
+ *
2846
+ * // List hypercerts using the correct collection name
2847
+ * const records = await repo.records.list({
2848
+ * collection: HYPERCERT_COLLECTIONS.RECORD,
2849
+ * });
2850
+ *
2851
+ * // List contributions
2852
+ * const contributions = await repo.records.list({
2853
+ * collection: HYPERCERT_COLLECTIONS.CONTRIBUTION,
2854
+ * });
2855
+ * ```
2856
+ *
2857
+ * @example Custom lexicon registration
2858
+ * ```typescript
2859
+ * import { LexiconRegistry } from "@hypercerts-org/sdk/lexicons";
2860
+ *
2861
+ * const registry = sdk.getLexiconRegistry();
2862
+ *
2863
+ * // Register custom lexicon
2864
+ * registry.register({
2865
+ * lexicon: 1,
2866
+ * id: "org.myapp.customRecord",
2867
+ * defs: { ... },
2868
+ * });
2869
+ *
2870
+ * // Validate a record
2871
+ * const result = registry.validate("org.myapp.customRecord", record);
2872
+ * if (!result.valid) {
2873
+ * console.error(result.error);
2874
+ * }
2875
+ * ```
2876
+ *
2877
+ * @packageDocumentation
2878
+ */
2879
+ /**
2880
+ * All hypercert-related lexicons for registration with AT Protocol Agent.
2881
+ * This array contains all lexicon documents from the published package.
2882
+ */
2883
+ const HYPERCERT_LEXICONS = [
2884
+ CERTIFIED_DEFS_LEXICON_JSON,
2885
+ LOCATION_LEXICON_JSON,
2886
+ STRONGREF_LEXICON_JSON,
2887
+ HYPERCERTS_DEFS_LEXICON_JSON,
2888
+ ACTIVITY_LEXICON_JSON,
2889
+ COLLECTION_LEXICON_JSON,
2890
+ CONTRIBUTION_LEXICON_JSON,
2891
+ EVALUATION_LEXICON_JSON,
2892
+ EVIDENCE_LEXICON_JSON,
2893
+ MEASUREMENT_LEXICON_JSON,
2894
+ RIGHTS_LEXICON_JSON,
2895
+ PROJECT_LEXICON_JSON,
2896
+ BADGE_AWARD_LEXICON_JSON,
2897
+ BADGE_DEFINITION_LEXICON_JSON,
2898
+ BADGE_RESPONSE_LEXICON_JSON,
2899
+ FUNDING_RECEIPT_LEXICON_JSON,
2900
+ ];
2901
+ /**
2902
+ * Collection NSIDs (Namespaced Identifiers) for hypercert records.
2903
+ *
2904
+ * Use these constants when performing record operations to ensure
2905
+ * correct collection names.
2906
+ */
2907
+ const HYPERCERT_COLLECTIONS = {
2908
+ /**
2909
+ * Main hypercert claim record collection.
2910
+ */
2911
+ CLAIM: ACTIVITY_NSID,
2912
+ /**
2913
+ * Rights record collection.
2914
+ */
2915
+ RIGHTS: RIGHTS_NSID,
2916
+ /**
2917
+ * Location record collection (shared certified lexicon).
2918
+ */
2919
+ LOCATION: LOCATION_NSID,
2920
+ /**
2921
+ * Contribution record collection.
2922
+ */
2923
+ CONTRIBUTION: CONTRIBUTION_NSID,
2924
+ /**
2925
+ * Measurement record collection.
2926
+ */
2927
+ MEASUREMENT: MEASUREMENT_NSID,
2928
+ /**
2929
+ * Evaluation record collection.
2930
+ */
2931
+ EVALUATION: EVALUATION_NSID,
2932
+ /**
2933
+ * Evidence record collection.
2934
+ */
2935
+ EVIDENCE: EVIDENCE_NSID,
2936
+ /**
2937
+ * Collection record collection (groups of hypercerts).
2938
+ */
2939
+ COLLECTION: COLLECTION_NSID,
2940
+ /**
2941
+ * Project record collection.
2942
+ */
2943
+ PROJECT: PROJECT_NSID,
2944
+ /**
2945
+ * Badge award record collection.
2946
+ */
2947
+ BADGE_AWARD: BADGE_AWARD_NSID,
2948
+ /**
2949
+ * Badge definition record collection.
2950
+ */
2951
+ BADGE_DEFINITION: BADGE_DEFINITION_NSID,
2952
+ /**
2953
+ * Badge response record collection.
2954
+ */
2955
+ BADGE_RESPONSE: BADGE_RESPONSE_NSID,
2956
+ /**
2957
+ * Funding receipt record collection.
2958
+ */
2959
+ FUNDING_RECEIPT: FUNDING_RECEIPT_NSID,
2960
+ };
2961
+
2072
2962
  /**
2073
2963
  * HypercertOperationsImpl - High-level hypercert operations.
2074
2964
  *
@@ -2132,17 +3022,15 @@ class HypercertOperationsImpl extends EventEmitter {
2132
3022
  * @param agent - AT Protocol Agent for making API calls
2133
3023
  * @param repoDid - DID of the repository to operate on
2134
3024
  * @param _serverUrl - Server URL (reserved for future use)
2135
- * @param lexiconRegistry - Registry for record validation
2136
3025
  * @param logger - Optional logger for debugging
2137
3026
  *
2138
3027
  * @internal
2139
3028
  */
2140
- constructor(agent, repoDid, _serverUrl, lexiconRegistry, logger) {
3029
+ constructor(agent, repoDid, _serverUrl, logger) {
2141
3030
  super();
2142
3031
  this.agent = agent;
2143
3032
  this.repoDid = repoDid;
2144
3033
  this._serverUrl = _serverUrl;
2145
- this.lexiconRegistry = lexiconRegistry;
2146
3034
  this.logger = logger;
2147
3035
  }
2148
3036
  /**
@@ -2162,6 +3050,202 @@ class HypercertOperationsImpl extends EventEmitter {
2162
3050
  }
2163
3051
  }
2164
3052
  }
3053
+ /**
3054
+ * Uploads an image blob and returns a blob reference.
3055
+ *
3056
+ * @param image - Image blob to upload
3057
+ * @param onProgress - Optional progress callback
3058
+ * @returns Promise resolving to blob reference or undefined
3059
+ * @throws {@link NetworkError} if upload fails
3060
+ * @internal
3061
+ */
3062
+ async uploadImageBlob(image, onProgress) {
3063
+ this.emitProgress(onProgress, { name: "uploadImage", status: "start" });
3064
+ try {
3065
+ const arrayBuffer = await image.arrayBuffer();
3066
+ const uint8Array = new Uint8Array(arrayBuffer);
3067
+ const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
3068
+ encoding: image.type || "image/jpeg",
3069
+ });
3070
+ if (uploadResult.success) {
3071
+ const blobRef = {
3072
+ $type: "blob",
3073
+ ref: { $link: uploadResult.data.blob.ref.toString() },
3074
+ mimeType: uploadResult.data.blob.mimeType,
3075
+ size: uploadResult.data.blob.size,
3076
+ };
3077
+ this.emitProgress(onProgress, {
3078
+ name: "uploadImage",
3079
+ status: "success",
3080
+ data: { size: image.size },
3081
+ });
3082
+ return blobRef;
3083
+ }
3084
+ throw new NetworkError("Image upload succeeded but returned no blob reference");
3085
+ }
3086
+ catch (error) {
3087
+ this.emitProgress(onProgress, { name: "uploadImage", status: "error", error: error });
3088
+ throw new NetworkError(`Failed to upload image: ${error instanceof Error ? error.message : "Unknown"}`, error);
3089
+ }
3090
+ }
3091
+ /**
3092
+ * Creates a rights record for a hypercert.
3093
+ *
3094
+ * @param rights - Rights data
3095
+ * @param createdAt - ISO timestamp for creation
3096
+ * @param onProgress - Optional progress callback
3097
+ * @returns Promise resolving to rights URI and CID
3098
+ * @throws {@link ValidationError} if validation fails
3099
+ * @throws {@link NetworkError} if creation fails
3100
+ * @internal
3101
+ */
3102
+ async createRightsRecord(rights, createdAt, onProgress) {
3103
+ this.emitProgress(onProgress, { name: "createRights", status: "start" });
3104
+ const rightsRecord = {
3105
+ $type: HYPERCERT_COLLECTIONS.RIGHTS,
3106
+ rightsName: rights.name,
3107
+ rightsType: rights.type,
3108
+ rightsDescription: rights.description,
3109
+ createdAt,
3110
+ };
3111
+ const rightsValidation = validate(rightsRecord, HYPERCERT_COLLECTIONS.RIGHTS, "main", false);
3112
+ if (!rightsValidation.success) {
3113
+ throw new ValidationError(`Invalid rights record: ${rightsValidation.error?.message}`);
3114
+ }
3115
+ const rightsResult = await this.agent.com.atproto.repo.createRecord({
3116
+ repo: this.repoDid,
3117
+ collection: HYPERCERT_COLLECTIONS.RIGHTS,
3118
+ record: rightsRecord,
3119
+ });
3120
+ if (!rightsResult.success) {
3121
+ throw new NetworkError("Failed to create rights record");
3122
+ }
3123
+ const uri = rightsResult.data.uri;
3124
+ const cid = rightsResult.data.cid;
3125
+ this.emit("rightsCreated", { uri, cid });
3126
+ this.emitProgress(onProgress, {
3127
+ name: "createRights",
3128
+ status: "success",
3129
+ data: { uri },
3130
+ });
3131
+ return { uri, cid };
3132
+ }
3133
+ /**
3134
+ * Creates the main hypercert record.
3135
+ *
3136
+ * @param params - Hypercert creation parameters
3137
+ * @param rightsUri - URI of the associated rights record
3138
+ * @param rightsCid - CID of the associated rights record
3139
+ * @param imageBlobRef - Optional image blob reference
3140
+ * @param createdAt - ISO timestamp for creation
3141
+ * @param onProgress - Optional progress callback
3142
+ * @returns Promise resolving to hypercert URI and CID
3143
+ * @throws {@link ValidationError} if validation fails
3144
+ * @throws {@link NetworkError} if creation fails
3145
+ * @internal
3146
+ */
3147
+ async createHypercertRecord(params, rightsUri, rightsCid, imageBlobRef, createdAt, onProgress) {
3148
+ this.emitProgress(onProgress, { name: "createHypercert", status: "start" });
3149
+ const hypercertRecord = {
3150
+ $type: HYPERCERT_COLLECTIONS.CLAIM,
3151
+ title: params.title,
3152
+ shortDescription: params.shortDescription,
3153
+ description: params.description,
3154
+ workScope: params.workScope,
3155
+ workTimeFrameFrom: params.workTimeFrameFrom,
3156
+ workTimeFrameTo: params.workTimeFrameTo,
3157
+ rights: { uri: rightsUri, cid: rightsCid },
3158
+ createdAt,
3159
+ };
3160
+ if (imageBlobRef) {
3161
+ hypercertRecord.image = imageBlobRef;
3162
+ }
3163
+ if (params.evidence && params.evidence.length > 0) {
3164
+ hypercertRecord.evidence = params.evidence;
3165
+ }
3166
+ const hypercertValidation = validate(hypercertRecord, HYPERCERT_COLLECTIONS.CLAIM, "#main", false);
3167
+ if (!hypercertValidation.success) {
3168
+ throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
3169
+ }
3170
+ const hypercertResult = await this.agent.com.atproto.repo.createRecord({
3171
+ repo: this.repoDid,
3172
+ collection: HYPERCERT_COLLECTIONS.CLAIM,
3173
+ record: hypercertRecord,
3174
+ });
3175
+ if (!hypercertResult.success) {
3176
+ throw new NetworkError("Failed to create hypercert record");
3177
+ }
3178
+ const uri = hypercertResult.data.uri;
3179
+ const cid = hypercertResult.data.cid;
3180
+ this.emit("recordCreated", { uri, cid });
3181
+ this.emitProgress(onProgress, {
3182
+ name: "createHypercert",
3183
+ status: "success",
3184
+ data: { uri },
3185
+ });
3186
+ return { uri, cid };
3187
+ }
3188
+ /**
3189
+ * Attaches a location to a hypercert with progress tracking.
3190
+ *
3191
+ * @param hypercertUri - URI of the hypercert
3192
+ * @param location - Location data
3193
+ * @param onProgress - Optional progress callback
3194
+ * @returns Promise resolving to location URI
3195
+ * @internal
3196
+ */
3197
+ async attachLocationWithProgress(hypercertUri, location, onProgress) {
3198
+ this.emitProgress(onProgress, { name: "attachLocation", status: "start" });
3199
+ try {
3200
+ const locationResult = await this.attachLocation(hypercertUri, location);
3201
+ this.emitProgress(onProgress, {
3202
+ name: "attachLocation",
3203
+ status: "success",
3204
+ data: { uri: locationResult.uri },
3205
+ });
3206
+ return locationResult.uri;
3207
+ }
3208
+ catch (error) {
3209
+ this.emitProgress(onProgress, { name: "attachLocation", status: "error", error: error });
3210
+ this.logger?.warn(`Failed to attach location: ${error instanceof Error ? error.message : "Unknown"}`);
3211
+ throw error;
3212
+ }
3213
+ }
3214
+ /**
3215
+ * Creates contribution records with progress tracking.
3216
+ *
3217
+ * @param hypercertUri - URI of the hypercert
3218
+ * @param contributions - Array of contribution data
3219
+ * @param onProgress - Optional progress callback
3220
+ * @returns Promise resolving to array of contribution URIs
3221
+ * @internal
3222
+ */
3223
+ async createContributionsWithProgress(hypercertUri, contributions, onProgress) {
3224
+ this.emitProgress(onProgress, { name: "createContributions", status: "start" });
3225
+ try {
3226
+ const contributionUris = [];
3227
+ for (const contrib of contributions) {
3228
+ const contribResult = await this.addContribution({
3229
+ hypercertUri,
3230
+ contributors: contrib.contributors,
3231
+ role: contrib.role,
3232
+ description: contrib.description,
3233
+ });
3234
+ contributionUris.push(contribResult.uri);
3235
+ }
3236
+ this.emitProgress(onProgress, {
3237
+ name: "createContributions",
3238
+ status: "success",
3239
+ data: { count: contributionUris.length },
3240
+ });
3241
+ return contributionUris;
3242
+ }
3243
+ catch (error) {
3244
+ this.emitProgress(onProgress, { name: "createContributions", status: "error", error: error });
3245
+ this.logger?.warn(`Failed to create contributions: ${error instanceof Error ? error.message : "Unknown"}`);
3246
+ throw error;
3247
+ }
3248
+ }
2165
3249
  /**
2166
3250
  * Creates a new hypercert with all related records.
2167
3251
  *
@@ -2238,142 +3322,31 @@ class HypercertOperationsImpl extends EventEmitter {
2238
3322
  };
2239
3323
  try {
2240
3324
  // Step 1: Upload image if provided
2241
- let imageBlobRef;
2242
- if (params.image) {
2243
- this.emitProgress(params.onProgress, { name: "uploadImage", status: "start" });
2244
- try {
2245
- const arrayBuffer = await params.image.arrayBuffer();
2246
- const uint8Array = new Uint8Array(arrayBuffer);
2247
- const uploadResult = await this.agent.com.atproto.repo.uploadBlob(uint8Array, {
2248
- encoding: params.image.type || "image/jpeg",
2249
- });
2250
- if (uploadResult.success) {
2251
- imageBlobRef = {
2252
- $type: "blob",
2253
- ref: { $link: uploadResult.data.blob.ref.toString() },
2254
- mimeType: uploadResult.data.blob.mimeType,
2255
- size: uploadResult.data.blob.size,
2256
- };
2257
- }
2258
- this.emitProgress(params.onProgress, {
2259
- name: "uploadImage",
2260
- status: "success",
2261
- data: { size: params.image.size },
2262
- });
2263
- }
2264
- catch (error) {
2265
- this.emitProgress(params.onProgress, { name: "uploadImage", status: "error", error: error });
2266
- throw new NetworkError(`Failed to upload image: ${error instanceof Error ? error.message : "Unknown"}`, error);
2267
- }
2268
- }
3325
+ const imageBlobRef = params.image ? await this.uploadImageBlob(params.image, params.onProgress) : undefined;
2269
3326
  // Step 2: Create rights record
2270
- this.emitProgress(params.onProgress, { name: "createRights", status: "start" });
2271
- const rightsRecord = {
2272
- $type: HYPERCERT_COLLECTIONS.RIGHTS,
2273
- rightsName: params.rights.name,
2274
- rightsType: params.rights.type,
2275
- rightsDescription: params.rights.description,
2276
- createdAt,
2277
- };
2278
- const rightsValidation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.RIGHTS, rightsRecord);
2279
- if (!rightsValidation.valid) {
2280
- throw new ValidationError(`Invalid rights record: ${rightsValidation.error}`);
2281
- }
2282
- const rightsResult = await this.agent.com.atproto.repo.createRecord({
2283
- repo: this.repoDid,
2284
- collection: HYPERCERT_COLLECTIONS.RIGHTS,
2285
- record: rightsRecord,
2286
- });
2287
- if (!rightsResult.success) {
2288
- throw new NetworkError("Failed to create rights record");
2289
- }
2290
- result.rightsUri = rightsResult.data.uri;
2291
- result.rightsCid = rightsResult.data.cid;
2292
- this.emit("rightsCreated", { uri: result.rightsUri, cid: result.rightsCid });
2293
- this.emitProgress(params.onProgress, {
2294
- name: "createRights",
2295
- status: "success",
2296
- data: { uri: result.rightsUri },
2297
- });
3327
+ const { uri: rightsUri, cid: rightsCid } = await this.createRightsRecord(params.rights, createdAt, params.onProgress);
3328
+ result.rightsUri = rightsUri;
3329
+ result.rightsCid = rightsCid;
2298
3330
  // Step 3: Create hypercert record
2299
- this.emitProgress(params.onProgress, { name: "createHypercert", status: "start" });
2300
- const hypercertRecord = {
2301
- $type: HYPERCERT_COLLECTIONS.CLAIM,
2302
- title: params.title,
2303
- shortDescription: params.shortDescription,
2304
- description: params.description,
2305
- workScope: params.workScope,
2306
- workTimeFrameFrom: params.workTimeFrameFrom,
2307
- workTimeFrameTo: params.workTimeFrameTo,
2308
- rights: { uri: result.rightsUri, cid: result.rightsCid },
2309
- createdAt,
2310
- };
2311
- if (imageBlobRef) {
2312
- hypercertRecord.image = imageBlobRef;
2313
- }
2314
- if (params.evidence && params.evidence.length > 0) {
2315
- hypercertRecord.evidence = params.evidence;
2316
- }
2317
- const hypercertValidation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CLAIM, hypercertRecord);
2318
- if (!hypercertValidation.valid) {
2319
- throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error}`);
2320
- }
2321
- const hypercertResult = await this.agent.com.atproto.repo.createRecord({
2322
- repo: this.repoDid,
2323
- collection: HYPERCERT_COLLECTIONS.CLAIM,
2324
- record: hypercertRecord,
2325
- });
2326
- if (!hypercertResult.success) {
2327
- throw new NetworkError("Failed to create hypercert record");
2328
- }
2329
- result.hypercertUri = hypercertResult.data.uri;
2330
- result.hypercertCid = hypercertResult.data.cid;
2331
- this.emit("recordCreated", { uri: result.hypercertUri, cid: result.hypercertCid });
2332
- this.emitProgress(params.onProgress, {
2333
- name: "createHypercert",
2334
- status: "success",
2335
- data: { uri: result.hypercertUri },
2336
- });
3331
+ const { uri: hypercertUri, cid: hypercertCid } = await this.createHypercertRecord(params, rightsUri, rightsCid, imageBlobRef, createdAt, params.onProgress);
3332
+ result.hypercertUri = hypercertUri;
3333
+ result.hypercertCid = hypercertCid;
2337
3334
  // Step 4: Attach location if provided
2338
3335
  if (params.location) {
2339
- this.emitProgress(params.onProgress, { name: "attachLocation", status: "start" });
2340
3336
  try {
2341
- const locationResult = await this.attachLocation(result.hypercertUri, params.location);
2342
- result.locationUri = locationResult.uri;
2343
- this.emitProgress(params.onProgress, {
2344
- name: "attachLocation",
2345
- status: "success",
2346
- data: { uri: result.locationUri },
2347
- });
3337
+ result.locationUri = await this.attachLocationWithProgress(hypercertUri, params.location, params.onProgress);
2348
3338
  }
2349
- catch (error) {
2350
- this.emitProgress(params.onProgress, { name: "attachLocation", status: "error", error: error });
2351
- this.logger?.warn(`Failed to attach location: ${error instanceof Error ? error.message : "Unknown"}`);
3339
+ catch {
3340
+ // Error already logged and progress emitted
2352
3341
  }
2353
3342
  }
2354
3343
  // Step 5: Create contributions if provided
2355
3344
  if (params.contributions && params.contributions.length > 0) {
2356
- this.emitProgress(params.onProgress, { name: "createContributions", status: "start" });
2357
- result.contributionUris = [];
2358
3345
  try {
2359
- for (const contrib of params.contributions) {
2360
- const contribResult = await this.addContribution({
2361
- hypercertUri: result.hypercertUri,
2362
- contributors: contrib.contributors,
2363
- role: contrib.role,
2364
- description: contrib.description,
2365
- });
2366
- result.contributionUris.push(contribResult.uri);
2367
- }
2368
- this.emitProgress(params.onProgress, {
2369
- name: "createContributions",
2370
- status: "success",
2371
- data: { count: result.contributionUris.length },
2372
- });
3346
+ result.contributionUris = await this.createContributionsWithProgress(hypercertUri, params.contributions, params.onProgress);
2373
3347
  }
2374
- catch (error) {
2375
- this.emitProgress(params.onProgress, { name: "createContributions", status: "error", error: error });
2376
- this.logger?.warn(`Failed to create contributions: ${error instanceof Error ? error.message : "Unknown"}`);
3348
+ catch {
3349
+ // Error already logged and progress emitted
2377
3350
  }
2378
3351
  }
2379
3352
  return result;
@@ -2475,9 +3448,9 @@ class HypercertOperationsImpl extends EventEmitter {
2475
3448
  // Preserve existing image
2476
3449
  recordForUpdate.image = existingRecord.image;
2477
3450
  }
2478
- const validation = this.lexiconRegistry.validate(collection, recordForUpdate);
2479
- if (!validation.valid) {
2480
- throw new ValidationError(`Invalid hypercert record: ${validation.error}`);
3451
+ const validation = validate(recordForUpdate, collection, "main", false);
3452
+ if (!validation.success) {
3453
+ throw new ValidationError(`Invalid hypercert record: ${validation.error?.message}`);
2481
3454
  }
2482
3455
  const result = await this.agent.com.atproto.repo.putRecord({
2483
3456
  repo: this.repoDid,
@@ -2526,10 +3499,10 @@ class HypercertOperationsImpl extends EventEmitter {
2526
3499
  if (!result.success) {
2527
3500
  throw new NetworkError("Failed to get hypercert");
2528
3501
  }
2529
- // Validate with lexicon registry (more lenient - doesn't require $type)
2530
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CLAIM, result.data.value);
2531
- if (!validation.valid) {
2532
- throw new ValidationError(`Invalid hypercert record format: ${validation.error}`);
3502
+ // Validate with lexicon (more lenient - doesn't require $type)
3503
+ const validation = validate(result.data.value, HYPERCERT_COLLECTIONS.CLAIM, "main", false);
3504
+ if (!validation.success) {
3505
+ throw new ValidationError(`Invalid hypercert record format: ${validation.error?.message}`);
2533
3506
  }
2534
3507
  return {
2535
3508
  uri: result.data.uri,
@@ -2712,9 +3685,9 @@ class HypercertOperationsImpl extends EventEmitter {
2712
3685
  name: location.name,
2713
3686
  description: location.description,
2714
3687
  };
2715
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.LOCATION, locationRecord);
2716
- if (!validation.valid) {
2717
- throw new ValidationError(`Invalid location record: ${validation.error}`);
3688
+ const validation = validate(locationRecord, HYPERCERT_COLLECTIONS.LOCATION, "main", false);
3689
+ if (!validation.success) {
3690
+ throw new ValidationError(`Invalid location record: ${validation.error?.message}`);
2718
3691
  }
2719
3692
  const result = await this.agent.com.atproto.repo.createRecord({
2720
3693
  repo: this.repoDid,
@@ -2808,9 +3781,9 @@ class HypercertOperationsImpl extends EventEmitter {
2808
3781
  const hypercert = await this.get(params.hypercertUri);
2809
3782
  contributionRecord.hypercert = { uri: hypercert.uri, cid: hypercert.cid };
2810
3783
  }
2811
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.CONTRIBUTION, contributionRecord);
2812
- if (!validation.valid) {
2813
- throw new ValidationError(`Invalid contribution record: ${validation.error}`);
3784
+ const validation = validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION, "main", false);
3785
+ if (!validation.success) {
3786
+ throw new ValidationError(`Invalid contribution record: ${validation.error?.message}`);
2814
3787
  }
2815
3788
  const result = await this.agent.com.atproto.repo.createRecord({
2816
3789
  repo: this.repoDid,
@@ -2872,9 +3845,9 @@ class HypercertOperationsImpl extends EventEmitter {
2872
3845
  measurementMethodURI: params.methodUri,
2873
3846
  evidenceURI: params.evidenceUris,
2874
3847
  };
2875
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.MEASUREMENT, measurementRecord);
2876
- if (!validation.valid) {
2877
- throw new ValidationError(`Invalid measurement record: ${validation.error}`);
3848
+ const validation = validate(measurementRecord, HYPERCERT_COLLECTIONS.MEASUREMENT, "main", false);
3849
+ if (!validation.success) {
3850
+ throw new ValidationError(`Invalid measurement record: ${validation.error?.message}`);
2878
3851
  }
2879
3852
  const result = await this.agent.com.atproto.repo.createRecord({
2880
3853
  repo: this.repoDid,
@@ -2925,9 +3898,9 @@ class HypercertOperationsImpl extends EventEmitter {
2925
3898
  summary: params.summary,
2926
3899
  createdAt,
2927
3900
  };
2928
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.EVALUATION, evaluationRecord);
2929
- if (!validation.valid) {
2930
- throw new ValidationError(`Invalid evaluation record: ${validation.error}`);
3901
+ const validation = validate(evaluationRecord, HYPERCERT_COLLECTIONS.EVALUATION, "main", false);
3902
+ if (!validation.success) {
3903
+ throw new ValidationError(`Invalid evaluation record: ${validation.error?.message}`);
2931
3904
  }
2932
3905
  const result = await this.agent.com.atproto.repo.createRecord({
2933
3906
  repo: this.repoDid,
@@ -3005,9 +3978,9 @@ class HypercertOperationsImpl extends EventEmitter {
3005
3978
  if (coverPhotoRef) {
3006
3979
  collectionRecord.coverPhoto = coverPhotoRef;
3007
3980
  }
3008
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.COLLECTION, collectionRecord);
3009
- if (!validation.valid) {
3010
- throw new ValidationError(`Invalid collection record: ${validation.error}`);
3981
+ const validation = validate(collectionRecord, HYPERCERT_COLLECTIONS.COLLECTION, "main", false);
3982
+ if (!validation.success) {
3983
+ throw new ValidationError(`Invalid collection record: ${validation.error?.message}`);
3011
3984
  }
3012
3985
  const result = await this.agent.com.atproto.repo.createRecord({
3013
3986
  repo: this.repoDid,
@@ -3057,9 +4030,9 @@ class HypercertOperationsImpl extends EventEmitter {
3057
4030
  throw new NetworkError("Failed to get collection");
3058
4031
  }
3059
4032
  // Validate with lexicon registry (more lenient - doesn't require $type)
3060
- const validation = this.lexiconRegistry.validate(HYPERCERT_COLLECTIONS.COLLECTION, result.data.value);
3061
- if (!validation.valid) {
3062
- throw new ValidationError(`Invalid collection record format: ${validation.error}`);
4033
+ const validation = validate(result.data.value, HYPERCERT_COLLECTIONS.COLLECTION, "#main", false);
4034
+ if (!validation.success) {
4035
+ throw new ValidationError(`Invalid collection record format: ${validation.error?.message}`);
3063
4036
  }
3064
4037
  return {
3065
4038
  uri: result.data.uri,
@@ -3856,7 +4829,6 @@ class Repository {
3856
4829
  * @param session - Authenticated OAuth session
3857
4830
  * @param serverUrl - Base URL of the AT Protocol server
3858
4831
  * @param repoDid - DID of the repository to operate on
3859
- * @param lexiconRegistry - Registry for lexicon validation
3860
4832
  * @param isSDS - Whether this is a Shared Data Server
3861
4833
  * @param logger - Optional logger for debugging
3862
4834
  *
@@ -3866,20 +4838,16 @@ class Repository {
3866
4838
  *
3867
4839
  * @internal
3868
4840
  */
3869
- constructor(session, serverUrl, repoDid, lexiconRegistry, isSDS, logger) {
4841
+ constructor(session, serverUrl, repoDid, isSDS, logger) {
3870
4842
  this.session = session;
3871
4843
  this.serverUrl = serverUrl;
3872
4844
  this.repoDid = repoDid;
3873
- this.lexiconRegistry = lexiconRegistry;
3874
4845
  this._isSDS = isSDS;
3875
4846
  this.logger = logger;
3876
4847
  // Create a ConfigurableAgent that routes requests to the specified server URL
3877
4848
  // This allows routing to PDS, SDS, or any custom server while maintaining
3878
4849
  // the OAuth session's authentication
3879
4850
  this.agent = new ConfigurableAgent(session, serverUrl);
3880
- this.lexiconRegistry.addToAgent(this.agent);
3881
- // Register hypercert lexicons
3882
- this.lexiconRegistry.registerMany(HYPERCERT_LEXICONS);
3883
4851
  }
3884
4852
  /**
3885
4853
  * The DID (Decentralized Identifier) of this repository.
@@ -3950,7 +4918,7 @@ class Repository {
3950
4918
  * ```
3951
4919
  */
3952
4920
  repo(did) {
3953
- return new Repository(this.session, this.serverUrl, did, this.lexiconRegistry, this._isSDS, this.logger);
4921
+ return new Repository(this.session, this.serverUrl, did, this._isSDS, this.logger);
3954
4922
  }
3955
4923
  /**
3956
4924
  * Low-level record operations for CRUD on any AT Protocol record type.
@@ -3983,7 +4951,7 @@ class Repository {
3983
4951
  */
3984
4952
  get records() {
3985
4953
  if (!this._records) {
3986
- this._records = new RecordOperationsImpl(this.agent, this.repoDid, this.lexiconRegistry);
4954
+ this._records = new RecordOperationsImpl(this.agent, this.repoDid);
3987
4955
  }
3988
4956
  return this._records;
3989
4957
  }
@@ -4078,7 +5046,7 @@ class Repository {
4078
5046
  */
4079
5047
  get hypercerts() {
4080
5048
  if (!this._hypercerts) {
4081
- this._hypercerts = new HypercertOperationsImpl(this.agent, this.repoDid, this.serverUrl, this.lexiconRegistry, this.logger);
5049
+ this._hypercerts = new HypercertOperationsImpl(this.agent, this.repoDid, this.serverUrl, this.logger);
4082
5050
  }
4083
5051
  return this._hypercerts;
4084
5052
  }
@@ -4182,9 +5150,34 @@ const OAuthConfigSchema = z.object({
4182
5150
  redirectUri: z.string().url(),
4183
5151
  /**
4184
5152
  * OAuth scopes to request, space-separated.
4185
- * Common scopes: "atproto", "transition:generic"
5153
+ *
5154
+ * Can be a string of space-separated permissions or use the permission system:
5155
+ *
5156
+ * @example Using presets
5157
+ * ```typescript
5158
+ * import { ScopePresets } from '@hypercerts-org/sdk-core';
5159
+ * scope: ScopePresets.EMAIL_AND_PROFILE
5160
+ * ```
5161
+ *
5162
+ * @example Building custom scopes
5163
+ * ```typescript
5164
+ * import { PermissionBuilder, buildScope } from '@hypercerts-org/sdk-core';
5165
+ * scope: buildScope(
5166
+ * new PermissionBuilder()
5167
+ * .accountEmail('read')
5168
+ * .repoWrite('app.bsky.feed.post')
5169
+ * .build()
5170
+ * )
5171
+ * ```
5172
+ *
5173
+ * @example Legacy scopes
5174
+ * ```typescript
5175
+ * scope: "atproto transition:generic"
5176
+ * ```
5177
+ *
5178
+ * @see https://atproto.com/specs/permission for permission details
4186
5179
  */
4187
- scope: z.string(),
5180
+ scope: z.string().min(1, "OAuth scope is required"),
4188
5181
  /**
4189
5182
  * URL to your public JWKS (JSON Web Key Set) endpoint.
4190
5183
  * Used by the authorization server to verify your client's signatures.
@@ -4349,8 +5342,6 @@ class ATProtoSDK {
4349
5342
  this.logger = config.logger;
4350
5343
  // Initialize OAuth client
4351
5344
  this.oauthClient = new OAuthClient(configWithDefaults);
4352
- // Initialize lexicon registry
4353
- this.lexiconRegistry = new LexiconRegistry();
4354
5345
  this.logger?.info("ATProto SDK initialized");
4355
5346
  }
4356
5347
  /**
@@ -4476,6 +5467,91 @@ class ATProtoSDK {
4476
5467
  }
4477
5468
  return this.oauthClient.revoke(did.trim());
4478
5469
  }
5470
+ /**
5471
+ * Gets the account email address from the authenticated session.
5472
+ *
5473
+ * This method retrieves the email address associated with the user's account
5474
+ * by calling the `com.atproto.server.getSession` endpoint. The email will only
5475
+ * be returned if the appropriate OAuth scope was granted during authorization.
5476
+ *
5477
+ * Required OAuth scopes:
5478
+ * - **Granular permissions**: `account:email?action=read` or `account:email`
5479
+ * - **Transitional permissions**: `transition:email`
5480
+ *
5481
+ * @param session - An authenticated OAuth session
5482
+ * @returns A Promise resolving to email info, or `null` if permission not granted
5483
+ * @throws {@link ValidationError} if the session is invalid
5484
+ * @throws {@link NetworkError} if the API request fails
5485
+ *
5486
+ * @example Using granular permissions
5487
+ * ```typescript
5488
+ * import { ScopePresets } from '@hypercerts-org/sdk-core';
5489
+ *
5490
+ * // Authorize with email scope
5491
+ * const authUrl = await sdk.authorize("user.bsky.social", {
5492
+ * scope: ScopePresets.EMAIL_READ
5493
+ * });
5494
+ *
5495
+ * // After callback...
5496
+ * const emailInfo = await sdk.getAccountEmail(session);
5497
+ * if (emailInfo) {
5498
+ * console.log(`Email: ${emailInfo.email}`);
5499
+ * console.log(`Confirmed: ${emailInfo.emailConfirmed}`);
5500
+ * } else {
5501
+ * console.log("Email permission not granted");
5502
+ * }
5503
+ * ```
5504
+ *
5505
+ * @example Using transitional permissions (legacy)
5506
+ * ```typescript
5507
+ * // Authorize with transition:email scope
5508
+ * const authUrl = await sdk.authorize("user.bsky.social", {
5509
+ * scope: "atproto transition:email"
5510
+ * });
5511
+ *
5512
+ * // After callback...
5513
+ * const emailInfo = await sdk.getAccountEmail(session);
5514
+ * ```
5515
+ */
5516
+ async getAccountEmail(session) {
5517
+ if (!session) {
5518
+ throw new ValidationError("Session is required");
5519
+ }
5520
+ try {
5521
+ // Determine PDS URL from session or config
5522
+ const pdsUrl = this.config.servers?.pds;
5523
+ if (!pdsUrl) {
5524
+ throw new ValidationError("PDS server URL not configured");
5525
+ }
5526
+ // Call com.atproto.server.getSession endpoint using session's fetchHandler
5527
+ // which automatically includes proper authorization with DPoP
5528
+ const response = await session.fetchHandler("/xrpc/com.atproto.server.getSession", {
5529
+ method: "GET",
5530
+ headers: {
5531
+ "Content-Type": "application/json",
5532
+ },
5533
+ });
5534
+ if (!response.ok) {
5535
+ throw new NetworkError(`Failed to get session info: ${response.status} ${response.statusText}`);
5536
+ }
5537
+ const data = (await response.json());
5538
+ // Return null if email not present (permission not granted)
5539
+ if (!data.email) {
5540
+ return null;
5541
+ }
5542
+ return {
5543
+ email: data.email,
5544
+ emailConfirmed: data.emailConfirmed ?? false,
5545
+ };
5546
+ }
5547
+ catch (error) {
5548
+ this.logger?.error("Failed to get account email", { error });
5549
+ if (error instanceof ValidationError || error instanceof NetworkError) {
5550
+ throw error;
5551
+ }
5552
+ throw new NetworkError(`Failed to get account email: ${error instanceof Error ? error.message : String(error)}`, error);
5553
+ }
5554
+ }
4479
5555
  /**
4480
5556
  * Creates a repository instance for data operations.
4481
5557
  *
@@ -4547,30 +5623,7 @@ class ATProtoSDK {
4547
5623
  }
4548
5624
  // Get repository DID (default to session DID)
4549
5625
  const repoDid = session.did || session.sub;
4550
- return new Repository(session, serverUrl, repoDid, this.lexiconRegistry, isSDS, this.logger);
4551
- }
4552
- /**
4553
- * Gets the lexicon registry for schema validation.
4554
- *
4555
- * The lexicon registry manages AT Protocol lexicon schemas used for
4556
- * validating record data. You can register custom lexicons to extend
4557
- * the SDK's capabilities.
4558
- *
4559
- * @returns The {@link LexiconRegistry} instance
4560
- *
4561
- * @example
4562
- * ```typescript
4563
- * const registry = sdk.getLexiconRegistry();
4564
- *
4565
- * // Register custom lexicons
4566
- * registry.register(myCustomLexicons);
4567
- *
4568
- * // Check if a lexicon is registered
4569
- * const hasLexicon = registry.has("org.example.myRecord");
4570
- * ```
4571
- */
4572
- getLexiconRegistry() {
4573
- return this.lexiconRegistry;
5626
+ return new Repository(session, serverUrl, repoDid, isSDS, this.logger);
4574
5627
  }
4575
5628
  /**
4576
5629
  * The configured PDS (Personal Data Server) URL.
@@ -4726,5 +5779,5 @@ const CollaboratorSchema = z.object({
4726
5779
  revokedAt: z.string().optional(),
4727
5780
  });
4728
5781
 
4729
- export { ATProtoSDK, ATProtoSDKConfigSchema, ATProtoSDKError, AuthenticationError, CollaboratorPermissionsSchema, CollaboratorSchema, ConfigurableAgent, InMemorySessionStore, InMemoryStateStore, LexiconRegistry, NetworkError, OAuthConfigSchema, OrganizationSchema, Repository, SDSRequiredError, ServerConfigSchema, SessionExpiredError, TimeoutConfigSchema, ValidationError, createATProtoSDK };
5782
+ export { ATPROTO_SCOPE, ATProtoSDK, ATProtoSDKConfigSchema, ATProtoSDKError, AccountActionSchema, AccountAttrSchema, AccountPermissionSchema, AuthenticationError, BlobPermissionSchema, CollaboratorPermissionsSchema, CollaboratorSchema, ConfigurableAgent, HYPERCERT_COLLECTIONS, HYPERCERT_LEXICONS, IdentityAttrSchema, IdentityPermissionSchema, InMemorySessionStore, InMemoryStateStore, IncludePermissionSchema, MimeTypeSchema, NetworkError, NsidSchema, OAuthConfigSchema, OrganizationSchema, PermissionBuilder, PermissionSchema, RepoActionSchema, RepoPermissionSchema, Repository, RpcPermissionSchema, SDSRequiredError, ScopePresets, ServerConfigSchema, SessionExpiredError, TRANSITION_SCOPES, TimeoutConfigSchema, TransitionScopeSchema, ValidationError, buildScope, createATProtoSDK, hasAllPermissions, hasAnyPermission, hasPermission, mergeScopes, parseScope, removePermissions, validateScope };
4730
5783
  //# sourceMappingURL=index.mjs.map