Skip to content

Commit 68ed479

Browse files
committed
feat(permissions): add scope utility functions
- Implement buildScope() to join permissions with spaces - Implement parseScope() to split scope strings - Add hasPermission() for checking single permission - Add hasAllPermissions() and hasAnyPermission() for multiple checks - Add mergeScopes() with deduplication - Add removePermissions() for filtering - Add validateScope() for basic well-formedness checking - Add 41 comprehensive tests for all utility functions - All 149 tests passing
1 parent bb394e4 commit 68ed479

File tree

2 files changed

+486
-0
lines changed

2 files changed

+486
-0
lines changed

packages/sdk-core/src/auth/permissions.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = /^(atproto|transition:|account:|repo:|blob:?|rpc:|identity:|include:)/;
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

Comments
 (0)