Skip to content

Commit bb394e4

Browse files
committed
feat(permissions): add PermissionBuilder with fluent API
- Implement PermissionBuilder class with method chaining - Add convenience methods: accountEmail, accountRepo, repoRead, repoWrite, repoFull - Support all permission types: account, repo, blob, rpc, identity, include - Add transitional scope support with short names (email, generic, chat.bsky) - Include utility methods: atproto(), custom(), clear(), count() - Add 36 comprehensive tests covering all builder methods - All 108 tests passing
1 parent ce36f61 commit bb394e4

File tree

2 files changed

+607
-0
lines changed

2 files changed

+607
-0
lines changed

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

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,336 @@ export type PermissionInput = z.input<typeof PermissionSchema>;
415415
* Output type for any permission (after transform).
416416
*/
417417
export type Permission = z.output<typeof PermissionSchema>;
418+
419+
/**
420+
* Fluent builder for constructing OAuth permission arrays.
421+
*
422+
* This class provides a convenient, type-safe way to build arrays of permissions
423+
* using method chaining.
424+
*
425+
* @example Basic usage
426+
* ```typescript
427+
* const builder = new PermissionBuilder()
428+
* .accountEmail('read')
429+
* .repoWrite('app.bsky.feed.post')
430+
* .blob(['image/*', 'video/*']);
431+
*
432+
* const permissions = builder.build();
433+
* // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post?action=create&action=update', 'blob:image/*,video/*']
434+
* ```
435+
*
436+
* @example With transitional scopes
437+
* ```typescript
438+
* const builder = new PermissionBuilder()
439+
* .transition('email')
440+
* .transition('generic');
441+
*
442+
* const scopes = builder.build();
443+
* // Returns: ['transition:email', 'transition:generic']
444+
* ```
445+
*/
446+
export class PermissionBuilder {
447+
private permissions: string[] = [];
448+
449+
/**
450+
* Add a transitional scope.
451+
*
452+
* @param scope - The transitional scope name ('email', 'generic', or 'chat.bsky')
453+
* @returns This builder for chaining
454+
*
455+
* @example
456+
* ```typescript
457+
* builder.transition('email').transition('generic');
458+
* ```
459+
*/
460+
transition(scope: "email" | "generic" | "chat.bsky"): this {
461+
const fullScope = `transition:${scope}`;
462+
const validated = TransitionScopeSchema.parse(fullScope);
463+
this.permissions.push(validated);
464+
return this;
465+
}
466+
467+
/**
468+
* Add an account permission.
469+
*
470+
* @param attr - The account attribute ('email' or 'repo')
471+
* @param action - Optional action ('read' or 'manage')
472+
* @returns This builder for chaining
473+
*
474+
* @example
475+
* ```typescript
476+
* builder.accountEmail('read').accountRepo('manage');
477+
* ```
478+
*/
479+
account(attr: z.infer<typeof AccountAttrSchema>, action?: z.infer<typeof AccountActionSchema>): this {
480+
const permission = AccountPermissionSchema.parse({
481+
type: "account",
482+
attr,
483+
action,
484+
});
485+
this.permissions.push(permission);
486+
return this;
487+
}
488+
489+
/**
490+
* Convenience method for account:email permission.
491+
*
492+
* @param action - Optional action ('read' or 'manage')
493+
* @returns This builder for chaining
494+
*
495+
* @example
496+
* ```typescript
497+
* builder.accountEmail('read');
498+
* ```
499+
*/
500+
accountEmail(action?: z.infer<typeof AccountActionSchema>): this {
501+
return this.account("email", action);
502+
}
503+
504+
/**
505+
* Convenience method for account:repo permission.
506+
*
507+
* @param action - Optional action ('read' or 'manage')
508+
* @returns This builder for chaining
509+
*
510+
* @example
511+
* ```typescript
512+
* builder.accountRepo('manage');
513+
* ```
514+
*/
515+
accountRepo(action?: z.infer<typeof AccountActionSchema>): this {
516+
return this.account("repo", action);
517+
}
518+
519+
/**
520+
* Add a repository permission.
521+
*
522+
* @param collection - The NSID of the collection or '*' for all
523+
* @param actions - Optional array of actions ('create', 'update', 'delete')
524+
* @returns This builder for chaining
525+
*
526+
* @example
527+
* ```typescript
528+
* builder.repo('app.bsky.feed.post', ['create', 'update']);
529+
* ```
530+
*/
531+
repo(collection: string, actions?: z.infer<typeof RepoActionSchema>[]): this {
532+
const permission = RepoPermissionSchema.parse({
533+
type: "repo",
534+
collection,
535+
actions,
536+
});
537+
this.permissions.push(permission);
538+
return this;
539+
}
540+
541+
/**
542+
* Convenience method for repository write permissions (create + update).
543+
*
544+
* @param collection - The NSID of the collection or '*' for all
545+
* @returns This builder for chaining
546+
*
547+
* @example
548+
* ```typescript
549+
* builder.repoWrite('app.bsky.feed.post');
550+
* ```
551+
*/
552+
repoWrite(collection: string): this {
553+
return this.repo(collection, ["create", "update"]);
554+
}
555+
556+
/**
557+
* Convenience method for repository read permission (no actions).
558+
*
559+
* @param collection - The NSID of the collection or '*' for all
560+
* @returns This builder for chaining
561+
*
562+
* @example
563+
* ```typescript
564+
* builder.repoRead('app.bsky.feed.post');
565+
* ```
566+
*/
567+
repoRead(collection: string): this {
568+
return this.repo(collection, []);
569+
}
570+
571+
/**
572+
* Convenience method for full repository permissions (create + update + delete).
573+
*
574+
* @param collection - The NSID of the collection or '*' for all
575+
* @returns This builder for chaining
576+
*
577+
* @example
578+
* ```typescript
579+
* builder.repoFull('app.bsky.feed.post');
580+
* ```
581+
*/
582+
repoFull(collection: string): this {
583+
return this.repo(collection, ["create", "update", "delete"]);
584+
}
585+
586+
/**
587+
* Add a blob permission.
588+
*
589+
* @param mimeTypes - Array of MIME types or a single MIME type
590+
* @returns This builder for chaining
591+
*
592+
* @example
593+
* ```typescript
594+
* builder.blob(['image/*', 'video/*']);
595+
* builder.blob('image/*');
596+
* ```
597+
*/
598+
blob(mimeTypes: string | string[]): this {
599+
const types = Array.isArray(mimeTypes) ? mimeTypes : [mimeTypes];
600+
const permission = BlobPermissionSchema.parse({
601+
type: "blob",
602+
mimeTypes: types,
603+
});
604+
this.permissions.push(permission);
605+
return this;
606+
}
607+
608+
/**
609+
* Add an RPC permission.
610+
*
611+
* @param lexicon - The NSID of the lexicon or '*' for all
612+
* @param aud - The audience (DID or URL)
613+
* @param inheritAud - Whether to inherit audience
614+
* @returns This builder for chaining
615+
*
616+
* @example
617+
* ```typescript
618+
* builder.rpc('com.atproto.repo.createRecord', 'did:web:api.example.com');
619+
* ```
620+
*/
621+
rpc(lexicon: string, aud: string, inheritAud?: boolean): this {
622+
const permission = RpcPermissionSchema.parse({
623+
type: "rpc",
624+
lexicon,
625+
aud,
626+
inheritAud,
627+
});
628+
this.permissions.push(permission);
629+
return this;
630+
}
631+
632+
/**
633+
* Add an identity permission.
634+
*
635+
* @param attr - The identity attribute ('handle' or '*')
636+
* @returns This builder for chaining
637+
*
638+
* @example
639+
* ```typescript
640+
* builder.identity('handle');
641+
* ```
642+
*/
643+
identity(attr: z.infer<typeof IdentityAttrSchema>): this {
644+
const permission = IdentityPermissionSchema.parse({
645+
type: "identity",
646+
attr,
647+
});
648+
this.permissions.push(permission);
649+
return this;
650+
}
651+
652+
/**
653+
* Add an include permission.
654+
*
655+
* @param nsid - The NSID of the scope set to include
656+
* @param aud - Optional audience restriction
657+
* @returns This builder for chaining
658+
*
659+
* @example
660+
* ```typescript
661+
* builder.include('com.example.authBasicFeatures');
662+
* ```
663+
*/
664+
include(nsid: string, aud?: string): this {
665+
const permission = IncludePermissionSchema.parse({
666+
type: "include",
667+
nsid,
668+
aud,
669+
});
670+
this.permissions.push(permission);
671+
return this;
672+
}
673+
674+
/**
675+
* Add a custom permission string directly (bypasses validation).
676+
*
677+
* Use this for testing or special cases where you need to add
678+
* a permission that doesn't fit the standard types.
679+
*
680+
* @param permission - The permission string
681+
* @returns This builder for chaining
682+
*
683+
* @example
684+
* ```typescript
685+
* builder.custom('atproto');
686+
* ```
687+
*/
688+
custom(permission: string): this {
689+
this.permissions.push(permission);
690+
return this;
691+
}
692+
693+
/**
694+
* Add the base atproto scope.
695+
*
696+
* @returns This builder for chaining
697+
*
698+
* @example
699+
* ```typescript
700+
* builder.atproto();
701+
* ```
702+
*/
703+
atproto(): this {
704+
this.permissions.push(ATPROTO_SCOPE);
705+
return this;
706+
}
707+
708+
/**
709+
* Build and return the array of permission strings.
710+
*
711+
* @returns Array of permission strings
712+
*
713+
* @example
714+
* ```typescript
715+
* const permissions = builder.build();
716+
* ```
717+
*/
718+
build(): string[] {
719+
return [...this.permissions];
720+
}
721+
722+
/**
723+
* Clear all permissions from the builder.
724+
*
725+
* @returns This builder for chaining
726+
*
727+
* @example
728+
* ```typescript
729+
* builder.clear().accountEmail('read');
730+
* ```
731+
*/
732+
clear(): this {
733+
this.permissions = [];
734+
return this;
735+
}
736+
737+
/**
738+
* Get the current number of permissions.
739+
*
740+
* @returns The number of permissions
741+
*
742+
* @example
743+
* ```typescript
744+
* const count = builder.count();
745+
* ```
746+
*/
747+
count(): number {
748+
return this.permissions.length;
749+
}
750+
}

0 commit comments

Comments
 (0)