From 57a93ce6eb392ffdbae1381f84834c3aa1a23180 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:20:48 +0100 Subject: [PATCH 01/15] feat(auth): add core permission type schemas - Add base OAuth scope constants - Add Zod schemas for permission primitives - Add NSID and MIME type validation patterns - Export inferred TypeScript types --- packages/sdk-core/src/auth/permissions.ts | 146 +++++++++++++++ .../sdk-core/tests/auth/permissions.test.ts | 172 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/sdk-core/src/auth/permissions.ts create mode 100644 packages/sdk-core/tests/auth/permissions.test.ts diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts new file mode 100644 index 0000000..0d5a098 --- /dev/null +++ b/packages/sdk-core/src/auth/permissions.ts @@ -0,0 +1,146 @@ +/** + * OAuth Scopes and Granular Permissions + * + * This module provides type-safe, Zod-validated OAuth scope and permission management + * for the ATProto SDK. It supports both legacy transitional scopes and the new + * granular permissions model. + * + * @see https://atproto.com/specs/oauth + * @see https://atproto.com/specs/permission + * + * @module auth/permissions + */ + +import { z } from "zod"; + +/** + * Base OAuth scope - required for all sessions + * + * @constant + */ +export const ATPROTO_SCOPE = 'atproto' as const; + +/** + * Transitional OAuth scopes for legacy compatibility. + * + * These scopes provide broad access and are maintained for backwards compatibility. + * New applications should use granular permissions instead. + * + * @deprecated Use granular permissions (account:*, repo:*, etc.) for better control + * @constant + */ +export const TRANSITION_SCOPES = { + /** Broad PDS permissions including record creation, blob uploads, and preferences */ + GENERIC: 'transition:generic', + /** Direct messages access (requires transition:generic) */ + CHAT: 'transition:chat.bsky', + /** Email address and confirmation status */ + EMAIL: 'transition:email', +} as const; + +/** + * Zod schema for transitional scopes. + * + * Validates that a scope string is one of the known transitional scopes. + * + * @example + * ```typescript + * TransitionScopeSchema.parse('transition:email'); // Valid + * TransitionScopeSchema.parse('invalid'); // Throws ZodError + * ``` + */ +export const TransitionScopeSchema = z.enum([ + 'transition:generic', + 'transition:chat.bsky', + 'transition:email', +]).describe('Legacy transitional OAuth scopes'); + +/** + * Type for transitional scopes inferred from schema. + */ +export type TransitionScope = z.infer; + +/** + * Zod schema for account permission attributes. + * + * Account attributes specify what aspect of the account is being accessed. + */ +export const AccountAttrSchema = z.enum(['email', 'repo']); + +/** + * Type for account attributes inferred from schema. + */ +export type AccountAttr = z.infer; + +/** + * Zod schema for account actions. + * + * Account actions specify the level of access (read-only or management). + */ +export const AccountActionSchema = z.enum(['read', 'manage']); + +/** + * Type for account actions inferred from schema. + */ +export type AccountAction = z.infer; + +/** + * Zod schema for repository actions. + * + * Repository actions specify what operations can be performed on records. + */ +export const RepoActionSchema = z.enum(['create', 'update', 'delete']); + +/** + * Type for repository actions inferred from schema. + */ +export type RepoAction = z.infer; + +/** + * Zod schema for identity permission attributes. + * + * Identity attributes specify what identity information can be managed. + */ +export const IdentityAttrSchema = z.enum(['handle', '*']); + +/** + * Type for identity attributes inferred from schema. + */ +export type IdentityAttr = z.infer; + +/** + * Zod schema for MIME type patterns. + * + * Validates MIME type strings like "image/*" or "video/mp4". + * + * @example + * ```typescript + * MimeTypeSchema.parse('image/*'); // Valid + * MimeTypeSchema.parse('video/mp4'); // Valid + * MimeTypeSchema.parse('invalid'); // Throws ZodError + * ``` + */ +export const MimeTypeSchema = z.string().regex( + /^[a-z]+\/[a-z0-9*+-]+$/i, + 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")' +); + +/** + * Zod schema for NSID (Namespaced Identifier). + * + * NSIDs are reverse-DNS style identifiers used throughout ATProto + * (e.g., "app.bsky.feed.post" or "com.example.myrecord"). + * + * @see https://atproto.com/specs/nsid + * + * @example + * ```typescript + * NsidSchema.parse('app.bsky.feed.post'); // Valid + * NsidSchema.parse('com.example.myrecord'); // Valid + * NsidSchema.parse('InvalidNSID'); // Throws ZodError + * ``` + */ +export const NsidSchema = z.string().regex( + /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, + 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")' +); diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts new file mode 100644 index 0000000..d4b7d2f --- /dev/null +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from "vitest"; +import { + ATPROTO_SCOPE, + TRANSITION_SCOPES, + TransitionScopeSchema, + AccountAttrSchema, + AccountActionSchema, + RepoActionSchema, + IdentityAttrSchema, + MimeTypeSchema, + NsidSchema, +} from "../../src/auth/permissions.js"; + +describe("Permission Constants", () => { + it("should export ATPROTO_SCOPE constant", () => { + expect(ATPROTO_SCOPE).toBe("atproto"); + }); + + it("should export TRANSITION_SCOPES with correct values", () => { + expect(TRANSITION_SCOPES.GENERIC).toBe("transition:generic"); + expect(TRANSITION_SCOPES.CHAT).toBe("transition:chat.bsky"); + expect(TRANSITION_SCOPES.EMAIL).toBe("transition:email"); + }); +}); + +describe("TransitionScopeSchema", () => { + it("should accept valid transitional scopes", () => { + expect(TransitionScopeSchema.parse("transition:generic")).toBe("transition:generic"); + expect(TransitionScopeSchema.parse("transition:chat.bsky")).toBe("transition:chat.bsky"); + expect(TransitionScopeSchema.parse("transition:email")).toBe("transition:email"); + }); + + it("should reject invalid transitional scopes", () => { + expect(() => TransitionScopeSchema.parse("atproto")).toThrow(); + expect(() => TransitionScopeSchema.parse("transition:invalid")).toThrow(); + expect(() => TransitionScopeSchema.parse("invalid")).toThrow(); + expect(() => TransitionScopeSchema.parse("")).toThrow(); + }); +}); + +describe("AccountAttrSchema", () => { + it("should accept valid account attributes", () => { + expect(AccountAttrSchema.parse("email")).toBe("email"); + expect(AccountAttrSchema.parse("repo")).toBe("repo"); + }); + + it("should reject invalid account attributes", () => { + expect(() => AccountAttrSchema.parse("invalid")).toThrow(); + expect(() => AccountAttrSchema.parse("handle")).toThrow(); + expect(() => AccountAttrSchema.parse("")).toThrow(); + }); +}); + +describe("AccountActionSchema", () => { + it("should accept valid account actions", () => { + expect(AccountActionSchema.parse("read")).toBe("read"); + expect(AccountActionSchema.parse("manage")).toBe("manage"); + }); + + it("should reject invalid account actions", () => { + expect(() => AccountActionSchema.parse("create")).toThrow(); + expect(() => AccountActionSchema.parse("delete")).toThrow(); + expect(() => AccountActionSchema.parse("invalid")).toThrow(); + expect(() => AccountActionSchema.parse("")).toThrow(); + }); +}); + +describe("RepoActionSchema", () => { + it("should accept valid repository actions", () => { + expect(RepoActionSchema.parse("create")).toBe("create"); + expect(RepoActionSchema.parse("update")).toBe("update"); + expect(RepoActionSchema.parse("delete")).toBe("delete"); + }); + + it("should reject invalid repository actions", () => { + expect(() => RepoActionSchema.parse("read")).toThrow(); + expect(() => RepoActionSchema.parse("manage")).toThrow(); + expect(() => RepoActionSchema.parse("invalid")).toThrow(); + expect(() => RepoActionSchema.parse("")).toThrow(); + }); +}); + +describe("IdentityAttrSchema", () => { + it("should accept valid identity attributes", () => { + expect(IdentityAttrSchema.parse("handle")).toBe("handle"); + expect(IdentityAttrSchema.parse("*")).toBe("*"); + }); + + it("should reject invalid identity attributes", () => { + expect(() => IdentityAttrSchema.parse("email")).toThrow(); + expect(() => IdentityAttrSchema.parse("repo")).toThrow(); + expect(() => IdentityAttrSchema.parse("invalid")).toThrow(); + expect(() => IdentityAttrSchema.parse("")).toThrow(); + }); +}); + +describe("MimeTypeSchema", () => { + it("should accept valid MIME type patterns", () => { + expect(MimeTypeSchema.parse("image/*")).toBe("image/*"); + expect(MimeTypeSchema.parse("video/*")).toBe("video/*"); + expect(MimeTypeSchema.parse("audio/*")).toBe("audio/*"); + expect(MimeTypeSchema.parse("text/html")).toBe("text/html"); + expect(MimeTypeSchema.parse("application/json")).toBe("application/json"); + expect(MimeTypeSchema.parse("image/png")).toBe("image/png"); + expect(MimeTypeSchema.parse("video/mp4")).toBe("video/mp4"); + }); + + it("should accept MIME types with numbers and hyphens", () => { + expect(MimeTypeSchema.parse("application/json-ld")).toBe("application/json-ld"); + expect(MimeTypeSchema.parse("application/ld+json")).toBe("application/ld+json"); + expect(MimeTypeSchema.parse("image/svg+xml")).toBe("image/svg+xml"); + }); + + it("should reject invalid MIME type patterns", () => { + expect(() => MimeTypeSchema.parse("invalid")).toThrow(); + expect(() => MimeTypeSchema.parse("image")).toThrow(); + expect(() => MimeTypeSchema.parse("/png")).toThrow(); + expect(() => MimeTypeSchema.parse("image/")).toThrow(); + expect(() => MimeTypeSchema.parse("")).toThrow(); + }); + + it("should accept MIME types in any case (case-insensitive)", () => { + // MIME types are case-insensitive per RFC 2045 + expect(MimeTypeSchema.parse("IMAGE/*")).toBe("IMAGE/*"); + expect(MimeTypeSchema.parse("Image/Png")).toBe("Image/Png"); + }); + + it("should provide helpful error message for invalid MIME types", () => { + try { + MimeTypeSchema.parse("invalid"); + expect.fail("Should have thrown"); + } catch (error) { + const zodError = error as { issues: Array<{ message: string }> }; + expect(zodError.issues[0].message).toContain("Invalid MIME type pattern"); + expect(zodError.issues[0].message).toContain("type/subtype"); + } + }); +}); + +describe("NsidSchema", () => { + it("should accept valid NSID formats", () => { + expect(NsidSchema.parse("app.bsky.feed.post")).toBe("app.bsky.feed.post"); + expect(NsidSchema.parse("com.example.myrecord")).toBe("com.example.myrecord"); + expect(NsidSchema.parse("org.example.test.nested.record")).toBe("org.example.test.nested.record"); + }); + + it("should accept NSIDs with hyphens", () => { + expect(NsidSchema.parse("com.example-app.record")).toBe("com.example-app.record"); + expect(NsidSchema.parse("app.my-test.record")).toBe("app.my-test.record"); + }); + + it("should reject invalid NSID formats", () => { + expect(() => NsidSchema.parse("InvalidNSID")).toThrow(); + expect(() => NsidSchema.parse("example")).toThrow(); // Need at least one dot + expect(() => NsidSchema.parse(".example.com")).toThrow(); // Can't start with dot + expect(() => NsidSchema.parse("example.com.")).toThrow(); // Can't end with dot + expect(() => NsidSchema.parse("Example.com")).toThrow(); // Must be lowercase + expect(() => NsidSchema.parse("example..com")).toThrow(); // No consecutive dots + expect(() => NsidSchema.parse("")).toThrow(); + }); + + it("should provide helpful error message for invalid NSIDs", () => { + try { + NsidSchema.parse("InvalidNSID"); + expect.fail("Should have thrown"); + } catch (error) { + const zodError = error as { issues: Array<{ message: string }> }; + expect(zodError.issues[0].message).toContain("Invalid NSID format"); + expect(zodError.issues[0].message).toContain("reverse-DNS"); + } + }); +}); From 745b76f43de55d78f17462dcb884686fb7d262ae Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:22:29 +0100 Subject: [PATCH 02/15] feat(auth): add account permission schema with transform - Implement AccountPermissionSchema with Zod transform - Support optional action parameter - Transform to correct permission string format - Add comprehensive validation tests --- packages/sdk-core/src/auth/permissions.ts | 81 ++++++++++++++----- .../sdk-core/tests/auth/permissions.test.ts | 58 +++++++++++++ 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index 0d5a098..5b32d90 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -18,7 +18,7 @@ import { z } from "zod"; * * @constant */ -export const ATPROTO_SCOPE = 'atproto' as const; +export const ATPROTO_SCOPE = "atproto" as const; /** * Transitional OAuth scopes for legacy compatibility. @@ -31,11 +31,11 @@ export const ATPROTO_SCOPE = 'atproto' as const; */ export const TRANSITION_SCOPES = { /** Broad PDS permissions including record creation, blob uploads, and preferences */ - GENERIC: 'transition:generic', + GENERIC: "transition:generic", /** Direct messages access (requires transition:generic) */ - CHAT: 'transition:chat.bsky', + CHAT: "transition:chat.bsky", /** Email address and confirmation status */ - EMAIL: 'transition:email', + EMAIL: "transition:email", } as const; /** @@ -49,11 +49,9 @@ export const TRANSITION_SCOPES = { * TransitionScopeSchema.parse('invalid'); // Throws ZodError * ``` */ -export const TransitionScopeSchema = z.enum([ - 'transition:generic', - 'transition:chat.bsky', - 'transition:email', -]).describe('Legacy transitional OAuth scopes'); +export const TransitionScopeSchema = z + .enum(["transition:generic", "transition:chat.bsky", "transition:email"]) + .describe("Legacy transitional OAuth scopes"); /** * Type for transitional scopes inferred from schema. @@ -65,7 +63,7 @@ export type TransitionScope = z.infer; * * Account attributes specify what aspect of the account is being accessed. */ -export const AccountAttrSchema = z.enum(['email', 'repo']); +export const AccountAttrSchema = z.enum(["email", "repo"]); /** * Type for account attributes inferred from schema. @@ -77,7 +75,7 @@ export type AccountAttr = z.infer; * * Account actions specify the level of access (read-only or management). */ -export const AccountActionSchema = z.enum(['read', 'manage']); +export const AccountActionSchema = z.enum(["read", "manage"]); /** * Type for account actions inferred from schema. @@ -89,7 +87,7 @@ export type AccountAction = z.infer; * * Repository actions specify what operations can be performed on records. */ -export const RepoActionSchema = z.enum(['create', 'update', 'delete']); +export const RepoActionSchema = z.enum(["create", "update", "delete"]); /** * Type for repository actions inferred from schema. @@ -101,7 +99,7 @@ export type RepoAction = z.infer; * * Identity attributes specify what identity information can be managed. */ -export const IdentityAttrSchema = z.enum(['handle', '*']); +export const IdentityAttrSchema = z.enum(["handle", "*"]); /** * Type for identity attributes inferred from schema. @@ -120,10 +118,12 @@ export type IdentityAttr = z.infer; * MimeTypeSchema.parse('invalid'); // Throws ZodError * ``` */ -export const MimeTypeSchema = z.string().regex( - /^[a-z]+\/[a-z0-9*+-]+$/i, - 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")' -); +export const MimeTypeSchema = z + .string() + .regex( + /^[a-z]+\/[a-z0-9*+-]+$/i, + 'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")', + ); /** * Zod schema for NSID (Namespaced Identifier). @@ -140,7 +140,46 @@ export const MimeTypeSchema = z.string().regex( * NsidSchema.parse('InvalidNSID'); // Throws ZodError * ``` */ -export const NsidSchema = z.string().regex( - /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, - 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")' -); +export const NsidSchema = z + .string() + .regex( + /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, + 'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")', + ); + +/** + * Zod schema for account permission. + * + * Account permissions control access to account-level information like email + * and repository management. + * + * @example Without action (read-only) + * ```typescript + * const input = { type: 'account', attr: 'email' }; + * AccountPermissionSchema.parse(input); // Returns: "account:email" + * ``` + * + * @example With action + * ```typescript + * const input = { type: 'account', attr: 'email', action: 'manage' }; + * AccountPermissionSchema.parse(input); // Returns: "account:email?action=manage" + * ``` + */ +export const AccountPermissionSchema = z + .object({ + type: z.literal("account"), + attr: AccountAttrSchema, + action: AccountActionSchema.optional(), + }) + .transform(({ attr, action }) => { + let perm = `account:${attr}`; + if (action) { + perm += `?action=${action}`; + } + return perm; + }); + +/** + * Input type for account permission (before transform). + */ +export type AccountPermissionInput = z.input; diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index d4b7d2f..6880c83 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -9,6 +9,7 @@ import { IdentityAttrSchema, MimeTypeSchema, NsidSchema, + AccountPermissionSchema, } from "../../src/auth/permissions.js"; describe("Permission Constants", () => { @@ -170,3 +171,60 @@ describe("NsidSchema", () => { } }); }); + +describe("AccountPermissionSchema", () => { + it("should transform account:email without action", () => { + const input = { type: "account" as const, attr: "email" as const }; + const result = AccountPermissionSchema.parse(input); + expect(result).toBe("account:email"); + }); + + it("should transform account:email with read action", () => { + const input = { type: "account" as const, attr: "email" as const, action: "read" as const }; + const result = AccountPermissionSchema.parse(input); + expect(result).toBe("account:email?action=read"); + }); + + it("should transform account:email with manage action", () => { + const input = { type: "account" as const, attr: "email" as const, action: "manage" as const }; + const result = AccountPermissionSchema.parse(input); + expect(result).toBe("account:email?action=manage"); + }); + + it("should transform account:repo without action", () => { + const input = { type: "account" as const, attr: "repo" as const }; + const result = AccountPermissionSchema.parse(input); + expect(result).toBe("account:repo"); + }); + + it("should transform account:repo with manage action", () => { + const input = { type: "account" as const, attr: "repo" as const, action: "manage" as const }; + const result = AccountPermissionSchema.parse(input); + expect(result).toBe("account:repo?action=manage"); + }); + + it("should reject invalid attr values", () => { + const input = { type: "account" as const, attr: "invalid" }; + expect(() => AccountPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject invalid action values", () => { + const input = { type: "account" as const, attr: "email" as const, action: "delete" }; + expect(() => AccountPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing type field", () => { + const input = { attr: "email" as const }; + expect(() => AccountPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject wrong type value", () => { + const input = { type: "repo", attr: "email" as const }; + expect(() => AccountPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing attr field", () => { + const input = { type: "account" as const }; + expect(() => AccountPermissionSchema.parse(input)).toThrow(); + }); +}); From 6b1c5ee31a6e289c9bcbfcbbe3377b26e091148c Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:26:04 +0100 Subject: [PATCH 03/15] feat(auth): add repository permission schema - Implement RepoPermissionSchema with NSID validation - Support optional actions array - Transform to correct query string format - Add validation for collection names --- packages/sdk-core/src/auth/permissions.ts | 48 ++++++++++ .../sdk-core/tests/auth/permissions.test.ts | 94 +++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index 5b32d90..19641f5 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -183,3 +183,51 @@ export const AccountPermissionSchema = z * Input type for account permission (before transform). */ export type AccountPermissionInput = z.input; + +/** + * Zod schema for repository permission. + * + * Repository permissions control write access to records by collection type. + * The collection must be a valid NSID or wildcard (*). + * + * @example Without actions (all actions allowed) + * ```typescript + * const input = { type: 'repo', collection: 'app.bsky.feed.post' }; + * RepoPermissionSchema.parse(input); // Returns: "repo:app.bsky.feed.post" + * ``` + * + * @example With specific actions + * ```typescript + * const input = { + * type: 'repo', + * collection: 'app.bsky.feed.post', + * actions: ['create', 'update'] + * }; + * RepoPermissionSchema.parse(input); // Returns: "repo:app.bsky.feed.post?action=create&action=update" + * ``` + * + * @example With wildcard collection + * ```typescript + * const input = { type: 'repo', collection: '*', actions: ['delete'] }; + * RepoPermissionSchema.parse(input); // Returns: "repo:*?action=delete" + * ``` + */ +export const RepoPermissionSchema = z + .object({ + type: z.literal("repo"), + collection: NsidSchema.or(z.literal("*")), + actions: z.array(RepoActionSchema).optional(), + }) + .transform(({ collection, actions }) => { + let perm = `repo:${collection}`; + if (actions && actions.length > 0) { + const params = actions.map((a) => `action=${a}`).join("&"); + perm += `?${params}`; + } + return perm; + }); + +/** + * Input type for repository permission (before transform). + */ +export type RepoPermissionInput = z.input; diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index 6880c83..c3bf19f 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -10,6 +10,7 @@ import { MimeTypeSchema, NsidSchema, AccountPermissionSchema, + RepoPermissionSchema, } from "../../src/auth/permissions.js"; describe("Permission Constants", () => { @@ -228,3 +229,96 @@ describe("AccountPermissionSchema", () => { expect(() => AccountPermissionSchema.parse(input)).toThrow(); }); }); + +describe("RepoPermissionSchema", () => { + it("should transform repo with NSID collection without actions", () => { + const input = { type: "repo" as const, collection: "app.bsky.feed.post" }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:app.bsky.feed.post"); + }); + + it("should transform repo with wildcard collection", () => { + const input = { type: "repo" as const, collection: "*" }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:*"); + }); + + it("should transform repo with single action", () => { + const input = { + type: "repo" as const, + collection: "app.bsky.feed.post", + actions: ["create" as const], + }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:app.bsky.feed.post?action=create"); + }); + + it("should transform repo with multiple actions", () => { + const input = { + type: "repo" as const, + collection: "app.bsky.feed.post", + actions: ["create" as const, "update" as const], + }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:app.bsky.feed.post?action=create&action=update"); + }); + + it("should transform repo with all three actions", () => { + const input = { + type: "repo" as const, + collection: "com.example.record", + actions: ["create" as const, "update" as const, "delete" as const], + }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:com.example.record?action=create&action=update&action=delete"); + }); + + it("should transform repo with wildcard and delete action", () => { + const input = { + type: "repo" as const, + collection: "*", + actions: ["delete" as const], + }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:*?action=delete"); + }); + + it("should handle empty actions array (no query params)", () => { + const input = { + type: "repo" as const, + collection: "app.bsky.feed.post", + actions: [], + }; + const result = RepoPermissionSchema.parse(input); + expect(result).toBe("repo:app.bsky.feed.post"); + }); + + it("should reject invalid NSID format", () => { + const input = { type: "repo" as const, collection: "InvalidNSID" }; + expect(() => RepoPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject invalid action values", () => { + const input = { + type: "repo" as const, + collection: "app.bsky.feed.post", + actions: ["invalid"], + }; + expect(() => RepoPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing type field", () => { + const input = { collection: "app.bsky.feed.post" }; + expect(() => RepoPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject wrong type value", () => { + const input = { type: "account", collection: "app.bsky.feed.post" }; + expect(() => RepoPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing collection field", () => { + const input = { type: "repo" as const }; + expect(() => RepoPermissionSchema.parse(input)).toThrow(); + }); +}); From ce36f615db32ee7514da862fcfa3403843197985 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:30:22 +0100 Subject: [PATCH 04/15] feat(permissions): add blob, rpc, identity, include permission schemas - Implement BlobPermissionSchema with MIME type validation - Implement RpcPermissionSchema with lexicon/aud validation - Implement IdentityPermissionSchema for handle permissions - Implement IncludePermissionSchema with NSID validation - Add PermissionSchema union combining all six permission types - Fix NSID regex to allow uppercase letters (valid per atproto spec) - Add comprehensive tests (72 tests, all passing) - Update NSID test cases to reflect correct specification --- packages/sdk-core/src/auth/permissions.ts | 186 ++++++++++++++- .../sdk-core/tests/auth/permissions.test.ts | 223 +++++++++++++++++- 2 files changed, 405 insertions(+), 4 deletions(-) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index 19641f5..be14226 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -143,7 +143,7 @@ export const MimeTypeSchema = z export const NsidSchema = z .string() .regex( - /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/, + /^[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")', ); @@ -231,3 +231,187 @@ export const RepoPermissionSchema = z * Input type for repository permission (before transform). */ export type RepoPermissionInput = z.input; + +/** + * Zod schema for blob permission. + * + * Blob permissions control media file uploads constrained by MIME type patterns. + * + * @example Single MIME type + * ```typescript + * const input = { type: 'blob', mimeTypes: ['image/*'] }; + * BlobPermissionSchema.parse(input); // Returns: "blob:image/*" + * ``` + * + * @example Multiple MIME types + * ```typescript + * const input = { type: 'blob', mimeTypes: ['image/*', 'video/*'] }; + * BlobPermissionSchema.parse(input); // Returns: "blob?accept=image/*&accept=video/*" + * ``` + */ +export const BlobPermissionSchema = z + .object({ + type: z.literal("blob"), + mimeTypes: z.array(MimeTypeSchema).min(1, "At least one MIME type required"), + }) + .transform(({ mimeTypes }) => { + if (mimeTypes.length === 1) { + return `blob:${mimeTypes[0]}`; + } + const accepts = mimeTypes.map((t) => `accept=${encodeURIComponent(t)}`).join("&"); + return `blob?${accepts}`; + }); + +/** + * Input type for blob permission (before transform). + */ +export type BlobPermissionInput = z.input; + +/** + * Zod schema for RPC permission. + * + * RPC permissions control authenticated API calls to remote services. + * At least one of lexicon or aud must be restricted (both cannot be wildcards). + * + * @example Specific lexicon with wildcard audience + * ```typescript + * const input = { + * type: 'rpc', + * lexicon: 'com.atproto.repo.createRecord', + * aud: '*' + * }; + * RpcPermissionSchema.parse(input); + * // Returns: "rpc:com.atproto.repo.createRecord?aud=*" + * ``` + * + * @example With specific audience + * ```typescript + * const input = { + * type: 'rpc', + * lexicon: 'com.atproto.repo.createRecord', + * aud: 'did:web:api.example.com', + * inheritAud: true + * }; + * RpcPermissionSchema.parse(input); + * // Returns: "rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com&inheritAud=true" + * ``` + */ +export const RpcPermissionSchema = z + .object({ + type: z.literal("rpc"), + lexicon: NsidSchema.or(z.literal("*")), + aud: z.string().min(1, "Audience is required"), + inheritAud: z.boolean().optional(), + }) + .refine( + ({ lexicon, aud }) => lexicon !== "*" || aud !== "*", + "At least one of lexicon or aud must be restricted (wildcards cannot both be used)", + ) + .transform(({ lexicon, aud, inheritAud }) => { + let perm = `rpc:${lexicon}?aud=${encodeURIComponent(aud)}`; + if (inheritAud) { + perm += "&inheritAud=true"; + } + return perm; + }); + +/** + * Input type for RPC permission (before transform). + */ +export type RpcPermissionInput = z.input; + +/** + * Zod schema for identity permission. + * + * Identity permissions control access to DID documents and handles. + * + * @example Handle management + * ```typescript + * const input = { type: 'identity', attr: 'handle' }; + * IdentityPermissionSchema.parse(input); // Returns: "identity:handle" + * ``` + * + * @example All identity attributes + * ```typescript + * const input = { type: 'identity', attr: '*' }; + * IdentityPermissionSchema.parse(input); // Returns: "identity:*" + * ``` + */ +export const IdentityPermissionSchema = z + .object({ + type: z.literal("identity"), + attr: IdentityAttrSchema, + }) + .transform(({ attr }) => `identity:${attr}`); + +/** + * Input type for identity permission (before transform). + */ +export type IdentityPermissionInput = z.input; + +/** + * Zod schema for permission set inclusion. + * + * Include permissions reference permission sets bundled under a single NSID. + * + * @example Without audience + * ```typescript + * const input = { type: 'include', nsid: 'com.example.authBasicFeatures' }; + * IncludePermissionSchema.parse(input); + * // Returns: "include:com.example.authBasicFeatures" + * ``` + * + * @example With audience + * ```typescript + * const input = { + * type: 'include', + * nsid: 'com.example.authBasicFeatures', + * aud: 'did:web:api.example.com' + * }; + * IncludePermissionSchema.parse(input); + * // Returns: "include:com.example.authBasicFeatures?aud=did%3Aweb%3Aapi.example.com" + * ``` + */ +export const IncludePermissionSchema = z + .object({ + type: z.literal("include"), + nsid: NsidSchema, + aud: z.string().optional(), + }) + .transform(({ nsid, aud }) => { + let perm = `include:${nsid}`; + if (aud) { + perm += `?aud=${encodeURIComponent(aud)}`; + } + return perm; + }); + +/** + * Input type for include permission (before transform). + */ +export type IncludePermissionInput = z.input; + +/** + * Union schema for all permission types. + * + * This schema accepts any of the supported permission types and validates + * them according to their specific rules. + */ +export const PermissionSchema = z.union([ + AccountPermissionSchema, + RepoPermissionSchema, + BlobPermissionSchema, + RpcPermissionSchema, + IdentityPermissionSchema, + IncludePermissionSchema, +]); + +/** + * Input type for any permission (before transform). + */ +export type PermissionInput = z.input; + +/** + * Output type for any permission (after transform). + */ +export type Permission = z.output; diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index c3bf19f..59f7688 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -11,6 +11,11 @@ import { NsidSchema, AccountPermissionSchema, RepoPermissionSchema, + BlobPermissionSchema, + RpcPermissionSchema, + IdentityPermissionSchema, + IncludePermissionSchema, + PermissionSchema, } from "../../src/auth/permissions.js"; describe("Permission Constants", () => { @@ -152,13 +157,13 @@ describe("NsidSchema", () => { }); it("should reject invalid NSID formats", () => { - expect(() => NsidSchema.parse("InvalidNSID")).toThrow(); + expect(() => NsidSchema.parse("InvalidNSID")).toThrow(); // Need at least one dot expect(() => NsidSchema.parse("example")).toThrow(); // Need at least one dot expect(() => NsidSchema.parse(".example.com")).toThrow(); // Can't start with dot expect(() => NsidSchema.parse("example.com.")).toThrow(); // Can't end with dot - expect(() => NsidSchema.parse("Example.com")).toThrow(); // Must be lowercase expect(() => NsidSchema.parse("example..com")).toThrow(); // No consecutive dots - expect(() => NsidSchema.parse("")).toThrow(); + expect(() => NsidSchema.parse("")).toThrow(); // Empty string + expect(() => NsidSchema.parse("123.example.com")).toThrow(); // Can't start with number }); it("should provide helpful error message for invalid NSIDs", () => { @@ -322,3 +327,215 @@ describe("RepoPermissionSchema", () => { expect(() => RepoPermissionSchema.parse(input)).toThrow(); }); }); + +describe("BlobPermissionSchema", () => { + it("should transform blob with single MIME type", () => { + const input = { type: "blob" as const, mimeTypes: ["image/*"] }; + const result = BlobPermissionSchema.parse(input); + expect(result).toBe("blob:image/*"); + }); + + it("should transform blob with multiple MIME types", () => { + const input = { type: "blob" as const, mimeTypes: ["image/*", "video/*"] }; + const result = BlobPermissionSchema.parse(input); + expect(result).toBe("blob?accept=image%2F*&accept=video%2F*"); + }); + + it("should transform blob with specific MIME types", () => { + const input = { type: "blob" as const, mimeTypes: ["image/png", "image/jpeg", "video/mp4"] }; + const result = BlobPermissionSchema.parse(input); + expect(result).toBe("blob?accept=image%2Fpng&accept=image%2Fjpeg&accept=video%2Fmp4"); + }); + + it("should reject empty MIME types array", () => { + const input = { type: "blob" as const, mimeTypes: [] }; + expect(() => BlobPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject invalid MIME type format", () => { + const input = { type: "blob" as const, mimeTypes: ["invalid"] }; + expect(() => BlobPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing mimeTypes field", () => { + const input = { type: "blob" as const }; + expect(() => BlobPermissionSchema.parse(input)).toThrow(); + }); +}); + +describe("RpcPermissionSchema", () => { + it("should transform RPC with specific lexicon and wildcard audience", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + aud: "*", + }; + const result = RpcPermissionSchema.parse(input); + expect(result).toBe("rpc:com.atproto.repo.createRecord?aud=*"); + }); + + it("should transform RPC with specific audience (URL encoded)", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + aud: "did:web:api.example.com", + }; + const result = RpcPermissionSchema.parse(input); + expect(result).toBe("rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com"); + }); + + it("should transform RPC with inheritAud flag", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + aud: "*", + inheritAud: true, + }; + const result = RpcPermissionSchema.parse(input); + expect(result).toBe("rpc:com.atproto.repo.createRecord?aud=*&inheritAud=true"); + }); + + it("should transform RPC with wildcard lexicon and specific aud", () => { + const input = { + type: "rpc" as const, + lexicon: "*", + aud: "did:web:api.example.com", + }; + const result = RpcPermissionSchema.parse(input); + expect(result).toBe("rpc:*?aud=did%3Aweb%3Aapi.example.com"); + }); + + it("should reject both wildcards (refinement)", () => { + const input = { + type: "rpc" as const, + lexicon: "*", + aud: "*", + }; + expect(() => RpcPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject invalid lexicon format", () => { + const input = { + type: "rpc" as const, + lexicon: "InvalidLexicon", + aud: "*", + }; + expect(() => RpcPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject empty audience", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + aud: "", + }; + expect(() => RpcPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing aud field", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + }; + expect(() => RpcPermissionSchema.parse(input)).toThrow(); + }); +}); + +describe("IdentityPermissionSchema", () => { + it("should transform identity:handle", () => { + const input = { type: "identity" as const, attr: "handle" as const }; + const result = IdentityPermissionSchema.parse(input); + expect(result).toBe("identity:handle"); + }); + + it("should transform identity:* (wildcard)", () => { + const input = { type: "identity" as const, attr: "*" as const }; + const result = IdentityPermissionSchema.parse(input); + expect(result).toBe("identity:*"); + }); + + it("should reject invalid attr values", () => { + const input = { type: "identity" as const, attr: "invalid" }; + expect(() => IdentityPermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing attr field", () => { + const input = { type: "identity" as const }; + expect(() => IdentityPermissionSchema.parse(input)).toThrow(); + }); +}); + +describe("IncludePermissionSchema", () => { + it("should transform include without audience", () => { + const input = { type: "include" as const, nsid: "com.example.authBasicFeatures" }; + const result = IncludePermissionSchema.parse(input); + expect(result).toBe("include:com.example.authBasicFeatures"); + }); + + it("should transform include with audience (URL encoded)", () => { + const input = { + type: "include" as const, + nsid: "com.example.authBasicFeatures", + aud: "did:web:api.example.com", + }; + const result = IncludePermissionSchema.parse(input); + expect(result).toBe("include:com.example.authBasicFeatures?aud=did%3Aweb%3Aapi.example.com"); + }); + + it("should reject invalid NSID format", () => { + const input = { type: "include" as const, nsid: "InvalidNSID" }; + expect(() => IncludePermissionSchema.parse(input)).toThrow(); + }); + + it("should reject missing nsid field", () => { + const input = { type: "include" as const }; + expect(() => IncludePermissionSchema.parse(input)).toThrow(); + }); +}); + +describe("PermissionSchema (Union)", () => { + it("should accept account permission", () => { + const input = { type: "account" as const, attr: "email" as const }; + const result = PermissionSchema.parse(input); + expect(result).toBe("account:email"); + }); + + it("should accept repo permission", () => { + const input = { type: "repo" as const, collection: "app.bsky.feed.post" }; + const result = PermissionSchema.parse(input); + expect(result).toBe("repo:app.bsky.feed.post"); + }); + + it("should accept blob permission", () => { + const input = { type: "blob" as const, mimeTypes: ["image/*"] }; + const result = PermissionSchema.parse(input); + expect(result).toBe("blob:image/*"); + }); + + it("should accept RPC permission", () => { + const input = { + type: "rpc" as const, + lexicon: "com.atproto.repo.createRecord", + aud: "*", + }; + const result = PermissionSchema.parse(input); + expect(result).toBe("rpc:com.atproto.repo.createRecord?aud=*"); + }); + + it("should accept identity permission", () => { + const input = { type: "identity" as const, attr: "handle" as const }; + const result = PermissionSchema.parse(input); + expect(result).toBe("identity:handle"); + }); + + it("should accept include permission", () => { + const input = { type: "include" as const, nsid: "com.example.authBasicFeatures" }; + const result = PermissionSchema.parse(input); + expect(result).toBe("include:com.example.authBasicFeatures"); + }); + + it("should reject invalid permission type", () => { + const input = { type: "invalid" }; + expect(() => PermissionSchema.parse(input)).toThrow(); + }); +}); From bb394e48ee5288e3b1a7914eb039b37d59a5545d Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:34:53 +0100 Subject: [PATCH 05/15] 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 --- packages/sdk-core/src/auth/permissions.ts | 333 ++++++++++++++++++ .../sdk-core/tests/auth/permissions.test.ts | 274 ++++++++++++++ 2 files changed, 607 insertions(+) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index be14226..a02007f 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -415,3 +415,336 @@ export type PermissionInput = z.input; * Output type for any permission (after transform). */ export type Permission = z.output; + +/** + * Fluent builder for constructing OAuth permission arrays. + * + * This class provides a convenient, type-safe way to build arrays of permissions + * using method chaining. + * + * @example Basic usage + * ```typescript + * const builder = new PermissionBuilder() + * .accountEmail('read') + * .repoWrite('app.bsky.feed.post') + * .blob(['image/*', 'video/*']); + * + * const permissions = builder.build(); + * // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post?action=create&action=update', 'blob:image/*,video/*'] + * ``` + * + * @example With transitional scopes + * ```typescript + * const builder = new PermissionBuilder() + * .transition('email') + * .transition('generic'); + * + * const scopes = builder.build(); + * // Returns: ['transition:email', 'transition:generic'] + * ``` + */ +export class PermissionBuilder { + private permissions: string[] = []; + + /** + * Add a transitional scope. + * + * @param scope - The transitional scope name ('email', 'generic', or 'chat.bsky') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.transition('email').transition('generic'); + * ``` + */ + transition(scope: "email" | "generic" | "chat.bsky"): this { + const fullScope = `transition:${scope}`; + const validated = TransitionScopeSchema.parse(fullScope); + this.permissions.push(validated); + return this; + } + + /** + * Add an account permission. + * + * @param attr - The account attribute ('email' or 'repo') + * @param action - Optional action ('read' or 'manage') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.accountEmail('read').accountRepo('manage'); + * ``` + */ + account(attr: z.infer, action?: z.infer): this { + const permission = AccountPermissionSchema.parse({ + type: "account", + attr, + action, + }); + this.permissions.push(permission); + return this; + } + + /** + * Convenience method for account:email permission. + * + * @param action - Optional action ('read' or 'manage') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.accountEmail('read'); + * ``` + */ + accountEmail(action?: z.infer): this { + return this.account("email", action); + } + + /** + * Convenience method for account:repo permission. + * + * @param action - Optional action ('read' or 'manage') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.accountRepo('manage'); + * ``` + */ + accountRepo(action?: z.infer): this { + return this.account("repo", action); + } + + /** + * Add a repository permission. + * + * @param collection - The NSID of the collection or '*' for all + * @param actions - Optional array of actions ('create', 'update', 'delete') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.repo('app.bsky.feed.post', ['create', 'update']); + * ``` + */ + repo(collection: string, actions?: z.infer[]): this { + const permission = RepoPermissionSchema.parse({ + type: "repo", + collection, + actions, + }); + this.permissions.push(permission); + return this; + } + + /** + * Convenience method for repository write permissions (create + update). + * + * @param collection - The NSID of the collection or '*' for all + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.repoWrite('app.bsky.feed.post'); + * ``` + */ + repoWrite(collection: string): this { + return this.repo(collection, ["create", "update"]); + } + + /** + * Convenience method for repository read permission (no actions). + * + * @param collection - The NSID of the collection or '*' for all + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.repoRead('app.bsky.feed.post'); + * ``` + */ + repoRead(collection: string): this { + return this.repo(collection, []); + } + + /** + * Convenience method for full repository permissions (create + update + delete). + * + * @param collection - The NSID of the collection or '*' for all + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.repoFull('app.bsky.feed.post'); + * ``` + */ + repoFull(collection: string): this { + return this.repo(collection, ["create", "update", "delete"]); + } + + /** + * Add a blob permission. + * + * @param mimeTypes - Array of MIME types or a single MIME type + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.blob(['image/*', 'video/*']); + * builder.blob('image/*'); + * ``` + */ + blob(mimeTypes: string | string[]): this { + const types = Array.isArray(mimeTypes) ? mimeTypes : [mimeTypes]; + const permission = BlobPermissionSchema.parse({ + type: "blob", + mimeTypes: types, + }); + this.permissions.push(permission); + return this; + } + + /** + * Add an RPC permission. + * + * @param lexicon - The NSID of the lexicon or '*' for all + * @param aud - The audience (DID or URL) + * @param inheritAud - Whether to inherit audience + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.rpc('com.atproto.repo.createRecord', 'did:web:api.example.com'); + * ``` + */ + rpc(lexicon: string, aud: string, inheritAud?: boolean): this { + const permission = RpcPermissionSchema.parse({ + type: "rpc", + lexicon, + aud, + inheritAud, + }); + this.permissions.push(permission); + return this; + } + + /** + * Add an identity permission. + * + * @param attr - The identity attribute ('handle' or '*') + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.identity('handle'); + * ``` + */ + identity(attr: z.infer): this { + const permission = IdentityPermissionSchema.parse({ + type: "identity", + attr, + }); + this.permissions.push(permission); + return this; + } + + /** + * Add an include permission. + * + * @param nsid - The NSID of the scope set to include + * @param aud - Optional audience restriction + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.include('com.example.authBasicFeatures'); + * ``` + */ + include(nsid: string, aud?: string): this { + const permission = IncludePermissionSchema.parse({ + type: "include", + nsid, + aud, + }); + this.permissions.push(permission); + return this; + } + + /** + * Add a custom permission string directly (bypasses validation). + * + * Use this for testing or special cases where you need to add + * a permission that doesn't fit the standard types. + * + * @param permission - The permission string + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.custom('atproto'); + * ``` + */ + custom(permission: string): this { + this.permissions.push(permission); + return this; + } + + /** + * Add the base atproto scope. + * + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.atproto(); + * ``` + */ + atproto(): this { + this.permissions.push(ATPROTO_SCOPE); + return this; + } + + /** + * Build and return the array of permission strings. + * + * @returns Array of permission strings + * + * @example + * ```typescript + * const permissions = builder.build(); + * ``` + */ + build(): string[] { + return [...this.permissions]; + } + + /** + * Clear all permissions from the builder. + * + * @returns This builder for chaining + * + * @example + * ```typescript + * builder.clear().accountEmail('read'); + * ``` + */ + clear(): this { + this.permissions = []; + return this; + } + + /** + * Get the current number of permissions. + * + * @returns The number of permissions + * + * @example + * ```typescript + * const count = builder.count(); + * ``` + */ + count(): number { + return this.permissions.length; + } +} diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index 59f7688..ac64d4f 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -16,6 +16,7 @@ import { IdentityPermissionSchema, IncludePermissionSchema, PermissionSchema, + PermissionBuilder, } from "../../src/auth/permissions.js"; describe("Permission Constants", () => { @@ -539,3 +540,276 @@ describe("PermissionSchema (Union)", () => { expect(() => PermissionSchema.parse(input)).toThrow(); }); }); + +describe("PermissionBuilder", () => { + describe("Transitional Scopes", () => { + it("should add transitional scopes", () => { + const builder = new PermissionBuilder(); + builder.transition("email").transition("generic"); + const result = builder.build(); + expect(result).toEqual(["transition:email", "transition:generic"]); + }); + + it("should validate transitional scope values", () => { + const builder = new PermissionBuilder(); + expect(() => builder.transition("invalid" as "email")).toThrow(); + }); + }); + + describe("Account Permissions", () => { + it("should add account:email without action", () => { + const builder = new PermissionBuilder(); + builder.accountEmail(); + expect(builder.build()).toEqual(["account:email"]); + }); + + it("should add account:email with read action", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read"); + expect(builder.build()).toEqual(["account:email?action=read"]); + }); + + it("should add account:email with manage action", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("manage"); + expect(builder.build()).toEqual(["account:email?action=manage"]); + }); + + it("should add account:repo without action", () => { + const builder = new PermissionBuilder(); + builder.accountRepo(); + expect(builder.build()).toEqual(["account:repo"]); + }); + + it("should add account:repo with manage action", () => { + const builder = new PermissionBuilder(); + builder.accountRepo("manage"); + expect(builder.build()).toEqual(["account:repo?action=manage"]); + }); + + it("should use generic account() method", () => { + const builder = new PermissionBuilder(); + builder.account("email", "read").account("repo", "manage"); + expect(builder.build()).toEqual(["account:email?action=read", "account:repo?action=manage"]); + }); + }); + + describe("Repository Permissions", () => { + it("should add repo read permission (no actions)", () => { + const builder = new PermissionBuilder(); + builder.repoRead("app.bsky.feed.post"); + expect(builder.build()).toEqual(["repo:app.bsky.feed.post"]); + }); + + it("should add repo write permission (create + update)", () => { + const builder = new PermissionBuilder(); + builder.repoWrite("app.bsky.feed.post"); + expect(builder.build()).toEqual(["repo:app.bsky.feed.post?action=create&action=update"]); + }); + + it("should add repo full permission (create + update + delete)", () => { + const builder = new PermissionBuilder(); + builder.repoFull("app.bsky.feed.post"); + expect(builder.build()).toEqual(["repo:app.bsky.feed.post?action=create&action=update&action=delete"]); + }); + + it("should add repo with custom actions", () => { + const builder = new PermissionBuilder(); + builder.repo("app.bsky.feed.post", ["create", "delete"]); + expect(builder.build()).toEqual(["repo:app.bsky.feed.post?action=create&action=delete"]); + }); + + it("should add repo with wildcard collection", () => { + const builder = new PermissionBuilder(); + builder.repoWrite("*"); + expect(builder.build()).toEqual(["repo:*?action=create&action=update"]); + }); + }); + + describe("Blob Permissions", () => { + it("should add blob with single MIME type", () => { + const builder = new PermissionBuilder(); + builder.blob("image/*"); + expect(builder.build()).toEqual(["blob:image/*"]); + }); + + it("should add blob with multiple MIME types", () => { + const builder = new PermissionBuilder(); + builder.blob(["image/*", "video/*"]); + expect(builder.build()).toEqual(["blob?accept=image%2F*&accept=video%2F*"]); + }); + + it("should add blob with specific MIME types", () => { + const builder = new PermissionBuilder(); + builder.blob(["image/png", "image/jpeg", "video/mp4"]); + expect(builder.build()).toEqual(["blob?accept=image%2Fpng&accept=image%2Fjpeg&accept=video%2Fmp4"]); + }); + }); + + describe("RPC Permissions", () => { + it("should add RPC with specific lexicon and wildcard aud", () => { + const builder = new PermissionBuilder(); + builder.rpc("com.atproto.repo.createRecord", "*"); + expect(builder.build()).toEqual(["rpc:com.atproto.repo.createRecord?aud=*"]); + }); + + it("should add RPC with specific lexicon and specific aud", () => { + const builder = new PermissionBuilder(); + builder.rpc("com.atproto.repo.createRecord", "did:web:api.example.com"); + expect(builder.build()).toEqual(["rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com"]); + }); + + it("should add RPC with inheritAud flag", () => { + const builder = new PermissionBuilder(); + builder.rpc("com.atproto.repo.createRecord", "did:web:api.example.com", true); + expect(builder.build()).toEqual([ + "rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com&inheritAud=true", + ]); + }); + + it("should add RPC with wildcard lexicon", () => { + const builder = new PermissionBuilder(); + builder.rpc("*", "did:web:api.example.com"); + expect(builder.build()).toEqual(["rpc:*?aud=did%3Aweb%3Aapi.example.com"]); + }); + + it("should reject both wildcards", () => { + const builder = new PermissionBuilder(); + expect(() => builder.rpc("*", "*")).toThrow(); + }); + }); + + describe("Identity Permissions", () => { + it("should add identity:handle permission", () => { + const builder = new PermissionBuilder(); + builder.identity("handle"); + expect(builder.build()).toEqual(["identity:handle"]); + }); + + it("should add identity:* permission", () => { + const builder = new PermissionBuilder(); + builder.identity("*"); + expect(builder.build()).toEqual(["identity:*"]); + }); + }); + + describe("Include Permissions", () => { + it("should add include without audience", () => { + const builder = new PermissionBuilder(); + builder.include("com.example.authBasicFeatures"); + expect(builder.build()).toEqual(["include:com.example.authBasicFeatures"]); + }); + + it("should add include with audience", () => { + const builder = new PermissionBuilder(); + builder.include("com.example.authBasicFeatures", "did:web:api.example.com"); + expect(builder.build()).toEqual(["include:com.example.authBasicFeatures?aud=did%3Aweb%3Aapi.example.com"]); + }); + }); + + describe("Custom and Special Permissions", () => { + it("should add custom permission string", () => { + const builder = new PermissionBuilder(); + builder.custom("custom:permission"); + expect(builder.build()).toEqual(["custom:permission"]); + }); + + it("should add atproto scope", () => { + const builder = new PermissionBuilder(); + builder.atproto(); + expect(builder.build()).toEqual(["atproto"]); + }); + }); + + describe("Builder Methods", () => { + it("should support method chaining", () => { + const builder = new PermissionBuilder(); + const result = builder + .accountEmail("read") + .repoWrite("app.bsky.feed.post") + .blob("image/*") + .identity("handle") + .build(); + + expect(result).toEqual([ + "account:email?action=read", + "repo:app.bsky.feed.post?action=create&action=update", + "blob:image/*", + "identity:handle", + ]); + }); + + it("should count permissions", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read").repoWrite("app.bsky.feed.post"); + expect(builder.count()).toBe(2); + }); + + it("should clear permissions", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read").repoWrite("app.bsky.feed.post"); + expect(builder.count()).toBe(2); + builder.clear(); + expect(builder.count()).toBe(0); + expect(builder.build()).toEqual([]); + }); + + it("should return a copy of permissions array (immutable)", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read"); + const result1 = builder.build(); + const result2 = builder.build(); + + expect(result1).toEqual(result2); + expect(result1).not.toBe(result2); // Different array instances + }); + + it("should allow reuse after clear", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read"); + expect(builder.build()).toEqual(["account:email?action=read"]); + + builder.clear().repoWrite("app.bsky.feed.post"); + expect(builder.build()).toEqual(["repo:app.bsky.feed.post?action=create&action=update"]); + }); + }); + + describe("Complex Scenarios", () => { + it("should build email access scope set", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read").repoRead("app.bsky.actor.profile"); + + expect(builder.build()).toEqual(["account:email?action=read", "repo:app.bsky.actor.profile"]); + }); + + it("should build posting app scope set", () => { + const builder = new PermissionBuilder(); + builder + .repoWrite("app.bsky.feed.post") + .repoWrite("app.bsky.feed.like") + .repoWrite("app.bsky.feed.repost") + .blob(["image/*", "video/*"]); + + expect(builder.build()).toEqual([ + "repo:app.bsky.feed.post?action=create&action=update", + "repo:app.bsky.feed.like?action=create&action=update", + "repo:app.bsky.feed.repost?action=create&action=update", + "blob?accept=image%2F*&accept=video%2F*", + ]); + }); + + it("should build moderation app scope set", () => { + const builder = new PermissionBuilder(); + builder.repoFull("*").identity("handle"); + + expect(builder.build()).toEqual(["repo:*?action=create&action=update&action=delete", "identity:handle"]); + }); + + it("should combine transitional and granular scopes", () => { + const builder = new PermissionBuilder(); + builder.transition("email").accountEmail("read").repoRead("app.bsky.actor.profile"); + + expect(builder.build()).toEqual(["transition:email", "account:email?action=read", "repo:app.bsky.actor.profile"]); + }); + }); +}); From 68ed479ae4eea46c1cf84ebfb5526a523a6413b1 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:38:54 +0100 Subject: [PATCH 06/15] 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 --- packages/sdk-core/src/auth/permissions.ts | 178 ++++++++++ .../sdk-core/tests/auth/permissions.test.ts | 308 ++++++++++++++++++ 2 files changed, 486 insertions(+) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index a02007f..486728d 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -748,3 +748,181 @@ export class PermissionBuilder { return this.permissions.length; } } + +/** + * Build a scope string from an array of permissions. + * + * This is a convenience function that joins permission strings with spaces, + * which is the standard format for OAuth scope parameters. + * + * @param permissions - Array of permission strings + * @returns Space-separated scope string + * + * @example + * ```typescript + * const permissions = ['account:email?action=read', 'repo:app.bsky.feed.post']; + * const scope = buildScope(permissions); + * // Returns: "account:email?action=read repo:app.bsky.feed.post" + * ``` + */ +export function buildScope(permissions: string[]): string { + return permissions.join(" "); +} + +/** + * Parse a scope string into an array of individual permissions. + * + * This splits a space-separated scope string into individual permission strings. + * + * @param scope - Space-separated scope string + * @returns Array of permission strings + * + * @example + * ```typescript + * const scope = "account:email?action=read repo:app.bsky.feed.post"; + * const permissions = parseScope(scope); + * // Returns: ['account:email?action=read', 'repo:app.bsky.feed.post'] + * ``` + */ +export function parseScope(scope: string): string[] { + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** + * Check if a scope string contains a specific permission. + * + * This function performs exact string matching. For more advanced + * permission checking (e.g., wildcard matching), you'll need to + * implement custom logic. + * + * @param scope - Space-separated scope string + * @param permission - The permission to check for + * @returns True if the scope contains the permission + * + * @example + * ```typescript + * const scope = "account:email?action=read repo:app.bsky.feed.post"; + * hasPermission(scope, "account:email?action=read"); // true + * hasPermission(scope, "account:repo"); // false + * ``` + */ +export function hasPermission(scope: string, permission: string): boolean { + const permissions = parseScope(scope); + return permissions.includes(permission); +} + +/** + * Check if a scope string contains all of the specified permissions. + * + * @param scope - Space-separated scope string + * @param requiredPermissions - Array of permissions to check for + * @returns True if the scope contains all required permissions + * + * @example + * ```typescript + * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + * hasAllPermissions(scope, ["account:email?action=read", "blob:image/*"]); // true + * hasAllPermissions(scope, ["account:email?action=read", "account:repo"]); // false + * ``` + */ +export function hasAllPermissions(scope: string, requiredPermissions: string[]): boolean { + const permissions = parseScope(scope); + return requiredPermissions.every((required) => permissions.includes(required)); +} + +/** + * Check if a scope string contains any of the specified permissions. + * + * @param scope - Space-separated scope string + * @param checkPermissions - Array of permissions to check for + * @returns True if the scope contains at least one of the permissions + * + * @example + * ```typescript + * const scope = "account:email?action=read repo:app.bsky.feed.post"; + * hasAnyPermission(scope, ["account:email?action=read", "account:repo"]); // true + * hasAnyPermission(scope, ["account:repo", "identity:handle"]); // false + * ``` + */ +export function hasAnyPermission(scope: string, checkPermissions: string[]): boolean { + const permissions = parseScope(scope); + return checkPermissions.some((check) => permissions.includes(check)); +} + +/** + * Merge multiple scope strings into a single scope string with deduplicated permissions. + * + * @param scopes - Array of scope strings to merge + * @returns Merged scope string with unique permissions + * + * @example + * ```typescript + * const scope1 = "account:email?action=read repo:app.bsky.feed.post"; + * const scope2 = "repo:app.bsky.feed.post blob:image/*"; + * const merged = mergeScopes([scope1, scope2]); + * // Returns: "account:email?action=read repo:app.bsky.feed.post blob:image/*" + * ``` + */ +export function mergeScopes(scopes: string[]): string { + const allPermissions = scopes.flatMap(parseScope); + const uniquePermissions = [...new Set(allPermissions)]; + return buildScope(uniquePermissions); +} + +/** + * Remove specific permissions from a scope string. + * + * @param scope - Space-separated scope string + * @param permissionsToRemove - Array of permissions to remove + * @returns New scope string without the specified permissions + * + * @example + * ```typescript + * const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + * const filtered = removePermissions(scope, ["blob:image/*"]); + * // Returns: "account:email?action=read repo:app.bsky.feed.post" + * ``` + */ +export function removePermissions(scope: string, permissionsToRemove: string[]): string { + const permissions = parseScope(scope); + const filtered = permissions.filter((p) => !permissionsToRemove.includes(p)); + return buildScope(filtered); +} + +/** + * Validate that all permissions in a scope string are well-formed. + * + * This checks that each permission matches expected patterns for transitional + * or granular permissions. It does NOT validate against the full Zod schemas. + * + * @param scope - Space-separated scope string + * @returns Object with isValid flag and array of invalid permissions + * + * @example + * ```typescript + * const scope = "account:email?action=read invalid:permission"; + * const result = validateScope(scope); + * // Returns: { isValid: false, invalidPermissions: ['invalid:permission'] } + * ``` + */ +export function validateScope(scope: string): { + isValid: boolean; + invalidPermissions: string[]; +} { + const permissions = parseScope(scope); + const invalidPermissions: string[] = []; + + // Pattern for valid permission prefixes + const validPrefixes = /^(atproto|transition:|account:|repo:|blob:?|rpc:|identity:|include:)/; + + for (const permission of permissions) { + if (!validPrefixes.test(permission)) { + invalidPermissions.push(permission); + } + } + + return { + isValid: invalidPermissions.length === 0, + invalidPermissions, + }; +} diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index ac64d4f..bbd09a9 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -17,6 +17,14 @@ import { IncludePermissionSchema, PermissionSchema, PermissionBuilder, + buildScope, + parseScope, + hasPermission, + hasAllPermissions, + hasAnyPermission, + mergeScopes, + removePermissions, + validateScope, } from "../../src/auth/permissions.js"; describe("Permission Constants", () => { @@ -813,3 +821,303 @@ describe("PermissionBuilder", () => { }); }); }); + +describe("Scope Utility Functions", () => { + describe("buildScope", () => { + it("should join permissions with spaces", () => { + const permissions = ["account:email?action=read", "repo:app.bsky.feed.post"]; + const scope = buildScope(permissions); + expect(scope).toBe("account:email?action=read repo:app.bsky.feed.post"); + }); + + it("should handle single permission", () => { + const permissions = ["account:email"]; + const scope = buildScope(permissions); + expect(scope).toBe("account:email"); + }); + + it("should handle empty array", () => { + const permissions: string[] = []; + const scope = buildScope(permissions); + expect(scope).toBe(""); + }); + + it("should handle complex permissions with query params", () => { + const permissions = [ + "account:email?action=read", + "repo:app.bsky.feed.post?action=create&action=update", + "blob?accept=image%2F*&accept=video%2F*", + ]; + const scope = buildScope(permissions); + expect(scope).toBe( + "account:email?action=read repo:app.bsky.feed.post?action=create&action=update blob?accept=image%2F*&accept=video%2F*", + ); + }); + }); + + describe("parseScope", () => { + it("should split scope string by spaces", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post"; + const permissions = parseScope(scope); + expect(permissions).toEqual(["account:email?action=read", "repo:app.bsky.feed.post"]); + }); + + it("should handle single permission", () => { + const scope = "account:email"; + const permissions = parseScope(scope); + expect(permissions).toEqual(["account:email"]); + }); + + it("should handle empty string", () => { + const scope = ""; + const permissions = parseScope(scope); + expect(permissions).toEqual([]); + }); + + it("should handle multiple spaces between permissions", () => { + const scope = "account:email repo:app.bsky.feed.post"; + const permissions = parseScope(scope); + expect(permissions).toEqual(["account:email", "repo:app.bsky.feed.post"]); + }); + + it("should trim whitespace", () => { + const scope = " account:email repo:app.bsky.feed.post "; + const permissions = parseScope(scope); + expect(permissions).toEqual(["account:email", "repo:app.bsky.feed.post"]); + }); + }); + + describe("hasPermission", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + + it("should return true when permission exists", () => { + expect(hasPermission(scope, "account:email?action=read")).toBe(true); + expect(hasPermission(scope, "repo:app.bsky.feed.post")).toBe(true); + expect(hasPermission(scope, "blob:image/*")).toBe(true); + }); + + it("should return false when permission does not exist", () => { + expect(hasPermission(scope, "account:repo")).toBe(false); + expect(hasPermission(scope, "identity:handle")).toBe(false); + }); + + it("should perform exact matching", () => { + expect(hasPermission(scope, "account:email")).toBe(false); + expect(hasPermission(scope, "blob:video/*")).toBe(false); + }); + + it("should handle empty scope", () => { + expect(hasPermission("", "account:email")).toBe(false); + }); + }); + + describe("hasAllPermissions", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + + it("should return true when all permissions exist", () => { + expect(hasAllPermissions(scope, ["account:email?action=read", "blob:image/*"])).toBe(true); + expect(hasAllPermissions(scope, ["account:email?action=read", "repo:app.bsky.feed.post"])).toBe(true); + }); + + it("should return false when any permission is missing", () => { + expect(hasAllPermissions(scope, ["account:email?action=read", "account:repo"])).toBe(false); + expect(hasAllPermissions(scope, ["identity:handle"])).toBe(false); + }); + + it("should handle empty required permissions array", () => { + expect(hasAllPermissions(scope, [])).toBe(true); + }); + + it("should handle empty scope", () => { + expect(hasAllPermissions("", ["account:email"])).toBe(false); + }); + }); + + describe("hasAnyPermission", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post"; + + it("should return true when at least one permission exists", () => { + expect(hasAnyPermission(scope, ["account:email?action=read", "account:repo"])).toBe(true); + expect(hasAnyPermission(scope, ["identity:handle", "repo:app.bsky.feed.post"])).toBe(true); + }); + + it("should return false when no permissions exist", () => { + expect(hasAnyPermission(scope, ["account:repo", "identity:handle"])).toBe(false); + expect(hasAnyPermission(scope, ["blob:image/*"])).toBe(false); + }); + + it("should handle empty check permissions array", () => { + expect(hasAnyPermission(scope, [])).toBe(false); + }); + + it("should handle empty scope", () => { + expect(hasAnyPermission("", ["account:email"])).toBe(false); + }); + }); + + describe("mergeScopes", () => { + it("should merge multiple scopes and deduplicate", () => { + const scope1 = "account:email?action=read repo:app.bsky.feed.post"; + const scope2 = "repo:app.bsky.feed.post blob:image/*"; + const merged = mergeScopes([scope1, scope2]); + + const permissions = parseScope(merged); + expect(permissions).toHaveLength(3); + expect(permissions).toContain("account:email?action=read"); + expect(permissions).toContain("repo:app.bsky.feed.post"); + expect(permissions).toContain("blob:image/*"); + }); + + it("should handle empty scopes array", () => { + const merged = mergeScopes([]); + expect(merged).toBe(""); + }); + + it("should handle single scope", () => { + const scope = "account:email repo:app.bsky.feed.post"; + const merged = mergeScopes([scope]); + expect(merged).toBe(scope); + }); + + it("should deduplicate permissions across multiple scopes", () => { + const scope1 = "account:email"; + const scope2 = "account:email repo:app.bsky.feed.post"; + const scope3 = "account:email"; + const merged = mergeScopes([scope1, scope2, scope3]); + + const permissions = parseScope(merged); + expect(permissions).toHaveLength(2); + expect(permissions).toContain("account:email"); + expect(permissions).toContain("repo:app.bsky.feed.post"); + }); + }); + + describe("removePermissions", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + + it("should remove specified permissions", () => { + const filtered = removePermissions(scope, ["blob:image/*"]); + expect(filtered).toBe("account:email?action=read repo:app.bsky.feed.post"); + }); + + it("should remove multiple permissions", () => { + const filtered = removePermissions(scope, ["account:email?action=read", "blob:image/*"]); + expect(filtered).toBe("repo:app.bsky.feed.post"); + }); + + it("should handle removing non-existent permissions", () => { + const filtered = removePermissions(scope, ["account:repo"]); + expect(filtered).toBe(scope); + }); + + it("should handle empty removal array", () => { + const filtered = removePermissions(scope, []); + expect(filtered).toBe(scope); + }); + + it("should handle removing all permissions", () => { + const filtered = removePermissions(scope, [ + "account:email?action=read", + "repo:app.bsky.feed.post", + "blob:image/*", + ]); + expect(filtered).toBe(""); + }); + }); + + describe("validateScope", () => { + it("should validate well-formed scopes", () => { + const scope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + }); + + it("should validate transitional scopes", () => { + const scope = "transition:email transition:generic"; + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + }); + + it("should validate atproto scope", () => { + const scope = "atproto"; + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + }); + + it("should detect invalid permission prefixes", () => { + const scope = "account:email invalid:permission another:bad"; + const result = validateScope(scope); + expect(result.isValid).toBe(false); + expect(result.invalidPermissions).toEqual(["invalid:permission", "another:bad"]); + }); + + it("should detect malformed permissions", () => { + const scope = "account:email malformed"; + const result = validateScope(scope); + expect(result.isValid).toBe(false); + expect(result.invalidPermissions).toEqual(["malformed"]); + }); + + it("should handle empty scope", () => { + const scope = ""; + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + }); + + it("should validate blob permissions with and without colon", () => { + const scope1 = "blob:image/*"; + const scope2 = "blob?accept=image%2F*"; + expect(validateScope(scope1).isValid).toBe(true); + expect(validateScope(scope2).isValid).toBe(true); + }); + + it("should validate all permission types", () => { + const scope = + "atproto transition:email account:email repo:* blob:image/* rpc:* identity:handle include:com.example.scope"; + const result = validateScope(scope); + expect(result.isValid).toBe(true); + expect(result.invalidPermissions).toEqual([]); + }); + }); + + describe("Integration: Builder with Utilities", () => { + it("should work with PermissionBuilder output", () => { + const builder = new PermissionBuilder(); + builder.accountEmail("read").repoWrite("app.bsky.feed.post").blob("image/*"); + + const permissions = builder.build(); + const scope = buildScope(permissions); + + expect(hasPermission(scope, "account:email?action=read")).toBe(true); + expect(hasPermission(scope, "repo:app.bsky.feed.post?action=create&action=update")).toBe(true); + expect(hasPermission(scope, "blob:image/*")).toBe(true); + }); + + it("should parse and rebuild scope correctly", () => { + const originalScope = "account:email?action=read repo:app.bsky.feed.post blob:image/*"; + const parsed = parseScope(originalScope); + const rebuilt = buildScope(parsed); + + expect(rebuilt).toBe(originalScope); + }); + + it("should merge builder outputs", () => { + const builder1 = new PermissionBuilder(); + builder1.accountEmail("read"); + + const builder2 = new PermissionBuilder(); + builder2.repoWrite("app.bsky.feed.post"); + + const scope1 = buildScope(builder1.build()); + const scope2 = buildScope(builder2.build()); + const merged = mergeScopes([scope1, scope2]); + + expect(hasPermission(merged, "account:email?action=read")).toBe(true); + expect(hasPermission(merged, "repo:app.bsky.feed.post?action=create&action=update")).toBe(true); + }); + }); +}); From 555cf46d5d828dfa3aa3ad62fbc2c9db04ed952c Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:41:32 +0100 Subject: [PATCH 07/15] feat(permissions): add scope presets for common use cases - Add ScopePresets object with 14 pre-built permission sets - Include EMAIL_READ, PROFILE_READ/WRITE, POST_WRITE presets - Add SOCIAL_WRITE for likes/reposts/follows - Add MEDIA_UPLOAD and IMAGE_UPLOAD presets - Add POSTING_APP preset combining posts and media - Add READ_ONLY and FULL_ACCESS presets - Include EMAIL_AND_PROFILE combo preset - Add transitional scope presets for backward compatibility - Add 18 comprehensive tests for all presets - All 167 tests passing --- packages/sdk-core/src/auth/permissions.ts | 196 ++++++++++++++++++ .../sdk-core/tests/auth/permissions.test.ts | 107 ++++++++++ 2 files changed, 303 insertions(+) diff --git a/packages/sdk-core/src/auth/permissions.ts b/packages/sdk-core/src/auth/permissions.ts index 486728d..062447b 100644 --- a/packages/sdk-core/src/auth/permissions.ts +++ b/packages/sdk-core/src/auth/permissions.ts @@ -769,6 +769,202 @@ export function buildScope(permissions: string[]): string { return permissions.join(" "); } +/** + * Pre-built scope presets for common use cases. + * + * These presets provide ready-to-use permission sets for typical application scenarios. + */ +export const ScopePresets = { + /** + * Email access scope - allows reading user's email address. + * + * Includes: + * - account:email?action=read + * + * @example + * ```typescript + * const scope = ScopePresets.EMAIL_READ; + * // Use in OAuth flow to request email access + * ``` + */ + EMAIL_READ: buildScope(new PermissionBuilder().accountEmail("read").build()), + + /** + * Profile read scope - allows reading user's profile. + * + * Includes: + * - repo:app.bsky.actor.profile (read-only) + * + * @example + * ```typescript + * const scope = ScopePresets.PROFILE_READ; + * ``` + */ + PROFILE_READ: buildScope(new PermissionBuilder().repoRead("app.bsky.actor.profile").build()), + + /** + * Profile write scope - allows updating user's profile. + * + * Includes: + * - repo:app.bsky.actor.profile (create + update) + * + * @example + * ```typescript + * const scope = ScopePresets.PROFILE_WRITE; + * ``` + */ + PROFILE_WRITE: buildScope(new PermissionBuilder().repoWrite("app.bsky.actor.profile").build()), + + /** + * Post creation scope - allows creating and updating posts. + * + * Includes: + * - repo:app.bsky.feed.post (create + update) + * + * @example + * ```typescript + * const scope = ScopePresets.POST_WRITE; + * ``` + */ + POST_WRITE: buildScope(new PermissionBuilder().repoWrite("app.bsky.feed.post").build()), + + /** + * Social interactions scope - allows liking, reposting, and following. + * + * Includes: + * - repo:app.bsky.feed.like (create + update) + * - repo:app.bsky.feed.repost (create + update) + * - repo:app.bsky.graph.follow (create + update) + * + * @example + * ```typescript + * const scope = ScopePresets.SOCIAL_WRITE; + * ``` + */ + SOCIAL_WRITE: buildScope( + new PermissionBuilder() + .repoWrite("app.bsky.feed.like") + .repoWrite("app.bsky.feed.repost") + .repoWrite("app.bsky.graph.follow") + .build(), + ), + + /** + * Media upload scope - allows uploading images and videos. + * + * Includes: + * - blob permissions for image/* and video/* + * + * @example + * ```typescript + * const scope = ScopePresets.MEDIA_UPLOAD; + * ``` + */ + MEDIA_UPLOAD: buildScope(new PermissionBuilder().blob(["image/*", "video/*"]).build()), + + /** + * Image upload only scope - allows uploading images. + * + * Includes: + * - blob:image/* + * + * @example + * ```typescript + * const scope = ScopePresets.IMAGE_UPLOAD; + * ``` + */ + IMAGE_UPLOAD: buildScope(new PermissionBuilder().blob("image/*").build()), + + /** + * Posting app scope - full posting capabilities including media. + * + * Includes: + * - repo:app.bsky.feed.post (create + update) + * - repo:app.bsky.feed.like (create + update) + * - repo:app.bsky.feed.repost (create + update) + * - blob permissions for image/* and video/* + * + * @example + * ```typescript + * const scope = ScopePresets.POSTING_APP; + * ``` + */ + POSTING_APP: buildScope( + new PermissionBuilder() + .repoWrite("app.bsky.feed.post") + .repoWrite("app.bsky.feed.like") + .repoWrite("app.bsky.feed.repost") + .blob(["image/*", "video/*"]) + .build(), + ), + + /** + * Read-only app scope - allows reading all repository data. + * + * Includes: + * - repo:* (read-only, no actions) + * + * @example + * ```typescript + * const scope = ScopePresets.READ_ONLY; + * ``` + */ + READ_ONLY: buildScope(new PermissionBuilder().repoRead("*").build()), + + /** + * Full access scope - allows all repository operations. + * + * Includes: + * - repo:* (create + update + delete) + * + * @example + * ```typescript + * const scope = ScopePresets.FULL_ACCESS; + * ``` + */ + FULL_ACCESS: buildScope(new PermissionBuilder().repoFull("*").build()), + + /** + * Email + Profile scope - common combination for user identification. + * + * Includes: + * - account:email?action=read + * - repo:app.bsky.actor.profile (read-only) + * + * @example + * ```typescript + * const scope = ScopePresets.EMAIL_AND_PROFILE; + * ``` + */ + EMAIL_AND_PROFILE: buildScope( + new PermissionBuilder().accountEmail("read").repoRead("app.bsky.actor.profile").build(), + ), + + /** + * Transitional email scope (legacy). + * + * Uses the transitional scope format for backward compatibility. + * + * @example + * ```typescript + * const scope = ScopePresets.TRANSITION_EMAIL; + * ``` + */ + TRANSITION_EMAIL: buildScope(new PermissionBuilder().transition("email").build()), + + /** + * Transitional generic scope (legacy). + * + * Uses the transitional scope format for backward compatibility. + * + * @example + * ```typescript + * const scope = ScopePresets.TRANSITION_GENERIC; + * ``` + */ + TRANSITION_GENERIC: buildScope(new PermissionBuilder().transition("generic").build()), +} as const; + /** * Parse a scope string into an array of individual permissions. * diff --git a/packages/sdk-core/tests/auth/permissions.test.ts b/packages/sdk-core/tests/auth/permissions.test.ts index bbd09a9..a3f50ed 100644 --- a/packages/sdk-core/tests/auth/permissions.test.ts +++ b/packages/sdk-core/tests/auth/permissions.test.ts @@ -17,6 +17,7 @@ import { IncludePermissionSchema, PermissionSchema, PermissionBuilder, + ScopePresets, buildScope, parseScope, hasPermission, @@ -1121,3 +1122,109 @@ describe("Scope Utility Functions", () => { }); }); }); + +describe("ScopePresets", () => { + describe("Basic Presets", () => { + it("should provide EMAIL_READ preset", () => { + expect(ScopePresets.EMAIL_READ).toBe("account:email?action=read"); + }); + + it("should provide PROFILE_READ preset", () => { + expect(ScopePresets.PROFILE_READ).toBe("repo:app.bsky.actor.profile"); + }); + + it("should provide PROFILE_WRITE preset", () => { + expect(ScopePresets.PROFILE_WRITE).toBe("repo:app.bsky.actor.profile?action=create&action=update"); + }); + + it("should provide POST_WRITE preset", () => { + expect(ScopePresets.POST_WRITE).toBe("repo:app.bsky.feed.post?action=create&action=update"); + }); + + it("should provide IMAGE_UPLOAD preset", () => { + expect(ScopePresets.IMAGE_UPLOAD).toBe("blob:image/*"); + }); + + it("should provide MEDIA_UPLOAD preset", () => { + expect(ScopePresets.MEDIA_UPLOAD).toBe("blob?accept=image%2F*&accept=video%2F*"); + }); + }); + + describe("Complex Presets", () => { + it("should provide SOCIAL_WRITE preset", () => { + const permissions = parseScope(ScopePresets.SOCIAL_WRITE); + expect(permissions).toHaveLength(3); + expect(permissions).toContain("repo:app.bsky.feed.like?action=create&action=update"); + expect(permissions).toContain("repo:app.bsky.feed.repost?action=create&action=update"); + expect(permissions).toContain("repo:app.bsky.graph.follow?action=create&action=update"); + }); + + it("should provide POSTING_APP preset", () => { + const permissions = parseScope(ScopePresets.POSTING_APP); + expect(permissions).toHaveLength(4); + expect(permissions).toContain("repo:app.bsky.feed.post?action=create&action=update"); + expect(permissions).toContain("repo:app.bsky.feed.like?action=create&action=update"); + expect(permissions).toContain("repo:app.bsky.feed.repost?action=create&action=update"); + expect(permissions).toContain("blob?accept=image%2F*&accept=video%2F*"); + }); + + it("should provide EMAIL_AND_PROFILE preset", () => { + const permissions = parseScope(ScopePresets.EMAIL_AND_PROFILE); + expect(permissions).toHaveLength(2); + expect(permissions).toContain("account:email?action=read"); + expect(permissions).toContain("repo:app.bsky.actor.profile"); + }); + }); + + describe("Access Level Presets", () => { + it("should provide READ_ONLY preset", () => { + expect(ScopePresets.READ_ONLY).toBe("repo:*"); + }); + + it("should provide FULL_ACCESS preset", () => { + expect(ScopePresets.FULL_ACCESS).toBe("repo:*?action=create&action=update&action=delete"); + }); + }); + + describe("Transitional Presets", () => { + it("should provide TRANSITION_EMAIL preset", () => { + expect(ScopePresets.TRANSITION_EMAIL).toBe("transition:email"); + }); + + it("should provide TRANSITION_GENERIC preset", () => { + expect(ScopePresets.TRANSITION_GENERIC).toBe("transition:generic"); + }); + }); + + describe("Preset Usage", () => { + it("should work with parseScope", () => { + const permissions = parseScope(ScopePresets.POSTING_APP); + expect(permissions.length).toBeGreaterThan(0); + }); + + it("should work with hasPermission", () => { + expect(hasPermission(ScopePresets.EMAIL_READ, "account:email?action=read")).toBe(true); + expect(hasPermission(ScopePresets.EMAIL_READ, "account:repo")).toBe(false); + }); + + it("should work with mergeScopes", () => { + const merged = mergeScopes([ScopePresets.EMAIL_READ, ScopePresets.POST_WRITE]); + expect(hasPermission(merged, "account:email?action=read")).toBe(true); + expect(hasPermission(merged, "repo:app.bsky.feed.post?action=create&action=update")).toBe(true); + }); + + it("should be valid scopes", () => { + expect(validateScope(ScopePresets.EMAIL_READ).isValid).toBe(true); + expect(validateScope(ScopePresets.POSTING_APP).isValid).toBe(true); + expect(validateScope(ScopePresets.FULL_ACCESS).isValid).toBe(true); + expect(validateScope(ScopePresets.TRANSITION_EMAIL).isValid).toBe(true); + }); + }); + + describe("Preset Immutability", () => { + it("should be readonly (const assertion)", () => { + expect(typeof ScopePresets.EMAIL_READ).toBe("string"); + expect(typeof ScopePresets.POSTING_APP).toBe("string"); + }); + }); +}); From 73e721efcc609339b9961951a584e02bbc419906 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 10:45:22 +0100 Subject: [PATCH 08/15] feat(permissions): export OAuth permissions system from package - Export all permission schemas and types - Export PermissionBuilder class - Export ScopePresets object - Export all utility functions (buildScope, parseScope, etc.) - Export TypeScript types for type inference - Build successful with new exports --- packages/sdk-core/src/index.ts | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index a933159..022ab51 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -108,3 +108,55 @@ export { InMemoryStateStore } from "./storage/InMemoryStateStore.js"; export type { DID, Organization, Collaborator, CollaboratorPermissions } from "./core/types.js"; export { OrganizationSchema, CollaboratorSchema, CollaboratorPermissionsSchema } from "./core/types.js"; export { ATProtoSDKConfigSchema, OAuthConfigSchema, ServerConfigSchema, TimeoutConfigSchema } from "./core/config.js"; + +// OAuth Permissions System +export { + // Constants + ATPROTO_SCOPE, + TRANSITION_SCOPES, + // Schemas + TransitionScopeSchema, + AccountAttrSchema, + AccountActionSchema, + RepoActionSchema, + IdentityAttrSchema, + MimeTypeSchema, + NsidSchema, + AccountPermissionSchema, + RepoPermissionSchema, + BlobPermissionSchema, + RpcPermissionSchema, + IdentityPermissionSchema, + IncludePermissionSchema, + PermissionSchema, + // Builder + PermissionBuilder, + // Presets + ScopePresets, + // Utilities + buildScope, + parseScope, + hasPermission, + hasAllPermissions, + hasAnyPermission, + mergeScopes, + removePermissions, + validateScope, +} from "./auth/permissions.js"; + +// OAuth Permission Types +export type { + TransitionScope, + AccountAttr, + AccountAction, + RepoAction, + IdentityAttr, + AccountPermissionInput, + RepoPermissionInput, + BlobPermissionInput, + RpcPermissionInput, + IdentityPermissionInput, + IncludePermissionInput, + PermissionInput, + Permission, +} from "./auth/permissions.js"; From c1128f11c7487ccda0d88562a6f8d107e58969b3 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 12:26:44 +0100 Subject: [PATCH 09/15] docs(readme): add OAuth permissions section - Add concise OAuth permissions explainer - Document PermissionBuilder and ScopePresets usage - List all available presets with descriptions - Include code examples for custom scope building - Link to detailed documentation --- packages/sdk-core/README.md | 48 +++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/sdk-core/README.md b/packages/sdk-core/README.md index 5febb27..b91b3cc 100644 --- a/packages/sdk-core/README.md +++ b/packages/sdk-core/README.md @@ -141,9 +141,11 @@ const orgRepo = sdsRepo.repo(organizationDid); // Teammate can now access orgRepo and create hypercerts ``` -### 2. Authentication +### 2. Authentication & OAuth Permissions -The SDK uses OAuth 2.0 for authentication with support for both PDS (Personal Data Server) and SDS (Shared Data Server). +The SDK uses OAuth 2.0 for authentication with granular permission control. + +#### Basic Authentication ```typescript // First-time user authentication @@ -164,6 +166,48 @@ const session = await sdk.restoreSession("did:plc:user123"); const repo = sdk.getRepository(session); ``` +#### OAuth Scopes & Permissions + +Control exactly what your app can access using type-safe permission builders: + +```typescript +import { PermissionBuilder, ScopePresets, buildScope } from '@hypercerts-org/sdk-core'; + +// Use ready-made presets +const scope = ScopePresets.EMAIL_AND_PROFILE; // Request email + profile access +const scope = ScopePresets.POSTING_APP; // Full posting capabilities + +// Or build custom permissions +const scope = buildScope( + new PermissionBuilder() + .accountEmail('read') // Read user's email + .repoWrite('app.bsky.feed.post') // Create/update posts + .blob(['image/*', 'video/*']) // Upload media + .build() +); + +// Use in OAuth configuration +const sdk = createATProtoSDK({ + oauth: { + clientId: 'your-client-id', + redirectUri: 'https://your-app.com/callback', + scope: scope, // Your custom scope + // ... other config + } +}); +``` + +**Available Presets:** +- `EMAIL_READ` - User's email address +- `PROFILE_READ` / `PROFILE_WRITE` - Profile access +- `POST_WRITE` - Create posts +- `SOCIAL_WRITE` - Likes, reposts, follows +- `MEDIA_UPLOAD` - Image and video uploads +- `POSTING_APP` - Full posting with media +- `EMAIL_AND_PROFILE` - Common combination + +See [OAuth Permissions Documentation](./docs/implementations/atproto_oauth_scopes.md) for detailed usage. + ### 3. Working with Hypercerts #### Creating a Hypercert From 8bada43accc12acffb20d651c0fbc852252dc397 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 12:31:06 +0100 Subject: [PATCH 10/15] feat(oauth): enhance OAuth config schema with permissions documentation - Add comprehensive JSDoc examples for scope field - Document usage with PermissionBuilder and ScopePresets - Add validation: scope must be non-empty string - Link to atproto permission specs - Build and lint successful --- packages/sdk-core/src/core/config.ts | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/sdk-core/src/core/config.ts b/packages/sdk-core/src/core/config.ts index 4427889..6de0269 100644 --- a/packages/sdk-core/src/core/config.ts +++ b/packages/sdk-core/src/core/config.ts @@ -25,9 +25,34 @@ export const OAuthConfigSchema = z.object({ /** * OAuth scopes to request, space-separated. - * Common scopes: "atproto", "transition:generic" + * + * Can be a string of space-separated permissions or use the permission system: + * + * @example Using presets + * ```typescript + * import { ScopePresets } from '@hypercerts-org/sdk-core'; + * scope: ScopePresets.EMAIL_AND_PROFILE + * ``` + * + * @example Building custom scopes + * ```typescript + * import { PermissionBuilder, buildScope } from '@hypercerts-org/sdk-core'; + * scope: buildScope( + * new PermissionBuilder() + * .accountEmail('read') + * .repoWrite('app.bsky.feed.post') + * .build() + * ) + * ``` + * + * @example Legacy scopes + * ```typescript + * scope: "atproto transition:generic" + * ``` + * + * @see https://atproto.com/specs/permission for permission details */ - scope: z.string(), + scope: z.string().min(1, "OAuth scope is required"), /** * URL to your public JWKS (JSON Web Key Set) endpoint. From 14ebd33fc6e5ee225f80eeaf548f6e8941f68a97 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 12:32:34 +0100 Subject: [PATCH 11/15] feat(oauth): enhance AuthorizeOptions with permissions examples - Add comprehensive JSDoc examples for scope parameter - Document usage with ScopePresets and PermissionBuilder - Include preset examples (EMAIL_AND_PROFILE, POSTING_APP) - Include custom scope building example - Preserve legacy scope examples - Build and lint successful --- packages/sdk-core/src/core/SDK.ts | 40 ++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/sdk-core/src/core/SDK.ts b/packages/sdk-core/src/core/SDK.ts index f4fcd25..e64de67 100644 --- a/packages/sdk-core/src/core/SDK.ts +++ b/packages/sdk-core/src/core/SDK.ts @@ -17,13 +17,47 @@ export interface AuthorizeOptions { * OAuth scope string to request specific permissions. * Overrides the default scope configured in {@link ATProtoSDKConfig.oauth.scope}. * - * @example + * Can use the permission system for type-safe scope building. + * + * @example Using presets + * ```typescript + * import { ScopePresets } from '@hypercerts-org/sdk-core'; + * + * // Request email and profile access + * await sdk.authorize("user.bsky.social", { + * scope: ScopePresets.EMAIL_AND_PROFILE + * }); + * + * // Request full posting capabilities + * await sdk.authorize("user.bsky.social", { + * scope: ScopePresets.POSTING_APP + * }); + * ``` + * + * @example Building custom scopes + * ```typescript + * import { PermissionBuilder, buildScope } from '@hypercerts-org/sdk-core'; + * + * const scope = buildScope( + * new PermissionBuilder() + * .accountEmail('read') + * .repoWrite('app.bsky.feed.post') + * .blob(['image/*']) + * .build() + * ); + * + * await sdk.authorize("user.bsky.social", { scope }); + * ``` + * + * @example Legacy scopes * ```typescript * // Request read-only access * await sdk.authorize("user.bsky.social", { scope: "atproto" }); * - * // Request full access (default typically includes transition:generic) - * await sdk.authorize("user.bsky.social", { scope: "atproto transition:generic" }); + * // Request full access (legacy) + * await sdk.authorize("user.bsky.social", { + * scope: "atproto transition:generic" + * }); * ``` */ scope?: string; From 43a7b75e497b69ba3e35c1f7a0be4619953d43a6 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 12:38:08 +0100 Subject: [PATCH 12/15] feat(oauth): add enhanced scope validation with migration suggestions --- packages/sdk-core/src/auth/OAuthClient.ts | 86 +++++++++++- .../sdk-core/tests/auth/OAuthClient.test.ts | 130 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/packages/sdk-core/src/auth/OAuthClient.ts b/packages/sdk-core/src/auth/OAuthClient.ts index 6e82614..1dbfb5f 100644 --- a/packages/sdk-core/src/auth/OAuthClient.ts +++ b/packages/sdk-core/src/auth/OAuthClient.ts @@ -4,6 +4,7 @@ import type { ATProtoSDKConfig } from "../core/config.js"; import { AuthenticationError, NetworkError } from "../core/errors.js"; import { InMemorySessionStore } from "../storage/InMemorySessionStore.js"; import { InMemoryStateStore } from "../storage/InMemoryStateStore.js"; +import { parseScope, validateScope, ATPROTO_SCOPE } from "./permissions.js"; /** * Options for the OAuth authorization flow. @@ -179,7 +180,7 @@ export class OAuthClient { */ private buildClientMetadata() { const clientIdUrl = new URL(this.config.oauth.clientId); - return { + const metadata = { client_id: this.config.oauth.clientId, client_name: "ATProto SDK Client", client_uri: clientIdUrl.origin, @@ -193,6 +194,89 @@ export class OAuthClient { dpop_bound_access_tokens: true, jwks_uri: this.config.oauth.jwksUri, } as const; + + // Validate scope before returning metadata + this.validateClientMetadataScope(metadata.scope); + + return metadata; + } + + /** + * Validates the OAuth scope in client metadata and logs warnings/suggestions. + * + * This method: + * 1. Checks if the scope is well-formed using permission utilities + * 2. Detects mixing of transitional and granular permissions + * 3. Logs warnings for missing `atproto` scope + * 4. Suggests migration to granular permissions for transitional scopes + * + * @param scope - The OAuth scope string to validate + * @internal + */ + private validateClientMetadataScope(scope: string): void { + // Parse the scope into individual permissions + const permissions = parseScope(scope); + + // Validate well-formedness + const validation = validateScope(scope); + if (!validation.isValid) { + this.logger?.error("Invalid OAuth scope detected", { + invalidPermissions: validation.invalidPermissions, + scope, + }); + } + + // Check for atproto scope + const hasAtproto = permissions.includes(ATPROTO_SCOPE); + if (!hasAtproto) { + this.logger?.warn("OAuth scope missing 'atproto' - basic API access may be limited", { + scope, + suggestion: "Add 'atproto' to your scope for basic API access", + }); + } + + // Detect transitional scopes + const transitionalScopes = permissions.filter((p) => p.startsWith("transition:")); + const granularScopes = permissions.filter( + (p) => + p.startsWith("account:") || + p.startsWith("repo:") || + p.startsWith("blob") || + p.startsWith("rpc:") || + p.startsWith("identity:") || + p.startsWith("include:"), + ); + + // Log info about transitional scopes + if (transitionalScopes.length > 0) { + this.logger?.info("Using transitional OAuth scopes (legacy)", { + transitionalScopes, + note: "Transitional scopes are supported but granular permissions are recommended", + }); + + // Suggest migration to granular permissions + if (transitionalScopes.includes("transition:email")) { + this.logger?.info("Consider migrating 'transition:email' to granular permissions", { + suggestion: "Use: account:email?action=read", + example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.EMAIL_READ", + }); + } + if (transitionalScopes.includes("transition:generic")) { + this.logger?.info("Consider migrating 'transition:generic' to granular permissions", { + suggestion: "Use specific permissions like: repo:* account:repo?action=read", + example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.FULL_ACCESS", + }); + } + } + + // Warn if mixing transitional and granular + if (transitionalScopes.length > 0 && granularScopes.length > 0) { + this.logger?.warn("Mixing transitional and granular OAuth scopes", { + transitionalScopes, + granularScopes, + note: "While supported, it's recommended to use either transitional or granular permissions consistently", + }); + } } /** diff --git a/packages/sdk-core/tests/auth/OAuthClient.test.ts b/packages/sdk-core/tests/auth/OAuthClient.test.ts index d291c71..55d3bb0 100644 --- a/packages/sdk-core/tests/auth/OAuthClient.test.ts +++ b/packages/sdk-core/tests/auth/OAuthClient.test.ts @@ -161,4 +161,134 @@ describe("OAuthClient", () => { expect(logger.logs.length).toBeGreaterThan(0); }); }); + + describe("scope validation", () => { + it("should log error for invalid scope", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithInvalidScope = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "atproto invalid:scope another-bad-scope", + }, + }); + + new OAuthClient(configWithInvalidScope); + + // Should have logged an error for invalid permissions + const errorLogs = logger.logs.filter((log) => log.level === "error"); + expect(errorLogs.length).toBeGreaterThan(0); + expect(errorLogs[0].message).toContain("Invalid OAuth scope detected"); + }); + + it("should log warning for missing atproto scope", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithoutAtproto = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "transition:email", + }, + }); + + // Note: The underlying @atproto/oauth-client library will throw during async initialization + // because it requires "atproto" scope. However, our validation runs synchronously first and logs the warning. + const client = new OAuthClient(configWithoutAtproto); + + // The client initialization promise will reject - we need to handle it to prevent unhandled rejection + // We use authorize() to trigger initialization, then catch the rejection + try { + await client.authorize("test.bsky.social"); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // Expected - underlying client initialization will fail due to missing atproto scope + } + + // Should have logged a warning for missing atproto during buildClientMetadata() + const warnLogs = logger.logs.filter((log) => log.level === "warn"); + expect(warnLogs.length).toBeGreaterThan(0); + expect(warnLogs[0].message).toContain("missing 'atproto'"); + }); + + it("should detect mixed transitional and granular permissions", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithMixedScopes = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "atproto transition:email account:email?action=read", + }, + }); + + new OAuthClient(configWithMixedScopes); + + // Should have logged a warning about mixing permission models + const warnLogs = logger.logs.filter((log) => log.level === "warn"); + const mixedWarning = warnLogs.find((log) => log.message.includes("Mixing transitional and granular")); + expect(mixedWarning).toBeDefined(); + }); + + it("should suggest migration for transition:email", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithTransitionEmail = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "atproto transition:email", + }, + }); + + new OAuthClient(configWithTransitionEmail); + + // Should have logged info about transitional scopes + const infoLogs = logger.logs.filter((log) => log.level === "info"); + const migrationSuggestion = infoLogs.find((log) => log.message.includes("migrating 'transition:email'")); + expect(migrationSuggestion).toBeDefined(); + expect(migrationSuggestion?.args[0]).toHaveProperty("suggestion"); + }); + + it("should suggest migration for transition:generic", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithTransitionGeneric = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "atproto transition:generic", + }, + }); + + new OAuthClient(configWithTransitionGeneric); + + // Should have logged info about transitional scopes + const infoLogs = logger.logs.filter((log) => log.level === "info"); + const migrationSuggestion = infoLogs.find((log) => log.message.includes("migrating 'transition:generic'")); + expect(migrationSuggestion).toBeDefined(); + expect(migrationSuggestion?.args[0]).toHaveProperty("suggestion"); + }); + + it("should not log warnings for valid granular permissions with atproto", async () => { + const { MockLogger } = await import("../utils/mocks.js"); + const logger = new MockLogger(); + const configWithValidScope = await createTestConfigAsync({ + logger, + oauth: { + ...config.oauth, + scope: "atproto account:email?action=read repo:app.bsky.feed.post?action=create", + }, + }); + + new OAuthClient(configWithValidScope); + + // Should not have logged any errors or warnings (only info about granular permissions is OK) + const errorLogs = logger.logs.filter((log) => log.level === "error"); + const warnLogs = logger.logs.filter((log) => log.level === "warn"); + expect(errorLogs.length).toBe(0); + expect(warnLogs.length).toBe(0); + }); + }); }); From 064bddfdb14e1d98b8699781b36eb3ecdc6b6e7e Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 12:48:27 +0100 Subject: [PATCH 13/15] feat(sdk): add getAccountEmail helper method - Implement email retrieval from authenticated session - Support both transitional and granular permissions - Return null when permission not granted - Add comprehensive error handling and validation - Add 7 test cases covering all scenarios --- packages/sdk-core/src/core/SDK.ts | 101 ++++++++++++++++++++++- packages/sdk-core/tests/core/SDK.test.ts | 101 +++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/packages/sdk-core/src/core/SDK.ts b/packages/sdk-core/src/core/SDK.ts index e64de67..1aa0165 100644 --- a/packages/sdk-core/src/core/SDK.ts +++ b/packages/sdk-core/src/core/SDK.ts @@ -6,7 +6,7 @@ import { InMemorySessionStore } from "../storage/InMemorySessionStore.js"; import { InMemoryStateStore } from "../storage/InMemoryStateStore.js"; import type { ATProtoSDKConfig } from "./config.js"; import { ATProtoSDKConfigSchema } from "./config.js"; -import { ValidationError } from "./errors.js"; +import { ValidationError, NetworkError } from "./errors.js"; import type { Session } from "./types.js"; /** @@ -303,6 +303,105 @@ export class ATProtoSDK { return this.oauthClient.revoke(did.trim()); } + /** + * Gets the account email address from the authenticated session. + * + * This method retrieves the email address associated with the user's account + * by calling the `com.atproto.server.getSession` endpoint. The email will only + * be returned if the appropriate OAuth scope was granted during authorization. + * + * Required OAuth scopes: + * - **Granular permissions**: `account:email?action=read` or `account:email` + * - **Transitional permissions**: `transition:email` + * + * @param session - An authenticated OAuth session + * @returns A Promise resolving to email info, or `null` if permission not granted + * @throws {@link ValidationError} if the session is invalid + * @throws {@link NetworkError} if the API request fails + * + * @example Using granular permissions + * ```typescript + * import { ScopePresets } from '@hypercerts-org/sdk-core'; + * + * // Authorize with email scope + * const authUrl = await sdk.authorize("user.bsky.social", { + * scope: ScopePresets.EMAIL_READ + * }); + * + * // After callback... + * const emailInfo = await sdk.getAccountEmail(session); + * if (emailInfo) { + * console.log(`Email: ${emailInfo.email}`); + * console.log(`Confirmed: ${emailInfo.emailConfirmed}`); + * } else { + * console.log("Email permission not granted"); + * } + * ``` + * + * @example Using transitional permissions (legacy) + * ```typescript + * // Authorize with transition:email scope + * const authUrl = await sdk.authorize("user.bsky.social", { + * scope: "atproto transition:email" + * }); + * + * // After callback... + * const emailInfo = await sdk.getAccountEmail(session); + * ``` + */ + async getAccountEmail(session: Session): Promise<{ email: string; emailConfirmed: boolean } | null> { + if (!session) { + throw new ValidationError("Session is required"); + } + + try { + // Determine PDS URL from session or config + const pdsUrl = this.config.servers?.pds; + if (!pdsUrl) { + throw new ValidationError("PDS server URL not configured"); + } + + // Call com.atproto.server.getSession endpoint using session's fetchHandler + // which automatically includes proper authorization with DPoP + const response = await session.fetchHandler("/xrpc/com.atproto.server.getSession", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new NetworkError(`Failed to get session info: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { + email?: string; + emailConfirmed?: boolean; + did: string; + handle: string; + }; + + // Return null if email not present (permission not granted) + if (!data.email) { + return null; + } + + return { + email: data.email, + emailConfirmed: data.emailConfirmed ?? false, + }; + } catch (error) { + this.logger?.error("Failed to get account email", { error }); + if (error instanceof ValidationError || error instanceof NetworkError) { + throw error; + } + throw new NetworkError( + `Failed to get account email: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + } + /** * Creates a repository instance for data operations. * diff --git a/packages/sdk-core/tests/core/SDK.test.ts b/packages/sdk-core/tests/core/SDK.test.ts index bb60140..f0f8ce6 100644 --- a/packages/sdk-core/tests/core/SDK.test.ts +++ b/packages/sdk-core/tests/core/SDK.test.ts @@ -107,6 +107,107 @@ describe("ATProtoSDK", () => { }); }); + describe("getAccountEmail", () => { + it("should throw ValidationError for null session", async () => { + const sdk = new ATProtoSDK(config); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(sdk.getAccountEmail(null as any)).rejects.toThrow(ValidationError); + }); + + it("should throw ValidationError when PDS not configured", async () => { + const configWithoutPds = await createTestConfigAsync(); + delete configWithoutPds.servers; + const sdk = new ATProtoSDK(configWithoutPds); + const mockSession = createMockSession(); + await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow(ValidationError); + await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("PDS server URL not configured"); + }); + + it("should return email info when permission granted", async () => { + const sdk = new ATProtoSDK(config); + const mockSession = createMockSession({ + fetchHandler: async () => + new Response( + JSON.stringify({ + did: "did:plc:testdid123456789012345678901234567890", + handle: "test.bsky.social", + email: "test@example.com", + emailConfirmed: true, + }), + { status: 200 }, + ), + }); + + const result = await sdk.getAccountEmail(mockSession); + expect(result).not.toBeNull(); + expect(result?.email).toBe("test@example.com"); + expect(result?.emailConfirmed).toBe(true); + }); + + it("should return null when permission not granted", async () => { + const sdk = new ATProtoSDK(config); + const mockSession = createMockSession({ + fetchHandler: async () => + new Response( + JSON.stringify({ + did: "did:plc:testdid123456789012345678901234567890", + handle: "test.bsky.social", + // No email field - permission not granted + }), + { status: 200 }, + ), + }); + + const result = await sdk.getAccountEmail(mockSession); + expect(result).toBeNull(); + }); + + it("should default emailConfirmed to false when not provided", async () => { + const sdk = new ATProtoSDK(config); + const mockSession = createMockSession({ + fetchHandler: async () => + new Response( + JSON.stringify({ + did: "did:plc:testdid123456789012345678901234567890", + handle: "test.bsky.social", + email: "test@example.com", + // emailConfirmed not provided + }), + { status: 200 }, + ), + }); + + const result = await sdk.getAccountEmail(mockSession); + expect(result).not.toBeNull(); + expect(result?.email).toBe("test@example.com"); + expect(result?.emailConfirmed).toBe(false); + }); + + it("should throw NetworkError on API failure", async () => { + const sdk = new ATProtoSDK(config); + const mockSession = createMockSession({ + fetchHandler: async () => + new Response(JSON.stringify({ error: "Internal Server Error" }), { + status: 500, + statusText: "Internal Server Error", + }), + }); + + await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("Failed to get session info"); + }); + + it("should throw NetworkError on network failure", async () => { + const sdk = new ATProtoSDK(config); + const mockSession = createMockSession({ + fetchHandler: async () => { + throw new Error("Network error"); + }, + }); + + await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("Failed to get account email"); + }); + }); + describe("repository", () => { it("should throw ValidationError when session is null", () => { const sdk = new ATProtoSDK(config); From 826b50c140a56fee4feeb6b6c83d1123e44c5118 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 13:02:40 +0100 Subject: [PATCH 14/15] chore: add changeset for OAuth permissions feature --- .changeset/oauth-permissions-and-email.md | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/oauth-permissions-and-email.md diff --git a/.changeset/oauth-permissions-and-email.md b/.changeset/oauth-permissions-and-email.md new file mode 100644 index 0000000..8b98bde --- /dev/null +++ b/.changeset/oauth-permissions-and-email.md @@ -0,0 +1,34 @@ +--- +"@hypercerts-org/sdk-core": minor +--- + +feat(auth): add OAuth scopes and granular permissions system + +Add comprehensive OAuth permissions system with support for granular permissions and easy email access: + +**Permission System** +- Zod schemas for all ATProto permission types (account, repo, blob, rpc, identity, include) +- Support for both transitional (legacy) and granular permission models +- Type-safe permission builder with fluent API +- 14 pre-built scope presets (EMAIL_READ, POSTING_APP, FULL_ACCESS, etc.) +- 8 utility functions for working with scopes + +**Email Access** +- New `getAccountEmail()` method to retrieve user email from authenticated session +- Returns null when permission not granted +- Comprehensive error handling + +**Enhanced OAuth Integration** +- Automatic scope validation with helpful warnings +- Migration suggestions from transitional to granular permissions +- Improved documentation with comprehensive examples + +**Breaking Changes**: None - fully backward compatible + +**New Exports**: +- `PermissionBuilder` - Fluent API for building type-safe scopes +- `ScopePresets` - 14 ready-to-use permission presets +- Utility functions: `buildScope()`, `parseScope()`, `hasPermission()`, `validateScope()`, etc. +- Permission schemas and types for TypeScript consumers + +See README for usage examples and migration guide. From ec17a4461d55176b67dd47abf8bfbda32813d839 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 8 Dec 2025 13:04:56 +0100 Subject: [PATCH 15/15] chore(bump): version bump --- packages/sdk-core/package.json | 2 +- packages/sdk-react/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index 8d93a68..e37b92c 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -1,6 +1,6 @@ { "name": "@hypercerts-org/sdk-core", - "version": "0.8.0", + "version": "0.9.0", "description": "Framework-agnostic ATProto SDK core for authentication, repository operations, and lexicon management", "main": "dist/index.cjs", "repository": { diff --git a/packages/sdk-react/package.json b/packages/sdk-react/package.json index 852f59a..7dee1e2 100644 --- a/packages/sdk-react/package.json +++ b/packages/sdk-react/package.json @@ -1,6 +1,6 @@ { "name": "@hypercerts-org/sdk-react", - "version": "0.8.0", + "version": "0.9.0", "description": "React hooks and components for the Hypercerts ATProto SDK", "type": "module", "main": "dist/index.cjs",