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! :)
10
fork

Configure Feed

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

Add AT-URI utilities and issue validation

New utilities:
- parseAtUri(): Parse AT-URI into components (did, collection, rkey)
- resolveHandleToDid(): Resolve handle to DID via AT Protocol
- buildRepoAtUri(): Construct repository AT-URI from owner and repo name

New validation schemas:
- issueTitleSchema: 1-256 characters
- issueBodySchema: Optional, max 50,000 characters
- atUriSchema: Validate AT-URI format

Includes comprehensive test coverage (45 tests passing)

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

+450
+88
src/utils/at-uri.ts
··· 1 + import type { TangledApiClient } from '../lib/api-client.js'; 2 + 3 + /** 4 + * Parse an AT-URI into its components 5 + * @param uri - AT-URI string (e.g., "at://did:plc:abc/collection/rkey") 6 + * @returns Parsed components or null if invalid 7 + */ 8 + export function parseAtUri(uri: string): { 9 + did: string; 10 + collection: string; 11 + rkey?: string; 12 + } | null { 13 + // AT-URI format: at://did:method:identifier/collection[/rkey] 14 + const match = uri.match(/^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/); 15 + 16 + if (!match) { 17 + return null; 18 + } 19 + 20 + const [, did, collection, rkey] = match; 21 + return { 22 + did, 23 + collection, 24 + ...(rkey && { rkey }), 25 + }; 26 + } 27 + 28 + /** 29 + * Resolve a handle to a DID using the AT Protocol identity resolution 30 + * @param handle - Handle string (e.g., "mark.bsky.social" or "@mark.bsky.social") 31 + * @param client - Authenticated API client 32 + * @returns DID string (e.g., "did:plc:abc123") 33 + * @throws Error if handle cannot be resolved 34 + */ 35 + export async function resolveHandleToDid( 36 + handle: string, 37 + client: TangledApiClient, 38 + ): Promise<string> { 39 + // Strip leading @ if present 40 + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 41 + 42 + try { 43 + const response = await client.getAgent().com.atproto.identity.resolveHandle({ 44 + handle: cleanHandle, 45 + }); 46 + 47 + if (!response.data.did) { 48 + throw new Error(`No DID found for handle: ${cleanHandle}`); 49 + } 50 + 51 + return response.data.did; 52 + } catch (error) { 53 + if (error instanceof Error) { 54 + throw new Error( 55 + `Failed to resolve handle '${cleanHandle}': ${error.message}`, 56 + ); 57 + } 58 + throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); 59 + } 60 + } 61 + 62 + /** 63 + * Build a repository AT-URI from owner and repository name 64 + * @param ownerDidOrHandle - DID (e.g., "did:plc:abc") or handle (e.g., "mark.bsky.social") 65 + * @param repoName - Repository name 66 + * @param client - Authenticated API client 67 + * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/repoName") 68 + */ 69 + export async function buildRepoAtUri( 70 + ownerDidOrHandle: string, 71 + repoName: string, 72 + client: TangledApiClient, 73 + ): Promise<string> { 74 + // Check if owner is already a DID 75 + const isDid = ownerDidOrHandle.startsWith('did:'); 76 + 77 + let did: string; 78 + if (isDid) { 79 + did = ownerDidOrHandle; 80 + } else { 81 + // Resolve handle to DID 82 + did = await resolveHandleToDid(ownerDidOrHandle, client); 83 + } 84 + 85 + // Construct AT-URI for repository 86 + // Format: at://{did}/sh.tangled.repo/{repoName} 87 + return `at://${did}/sh.tangled.repo/${repoName}`; 88 + }
+53
src/utils/validation.ts
··· 130 130 export function isValidTangledDid(did: string): boolean { 131 131 return tangledDidSchema.safeParse(did).success; 132 132 } 133 + 134 + /** 135 + * Validation schema for issue title 136 + * Titles must be 1-256 characters 137 + */ 138 + export const issueTitleSchema = z 139 + .string() 140 + .min(1, 'Issue title cannot be empty') 141 + .max(256, 'Issue title must be 256 characters or less'); 142 + 143 + /** 144 + * Validation schema for issue body 145 + * Body is optional but limited to 50,000 characters 146 + */ 147 + export const issueBodySchema = z 148 + .string() 149 + .max(50000, 'Issue body must be 50,000 characters or less') 150 + .optional(); 151 + 152 + /** 153 + * Validation schema for AT-URI 154 + * Format: at://did:method:identifier/collection[/rkey] 155 + */ 156 + export const atUriSchema = z 157 + .string() 158 + .regex( 159 + /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, 160 + 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]', 161 + ); 162 + 163 + /** 164 + * Validate an issue title 165 + * @throws {z.ZodError} if validation fails 166 + */ 167 + export function validateIssueTitle(title: string): string { 168 + return issueTitleSchema.parse(title); 169 + } 170 + 171 + /** 172 + * Validate an issue body 173 + * @throws {z.ZodError} if validation fails 174 + */ 175 + export function validateIssueBody(body: string): string { 176 + return issueBodySchema.parse(body) ?? ''; 177 + } 178 + 179 + /** 180 + * Check if a string is a valid AT-URI 181 + * Returns true/false without throwing 182 + */ 183 + export function isValidAtUri(uri: string): boolean { 184 + return atUriSchema.safeParse(uri).success; 185 + }
+240
tests/utils/at-uri.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { 3 + buildRepoAtUri, 4 + parseAtUri, 5 + resolveHandleToDid, 6 + } from '../../src/utils/at-uri.js'; 7 + import type { TangledApiClient } from '../../src/lib/api-client.js'; 8 + 9 + // Mock API client 10 + const createMockClient = (): TangledApiClient => { 11 + return { 12 + getAgent: vi.fn(() => ({ 13 + com: { 14 + atproto: { 15 + identity: { 16 + resolveHandle: vi.fn(), 17 + }, 18 + }, 19 + }, 20 + })), 21 + } as unknown as TangledApiClient; 22 + }; 23 + 24 + describe('parseAtUri', () => { 25 + it('should parse AT-URI with rkey', () => { 26 + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; 27 + const result = parseAtUri(uri); 28 + 29 + expect(result).toEqual({ 30 + did: 'did:plc:abc123', 31 + collection: 'sh.tangled.repo.issue', 32 + rkey: 'xyz789', 33 + }); 34 + }); 35 + 36 + it('should parse AT-URI without rkey', () => { 37 + const uri = 'at://did:plc:abc123/sh.tangled.repo'; 38 + const result = parseAtUri(uri); 39 + 40 + expect(result).toEqual({ 41 + did: 'did:plc:abc123', 42 + collection: 'sh.tangled.repo', 43 + }); 44 + }); 45 + 46 + it('should parse AT-URI with nested collection', () => { 47 + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; 48 + const result = parseAtUri(uri); 49 + 50 + expect(result).toEqual({ 51 + did: 'did:plc:abc123', 52 + collection: 'sh.tangled.repo.issue.state', 53 + rkey: 'xyz', 54 + }); 55 + }); 56 + 57 + it('should return null for invalid URI', () => { 58 + expect(parseAtUri('not-a-uri')).toBeNull(); 59 + expect(parseAtUri('http://example.com')).toBeNull(); 60 + expect(parseAtUri('at://invalid-did/collection')).toBeNull(); 61 + expect(parseAtUri('')).toBeNull(); 62 + }); 63 + 64 + it('should handle DIDs with various characters', () => { 65 + const uri = 'at://did:web:example.com/collection/rkey'; 66 + const result = parseAtUri(uri); 67 + 68 + expect(result).toEqual({ 69 + did: 'did:web:example.com', 70 + collection: 'collection', 71 + rkey: 'rkey', 72 + }); 73 + }); 74 + }); 75 + 76 + describe('resolveHandleToDid', () => { 77 + let mockClient: TangledApiClient; 78 + 79 + beforeEach(() => { 80 + mockClient = createMockClient(); 81 + }); 82 + 83 + it('should resolve handle to DID', async () => { 84 + const mockResolve = vi.fn().mockResolvedValue({ 85 + data: { did: 'did:plc:abc123' }, 86 + }); 87 + 88 + vi.mocked(mockClient.getAgent).mockReturnValue({ 89 + com: { 90 + atproto: { 91 + identity: { 92 + resolveHandle: mockResolve, 93 + }, 94 + }, 95 + }, 96 + } as never); 97 + 98 + const result = await resolveHandleToDid('mark.bsky.social', mockClient); 99 + 100 + expect(result).toBe('did:plc:abc123'); 101 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 102 + }); 103 + 104 + it('should strip leading @ from handle', async () => { 105 + const mockResolve = vi.fn().mockResolvedValue({ 106 + data: { did: 'did:plc:abc123' }, 107 + }); 108 + 109 + vi.mocked(mockClient.getAgent).mockReturnValue({ 110 + com: { 111 + atproto: { 112 + identity: { 113 + resolveHandle: mockResolve, 114 + }, 115 + }, 116 + }, 117 + } as never); 118 + 119 + await resolveHandleToDid('@mark.bsky.social', mockClient); 120 + 121 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 122 + }); 123 + 124 + it('should throw error when handle not found', async () => { 125 + const mockResolve = vi.fn().mockResolvedValue({ 126 + data: { did: null }, 127 + }); 128 + 129 + vi.mocked(mockClient.getAgent).mockReturnValue({ 130 + com: { 131 + atproto: { 132 + identity: { 133 + resolveHandle: mockResolve, 134 + }, 135 + }, 136 + }, 137 + } as never); 138 + 139 + await expect( 140 + resolveHandleToDid('nonexistent.bsky.social', mockClient), 141 + ).rejects.toThrow('No DID found for handle: nonexistent.bsky.social'); 142 + }); 143 + 144 + it('should throw error on network failure', async () => { 145 + const mockResolve = vi 146 + .fn() 147 + .mockRejectedValue(new Error('Network error')); 148 + 149 + vi.mocked(mockClient.getAgent).mockReturnValue({ 150 + com: { 151 + atproto: { 152 + identity: { 153 + resolveHandle: mockResolve, 154 + }, 155 + }, 156 + }, 157 + } as never); 158 + 159 + await expect( 160 + resolveHandleToDid('mark.bsky.social', mockClient), 161 + ).rejects.toThrow( 162 + "Failed to resolve handle 'mark.bsky.social': Network error", 163 + ); 164 + }); 165 + }); 166 + 167 + describe('buildRepoAtUri', () => { 168 + let mockClient: TangledApiClient; 169 + 170 + beforeEach(() => { 171 + mockClient = createMockClient(); 172 + }); 173 + 174 + it('should build AT-URI from DID', async () => { 175 + const result = await buildRepoAtUri( 176 + 'did:plc:abc123', 177 + 'my-repo', 178 + mockClient, 179 + ); 180 + 181 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 182 + }); 183 + 184 + it('should build AT-URI from handle', async () => { 185 + const mockResolve = vi.fn().mockResolvedValue({ 186 + data: { did: 'did:plc:abc123' }, 187 + }); 188 + 189 + vi.mocked(mockClient.getAgent).mockReturnValue({ 190 + com: { 191 + atproto: { 192 + identity: { 193 + resolveHandle: mockResolve, 194 + }, 195 + }, 196 + }, 197 + } as never); 198 + 199 + const result = await buildRepoAtUri( 200 + 'mark.bsky.social', 201 + 'my-repo', 202 + mockClient, 203 + ); 204 + 205 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 206 + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 207 + }); 208 + 209 + it('should handle repository names with special characters', async () => { 210 + const result = await buildRepoAtUri( 211 + 'did:plc:abc123', 212 + 'repo-name_123', 213 + mockClient, 214 + ); 215 + 216 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); 217 + }); 218 + 219 + it('should throw error when handle resolution fails', async () => { 220 + const mockResolve = vi 221 + .fn() 222 + .mockRejectedValue(new Error('Resolution failed')); 223 + 224 + vi.mocked(mockClient.getAgent).mockReturnValue({ 225 + com: { 226 + atproto: { 227 + identity: { 228 + resolveHandle: mockResolve, 229 + }, 230 + }, 231 + }, 232 + } as never); 233 + 234 + await expect( 235 + buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient), 236 + ).rejects.toThrow( 237 + "Failed to resolve handle 'mark.bsky.social': Resolution failed", 238 + ); 239 + }); 240 + });
+69
tests/utils/validation.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 import { 3 + isValidAtUri, 3 4 isValidHandle, 4 5 isValidTangledDid, 5 6 safeValidateDid, ··· 9 10 validateDid, 10 11 validateHandle, 11 12 validateIdentifier, 13 + validateIssueBody, 14 + validateIssueTitle, 12 15 } from '../../src/utils/validation.js'; 13 16 14 17 describe('Handle Validation', () => { ··· 171 174 }); 172 175 }); 173 176 }); 177 + 178 + describe('Issue Validation', () => { 179 + describe('validateIssueTitle', () => { 180 + it('should accept valid issue titles', () => { 181 + expect(validateIssueTitle('Bug: Fix login error')).toBe('Bug: Fix login error'); 182 + expect(validateIssueTitle('Feature: Add dark mode')).toBe('Feature: Add dark mode'); 183 + expect(validateIssueTitle('A')).toBe('A'); // minimum length 184 + }); 185 + 186 + it('should accept titles up to 256 characters', () => { 187 + const longTitle = 'A'.repeat(256); 188 + expect(validateIssueTitle(longTitle)).toBe(longTitle); 189 + }); 190 + 191 + it('should reject empty titles', () => { 192 + expect(() => validateIssueTitle('')).toThrow('Issue title cannot be empty'); 193 + }); 194 + 195 + it('should reject titles over 256 characters', () => { 196 + const tooLong = 'A'.repeat(257); 197 + expect(() => validateIssueTitle(tooLong)).toThrow('Issue title must be 256 characters or less'); 198 + }); 199 + }); 200 + 201 + describe('validateIssueBody', () => { 202 + it('should accept valid issue bodies', () => { 203 + expect(validateIssueBody('This is a description')).toBe('This is a description'); 204 + expect(validateIssueBody('Multi\nline\ndescription')).toBe('Multi\nline\ndescription'); 205 + }); 206 + 207 + it('should accept bodies up to 50,000 characters', () => { 208 + const longBody = 'A'.repeat(50000); 209 + expect(validateIssueBody(longBody)).toBe(longBody); 210 + }); 211 + 212 + it('should accept empty string', () => { 213 + expect(validateIssueBody('')).toBe(''); 214 + }); 215 + 216 + it('should reject bodies over 50,000 characters', () => { 217 + const tooLong = 'A'.repeat(50001); 218 + expect(() => validateIssueBody(tooLong)).toThrow('Issue body must be 50,000 characters or less'); 219 + }); 220 + }); 221 + }); 222 + 223 + describe('AT-URI Validation', () => { 224 + describe('isValidAtUri', () => { 225 + it('should return true for valid AT-URIs', () => { 226 + expect(isValidAtUri('at://did:plc:abc123/sh.tangled.repo/my-repo')).toBe(true); 227 + expect(isValidAtUri('at://did:plc:abc123/sh.tangled.repo.issue/xyz789')).toBe(true); 228 + expect(isValidAtUri('at://did:web:example.com/collection')).toBe(true); 229 + }); 230 + 231 + it('should return true for AT-URIs without rkey', () => { 232 + expect(isValidAtUri('at://did:plc:abc123/collection')).toBe(true); 233 + }); 234 + 235 + it('should return false for invalid AT-URIs', () => { 236 + expect(isValidAtUri('http://example.com')).toBe(false); 237 + expect(isValidAtUri('at://not-a-did/collection')).toBe(false); 238 + expect(isValidAtUri('at://did:plc:abc/invalid collection')).toBe(false); 239 + expect(isValidAtUri('')).toBe(false); 240 + }); 241 + }); 242 + });