WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement input validation with Zod schemas

- Add validation schemas for handles, DIDs, identifiers, and passwords
- Support custom domain handles (e.g., markbennett.ca)
- Provide both throwing and safe validation functions
- Add 17 comprehensive validation tests covering all edge cases

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+247
+109
src/utils/validation.ts
··· 1 + import { z } from 'zod'; 2 + 3 + /** 4 + * Validation schema for AT Protocol handle 5 + * Supports standard Bluesky handles (user.bsky.social) and custom domains (example.com) 6 + */ 7 + export const handleSchema = z 8 + .string() 9 + .min(1, 'Handle cannot be empty') 10 + .regex( 11 + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, 12 + 'Invalid handle format. Must be a valid domain (e.g., user.bsky.social or example.com)' 13 + ); 14 + 15 + /** 16 + * Validation schema for AT Protocol DID 17 + */ 18 + export const didSchema = z 19 + .string() 20 + .min(1, 'DID cannot be empty') 21 + .regex( 22 + /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/, 23 + 'Invalid DID format. Must start with "did:" followed by method and identifier' 24 + ); 25 + 26 + /** 27 + * Validation schema for app password 28 + * AT Protocol app passwords are typically 19 characters with dashes 29 + */ 30 + export const appPasswordSchema = z 31 + .string() 32 + .min(1, 'Password cannot be empty') 33 + .max(1000, 'Password is too long'); 34 + 35 + /** 36 + * Validation schema for identifier (handle or DID) 37 + */ 38 + export const identifierSchema = z.union([handleSchema, didSchema]); 39 + 40 + /** 41 + * Validate a handle 42 + * @throws {z.ZodError} if validation fails 43 + */ 44 + export function validateHandle(handle: string): string { 45 + return handleSchema.parse(handle); 46 + } 47 + 48 + /** 49 + * Validate a DID 50 + * @throws {z.ZodError} if validation fails 51 + */ 52 + export function validateDid(did: string): string { 53 + return didSchema.parse(did); 54 + } 55 + 56 + /** 57 + * Validate an identifier (handle or DID) 58 + * @throws {z.ZodError} if validation fails 59 + */ 60 + export function validateIdentifier(identifier: string): string { 61 + return identifierSchema.parse(identifier); 62 + } 63 + 64 + /** 65 + * Validate an app password 66 + * @throws {z.ZodError} if validation fails 67 + */ 68 + export function validateAppPassword(password: string): string { 69 + return appPasswordSchema.parse(password); 70 + } 71 + 72 + /** 73 + * Safe validation that returns success/error instead of throwing 74 + */ 75 + export function safeValidateHandle( 76 + handle: string 77 + ): { success: true; data: string } | { success: false; error: string } { 78 + const result = handleSchema.safeParse(handle); 79 + if (result.success) { 80 + return { success: true, data: result.data }; 81 + } 82 + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; 83 + } 84 + 85 + /** 86 + * Safe validation that returns success/error instead of throwing 87 + */ 88 + export function safeValidateDid( 89 + did: string 90 + ): { success: true; data: string } | { success: false; error: string } { 91 + const result = didSchema.safeParse(did); 92 + if (result.success) { 93 + return { success: true, data: result.data }; 94 + } 95 + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; 96 + } 97 + 98 + /** 99 + * Safe validation that returns success/error instead of throwing 100 + */ 101 + export function safeValidateIdentifier( 102 + identifier: string 103 + ): { success: true; data: string } | { success: false; error: string } { 104 + const result = identifierSchema.safeParse(identifier); 105 + if (result.success) { 106 + return { success: true, data: result.data }; 107 + } 108 + return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; 109 + }
+138
tests/utils/validation.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + safeValidateDid, 4 + safeValidateHandle, 5 + safeValidateIdentifier, 6 + validateAppPassword, 7 + validateDid, 8 + validateHandle, 9 + validateIdentifier, 10 + } from '../../src/utils/validation.js'; 11 + 12 + describe('Handle Validation', () => { 13 + describe('validateHandle', () => { 14 + it('should accept standard Bluesky handles', () => { 15 + expect(validateHandle('user.bsky.social')).toBe('user.bsky.social'); 16 + expect(validateHandle('test.bsky.social')).toBe('test.bsky.social'); 17 + }); 18 + 19 + it('should accept custom domain handles', () => { 20 + expect(validateHandle('markbennett.ca')).toBe('markbennett.ca'); 21 + expect(validateHandle('example.com')).toBe('example.com'); 22 + expect(validateHandle('subdomain.example.com')).toBe('subdomain.example.com'); 23 + }); 24 + 25 + it('should reject invalid handles', () => { 26 + expect(() => validateHandle('')).toThrow('Handle cannot be empty'); 27 + expect(() => validateHandle('invalid')).toThrow('Invalid handle format'); 28 + expect(() => validateHandle('invalid..com')).toThrow('Invalid handle format'); 29 + expect(() => validateHandle('.example.com')).toThrow('Invalid handle format'); 30 + expect(() => validateHandle('example.com.')).toThrow('Invalid handle format'); 31 + }); 32 + }); 33 + 34 + describe('safeValidateHandle', () => { 35 + it('should return success for valid handles', () => { 36 + const result = safeValidateHandle('user.bsky.social'); 37 + expect(result.success).toBe(true); 38 + if (result.success) { 39 + expect(result.data).toBe('user.bsky.social'); 40 + } 41 + }); 42 + 43 + it('should return error for invalid handles', () => { 44 + const result = safeValidateHandle('invalid'); 45 + expect(result.success).toBe(false); 46 + if (!result.success) { 47 + expect(result.error).toContain('Invalid handle format'); 48 + } 49 + }); 50 + }); 51 + }); 52 + 53 + describe('DID Validation', () => { 54 + describe('validateDid', () => { 55 + it('should accept valid DIDs', () => { 56 + expect(validateDid('did:plc:test123')).toBe('did:plc:test123'); 57 + expect(validateDid('did:web:example.com')).toBe('did:web:example.com'); 58 + expect(validateDid('did:key:z6MkhaXg')).toBe('did:key:z6MkhaXg'); 59 + }); 60 + 61 + it('should reject invalid DIDs', () => { 62 + expect(() => validateDid('')).toThrow('DID cannot be empty'); 63 + expect(() => validateDid('not-a-did')).toThrow('Invalid DID format'); 64 + expect(() => validateDid('did:')).toThrow('Invalid DID format'); 65 + expect(() => validateDid('did:plc:')).toThrow('Invalid DID format'); 66 + }); 67 + }); 68 + 69 + describe('safeValidateDid', () => { 70 + it('should return success for valid DIDs', () => { 71 + const result = safeValidateDid('did:plc:test123'); 72 + expect(result.success).toBe(true); 73 + if (result.success) { 74 + expect(result.data).toBe('did:plc:test123'); 75 + } 76 + }); 77 + 78 + it('should return error for invalid DIDs', () => { 79 + const result = safeValidateDid('not-a-did'); 80 + expect(result.success).toBe(false); 81 + if (!result.success) { 82 + expect(result.error).toContain('Invalid DID format'); 83 + } 84 + }); 85 + }); 86 + }); 87 + 88 + describe('Identifier Validation', () => { 89 + describe('validateIdentifier', () => { 90 + it('should accept valid handles', () => { 91 + expect(validateIdentifier('user.bsky.social')).toBe('user.bsky.social'); 92 + expect(validateIdentifier('example.com')).toBe('example.com'); 93 + }); 94 + 95 + it('should accept valid DIDs', () => { 96 + expect(validateIdentifier('did:plc:test123')).toBe('did:plc:test123'); 97 + expect(validateIdentifier('did:web:example.com')).toBe('did:web:example.com'); 98 + }); 99 + 100 + it('should reject invalid identifiers', () => { 101 + expect(() => validateIdentifier('')).toThrow(); 102 + expect(() => validateIdentifier('invalid')).toThrow(); 103 + }); 104 + }); 105 + 106 + describe('safeValidateIdentifier', () => { 107 + it('should return success for valid identifiers', () => { 108 + expect(safeValidateIdentifier('user.bsky.social').success).toBe(true); 109 + expect(safeValidateIdentifier('did:plc:test123').success).toBe(true); 110 + }); 111 + 112 + it('should return error for invalid identifiers', () => { 113 + const result = safeValidateIdentifier('invalid'); 114 + expect(result.success).toBe(false); 115 + if (!result.success) { 116 + expect(result.error).toBeTruthy(); 117 + } 118 + }); 119 + }); 120 + }); 121 + 122 + describe('App Password Validation', () => { 123 + describe('validateAppPassword', () => { 124 + it('should accept valid passwords', () => { 125 + expect(validateAppPassword('password123')).toBe('password123'); 126 + expect(validateAppPassword('xxxx-xxxx-xxxx-xxxx')).toBe('xxxx-xxxx-xxxx-xxxx'); 127 + expect(validateAppPassword('a'.repeat(100))).toBe('a'.repeat(100)); 128 + }); 129 + 130 + it('should reject empty passwords', () => { 131 + expect(() => validateAppPassword('')).toThrow('Password cannot be empty'); 132 + }); 133 + 134 + it('should reject extremely long passwords', () => { 135 + expect(() => validateAppPassword('a'.repeat(1001))).toThrow('Password is too long'); 136 + }); 137 + }); 138 + });