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! :)
1import { 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 */
7export 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 */
18export 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 */
30export 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 */
38export const identifierSchema = z.union([handleSchema, didSchema]);
39
40/**
41 * Validate a handle
42 * @throws {z.ZodError} if validation fails
43 */
44export 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 */
52export 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 */
60export 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 */
68export function validateAppPassword(password: string): string {
69 return appPasswordSchema.parse(password);
70}
71
72/**
73 * Safe validation that returns success/error instead of throwing
74 */
75export 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 */
88export 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 */
101export 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}
110
111/**
112 * Validation schema for Tangled-specific DID (did:plc: format only)
113 */
114export const tangledDidSchema = z
115 .string()
116 .regex(/^did:plc:[a-z0-9]+$/, 'Invalid Tangled DID format. Expected: did:plc:...');
117
118/**
119 * Check if a string is a valid AT Protocol handle
120 * Returns true/false without throwing
121 */
122export function isValidHandle(handle: string): boolean {
123 return handleSchema.safeParse(handle).success;
124}
125
126/**
127 * Check if a string is a valid Tangled DID (did:plc: format)
128 * Returns true/false without throwing
129 */
130export function isValidTangledDid(did: string): boolean {
131 return tangledDidSchema.safeParse(did).success;
132}
133
134/**
135 * Validation schema for issue title
136 * Titles must be 1-256 characters
137 */
138export 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 */
147export 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 */
156export 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 */
167export 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 */
175export 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 */
183export function isValidAtUri(uri: string): boolean {
184 return atUriSchema.safeParse(uri).success;
185}