Skip to content

Commit 745b76f

Browse files
committed
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
1 parent 57a93ce commit 745b76f

File tree

2 files changed

+118
-21
lines changed

2 files changed

+118
-21
lines changed

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

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { z } from "zod";
1818
*
1919
* @constant
2020
*/
21-
export const ATPROTO_SCOPE = 'atproto' as const;
21+
export const ATPROTO_SCOPE = "atproto" as const;
2222

2323
/**
2424
* Transitional OAuth scopes for legacy compatibility.
@@ -31,11 +31,11 @@ export const ATPROTO_SCOPE = 'atproto' as const;
3131
*/
3232
export const TRANSITION_SCOPES = {
3333
/** Broad PDS permissions including record creation, blob uploads, and preferences */
34-
GENERIC: 'transition:generic',
34+
GENERIC: "transition:generic",
3535
/** Direct messages access (requires transition:generic) */
36-
CHAT: 'transition:chat.bsky',
36+
CHAT: "transition:chat.bsky",
3737
/** Email address and confirmation status */
38-
EMAIL: 'transition:email',
38+
EMAIL: "transition:email",
3939
} as const;
4040

4141
/**
@@ -49,11 +49,9 @@ export const TRANSITION_SCOPES = {
4949
* TransitionScopeSchema.parse('invalid'); // Throws ZodError
5050
* ```
5151
*/
52-
export const TransitionScopeSchema = z.enum([
53-
'transition:generic',
54-
'transition:chat.bsky',
55-
'transition:email',
56-
]).describe('Legacy transitional OAuth scopes');
52+
export const TransitionScopeSchema = z
53+
.enum(["transition:generic", "transition:chat.bsky", "transition:email"])
54+
.describe("Legacy transitional OAuth scopes");
5755

5856
/**
5957
* Type for transitional scopes inferred from schema.
@@ -65,7 +63,7 @@ export type TransitionScope = z.infer<typeof TransitionScopeSchema>;
6563
*
6664
* Account attributes specify what aspect of the account is being accessed.
6765
*/
68-
export const AccountAttrSchema = z.enum(['email', 'repo']);
66+
export const AccountAttrSchema = z.enum(["email", "repo"]);
6967

7068
/**
7169
* Type for account attributes inferred from schema.
@@ -77,7 +75,7 @@ export type AccountAttr = z.infer<typeof AccountAttrSchema>;
7775
*
7876
* Account actions specify the level of access (read-only or management).
7977
*/
80-
export const AccountActionSchema = z.enum(['read', 'manage']);
78+
export const AccountActionSchema = z.enum(["read", "manage"]);
8179

8280
/**
8381
* Type for account actions inferred from schema.
@@ -89,7 +87,7 @@ export type AccountAction = z.infer<typeof AccountActionSchema>;
8987
*
9088
* Repository actions specify what operations can be performed on records.
9189
*/
92-
export const RepoActionSchema = z.enum(['create', 'update', 'delete']);
90+
export const RepoActionSchema = z.enum(["create", "update", "delete"]);
9391

9492
/**
9593
* Type for repository actions inferred from schema.
@@ -101,7 +99,7 @@ export type RepoAction = z.infer<typeof RepoActionSchema>;
10199
*
102100
* Identity attributes specify what identity information can be managed.
103101
*/
104-
export const IdentityAttrSchema = z.enum(['handle', '*']);
102+
export const IdentityAttrSchema = z.enum(["handle", "*"]);
105103

106104
/**
107105
* Type for identity attributes inferred from schema.
@@ -120,10 +118,12 @@ export type IdentityAttr = z.infer<typeof IdentityAttrSchema>;
120118
* MimeTypeSchema.parse('invalid'); // Throws ZodError
121119
* ```
122120
*/
123-
export const MimeTypeSchema = z.string().regex(
124-
/^[a-z]+\/[a-z0-9*+-]+$/i,
125-
'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")'
126-
);
121+
export const MimeTypeSchema = z
122+
.string()
123+
.regex(
124+
/^[a-z]+\/[a-z0-9*+-]+$/i,
125+
'Invalid MIME type pattern. Expected format: type/subtype (e.g., "image/*" or "video/mp4")',
126+
);
127127

128128
/**
129129
* Zod schema for NSID (Namespaced Identifier).
@@ -140,7 +140,46 @@ export const MimeTypeSchema = z.string().regex(
140140
* NsidSchema.parse('InvalidNSID'); // Throws ZodError
141141
* ```
142142
*/
143-
export const NsidSchema = z.string().regex(
144-
/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/,
145-
'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")'
146-
);
143+
export const NsidSchema = z
144+
.string()
145+
.regex(
146+
/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/,
147+
'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")',
148+
);
149+
150+
/**
151+
* Zod schema for account permission.
152+
*
153+
* Account permissions control access to account-level information like email
154+
* and repository management.
155+
*
156+
* @example Without action (read-only)
157+
* ```typescript
158+
* const input = { type: 'account', attr: 'email' };
159+
* AccountPermissionSchema.parse(input); // Returns: "account:email"
160+
* ```
161+
*
162+
* @example With action
163+
* ```typescript
164+
* const input = { type: 'account', attr: 'email', action: 'manage' };
165+
* AccountPermissionSchema.parse(input); // Returns: "account:email?action=manage"
166+
* ```
167+
*/
168+
export const AccountPermissionSchema = z
169+
.object({
170+
type: z.literal("account"),
171+
attr: AccountAttrSchema,
172+
action: AccountActionSchema.optional(),
173+
})
174+
.transform(({ attr, action }) => {
175+
let perm = `account:${attr}`;
176+
if (action) {
177+
perm += `?action=${action}`;
178+
}
179+
return perm;
180+
});
181+
182+
/**
183+
* Input type for account permission (before transform).
184+
*/
185+
export type AccountPermissionInput = z.input<typeof AccountPermissionSchema>;

packages/sdk-core/tests/auth/permissions.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
IdentityAttrSchema,
1010
MimeTypeSchema,
1111
NsidSchema,
12+
AccountPermissionSchema,
1213
} from "../../src/auth/permissions.js";
1314

1415
describe("Permission Constants", () => {
@@ -170,3 +171,60 @@ describe("NsidSchema", () => {
170171
}
171172
});
172173
});
174+
175+
describe("AccountPermissionSchema", () => {
176+
it("should transform account:email without action", () => {
177+
const input = { type: "account" as const, attr: "email" as const };
178+
const result = AccountPermissionSchema.parse(input);
179+
expect(result).toBe("account:email");
180+
});
181+
182+
it("should transform account:email with read action", () => {
183+
const input = { type: "account" as const, attr: "email" as const, action: "read" as const };
184+
const result = AccountPermissionSchema.parse(input);
185+
expect(result).toBe("account:email?action=read");
186+
});
187+
188+
it("should transform account:email with manage action", () => {
189+
const input = { type: "account" as const, attr: "email" as const, action: "manage" as const };
190+
const result = AccountPermissionSchema.parse(input);
191+
expect(result).toBe("account:email?action=manage");
192+
});
193+
194+
it("should transform account:repo without action", () => {
195+
const input = { type: "account" as const, attr: "repo" as const };
196+
const result = AccountPermissionSchema.parse(input);
197+
expect(result).toBe("account:repo");
198+
});
199+
200+
it("should transform account:repo with manage action", () => {
201+
const input = { type: "account" as const, attr: "repo" as const, action: "manage" as const };
202+
const result = AccountPermissionSchema.parse(input);
203+
expect(result).toBe("account:repo?action=manage");
204+
});
205+
206+
it("should reject invalid attr values", () => {
207+
const input = { type: "account" as const, attr: "invalid" };
208+
expect(() => AccountPermissionSchema.parse(input)).toThrow();
209+
});
210+
211+
it("should reject invalid action values", () => {
212+
const input = { type: "account" as const, attr: "email" as const, action: "delete" };
213+
expect(() => AccountPermissionSchema.parse(input)).toThrow();
214+
});
215+
216+
it("should reject missing type field", () => {
217+
const input = { attr: "email" as const };
218+
expect(() => AccountPermissionSchema.parse(input)).toThrow();
219+
});
220+
221+
it("should reject wrong type value", () => {
222+
const input = { type: "repo", attr: "email" as const };
223+
expect(() => AccountPermissionSchema.parse(input)).toThrow();
224+
});
225+
226+
it("should reject missing attr field", () => {
227+
const input = { type: "account" as const };
228+
expect(() => AccountPermissionSchema.parse(input)).toThrow();
229+
});
230+
});

0 commit comments

Comments
 (0)