Skip to content

Commit 57a93ce

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

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-0
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* OAuth Scopes and Granular Permissions
3+
*
4+
* This module provides type-safe, Zod-validated OAuth scope and permission management
5+
* for the ATProto SDK. It supports both legacy transitional scopes and the new
6+
* granular permissions model.
7+
*
8+
* @see https://atproto.com/specs/oauth
9+
* @see https://atproto.com/specs/permission
10+
*
11+
* @module auth/permissions
12+
*/
13+
14+
import { z } from "zod";
15+
16+
/**
17+
* Base OAuth scope - required for all sessions
18+
*
19+
* @constant
20+
*/
21+
export const ATPROTO_SCOPE = 'atproto' as const;
22+
23+
/**
24+
* Transitional OAuth scopes for legacy compatibility.
25+
*
26+
* These scopes provide broad access and are maintained for backwards compatibility.
27+
* New applications should use granular permissions instead.
28+
*
29+
* @deprecated Use granular permissions (account:*, repo:*, etc.) for better control
30+
* @constant
31+
*/
32+
export const TRANSITION_SCOPES = {
33+
/** Broad PDS permissions including record creation, blob uploads, and preferences */
34+
GENERIC: 'transition:generic',
35+
/** Direct messages access (requires transition:generic) */
36+
CHAT: 'transition:chat.bsky',
37+
/** Email address and confirmation status */
38+
EMAIL: 'transition:email',
39+
} as const;
40+
41+
/**
42+
* Zod schema for transitional scopes.
43+
*
44+
* Validates that a scope string is one of the known transitional scopes.
45+
*
46+
* @example
47+
* ```typescript
48+
* TransitionScopeSchema.parse('transition:email'); // Valid
49+
* TransitionScopeSchema.parse('invalid'); // Throws ZodError
50+
* ```
51+
*/
52+
export const TransitionScopeSchema = z.enum([
53+
'transition:generic',
54+
'transition:chat.bsky',
55+
'transition:email',
56+
]).describe('Legacy transitional OAuth scopes');
57+
58+
/**
59+
* Type for transitional scopes inferred from schema.
60+
*/
61+
export type TransitionScope = z.infer<typeof TransitionScopeSchema>;
62+
63+
/**
64+
* Zod schema for account permission attributes.
65+
*
66+
* Account attributes specify what aspect of the account is being accessed.
67+
*/
68+
export const AccountAttrSchema = z.enum(['email', 'repo']);
69+
70+
/**
71+
* Type for account attributes inferred from schema.
72+
*/
73+
export type AccountAttr = z.infer<typeof AccountAttrSchema>;
74+
75+
/**
76+
* Zod schema for account actions.
77+
*
78+
* Account actions specify the level of access (read-only or management).
79+
*/
80+
export const AccountActionSchema = z.enum(['read', 'manage']);
81+
82+
/**
83+
* Type for account actions inferred from schema.
84+
*/
85+
export type AccountAction = z.infer<typeof AccountActionSchema>;
86+
87+
/**
88+
* Zod schema for repository actions.
89+
*
90+
* Repository actions specify what operations can be performed on records.
91+
*/
92+
export const RepoActionSchema = z.enum(['create', 'update', 'delete']);
93+
94+
/**
95+
* Type for repository actions inferred from schema.
96+
*/
97+
export type RepoAction = z.infer<typeof RepoActionSchema>;
98+
99+
/**
100+
* Zod schema for identity permission attributes.
101+
*
102+
* Identity attributes specify what identity information can be managed.
103+
*/
104+
export const IdentityAttrSchema = z.enum(['handle', '*']);
105+
106+
/**
107+
* Type for identity attributes inferred from schema.
108+
*/
109+
export type IdentityAttr = z.infer<typeof IdentityAttrSchema>;
110+
111+
/**
112+
* Zod schema for MIME type patterns.
113+
*
114+
* Validates MIME type strings like "image/*" or "video/mp4".
115+
*
116+
* @example
117+
* ```typescript
118+
* MimeTypeSchema.parse('image/*'); // Valid
119+
* MimeTypeSchema.parse('video/mp4'); // Valid
120+
* MimeTypeSchema.parse('invalid'); // Throws ZodError
121+
* ```
122+
*/
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+
);
127+
128+
/**
129+
* Zod schema for NSID (Namespaced Identifier).
130+
*
131+
* NSIDs are reverse-DNS style identifiers used throughout ATProto
132+
* (e.g., "app.bsky.feed.post" or "com.example.myrecord").
133+
*
134+
* @see https://atproto.com/specs/nsid
135+
*
136+
* @example
137+
* ```typescript
138+
* NsidSchema.parse('app.bsky.feed.post'); // Valid
139+
* NsidSchema.parse('com.example.myrecord'); // Valid
140+
* NsidSchema.parse('InvalidNSID'); // Throws ZodError
141+
* ```
142+
*/
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+
);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
ATPROTO_SCOPE,
4+
TRANSITION_SCOPES,
5+
TransitionScopeSchema,
6+
AccountAttrSchema,
7+
AccountActionSchema,
8+
RepoActionSchema,
9+
IdentityAttrSchema,
10+
MimeTypeSchema,
11+
NsidSchema,
12+
} from "../../src/auth/permissions.js";
13+
14+
describe("Permission Constants", () => {
15+
it("should export ATPROTO_SCOPE constant", () => {
16+
expect(ATPROTO_SCOPE).toBe("atproto");
17+
});
18+
19+
it("should export TRANSITION_SCOPES with correct values", () => {
20+
expect(TRANSITION_SCOPES.GENERIC).toBe("transition:generic");
21+
expect(TRANSITION_SCOPES.CHAT).toBe("transition:chat.bsky");
22+
expect(TRANSITION_SCOPES.EMAIL).toBe("transition:email");
23+
});
24+
});
25+
26+
describe("TransitionScopeSchema", () => {
27+
it("should accept valid transitional scopes", () => {
28+
expect(TransitionScopeSchema.parse("transition:generic")).toBe("transition:generic");
29+
expect(TransitionScopeSchema.parse("transition:chat.bsky")).toBe("transition:chat.bsky");
30+
expect(TransitionScopeSchema.parse("transition:email")).toBe("transition:email");
31+
});
32+
33+
it("should reject invalid transitional scopes", () => {
34+
expect(() => TransitionScopeSchema.parse("atproto")).toThrow();
35+
expect(() => TransitionScopeSchema.parse("transition:invalid")).toThrow();
36+
expect(() => TransitionScopeSchema.parse("invalid")).toThrow();
37+
expect(() => TransitionScopeSchema.parse("")).toThrow();
38+
});
39+
});
40+
41+
describe("AccountAttrSchema", () => {
42+
it("should accept valid account attributes", () => {
43+
expect(AccountAttrSchema.parse("email")).toBe("email");
44+
expect(AccountAttrSchema.parse("repo")).toBe("repo");
45+
});
46+
47+
it("should reject invalid account attributes", () => {
48+
expect(() => AccountAttrSchema.parse("invalid")).toThrow();
49+
expect(() => AccountAttrSchema.parse("handle")).toThrow();
50+
expect(() => AccountAttrSchema.parse("")).toThrow();
51+
});
52+
});
53+
54+
describe("AccountActionSchema", () => {
55+
it("should accept valid account actions", () => {
56+
expect(AccountActionSchema.parse("read")).toBe("read");
57+
expect(AccountActionSchema.parse("manage")).toBe("manage");
58+
});
59+
60+
it("should reject invalid account actions", () => {
61+
expect(() => AccountActionSchema.parse("create")).toThrow();
62+
expect(() => AccountActionSchema.parse("delete")).toThrow();
63+
expect(() => AccountActionSchema.parse("invalid")).toThrow();
64+
expect(() => AccountActionSchema.parse("")).toThrow();
65+
});
66+
});
67+
68+
describe("RepoActionSchema", () => {
69+
it("should accept valid repository actions", () => {
70+
expect(RepoActionSchema.parse("create")).toBe("create");
71+
expect(RepoActionSchema.parse("update")).toBe("update");
72+
expect(RepoActionSchema.parse("delete")).toBe("delete");
73+
});
74+
75+
it("should reject invalid repository actions", () => {
76+
expect(() => RepoActionSchema.parse("read")).toThrow();
77+
expect(() => RepoActionSchema.parse("manage")).toThrow();
78+
expect(() => RepoActionSchema.parse("invalid")).toThrow();
79+
expect(() => RepoActionSchema.parse("")).toThrow();
80+
});
81+
});
82+
83+
describe("IdentityAttrSchema", () => {
84+
it("should accept valid identity attributes", () => {
85+
expect(IdentityAttrSchema.parse("handle")).toBe("handle");
86+
expect(IdentityAttrSchema.parse("*")).toBe("*");
87+
});
88+
89+
it("should reject invalid identity attributes", () => {
90+
expect(() => IdentityAttrSchema.parse("email")).toThrow();
91+
expect(() => IdentityAttrSchema.parse("repo")).toThrow();
92+
expect(() => IdentityAttrSchema.parse("invalid")).toThrow();
93+
expect(() => IdentityAttrSchema.parse("")).toThrow();
94+
});
95+
});
96+
97+
describe("MimeTypeSchema", () => {
98+
it("should accept valid MIME type patterns", () => {
99+
expect(MimeTypeSchema.parse("image/*")).toBe("image/*");
100+
expect(MimeTypeSchema.parse("video/*")).toBe("video/*");
101+
expect(MimeTypeSchema.parse("audio/*")).toBe("audio/*");
102+
expect(MimeTypeSchema.parse("text/html")).toBe("text/html");
103+
expect(MimeTypeSchema.parse("application/json")).toBe("application/json");
104+
expect(MimeTypeSchema.parse("image/png")).toBe("image/png");
105+
expect(MimeTypeSchema.parse("video/mp4")).toBe("video/mp4");
106+
});
107+
108+
it("should accept MIME types with numbers and hyphens", () => {
109+
expect(MimeTypeSchema.parse("application/json-ld")).toBe("application/json-ld");
110+
expect(MimeTypeSchema.parse("application/ld+json")).toBe("application/ld+json");
111+
expect(MimeTypeSchema.parse("image/svg+xml")).toBe("image/svg+xml");
112+
});
113+
114+
it("should reject invalid MIME type patterns", () => {
115+
expect(() => MimeTypeSchema.parse("invalid")).toThrow();
116+
expect(() => MimeTypeSchema.parse("image")).toThrow();
117+
expect(() => MimeTypeSchema.parse("/png")).toThrow();
118+
expect(() => MimeTypeSchema.parse("image/")).toThrow();
119+
expect(() => MimeTypeSchema.parse("")).toThrow();
120+
});
121+
122+
it("should accept MIME types in any case (case-insensitive)", () => {
123+
// MIME types are case-insensitive per RFC 2045
124+
expect(MimeTypeSchema.parse("IMAGE/*")).toBe("IMAGE/*");
125+
expect(MimeTypeSchema.parse("Image/Png")).toBe("Image/Png");
126+
});
127+
128+
it("should provide helpful error message for invalid MIME types", () => {
129+
try {
130+
MimeTypeSchema.parse("invalid");
131+
expect.fail("Should have thrown");
132+
} catch (error) {
133+
const zodError = error as { issues: Array<{ message: string }> };
134+
expect(zodError.issues[0].message).toContain("Invalid MIME type pattern");
135+
expect(zodError.issues[0].message).toContain("type/subtype");
136+
}
137+
});
138+
});
139+
140+
describe("NsidSchema", () => {
141+
it("should accept valid NSID formats", () => {
142+
expect(NsidSchema.parse("app.bsky.feed.post")).toBe("app.bsky.feed.post");
143+
expect(NsidSchema.parse("com.example.myrecord")).toBe("com.example.myrecord");
144+
expect(NsidSchema.parse("org.example.test.nested.record")).toBe("org.example.test.nested.record");
145+
});
146+
147+
it("should accept NSIDs with hyphens", () => {
148+
expect(NsidSchema.parse("com.example-app.record")).toBe("com.example-app.record");
149+
expect(NsidSchema.parse("app.my-test.record")).toBe("app.my-test.record");
150+
});
151+
152+
it("should reject invalid NSID formats", () => {
153+
expect(() => NsidSchema.parse("InvalidNSID")).toThrow();
154+
expect(() => NsidSchema.parse("example")).toThrow(); // Need at least one dot
155+
expect(() => NsidSchema.parse(".example.com")).toThrow(); // Can't start with dot
156+
expect(() => NsidSchema.parse("example.com.")).toThrow(); // Can't end with dot
157+
expect(() => NsidSchema.parse("Example.com")).toThrow(); // Must be lowercase
158+
expect(() => NsidSchema.parse("example..com")).toThrow(); // No consecutive dots
159+
expect(() => NsidSchema.parse("")).toThrow();
160+
});
161+
162+
it("should provide helpful error message for invalid NSIDs", () => {
163+
try {
164+
NsidSchema.parse("InvalidNSID");
165+
expect.fail("Should have thrown");
166+
} catch (error) {
167+
const zodError = error as { issues: Array<{ message: string }> };
168+
expect(zodError.issues[0].message).toContain("Invalid NSID format");
169+
expect(zodError.issues[0].message).toContain("reverse-DNS");
170+
}
171+
});
172+
});

0 commit comments

Comments
 (0)