@@ -748,3 +748,181 @@ export class PermissionBuilder {
748748 return this . permissions . length ;
749749 }
750750}
751+
752+ /**
753+ * Build a scope string from an array of permissions.
754+ *
755+ * This is a convenience function that joins permission strings with spaces,
756+ * which is the standard format for OAuth scope parameters.
757+ *
758+ * @param permissions - Array of permission strings
759+ * @returns Space-separated scope string
760+ *
761+ * @example
762+ * ```typescript
763+ * const permissions = ['account:email?action=read', 'repo:app.bsky.feed.post'];
764+ * const scope = buildScope(permissions);
765+ * // Returns: "account:email?action=read repo:app.bsky.feed.post"
766+ * ```
767+ */
768+ export function buildScope ( permissions : string [ ] ) : string {
769+ return permissions . join ( " " ) ;
770+ }
771+
772+ /**
773+ * Parse a scope string into an array of individual permissions.
774+ *
775+ * This splits a space-separated scope string into individual permission strings.
776+ *
777+ * @param scope - Space-separated scope string
778+ * @returns Array of permission strings
779+ *
780+ * @example
781+ * ```typescript
782+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
783+ * const permissions = parseScope(scope);
784+ * // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post']
785+ * ```
786+ */
787+ export function parseScope ( scope : string ) : string [ ] {
788+ return scope . trim ( ) . split ( / \s + / ) . filter ( Boolean ) ;
789+ }
790+
791+ /**
792+ * Check if a scope string contains a specific permission.
793+ *
794+ * This function performs exact string matching. For more advanced
795+ * permission checking (e.g., wildcard matching), you'll need to
796+ * implement custom logic.
797+ *
798+ * @param scope - Space-separated scope string
799+ * @param permission - The permission to check for
800+ * @returns True if the scope contains the permission
801+ *
802+ * @example
803+ * ```typescript
804+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
805+ * hasPermission(scope, "account:email?action=read"); // true
806+ * hasPermission(scope, "account:repo"); // false
807+ * ```
808+ */
809+ export function hasPermission ( scope : string , permission : string ) : boolean {
810+ const permissions = parseScope ( scope ) ;
811+ return permissions . includes ( permission ) ;
812+ }
813+
814+ /**
815+ * Check if a scope string contains all of the specified permissions.
816+ *
817+ * @param scope - Space-separated scope string
818+ * @param requiredPermissions - Array of permissions to check for
819+ * @returns True if the scope contains all required permissions
820+ *
821+ * @example
822+ * ```typescript
823+ * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*";
824+ * hasAllPermissions(scope, ["account:email?action=read", "blob:image/*"]); // true
825+ * hasAllPermissions(scope, ["account:email?action=read", "account:repo"]); // false
826+ * ```
827+ */
828+ export function hasAllPermissions ( scope : string , requiredPermissions : string [ ] ) : boolean {
829+ const permissions = parseScope ( scope ) ;
830+ return requiredPermissions . every ( ( required ) => permissions . includes ( required ) ) ;
831+ }
832+
833+ /**
834+ * Check if a scope string contains any of the specified permissions.
835+ *
836+ * @param scope - Space-separated scope string
837+ * @param checkPermissions - Array of permissions to check for
838+ * @returns True if the scope contains at least one of the permissions
839+ *
840+ * @example
841+ * ```typescript
842+ * const scope = "account:email?action=read repo:app.bsky.feed.post";
843+ * hasAnyPermission(scope, ["account:email?action=read", "account:repo"]); // true
844+ * hasAnyPermission(scope, ["account:repo", "identity:handle"]); // false
845+ * ```
846+ */
847+ export function hasAnyPermission ( scope : string , checkPermissions : string [ ] ) : boolean {
848+ const permissions = parseScope ( scope ) ;
849+ return checkPermissions . some ( ( check ) => permissions . includes ( check ) ) ;
850+ }
851+
852+ /**
853+ * Merge multiple scope strings into a single scope string with deduplicated permissions.
854+ *
855+ * @param scopes - Array of scope strings to merge
856+ * @returns Merged scope string with unique permissions
857+ *
858+ * @example
859+ * ```typescript
860+ * const scope1 = "account:email?action=read repo:app.bsky.feed.post";
861+ * const scope2 = "repo:app.bsky.feed.post blob:image/*";
862+ * const merged = mergeScopes([scope1, scope2]);
863+ * // Returns: "account:email?action=read repo:app.bsky.feed.post blob:image/*"
864+ * ```
865+ */
866+ export function mergeScopes ( scopes : string [ ] ) : string {
867+ const allPermissions = scopes . flatMap ( parseScope ) ;
868+ const uniquePermissions = [ ...new Set ( allPermissions ) ] ;
869+ return buildScope ( uniquePermissions ) ;
870+ }
871+
872+ /**
873+ * Remove specific permissions from a scope string.
874+ *
875+ * @param scope - Space-separated scope string
876+ * @param permissionsToRemove - Array of permissions to remove
877+ * @returns New scope string without the specified permissions
878+ *
879+ * @example
880+ * ```typescript
881+ * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*";
882+ * const filtered = removePermissions(scope, ["blob:image/*"]);
883+ * // Returns: "account:email?action=read repo:app.bsky.feed.post"
884+ * ```
885+ */
886+ export function removePermissions ( scope : string , permissionsToRemove : string [ ] ) : string {
887+ const permissions = parseScope ( scope ) ;
888+ const filtered = permissions . filter ( ( p ) => ! permissionsToRemove . includes ( p ) ) ;
889+ return buildScope ( filtered ) ;
890+ }
891+
892+ /**
893+ * Validate that all permissions in a scope string are well-formed.
894+ *
895+ * This checks that each permission matches expected patterns for transitional
896+ * or granular permissions. It does NOT validate against the full Zod schemas.
897+ *
898+ * @param scope - Space-separated scope string
899+ * @returns Object with isValid flag and array of invalid permissions
900+ *
901+ * @example
902+ * ```typescript
903+ * const scope = "account:email?action=read invalid:permission";
904+ * const result = validateScope(scope);
905+ * // Returns: { isValid: false, invalidPermissions: ['invalid:permission'] }
906+ * ```
907+ */
908+ export function validateScope ( scope : string ) : {
909+ isValid : boolean ;
910+ invalidPermissions : string [ ] ;
911+ } {
912+ const permissions = parseScope ( scope ) ;
913+ const invalidPermissions : string [ ] = [ ] ;
914+
915+ // Pattern for valid permission prefixes
916+ const validPrefixes = / ^ ( a t p r o t o | t r a n s i t i o n : | a c c o u n t : | r e p o : | b l o b : ? | r p c : | i d e n t i t y : | i n c l u d e : ) / ;
917+
918+ for ( const permission of permissions ) {
919+ if ( ! validPrefixes . test ( permission ) ) {
920+ invalidPermissions . push ( permission ) ;
921+ }
922+ }
923+
924+ return {
925+ isValid : invalidPermissions . length === 0 ,
926+ invalidPermissions,
927+ } ;
928+ }
0 commit comments