[READ-ONLY] a fast, modern browser for the npm registry
at main 319 lines 8.8 kB view raw
1import * as v from 'valibot' 2import validateNpmPackageName from 'validate-npm-package-name' 3 4// Validation pattern for npm usernames/org names 5// These follow similar rules: lowercase alphanumeric with hyphens, can't start/end with hyphen 6const NPM_USERNAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i 7 8// ============================================================================ 9// Base Schemas 10// ============================================================================ 11 12/** 13 * Validates an npm package name using the official npm validation package 14 * Accepts both new and legacy package name formats 15 */ 16export const PackageNameSchema = v.pipe( 17 v.string(), 18 v.nonEmpty('Package name is required'), 19 v.check(input => { 20 const result = validateNpmPackageName(input) 21 return result.validForNewPackages || result.validForOldPackages 22 }, 'Invalid package name format'), 23) 24 25/** 26 * Validates an npm package name for new packages only 27 * Stricter than PackageNameSchema - rejects legacy formats (uppercase, etc.) 28 * @internal 29 */ 30export const NewPackageNameSchema = v.pipe( 31 v.string(), 32 v.nonEmpty('Package name is required'), 33 v.check(input => { 34 const result = validateNpmPackageName(input) 35 return result.validForNewPackages === true 36 }, 'Invalid package name format. New packages must be lowercase and follow npm naming conventions.'), 37) 38 39/** 40 * Validates an npm username 41 * Must be alphanumeric with hyphens, max 50 chars, can't start/end with hyphen 42 */ 43export const UsernameSchema = v.pipe( 44 v.string(), 45 v.nonEmpty('Username is required'), 46 v.maxLength(50, 'Username is too long'), 47 v.regex(NPM_USERNAME_RE, 'Invalid username format'), 48) 49 50/** 51 * Validates an npm org name (without the @ prefix) 52 * Same rules as username 53 */ 54export const OrgNameSchema = v.pipe( 55 v.string(), 56 v.nonEmpty('Org name is required'), 57 v.maxLength(50, 'Org name is too long'), 58 v.regex(NPM_USERNAME_RE, 'Invalid org name format'), 59) 60 61/** 62 * Validates a scope:team format (e.g., @myorg:developers) 63 */ 64export const ScopeTeamSchema = v.pipe( 65 v.string(), 66 v.nonEmpty('Scope:team is required'), 67 v.maxLength(100, 'Scope:team is too long'), 68 v.check(input => { 69 const match = input.match(/^@([^:]+):(.+)$/) 70 if (!match) return false 71 const [, scope, team] = match 72 if (!scope || !NPM_USERNAME_RE.test(scope)) return false 73 if (!team || !NPM_USERNAME_RE.test(team)) return false 74 return true 75 }, 'Invalid scope:team format. Expected @scope:team'), 76) 77 78/** 79 * Validates org roles 80 * @internal 81 */ 82export const OrgRoleSchema = v.picklist( 83 ['developer', 'admin', 'owner'], 84 'Invalid role. Must be developer, admin, or owner', 85) 86 87/** 88 * Validates access permissions 89 * @internal 90 */ 91export const PermissionSchema = v.picklist( 92 ['read-only', 'read-write'], 93 'Invalid permission. Must be read-only or read-write', 94) 95 96/** 97 * Validates operation types 98 */ 99export const OperationTypeSchema = v.picklist([ 100 'org:add-user', 101 'org:rm-user', 102 'org:set-role', 103 'team:create', 104 'team:destroy', 105 'team:add-user', 106 'team:rm-user', 107 'access:grant', 108 'access:revoke', 109 'owner:add', 110 'owner:rm', 111 'package:init', 112]) 113 114/** 115 * Validates OTP (6-digit code) 116 * @internal 117 */ 118export const OtpSchema = v.optional( 119 v.pipe(v.string(), v.regex(/^\d{6}$/, 'OTP must be a 6-digit code')), 120) 121 122/** 123 * Validates a hex token (like session tokens and operation IDs) 124 * @internal 125 */ 126export const HexTokenSchema = v.pipe( 127 v.string(), 128 v.nonEmpty('Token is required'), 129 v.regex(/^[a-f0-9]+$/i, 'Invalid token format'), 130) 131 132/** 133 * Validates operation ID (16-char hex) 134 * @internal 135 */ 136export const OperationIdSchema = v.pipe( 137 v.string(), 138 v.nonEmpty('Operation ID is required'), 139 v.regex(/^[a-f0-9]{16}$/i, 'Invalid operation ID format'), 140) 141 142// ============================================================================ 143// Request Body Schemas 144// ============================================================================ 145 146/** 147 * Schema for /connect request body 148 */ 149export const ConnectBodySchema = v.object({ 150 token: HexTokenSchema, 151}) 152 153/** 154 * Schema for /execute request body. 155 * - `otp`: optional 6-digit OTP code for 2FA 156 * - `interactive`: when true, commands run via a real PTY (node-pty) instead of execFile, so npm's OTP handler can activate. 157 * - `openUrls`: when true (default), npm opens auth URLs in the user's browser automatically. When false, URLs are suppressed on the connector side and only returned in the response / exposed in /state 158 */ 159export const ExecuteBodySchema = v.object({ 160 otp: OtpSchema, 161 interactive: v.optional(v.boolean()), 162 openUrls: v.optional(v.boolean()), 163}) 164 165/** 166 * Schema for operation params based on type 167 * Validates the params object for each operation type 168 */ 169const OperationParamsSchema = v.record(v.string(), v.string()) 170 171/** 172 * Schema for single operation request body 173 */ 174export const CreateOperationBodySchema = v.object({ 175 type: OperationTypeSchema, 176 params: OperationParamsSchema, 177 description: v.pipe(v.string(), v.nonEmpty('Description is required'), v.maxLength(500)), 178 command: v.pipe(v.string(), v.nonEmpty('Command is required'), v.maxLength(1000)), 179}) 180 181/** 182 * Schema for batch operation request body 183 */ 184export const BatchOperationsBodySchema = v.array(CreateOperationBodySchema) 185 186// ============================================================================ 187// Type-specific Operation Params Schemas 188// ============================================================================ 189 190/** @internal */ 191export const OrgAddUserParamsSchema = v.object({ 192 org: OrgNameSchema, 193 user: UsernameSchema, 194 role: OrgRoleSchema, 195}) 196 197const OrgRemoveUserParamsSchema = v.object({ 198 org: OrgNameSchema, 199 user: UsernameSchema, 200}) 201 202const TeamCreateParamsSchema = v.object({ 203 scopeTeam: ScopeTeamSchema, 204}) 205 206const TeamDestroyParamsSchema = v.object({ 207 scopeTeam: ScopeTeamSchema, 208}) 209 210const TeamAddUserParamsSchema = v.object({ 211 scopeTeam: ScopeTeamSchema, 212 user: UsernameSchema, 213}) 214 215const TeamRemoveUserParamsSchema = v.object({ 216 scopeTeam: ScopeTeamSchema, 217 user: UsernameSchema, 218}) 219 220/** @internal */ 221export const AccessGrantParamsSchema = v.object({ 222 permission: PermissionSchema, 223 scopeTeam: ScopeTeamSchema, 224 pkg: PackageNameSchema, 225}) 226 227const AccessRevokeParamsSchema = v.object({ 228 scopeTeam: ScopeTeamSchema, 229 pkg: PackageNameSchema, 230}) 231 232const OwnerAddParamsSchema = v.object({ 233 user: UsernameSchema, 234 pkg: PackageNameSchema, 235}) 236 237const OwnerRemoveParamsSchema = v.object({ 238 user: UsernameSchema, 239 pkg: PackageNameSchema, 240}) 241 242/** @internal */ 243export const PackageInitParamsSchema = v.object({ 244 name: NewPackageNameSchema, 245 author: v.optional(UsernameSchema), 246}) 247 248// ============================================================================ 249// Helper Functions 250// ============================================================================ 251 252/** 253 * Validates operation params based on operation type 254 * @throws ValiError if validation fails 255 */ 256export function validateOperationParams( 257 type: v.InferOutput<typeof OperationTypeSchema>, 258 params: Record<string, string>, 259): void { 260 switch (type) { 261 case 'org:add-user': 262 v.parse(OrgAddUserParamsSchema, params) 263 break 264 case 'org:rm-user': 265 v.parse(OrgRemoveUserParamsSchema, params) 266 break 267 case 'org:set-role': 268 v.parse(OrgAddUserParamsSchema, params) // same params as add-user 269 break 270 case 'team:create': 271 v.parse(TeamCreateParamsSchema, params) 272 break 273 case 'team:destroy': 274 v.parse(TeamDestroyParamsSchema, params) 275 break 276 case 'team:add-user': 277 v.parse(TeamAddUserParamsSchema, params) 278 break 279 case 'team:rm-user': 280 v.parse(TeamRemoveUserParamsSchema, params) 281 break 282 case 'access:grant': 283 v.parse(AccessGrantParamsSchema, params) 284 break 285 case 'access:revoke': 286 v.parse(AccessRevokeParamsSchema, params) 287 break 288 case 'owner:add': 289 v.parse(OwnerAddParamsSchema, params) 290 break 291 case 'owner:rm': 292 v.parse(OwnerRemoveParamsSchema, params) 293 break 294 case 'package:init': 295 v.parse(PackageInitParamsSchema, params) 296 break 297 } 298} 299 300/** 301 * Safe parse wrapper that returns a formatted error message 302 */ 303export function safeParse<T extends v.GenericSchema>( 304 schema: T, 305 input: unknown, 306): { success: true; data: v.InferOutput<T> } | { success: false; error: string } { 307 const result = v.safeParse(schema, input) 308 if (result.success) { 309 return { success: true, data: result.output } 310 } 311 // Format the first error message 312 const firstIssue = result.issues[0] 313 const path = firstIssue?.path?.map(p => p.key).join('.') || '' 314 const message = firstIssue?.message || 'Validation failed' 315 return { 316 success: false, 317 error: path ? `${path}: ${message}` : message, 318 } 319}