Skip to content

Commit ce36f61

Browse files
committed
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
1 parent 6b1c5ee commit ce36f61

File tree

2 files changed

+405
-4
lines changed

2 files changed

+405
-4
lines changed

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

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export const MimeTypeSchema = z
143143
export const NsidSchema = z
144144
.string()
145145
.regex(
146-
/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/,
146+
/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)+$/,
147147
'Invalid NSID format. Expected reverse-DNS format (e.g., "app.bsky.feed.post")',
148148
);
149149

@@ -231,3 +231,187 @@ export const RepoPermissionSchema = z
231231
* Input type for repository permission (before transform).
232232
*/
233233
export type RepoPermissionInput = z.input<typeof RepoPermissionSchema>;
234+
235+
/**
236+
* Zod schema for blob permission.
237+
*
238+
* Blob permissions control media file uploads constrained by MIME type patterns.
239+
*
240+
* @example Single MIME type
241+
* ```typescript
242+
* const input = { type: 'blob', mimeTypes: ['image/*'] };
243+
* BlobPermissionSchema.parse(input); // Returns: "blob:image/*"
244+
* ```
245+
*
246+
* @example Multiple MIME types
247+
* ```typescript
248+
* const input = { type: 'blob', mimeTypes: ['image/*', 'video/*'] };
249+
* BlobPermissionSchema.parse(input); // Returns: "blob?accept=image/*&accept=video/*"
250+
* ```
251+
*/
252+
export const BlobPermissionSchema = z
253+
.object({
254+
type: z.literal("blob"),
255+
mimeTypes: z.array(MimeTypeSchema).min(1, "At least one MIME type required"),
256+
})
257+
.transform(({ mimeTypes }) => {
258+
if (mimeTypes.length === 1) {
259+
return `blob:${mimeTypes[0]}`;
260+
}
261+
const accepts = mimeTypes.map((t) => `accept=${encodeURIComponent(t)}`).join("&");
262+
return `blob?${accepts}`;
263+
});
264+
265+
/**
266+
* Input type for blob permission (before transform).
267+
*/
268+
export type BlobPermissionInput = z.input<typeof BlobPermissionSchema>;
269+
270+
/**
271+
* Zod schema for RPC permission.
272+
*
273+
* RPC permissions control authenticated API calls to remote services.
274+
* At least one of lexicon or aud must be restricted (both cannot be wildcards).
275+
*
276+
* @example Specific lexicon with wildcard audience
277+
* ```typescript
278+
* const input = {
279+
* type: 'rpc',
280+
* lexicon: 'com.atproto.repo.createRecord',
281+
* aud: '*'
282+
* };
283+
* RpcPermissionSchema.parse(input);
284+
* // Returns: "rpc:com.atproto.repo.createRecord?aud=*"
285+
* ```
286+
*
287+
* @example With specific audience
288+
* ```typescript
289+
* const input = {
290+
* type: 'rpc',
291+
* lexicon: 'com.atproto.repo.createRecord',
292+
* aud: 'did:web:api.example.com',
293+
* inheritAud: true
294+
* };
295+
* RpcPermissionSchema.parse(input);
296+
* // Returns: "rpc:com.atproto.repo.createRecord?aud=did%3Aweb%3Aapi.example.com&inheritAud=true"
297+
* ```
298+
*/
299+
export const RpcPermissionSchema = z
300+
.object({
301+
type: z.literal("rpc"),
302+
lexicon: NsidSchema.or(z.literal("*")),
303+
aud: z.string().min(1, "Audience is required"),
304+
inheritAud: z.boolean().optional(),
305+
})
306+
.refine(
307+
({ lexicon, aud }) => lexicon !== "*" || aud !== "*",
308+
"At least one of lexicon or aud must be restricted (wildcards cannot both be used)",
309+
)
310+
.transform(({ lexicon, aud, inheritAud }) => {
311+
let perm = `rpc:${lexicon}?aud=${encodeURIComponent(aud)}`;
312+
if (inheritAud) {
313+
perm += "&inheritAud=true";
314+
}
315+
return perm;
316+
});
317+
318+
/**
319+
* Input type for RPC permission (before transform).
320+
*/
321+
export type RpcPermissionInput = z.input<typeof RpcPermissionSchema>;
322+
323+
/**
324+
* Zod schema for identity permission.
325+
*
326+
* Identity permissions control access to DID documents and handles.
327+
*
328+
* @example Handle management
329+
* ```typescript
330+
* const input = { type: 'identity', attr: 'handle' };
331+
* IdentityPermissionSchema.parse(input); // Returns: "identity:handle"
332+
* ```
333+
*
334+
* @example All identity attributes
335+
* ```typescript
336+
* const input = { type: 'identity', attr: '*' };
337+
* IdentityPermissionSchema.parse(input); // Returns: "identity:*"
338+
* ```
339+
*/
340+
export const IdentityPermissionSchema = z
341+
.object({
342+
type: z.literal("identity"),
343+
attr: IdentityAttrSchema,
344+
})
345+
.transform(({ attr }) => `identity:${attr}`);
346+
347+
/**
348+
* Input type for identity permission (before transform).
349+
*/
350+
export type IdentityPermissionInput = z.input<typeof IdentityPermissionSchema>;
351+
352+
/**
353+
* Zod schema for permission set inclusion.
354+
*
355+
* Include permissions reference permission sets bundled under a single NSID.
356+
*
357+
* @example Without audience
358+
* ```typescript
359+
* const input = { type: 'include', nsid: 'com.example.authBasicFeatures' };
360+
* IncludePermissionSchema.parse(input);
361+
* // Returns: "include:com.example.authBasicFeatures"
362+
* ```
363+
*
364+
* @example With audience
365+
* ```typescript
366+
* const input = {
367+
* type: 'include',
368+
* nsid: 'com.example.authBasicFeatures',
369+
* aud: 'did:web:api.example.com'
370+
* };
371+
* IncludePermissionSchema.parse(input);
372+
* // Returns: "include:com.example.authBasicFeatures?aud=did%3Aweb%3Aapi.example.com"
373+
* ```
374+
*/
375+
export const IncludePermissionSchema = z
376+
.object({
377+
type: z.literal("include"),
378+
nsid: NsidSchema,
379+
aud: z.string().optional(),
380+
})
381+
.transform(({ nsid, aud }) => {
382+
let perm = `include:${nsid}`;
383+
if (aud) {
384+
perm += `?aud=${encodeURIComponent(aud)}`;
385+
}
386+
return perm;
387+
});
388+
389+
/**
390+
* Input type for include permission (before transform).
391+
*/
392+
export type IncludePermissionInput = z.input<typeof IncludePermissionSchema>;
393+
394+
/**
395+
* Union schema for all permission types.
396+
*
397+
* This schema accepts any of the supported permission types and validates
398+
* them according to their specific rules.
399+
*/
400+
export const PermissionSchema = z.union([
401+
AccountPermissionSchema,
402+
RepoPermissionSchema,
403+
BlobPermissionSchema,
404+
RpcPermissionSchema,
405+
IdentityPermissionSchema,
406+
IncludePermissionSchema,
407+
]);
408+
409+
/**
410+
* Input type for any permission (before transform).
411+
*/
412+
export type PermissionInput = z.input<typeof PermissionSchema>;
413+
414+
/**
415+
* Output type for any permission (after transform).
416+
*/
417+
export type Permission = z.output<typeof PermissionSchema>;

0 commit comments

Comments
 (0)