forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}