diff --git a/src/utils/at-uri.ts b/src/utils/at-uri.ts new file mode 100644 index 0000000..c14ccda --- /dev/null +++ b/src/utils/at-uri.ts @@ -0,0 +1,88 @@ +import type { TangledApiClient } from '../lib/api-client.js'; + +/** + * Parse an AT-URI into its components + * @param uri - AT-URI string (e.g., "at://did:plc:abc/collection/rkey") + * @returns Parsed components or null if invalid + */ +export function parseAtUri(uri: string): { + did: string; + collection: string; + rkey?: string; +} | null { + // AT-URI format: at://did:method:identifier/collection[/rkey] + const match = uri.match(/^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/); + + if (!match) { + return null; + } + + const [, did, collection, rkey] = match; + return { + did, + collection, + ...(rkey && { rkey }), + }; +} + +/** + * Resolve a handle to a DID using the AT Protocol identity resolution + * @param handle - Handle string (e.g., "mark.bsky.social" or "@mark.bsky.social") + * @param client - Authenticated API client + * @returns DID string (e.g., "did:plc:abc123") + * @throws Error if handle cannot be resolved + */ +export async function resolveHandleToDid( + handle: string, + client: TangledApiClient, +): Promise { + // Strip leading @ if present + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; + + try { + const response = await client.getAgent().com.atproto.identity.resolveHandle({ + handle: cleanHandle, + }); + + if (!response.data.did) { + throw new Error(`No DID found for handle: ${cleanHandle}`); + } + + return response.data.did; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to resolve handle '${cleanHandle}': ${error.message}`, + ); + } + throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); + } +} + +/** + * Build a repository AT-URI from owner and repository name + * @param ownerDidOrHandle - DID (e.g., "did:plc:abc") or handle (e.g., "mark.bsky.social") + * @param repoName - Repository name + * @param client - Authenticated API client + * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/repoName") + */ +export async function buildRepoAtUri( + ownerDidOrHandle: string, + repoName: string, + client: TangledApiClient, +): Promise { + // Check if owner is already a DID + const isDid = ownerDidOrHandle.startsWith('did:'); + + let did: string; + if (isDid) { + did = ownerDidOrHandle; + } else { + // Resolve handle to DID + did = await resolveHandleToDid(ownerDidOrHandle, client); + } + + // Construct AT-URI for repository + // Format: at://{did}/sh.tangled.repo/{repoName} + return `at://${did}/sh.tangled.repo/${repoName}`; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 3ea318a..bc3fa2e 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -130,3 +130,56 @@ export function isValidHandle(handle: string): boolean { export function isValidTangledDid(did: string): boolean { return tangledDidSchema.safeParse(did).success; } + +/** + * Validation schema for issue title + * Titles must be 1-256 characters + */ +export const issueTitleSchema = z + .string() + .min(1, 'Issue title cannot be empty') + .max(256, 'Issue title must be 256 characters or less'); + +/** + * Validation schema for issue body + * Body is optional but limited to 50,000 characters + */ +export const issueBodySchema = z + .string() + .max(50000, 'Issue body must be 50,000 characters or less') + .optional(); + +/** + * Validation schema for AT-URI + * Format: at://did:method:identifier/collection[/rkey] + */ +export const atUriSchema = z + .string() + .regex( + /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, + 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]', + ); + +/** + * Validate an issue title + * @throws {z.ZodError} if validation fails + */ +export function validateIssueTitle(title: string): string { + return issueTitleSchema.parse(title); +} + +/** + * Validate an issue body + * @throws {z.ZodError} if validation fails + */ +export function validateIssueBody(body: string): string { + return issueBodySchema.parse(body) ?? ''; +} + +/** + * Check if a string is a valid AT-URI + * Returns true/false without throwing + */ +export function isValidAtUri(uri: string): boolean { + return atUriSchema.safeParse(uri).success; +} diff --git a/tests/utils/at-uri.test.ts b/tests/utils/at-uri.test.ts new file mode 100644 index 0000000..34f73db --- /dev/null +++ b/tests/utils/at-uri.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildRepoAtUri, + parseAtUri, + resolveHandleToDid, +} from '../../src/utils/at-uri.js'; +import type { TangledApiClient } from '../../src/lib/api-client.js'; + +// Mock API client +const createMockClient = (): TangledApiClient => { + return { + getAgent: vi.fn(() => ({ + com: { + atproto: { + identity: { + resolveHandle: vi.fn(), + }, + }, + }, + })), + } as unknown as TangledApiClient; +}; + +describe('parseAtUri', () => { + it('should parse AT-URI with rkey', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo.issue', + rkey: 'xyz789', + }); + }); + + it('should parse AT-URI without rkey', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo', + }); + }); + + it('should parse AT-URI with nested collection', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo.issue.state', + rkey: 'xyz', + }); + }); + + it('should return null for invalid URI', () => { + expect(parseAtUri('not-a-uri')).toBeNull(); + expect(parseAtUri('http://example.com')).toBeNull(); + expect(parseAtUri('at://invalid-did/collection')).toBeNull(); + expect(parseAtUri('')).toBeNull(); + }); + + it('should handle DIDs with various characters', () => { + const uri = 'at://did:web:example.com/collection/rkey'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:web:example.com', + collection: 'collection', + rkey: 'rkey', + }); + }); +}); + +describe('resolveHandleToDid', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it('should resolve handle to DID', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + const result = await resolveHandleToDid('mark.bsky.social', mockClient); + + expect(result).toBe('did:plc:abc123'); + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should strip leading @ from handle', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await resolveHandleToDid('@mark.bsky.social', mockClient); + + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should throw error when handle not found', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: null }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect( + resolveHandleToDid('nonexistent.bsky.social', mockClient), + ).rejects.toThrow('No DID found for handle: nonexistent.bsky.social'); + }); + + it('should throw error on network failure', async () => { + const mockResolve = vi + .fn() + .mockRejectedValue(new Error('Network error')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect( + resolveHandleToDid('mark.bsky.social', mockClient), + ).rejects.toThrow( + "Failed to resolve handle 'mark.bsky.social': Network error", + ); + }); +}); + +describe('buildRepoAtUri', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it('should build AT-URI from DID', async () => { + const result = await buildRepoAtUri( + 'did:plc:abc123', + 'my-repo', + mockClient, + ); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); + }); + + it('should build AT-URI from handle', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + const result = await buildRepoAtUri( + 'mark.bsky.social', + 'my-repo', + mockClient, + ); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should handle repository names with special characters', async () => { + const result = await buildRepoAtUri( + 'did:plc:abc123', + 'repo-name_123', + mockClient, + ); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); + }); + + it('should throw error when handle resolution fails', async () => { + const mockResolve = vi + .fn() + .mockRejectedValue(new Error('Resolution failed')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect( + buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient), + ).rejects.toThrow( + "Failed to resolve handle 'mark.bsky.social': Resolution failed", + ); + }); +}); diff --git a/tests/utils/validation.test.ts b/tests/utils/validation.test.ts index aa6eef5..61b5dc3 100644 --- a/tests/utils/validation.test.ts +++ b/tests/utils/validation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isValidAtUri, isValidHandle, isValidTangledDid, safeValidateDid, @@ -9,6 +10,8 @@ import { validateDid, validateHandle, validateIdentifier, + validateIssueBody, + validateIssueTitle, } from '../../src/utils/validation.js'; describe('Handle Validation', () => { @@ -171,3 +174,69 @@ describe('Boolean Validation Helpers', () => { }); }); }); + +describe('Issue Validation', () => { + describe('validateIssueTitle', () => { + it('should accept valid issue titles', () => { + expect(validateIssueTitle('Bug: Fix login error')).toBe('Bug: Fix login error'); + expect(validateIssueTitle('Feature: Add dark mode')).toBe('Feature: Add dark mode'); + expect(validateIssueTitle('A')).toBe('A'); // minimum length + }); + + it('should accept titles up to 256 characters', () => { + const longTitle = 'A'.repeat(256); + expect(validateIssueTitle(longTitle)).toBe(longTitle); + }); + + it('should reject empty titles', () => { + expect(() => validateIssueTitle('')).toThrow('Issue title cannot be empty'); + }); + + it('should reject titles over 256 characters', () => { + const tooLong = 'A'.repeat(257); + expect(() => validateIssueTitle(tooLong)).toThrow('Issue title must be 256 characters or less'); + }); + }); + + describe('validateIssueBody', () => { + it('should accept valid issue bodies', () => { + expect(validateIssueBody('This is a description')).toBe('This is a description'); + expect(validateIssueBody('Multi\nline\ndescription')).toBe('Multi\nline\ndescription'); + }); + + it('should accept bodies up to 50,000 characters', () => { + const longBody = 'A'.repeat(50000); + expect(validateIssueBody(longBody)).toBe(longBody); + }); + + it('should accept empty string', () => { + expect(validateIssueBody('')).toBe(''); + }); + + it('should reject bodies over 50,000 characters', () => { + const tooLong = 'A'.repeat(50001); + expect(() => validateIssueBody(tooLong)).toThrow('Issue body must be 50,000 characters or less'); + }); + }); +}); + +describe('AT-URI Validation', () => { + describe('isValidAtUri', () => { + it('should return true for valid AT-URIs', () => { + expect(isValidAtUri('at://did:plc:abc123/sh.tangled.repo/my-repo')).toBe(true); + expect(isValidAtUri('at://did:plc:abc123/sh.tangled.repo.issue/xyz789')).toBe(true); + expect(isValidAtUri('at://did:web:example.com/collection')).toBe(true); + }); + + it('should return true for AT-URIs without rkey', () => { + expect(isValidAtUri('at://did:plc:abc123/collection')).toBe(true); + }); + + it('should return false for invalid AT-URIs', () => { + expect(isValidAtUri('http://example.com')).toBe(false); + expect(isValidAtUri('at://not-a-did/collection')).toBe(false); + expect(isValidAtUri('at://did:plc:abc/invalid collection')).toBe(false); + expect(isValidAtUri('')).toBe(false); + }); + }); +});