// Package zxcvbnjs wraps @zxcvbn-ts/core via goja (ExecJS-style) for exact // parity between frontend and backend password strength scoring. // // The bundled JS contains the same @zxcvbn-ts/core library used in the // SvelteKit frontend, so scores are identical for any given password. package zxcvbnjs import ( "embed" "fmt" "log" "sync" "github.com/dop251/goja" ) //go:embed bundle.js var bundleFS embed.FS var ( vm *goja.Runtime vmOnce sync.Once vmErr error ) func getVM() (*goja.Runtime, error) { vmOnce.Do(func() { vm = goja.New() bundleData, err := bundleFS.ReadFile("bundle.js") if err != nil { vmErr = fmt.Errorf("failed to read zxcvbn bundle: %w", err) return } _, err = vm.RunScript("zxcvbn-bundle.js", string(bundleData)) if err != nil { vmErr = fmt.Errorf("failed to evaluate zxcvbn bundle: %w", err) return } }) return vm, vmErr } // Score returns the zxcvbn-ts score (0-4) for the given password. // It uses the exact same @zxcvbn-ts/core library as the frontend, // guaranteeing identical results for the same input. func Score(password string) (int, error) { runtime, err := getVM() if err != nil { return 0, fmt.Errorf("zxcvbnjs init error: %w", err) } zxcvbnScore, ok := goja.AssertFunction(runtime.Get("zxcvbnScore")) if !ok { return 0, fmt.Errorf("zxcvbnScore not found in JS runtime") } result, err := zxcvbnScore(goja.Undefined(), runtime.ToValue(password)) if err != nil { log.Printf("zxcvbnjs: eval error: %v", err) return 0, fmt.Errorf("zxcvbnScore eval failed: %w", err) } score := int(result.ToInteger()) if score < 0 || score > 4 { return 0, fmt.Errorf("unexpected zxcvbn score: %d", score) } return score, nil }